diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4679160b5..88c4bf5ac 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@be3c94b385c4f180051c996d336f57a34c397495 # v3.6.1 with: - go-version: '1.21' + go-version: '1.25' id: go - name: Check out code into the Go module directory diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 113eeeade..601db2082 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -13,7 +13,7 @@ jobs: - name: Set up Go uses: actions/setup-go@be3c94b385c4f180051c996d336f57a34c397495 # v3.6.1 with: - go-version: '1.21' + go-version: '1.25' id: go - name: Check out code into the Go module directory diff --git a/.gitignore b/.gitignore index bb76b36be..f1f28b52e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,8 @@ go.work # VSCode files for debugging .vscode/ -.DS_Store \ No newline at end of file +.DS_Store + +kind-kubeconfig +e2e/kind.yaml +bin diff --git a/.golangci.yml b/.golangci.yml index 508120940..9fb3d0f0d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -7,48 +7,103 @@ run: modules-download-mode: readonly allow-parallel-runners: false linters: - # Start from no linters and explicitly enable what you care about. - default: none + default: standard enable: - revive - govet - misspell - - unused - # Settings for linters (v2: moved here from `linters-settings`) + # bugs presets + - asasalint + - asciicheck + - bidichk + - bodyclose + - contextcheck + - durationcheck + - errcheck + - errchkjson + - errorlint + - exhaustive + - gocheckcompilerdirectives + - gochecksumtype + - gosec + - gosmopolitan + - loggercheck + - makezero + - musttag + - nilerr + - nilnesserr + - noctx + - protogetter + - reassign + - recvcheck + - rowserrcheck + - spancheck + - sqlclosecheck + - staticcheck + - testifylint + - zerologlint + # unused presets + - ineffassign + - unparam + # - unused settings: + revive: + rules: + - name: exported + disabled: true govet: - shadow: true + # report about shadowed variables + enable: + - shadow misspell: locale: US + # (rkatz) - Added as we have a couple of APIs conversion (eg Istio, Kong) that convert uint->int and there's no solution gosec: excludes: - G115 - revive: - rules: - - name: package-comments - disabled: true - - name: var-naming - disabled: true + exclusions: + paths: + - third_party$ + - builtin$ + - examples$ + rules: + # Exclude some linters from running on tests files. + - path: '_test\.go' + linters: + - gocyclo + - errcheck + - dupl + # Exclude gosec G104 (unhandled errors) from test files + - path: '_test\.go' + linters: + - gosec + text: "G104" + # Allow underscores in emitter package names + - path: pkg/i2gw/emitters/ + linters: + - revive + text: "var-naming: don't use an underscore in package name" + - path: '(.+)\.go$' + text: "Using the variable on range scope `tc` in function literal" + # Allow capitalized error messages + - linters: + - staticcheck + text: "ST1005:" + # Allow embedded field, currently this style is used in many places + - linters: + - staticcheck + text: "QF1008:" + formatters: - # In v2, gofmt/goimports live under "formatters", not "linters.enable" enable: - gofmt - goimports settings: gofmt: + # simplify code: gofmt with `-s` option, true by default simplify: true -issues: - exclude-rules: - # Same as before: skip some linters on *_test.go - - path: _test\.go - linters: - - gocyclo - - errcheck - - dupl - # Allow underscores in emitter package names - - path: pkg/i2gw/emitters/ - linters: - - revive - text: "var-naming: don't use an underscore in package name" - exclude: - - Using the variable on range scope `tc` in function literal + exclusions: + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 3fa64adfb..120d1cf51 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -13,9 +13,11 @@ builds: - linux - darwin - windows + ldflags: + - -X 'github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw.Version={{.Version}}' archives: - - format: tar.gz + - formats: ['tar.gz'] # this name template makes the OS and Arch compatible with the results of uname. name_template: >- {{ .ProjectName }}_ @@ -27,7 +29,7 @@ archives: # use zip for windows archives format_overrides: - goos: windows - format: zip + formats: ['zip'] checksum: name_template: 'checksums.txt' snapshot: diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..1d1df2cd7 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,3 @@ +* @LiorLieberman @rikatz @robscott @Stevenjin8 @youngnick +/pkg/i2gw/emitters/envoygateway/ @kkk777-7 +/pkg/i2gw/emitters/agentgateway/ @howardjohn @npolshakova @markuskobler @danehans @puertomontt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3cbbabb52..a2949e802 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,10 +8,6 @@ _As contributors and maintainers of this project, and in the interest of fosteri We have full documentation on how to get started contributing here: - - - [Contributor License Agreement](https://git.k8s.io/community/CLA.md) Kubernetes projects require that you sign a Contributor License Agreement (CLA) before we can accept your pull requests - [Kubernetes Contributor Guide](https://git.k8s.io/community/contributors/guide) - Main contributor documentation, or you can just jump directly to the [contributing section](https://git.k8s.io/community/contributors/guide#contributing) - [Contributor Cheat Sheet](https://git.k8s.io/community/contributors/guide/contributor-cheatsheet) - Common resources for existing developers @@ -19,13 +15,3 @@ If your repo has certain guidelines for contribution, put them here ahead of the ## Mentorship - [Mentoring Initiatives](https://git.k8s.io/community/mentoring) - We have a diverse set of mentorship programs available that are always looking for volunteers! - - diff --git a/Makefile b/Makefile index b2e667620..d8f11d629 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,8 @@ # We need all the Make variables exported as env vars. # Note that the ?= operator works regardless. +REPO_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) + # Enable Go modules. export GO111MODULE=on @@ -31,6 +33,14 @@ GIT_VERSION_STRING := $(shell git describe --tags --always --dirty 2>/dev/null) # Construct the LDFLAGS string to inject the version LDFLAGS := -ldflags="-X '$(I2GWPKG).Version=$(GIT_VERSION_STRING)'" +# Directory for local binaries (used in CI where we can't install to /usr/local/bin). +LOCAL_BIN := $(REPO_ROOT)/bin + +KIND_VERSION ?= v0.25.0 + +# Default arguments for `go test` in e2e tests. +I2GW_GO_TEST_ARGS ?= -race -v -count=1 -timeout=30m + # Print the help menu. .PHONY: help help: @@ -78,7 +88,100 @@ test-all: test test-e2e;$(info $(M)...Completed test-all.) @ ## Run integration build: vet;$(info $(M)...Build the binary.) @ ## Build the binary. go build $(LDFLAGS) -o ingress2gateway . +# install the binary +.PHONY: install +install: vet;$(info $(M)...Install the binary.) @ ## Build the binary. + go install $(LDFLAGS) + # Run static analysis. .PHONY: verify verify: hack/verify-all.sh -v + +# Detect OS and architecture for kind installation +OS := $(shell uname -s | tr '[:upper:]' '[:lower:]') +ARCH := $(shell uname -m) +ifeq ($(ARCH),x86_64) + ARCH := amd64 +endif +ifeq ($(ARCH),aarch64) + ARCH := arm64 +endif + +KIND_BINARY_URL := https://kind.sigs.k8s.io/dl/$(KIND_VERSION)/kind-$(OS)-$(ARCH) +KIND := $(shell command -v kind 2>/dev/null || echo "$(LOCAL_BIN)/kind") + +.PHONY: ensure-kind +ensure-kind: + @if command -v kind >/dev/null 2>&1; then \ + echo "Found kind binary: $$(kind version)"; \ + elif [ -x "$(LOCAL_BIN)/kind" ]; then \ + echo "Found kind binary in $(LOCAL_BIN): $$($(LOCAL_BIN)/kind version)"; \ + else \ + echo "kind binary not found. Installing kind $(KIND_VERSION) for $(OS)/$(ARCH)..."; \ + mkdir -p $(LOCAL_BIN); \ + curl -Lo $(LOCAL_BIN)/kind $(KIND_BINARY_URL); \ + chmod +x $(LOCAL_BIN)/kind; \ + echo "kind installed successfully to $(LOCAL_BIN)/kind"; \ + fi + +KIND_CONFIG := $(REPO_ROOT)/e2e/kind.yaml + +.PHONY: generate-kind-config +generate-kind-config: + @mkdir -p $(dir $(KIND_CONFIG)) + @printf '%s\n' \ + 'kind: Cluster' \ + 'apiVersion: kind.x-k8s.io/v1alpha4' \ + 'nodes:' \ + '- role: control-plane' \ + '- role: worker' \ + '- role: worker' \ + '- role: worker' \ + > $(KIND_CONFIG) + +.PHONY: kind +kind: ensure-kind generate-kind-config + @if ! $(KIND) get clusters | grep -q i2gw-e2e; then \ + $(KIND) create cluster -n i2gw-e2e --kubeconfig $(REPO_ROOT)/kind-kubeconfig --config $(KIND_CONFIG); \ + else \ + echo "Cluster i2gw-e2e already exists. Reusing it."; \ + $(KIND) get kubeconfig --name i2gw-e2e > $(REPO_ROOT)/kind-kubeconfig; \ + fi + +# Set I2GW_KUBECONFIG to a path to a kubeconfig file to run the tests against an existing cluster. +# Running without setting this variable creates a local kind cluster and uses it for running the +# tests. +# See README.md for more info. +.PHONY: e2e +e2e: build ## Run end-to-end tests. + @if [ ! -z "$${KUBECONFIG}" ]; then \ + echo "ERROR: KUBECONFIG is set in current shell. Refusing to run to avoid touching an"; \ + echo "unrelated cluster."; \ + echo "Unset KUBECONFIG and run 'I2GW_KUBECONFIG=/path/to/kubeconfig make e2e' to run"; \ + echo "the tests against an existing cluster, or run 'make e2e' with no vars to use an"; \ + echo "auto-created kind cluster."; \ + exit 1; \ + fi + @cleanup_kind=false; \ + kubeconfig="$${I2GW_KUBECONFIG}"; \ + if [ -z "$${I2GW_KUBECONFIG}" ]; then \ + $(MAKE) kind || exit 1; \ + kubeconfig="$(REPO_ROOT)/kind-kubeconfig"; \ + cleanup_kind=true; \ + fi; \ + set -x; \ + I2GW_BINARY_PATH=$(REPO_ROOT)/ingress2gateway KUBECONFIG=$${kubeconfig} \ + go test $(I2GW_GO_TEST_ARGS) $(REPO_ROOT)/e2e; \ + test_exit_code=$$?; \ + set +x; \ + if [ "$${cleanup_kind}" = "true" ] && [ "$${SKIP_CLEANUP}" != "1" ]; then \ + $(MAKE) clean-kind; \ + fi; \ + exit $$test_exit_code + +.PHONY: clean-kind +clean-kind: + $(KIND) delete cluster -n i2gw-e2e + rm -f $(REPO_ROOT)/kind-kubeconfig + rm -f $(KIND_CONFIG) diff --git a/README.md b/README.md index 77028dfec..296444593 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,24 @@ This is a fork of the upstream [ingress2gateway](https://github.com/kubernetes-s that translates Ingress resources to Gateway API and [Kgateway](https://kgateway.dev/)-specific resources, e.g. [TrafficPolicy](https://kgateway.dev/docs/envoy/2.0.x/about/policies/trafficpolicy/). -## Supported providers +## Providers vs Emitters -* [ingress-nginx](pkg/i2gw/providers/ingressnginx/README.md) +Ingress2gateway has two main components: **providers** and **emitters**. + +- **Providers** read Ingress resources and provider-specific CRDs, then convert + them into a generic intermediate representation (IR). +- **Emitters** take that IR and produce the final Gateway API output. The default + `standard` emitter outputs core Gateway API resources (like `Gateway` and + `HTTPRoute`), while other emitters can additionally output resources tailored to + a specific Gateway API project (e.g. `EnvoyGateway` `BackendTrafficPolicy` + or `GKE` `HealthCheckPolicy`). + +For a detailed look at the architecture, see [docs/emitters.md](docs/emitters.md). +### Supported Providers + +* [ingress-nginx](pkg/i2gw/providers/ingressnginx/README.md) +* [gloo-edge](pkg/i2gw/providers/glooedge/README.md) If your provider, or a specific feature, is not currently supported, please open an issue and describe your use case. @@ -36,9 +50,9 @@ Alternatively, you can download the binary at the [releases page](https://github * Install Git: Make sure Git is installed on your system to clone the project repository. - * Install Go: Make sure the go language is installed on your system. You can - download it from the official website (https://golang.org/dl/) and follow the - installation instructions. + * Install Go 1.25.5 or later: Make sure the Go language is installed on your + system. You can download it from the official website + (https://golang.org/dl/) and follow the installation instructions. 1. Clone the project repository @@ -52,6 +66,12 @@ Alternatively, you can download the binary at the [releases page](https://github make build ``` +1. Install the binary to your system + + ```shell + go install . + ``` + ## Usage Ingress2gateway reads Ingress resources from a Kubernetes cluster or a file. It will output the equivalent @@ -61,6 +81,11 @@ all ingresses from the ingress-nginx provider: ```shell ./ingress2gateway print --providers=ingress-nginx --emitter=kgateway ``` +to convert Gloo Edge VirtualService + +```shell +./ingress2gateway print --providers=gloo-edge --emitter=kgateway +``` The above command will: @@ -73,13 +98,27 @@ The above command will: ### `print` command +| Flag | Short | Default Value | Required | Description | +| -------------- | ----- | ----------------------- | -------- | ------------------------------------------------------------ | +| all-namespaces | -A | false | No | If present, list the requested object(s) across all namespaces. Namespace in the current context is ignored even if specified with --namespace. | +| allow-experimental-gw-api | | false | No | If present, include Experimental Gateway API fields (e.g. URLRewrite) in the output. | +| emitter | | standard | No | The emitter to use for generating Gateway API resources. | +| input-file | | | No | Path to the manifest file(s). When set, the tool will read ingresses from the file(s) instead of reading from the cluster. Supports yaml and json. Can be specified multiple times. | +| kubeconfig | | | No | The kubeconfig file to use when talking to the cluster. If the flag is not set, a set of standard locations can be searched for an existing kubeconfig file. | +| namespace | -n | | No | If present, the namespace scope for the invocation. | +| no-color | | false | No | Disable ANSI color codes in the output. | +| output | -o | yaml | No | The output format. One of: yaml, json, kyaml. | +| providers | | | Yes | Comma-separated list of providers. | + +#### Provider-specific flags + | Flag | Default Value | Required | Description | | -------------- | ----------------------- | -------- | ------------------------------------------------------------ | | all-namespaces | False | No | If present, list the requested object(s) across all namespaces. Namespace in the current context is ignored even if specified with --namespace. | | input-file | | No | Path to the manifest file. When set, the tool will read ingresses from the file instead of reading from the cluster. Supported files are yaml and json. | | namespace | | No | If present, the namespace scope for the invocation. | | output | yaml | No | The output format, either yaml or json. | -| providers | | Yes | Comma-separated list of providers (only ingress-nginx is supported in this downstream). | +| providers | | Yes | Comma-separated list of providers (ingress-nginx and Gloo-edge is supported). | | emitter | standard | No | The emitter to use for generating Gateway API resources (supported values: standard, kgateway). | | kubeconfig | | No | The kubeconfig file to use when talking to the cluster. If the flag is not set, a set of standard locations can be searched for an existing kubeconfig file. | @@ -101,20 +140,30 @@ one that sorted later. Since the Ingress v1 spec does not itself have a conflict resolution guide, we have adopted this one. These rules are similar to the [Gateway API conflict resolution guidelines](https://gateway-api.sigs.k8s.io/concepts/guidelines/#conflicts). +# Provider-Specific Conversions -### Ingress resource fields to Gateway API fields +## Ingress-Nginx -Given a set of Ingress resources, `ingress2gateway` will generate a Gateway with -various HTTP and HTTPS Listeners as well as HTTPRoutes that should represent equivalent -routing rules. +Ingress resources will be converted to Gateway API resources as follows: | Ingress Field | Gateway API configuration | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ingressClassName` | If configured on an Ingress resource, this value will be translated to `kgateway`. | +| `ingressClassName` | If configured on an Ingress resource, this value will be translated to the corresponding Gateway class. | | `defaultBackend` | If present, this configuration will generate a Gateway Listener with no `hostname` specified as well as a catchall HTTPRoute that references this listener. The backend specified here will be translated to a HTTPRoute `rules[].backendRefs[]` element. | | `tls[].hosts` | Each host in an IngressTLS will result in a HTTPS Listener on the generated Gateway with the following: `listeners[].hostname` = host as described, `listeners[].port` = `443`, `listeners[].protocol` = `HTTPS`, `listeners[].tls.mode` = `Terminate` | | `tls[].secretName` | The secret specified here will be referenced in the Gateway HTTPS Listeners mentioned above with the field `listeners[].tls.certificateRefs`. Each Listener for each host in an IngressTLS will get this secret. | -| `rules[].host` | If non-empty, each distinct value for this field in the provided Ingress resources will result in a separate Gateway HTTP Listener with matching `listeners[].hostname`. `listeners[].port` will be set to `80` and `listeners[].protocol` set to `HTTPS`. In addition, Ingress rules with the same hostname will generate HTTPRoute rules in a HTTPRoute with `hostnames` containing it as the single element. If empty, similar to the `defaultBackend`, a Gateway Listener with no hostname configuration will be generated (if it doesn't exist) and routing rules will be generated in a catchall HTTPRoute. | -| `rules[].http.paths[].path` | This field translates to a HTTPRoute `rules[].matches[].path.value` configuration. | -| `rules[].http.paths[].pathType` | This field translates to a HTTPRoute `rules[].matches[].path.type` configuration. Ingress `Exact` = HTTPRoute `Exact` match. Ingress `Prefix` = HTTPRoute `PathPrefix` match. | -| `rules[].http.paths[].backend` | The backend specified here will be translated to a HTTPRoute `rules[].backendRefs[]` element. | +| `rules[].host` | If non-empty, each distinct value for this field in the provided Ingress resources will result in a separate Gateway HTTP Listener with matching `listeners[].hostname`. `listeners[].port` will be set to `80` and `listeners[].protocol` set to `HTTP`. In addition, Ingress rules with the same hostname will generate HTTPRoute rules in a HTTPRoute with `hostnames` containing it as the single element. If empty, similar to the `defaultBackend`, a Gateway Listener with no hostname configuration will be generated (if it doesn't exist) and routing rules will be generated in a catchall HTTPRoute. | +| `rules[].http.paths[].path` | This field translates to a HTTPRoute `rules[].matches[].path.value` configuration. | +| `rules[].http.paths[].pathType` | This field translates to a HTTPRoute `rules[].matches[].path.type` configuration. Ingress `Exact` = HTTPRoute `Exact` match. Ingress `Prefix` = HTTPRoute `PathPrefix` match. | +| `rules[].http.paths[].backend` | The backend specified here will be translated to a HTTPRoute `rules[].backendRefs[]` element. | + +## Gloo Edge + +Gloo Edge VirtualServices will be converted to Gateway API resources as follows: + +| VirtualService Field | Gateway API configuration | +| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `spec.hosts` | Each host in the VirtualService hosts list will result in a separate Gateway HTTP Listener with matching `listeners[].hostname`. `listeners[].port` will be set to `80` and `listeners[].protocol` set to `HTTP`. HTTPRoute resources will be created with `hostnames` containing the corresponding host as the single element. | +| `spec.virtualHost.routes[].matchers[].prefix` | This field translates to a HTTPRoute `rules[].matches[].path.value` configuration with `type` set to `PathPrefix`. | +| `spec.virtualHost.routes[].routeAction.single.upstream` | The upstream specified here will be translated to a HTTPRoute `rules[].backendRefs[]` element with the upstream name as the backend Service name. | +| `spec.virtualHost.routes[].routeAction.single.upstream.port` | The port specified on the upstream will be translated to the HTTPRoute backend ref `port` field. | diff --git a/cmd/print.go b/cmd/print.go index 389763f7d..528dcc79a 100644 --- a/cmd/print.go +++ b/cmd/print.go @@ -18,11 +18,14 @@ package cmd import ( "fmt" + "io" "os" + "path/filepath" "slices" "strings" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" "github.com/samber/lo" "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -32,12 +35,15 @@ import ( // Call init function for the providers _ "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/ingressnginx" + _ "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/glooedge" // Call init for notifications _ "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" // Call init for emitters _ "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitters/agentgateway" + _ "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitters/envoygateway" + _ "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitters/gce" _ "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitters/kgateway" _ "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitters/standard" ) @@ -47,8 +53,8 @@ type PrintRunner struct { // Defaults to YAML. outputFormat string - // The path to the input yaml config file. Value assigned via --input-file flag - inputFile string + // inputFile contains the paths to YAML manifest files to process. Value assigned via --input-file flag. + inputFile []string // The namespace used to query Gateway API objects. Value assigned via // --namespace/-n flag. @@ -74,6 +80,9 @@ type PrintRunner struct { // emitter indicates which emitter is used to generate the Gateway API resources. // Defaults to "standard". emitter string + + // allowExperimentalGatewayAPI indicates whether Experimental Gateway API features (like URLRewrite) should be included in the output. + allowExperimentalGatewayAPI bool } // PrintGatewayAPIObjects performs necessary steps to digest and print @@ -83,22 +92,69 @@ type PrintRunner struct { func (pr *PrintRunner) PrintGatewayAPIObjects(cmd *cobra.Command, _ []string) error { err := pr.initializeResourcePrinter() if err != nil { - return fmt.Errorf("failed to initialize resrouce printer: %w", err) + return fmt.Errorf("failed to initialize resource printer: %w", err) } err = pr.initializeNamespaceFilter() if err != nil { return fmt.Errorf("failed to initialize namespace filter: %w", err) } - gatewayResources, notificationTablesMap, err := i2gw.ToGatewayAPIResources(cmd.Context(), pr.namespaceFilter, pr.inputFile, pr.providers, pr.emitter, pr.getProviderSpecificFlags()) - if err != nil { - return err + allFiles := []string{} + + for _, path := range pr.inputFile { + // Check if path is a directory + info, statErr := os.Stat(path) + if statErr != nil { + return fmt.Errorf("input path does not exist: %s", path) + } + if info.IsDir() { + return fmt.Errorf("provided input path %s is a directory", path) + } + allFiles = append(allFiles, path) } + var gatewayResources []i2gw.GatewayResources + var report *notifications.Report + + var inputReader io.Reader - for _, table := range notificationTablesMap { - fmt.Fprintln(os.Stderr, table) + if len(allFiles) > 0 { + var readers []io.Reader + + for i, file := range allFiles { + cleanPath := filepath.Clean(file) + + f, openErr := os.Open(cleanPath) + if openErr != nil { + return fmt.Errorf("error reading file %s: %w", file, openErr) + } + + readers = append(readers, f) + + if i < len(allFiles)-1 { + readers = append(readers, strings.NewReader("\n---\n")) + } + + defer func(f *os.File, path string) { + if closeErr := f.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to close file %s: %v\n", path, closeErr) + } + }(f, cleanPath) + } + + if len(readers) == 1 { + inputReader = readers[0] + } else { + inputReader = io.MultiReader(readers...) + } + } + + gatewayResources, report, err = i2gw.ToGatewayAPIResources(cmd.Context(), pr.namespaceFilter, inputReader, pr.providers, pr.emitter, pr.getProviderSpecificFlags(), pr.allowExperimentalGatewayAPI, noColor) + + if err != nil { + return err } + fmt.Fprint(os.Stderr, report.Render()) pr.outputResult(gatewayResources) return nil @@ -283,8 +339,10 @@ func (pr *PrintRunner) initializeResourcePrinter() error { // 3. If namespace is specified, it filters resources based on that namespace. // 4. If no namespace is specified and reading from the cluster, it attempts to get the namespace from the cluster; if unsuccessful, initialization fails. func (pr *PrintRunner) initializeNamespaceFilter() error { + hasFileInput := len(pr.inputFile) > 0 + // When we should use all namespaces, empty string is used as the filter. - if pr.allNamespaces || (pr.inputFile != "" && pr.namespace == "") { + if pr.allNamespaces || (hasFileInput && pr.namespace == "") { pr.namespaceFilter = "" return nil } @@ -292,7 +350,7 @@ func (pr *PrintRunner) initializeNamespaceFilter() error { // If namespace flag is not specified, try to use the default namespace from the cluster if pr.namespace == "" { ns, err := getNamespaceInCurrentContext() - if err != nil && pr.inputFile == "" { + if err != nil && !hasFileInput { // When asked to read from the cluster, but getting the current namespace // failed for whatever reason - do not process the request. return err @@ -340,8 +398,8 @@ func newPrintCommand() *cobra.Command { cmd.Flags().StringVarP(&pr.outputFormat, "output", "o", "yaml", "Output format. One of: (yaml, json, kyaml).") - cmd.Flags().StringVar(&pr.inputFile, "input-file", "", - `Path to the manifest file. When set, the tool will read ingresses from the file instead of reading from the cluster. Supported files are yaml and json.`) + cmd.Flags().StringSliceVar(&pr.inputFile, "input-file", []string{}, + `Path to manifest files. When set, the tool will read ingresses from the files instead of reading from the cluster. Supported files are yaml and json.`) cmd.Flags().StringVarP(&pr.namespace, "namespace", "n", "", `If present, the namespace scope for this CLI request.`) @@ -356,6 +414,8 @@ if specified with --namespace.`) cmd.Flags().StringSliceVar(&pr.providers, "providers", []string{}, fmt.Sprintf("If present, the tool will try to convert only resources related to the specified providers, supported values are %v.", i2gw.GetSupportedProviders())) + cmd.Flags().BoolVar(&pr.allowExperimentalGatewayAPI, "allow-experimental-gw-api", false, "If present, the tool will include Experimental Gateway API fields (e.g. URLRewrite) in the output. Default is false.") + pr.providerSpecificFlags = make(map[string]*string) for provider, flags := range i2gw.GetProviderSpecificFlagDefinitions() { for _, flag := range flags { diff --git a/cmd/print_test.go b/cmd/print_test.go index 02d1fdaaa..b94161e67 100644 --- a/cmd/print_test.go +++ b/cmd/print_test.go @@ -149,10 +149,14 @@ func Test_getNamespaceFilter(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + var inputFile []string + if tc.inputfile != "" { + inputFile = []string{tc.inputfile} + } pr := PrintRunner{ namespace: tc.namespace, allNamespaces: tc.allNamespaces, - inputFile: tc.inputfile, + inputFile: inputFile, } err = pr.initializeNamespaceFilter() @@ -224,7 +228,7 @@ preferences: {} kubeConfigFile := fmt.Sprintf("%s/config", dir) - f, err := os.Create(kubeConfigFile) + f, err := os.Create(filepath.Clean(kubeConfigFile)) if err != nil { return nil, err } diff --git a/cmd/root.go b/cmd/root.go index 1e42f4bc4..dce2133dd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,14 +17,18 @@ limitations under the License. package cmd import ( + "fmt" "os" - "github.com/spf13/cobra" + "github.com/spf13/cobra" ) // kubeconfig indicates kubeconfig file location. var kubeconfig string +// noColor disables ANSI color codes in the output. +var noColor bool + func newRootCmd() *cobra.Command { rootCmd := &cobra.Command{ Use: "ingress2gateway", @@ -36,12 +40,17 @@ func newRootCmd() *cobra.Command { rootCmd.PersistentFlags().StringVar(&kubeconfig, "kubeconfig", "", `The kubeconfig file to use when talking to the cluster. If the flag is not set, a set of standard locations can be searched for an existing kubeconfig file.`) + rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, + `Disable ANSI color codes in the output.`) return rootCmd } func getKubeconfig() { if kubeconfig != "" { - os.Setenv("KUBECONFIG", kubeconfig) + if err := os.Setenv("KUBECONFIG", kubeconfig); err != nil { + fmt.Fprintf(os.Stderr, "Failed to set KUBECONFIG: %v\n", err) + os.Exit(1) + } } } diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 000000000..806a8d8dd --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,143 @@ +# E2E Tests + +End-to-end tests for ingress2gateway. + +## Requirements + +- Docker (for kind cluster) +- `kubectl` +- [kind](https://kind.sigs.k8s.io/) (auto-installed if missing) + +## Running the tests + +### Using a local kind cluster (recommended) + +```bash +make e2e +``` + +This will automatically create a kind cluster named `i2gw-e2e`, run the tests and clean up at the +end. + +### Using an existing cluster + +A generic k8s cluster can be used to execute the tests: + +```bash +I2GW_KUBECONFIG=/path/to/kubeconfig make e2e +``` + +>**NOTE:** Do not have `KUBECONFIG` set in your shell when running tests. The e2e tests +>deliberately refuse to run when this var is set to avoid accidental execution against an unrelated +>cluster. + +### Customizing test execution + +By default, `make e2e` passes the following arguments to `go test`: + +``` +-race -v -count=1 -timeout=30m +``` + +Custom arguments can be passed using `I2GW_GO_TEST_ARGS` for things like running a subset of the +tests or changing the timeout: + +```bash +I2GW_GO_TEST_ARGS="-race -v -count=1 -timeout=10m -run TestIngressNginx" make e2e +``` + +>NOTE: When using a `$` in `-run` as part of a regex, the entire string should be single-quoted AND +>the `$` should be Make-escaped as `$$`. + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `I2GW_KUBECONFIG` | Path to kubeconfig for an existing cluster. If unset, a kind cluster is created. | +| `I2GW_GO_TEST_ARGS` | Custom arguments to pass to `go test`. Default: `-race -v -count=1 -timeout=30m`. | +| `SKIP_CLEANUP` | Set to `1` to skip cleanup of test resources and kind cluster after tests. | + +### Cleanup + +To manually delete a leftover kind cluster: + +```bash +make clean-kind +``` + +## Writing new tests + +### Test organization + +Each test combines three dimensions: a **provider** (reads ingresses and converts them to +an intermediate representation), an **emitter** (transforms the IR into Gateway API +resources and optional implementation-specific CRDs) and a gateway **implementation** +(routes traffic based on those resources). + +Rather than testing every (provider × emitter × implementation) combination, the tests are +organized into 5 categories that exercise each dimension independently to maximize coverage +while minimizing repetition. The key insight behind this design is that providers and +implementations are decoupled by the Gateway API contract: the intermediate resources +(`HTTPRoute`, `Gateway`, etc.) are identical regardless of which provider produced them. +This means that if Category 1 proves a provider can emit correct Gateway API resources, and +Category 5 proves an implementation can route traffic from those resources, the +provider → implementation combination is transitively covered without a dedicated test. +The only exception is emitter-specific CRDs (Category 4), which are inherently tied to a +particular implementation and therefore require explicit pairing. + +#### Category 1 — provider smoke tests (`provider_test.go`) + +One test per provider, all using the Istio implementation and the standard emitter. Validates +that each provider's core ingress → Gateway API conversion works. + +#### Category 2 — provider features (`provider__test.go`) + +One test per provider-specific annotation or feature using the Istio implementation and the +standard emitter. Only the provider that owns the feature is used. Validates that the feature is +correctly converted to Gateway API resources. + +#### Category 3 — multi-provider (`multiprovider_test.go`) + +Tests that combine multiple providers in a single conversion (e.g. ingress-nginx + kong) +using the Istio implementation and the standard emitter. Ensures that multiple providers can be +used together without conflicts. + +#### Category 4 — emitter features (TODO) + +One test per emitter-specific feature using the ingress-nginx provider paired with the +emitter's corresponding implementation (e.g. `envoygateway` emitter with Envoy Gateway). +Validates emitter-specific CRDs like `BackendTrafficPolicy` or `GCPGatewayPolicy`. + +Sample future files: `emitter_envoygateway_test.go`, `emitter_kgateway_test.go` etc. + +#### Category 5 — implementation smoke tests (`implementation_test.go`) + +One test per gateway implementation, all using the ingress-nginx provider and the standard +emitter. Because gateway implementations only consume standard Gateway API resources, a single +well-tested provider (ingress-nginx, already proven by Categories 1 and 2) is enough. +Using the same provider across all implementations keeps fault isolation clean: a failure in +this category can only be caused by the implementation, never the provider. + +### Directory structure + +``` +e2e/ +├── framework/ # Shared test infrastructure (no _test.go files) +├── provider/ # Provider deployment helpers — one .go file per provider +├── implementation/ # Gateway implementation deployment helpers — one .go file per implementation +├── helpers.go # runTestCase wrapper that wires providers + implementations to the framework +├── *_test.go # Test files, organized by category (see above) +└── README.md +``` + +### Auto-generated host field + +Setting the `Host` field in ingress rules and in verifiers is optional. When omitted, a random +host is generated and used automatically for all ingress objects and verifiers in the test case. +Most test cases likely don't need an explicit `Host` value since the value doesn't matter as long +as the verifier verifies the correct host. + +If a specific `Host` value **is** important for a test case, pay attention to duplicate host values +across test cases: while Kubernetes allows defining multiple ingress objects with identical host +values, whether doing so makes sense (or even works) depends on the ingress controller and can +influence test results. diff --git a/e2e/framework/clients.go b/e2e/framework/clients.go new file mode 100644 index 000000000..dd474840f --- /dev/null +++ b/e2e/framework/clients.go @@ -0,0 +1,85 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + gwclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" +) + +// Accepts a path to a kubeconfig file and returns a k8s client set. +func newClientFromKubeconfigPath(path string) (*kubernetes.Clientset, error) { + cc, err := configFromKubeconfigPath(path) + if err != nil { + return nil, err + } + + return kubernetes.NewForConfig(cc) +} + +// Accepts a path to a kubeconfig file and returns a rest.Config. This is useful for operations +// that need direct access to the rest config, such as port forwarding. +func newRestConfigFromKubeconfigPath(path string) (*rest.Config, error) { + return configFromKubeconfigPath(path) +} + +// Accepts a path to a kubeconfig file and returns a Gateway API client set. +func newGatewayClientFromKubeconfigPath(path string) (*gwclientset.Clientset, error) { + cc, err := configFromKubeconfigPath(path) + if err != nil { + return nil, err + } + + return gwclientset.NewForConfig(cc) +} + +// Accepts a path to a kubeconfig file and returns an API extensions client set. +func newAPIExtensionsClientFromKubeconfigPath(path string) (*apiextensionsclientset.Clientset, error) { + cc, err := configFromKubeconfigPath(path) + if err != nil { + return nil, err + } + + return apiextensionsclientset.NewForConfig(cc) +} + +// Accepts a path to a kubeconfig file and returns a rest config. +// Configures increased QPS and Burst for parallel test execution. +func configFromKubeconfigPath(path string) (*rest.Config, error) { + rules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: path} + + cfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + rules, + &clientcmd.ConfigOverrides{}, + ) + + restConfig, err := cfg.ClientConfig() + if err != nil { + return nil, err + } + + // Increase rate limits for parallel test execution. + // Default QPS is 5, Burst is 10, which is too low for parallel e2e tests + // that make many API calls concurrently. + restConfig.QPS = 50 + restConfig.Burst = 100 + + return restConfig, nil +} diff --git a/e2e/framework/configmaps.go b/e2e/framework/configmaps.go new file mode 100644 index 000000000..5e3b78e84 --- /dev/null +++ b/e2e/framework/configmaps.go @@ -0,0 +1,71 @@ +/* +Copyright The Kubernetes Authors. + +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 framework + +import ( + "context" + "fmt" + "log" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +func createConfigMaps(ctx context.Context, l Logger, client *kubernetes.Clientset, ns string, configMaps []*corev1.ConfigMap, skipCleanup bool) (func(), error) { + for _, cm := range configMaps { + if cm.Namespace == "" { + cm.Namespace = ns + } + + y, err := toYAML(cm) + if err != nil { + return nil, fmt.Errorf("converting configmap to YAML: %w", err) + } + + l.Logf("Creating configmap:\n%s", y) + + _, err = client.CoreV1().ConfigMaps(cm.Namespace).Create(ctx, cm, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("creating configmap %s/%s: %w", cm.Namespace, cm.Name, err) + } + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + log.Printf("Skipping cleanup of configmaps") + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, cm := range configMaps { + namespace := cm.Namespace + if namespace == "" { + namespace = ns + } + log.Printf("Deleting configmap %s/%s", namespace, cm.Name) + err := client.CoreV1().ConfigMaps(namespace).Delete(cleanupCtx, cm.Name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting configmap %s: %v", cm.Name, err) + } + } + }, nil +} diff --git a/e2e/framework/crd.go b/e2e/framework/crd.go new file mode 100644 index 000000000..8908c0356 --- /dev/null +++ b/e2e/framework/crd.go @@ -0,0 +1,168 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "time" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/yaml" +) + +// DeployCRDs fetches and installs CRDs specified by the given URL. Returns a cleanup function. +func DeployCRDs(ctx context.Context, l Logger, client *apiextensionsclientset.Clientset, url string, skipCleanup bool) (CleanupFunc, error) { + l.Logf("Fetching manifests from %s", url) + yamlData, err := fetchManifests(ctx, l, url) + if err != nil { + return nil, fmt.Errorf("fetching manifests from %s: %w", url, err) + } + + crds, err := decodeCRDs(yamlData) + if err != nil { + return nil, fmt.Errorf("decoding CRDs: %w", err) + } + + for _, crd := range crds { + crd.TypeMeta = metav1.TypeMeta{ + APIVersion: "apiextensions.k8s.io/v1", + Kind: "CustomResourceDefinition", + } + data, err := json.Marshal(crd) + if err != nil { + return nil, fmt.Errorf("converting CRD %s to JSON: %w", crd.Name, err) + } + + // Use server-side apply. + if _, err = client.ApiextensionsV1().CustomResourceDefinitions().Patch(ctx, crd.Name, types.ApplyPatchType, data, metav1.PatchOptions{ + FieldManager: "ingress2gateway-e2e", + }); err != nil { + return nil, fmt.Errorf("applying CRD %s: %w", crd.Name, err) + } + l.Logf("Applied CRD %s", crd.Name) + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + log.Printf("Skipping cleanup of CRDs") + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, crd := range crds { + log.Printf("Deleting CRD %s", crd.Name) + if err := client.ApiextensionsV1().CustomResourceDefinitions().Delete(cleanupCtx, crd.Name, metav1.DeleteOptions{}); err != nil { + log.Printf("Deleting CRD %s: %v", crd.Name, err) + } + } + }, nil +} + +func decodeCRDs(yamlData []byte) ([]apiextensionsv1.CustomResourceDefinition, error) { + objs, err := decodeManifests(yamlData) + if err != nil { + return nil, fmt.Errorf("decoding manifests: %w", err) + } + + var out []apiextensionsv1.CustomResourceDefinition + + for _, obj := range objs { + if obj.GetKind() != "CustomResourceDefinition" { + continue + } + + var crd apiextensionsv1.CustomResourceDefinition + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &crd); err != nil { + return nil, fmt.Errorf("converting object: %w", err) + } + + if crd.Name == "" { + continue + } + out = append(out, crd) + } + + return out, nil +} + +func fetchManifests(ctx context.Context, log Logger, url string) ([]byte, error) { + return retryWithData(ctx, log, defaultRetryConfig(), + func(attempt, maxAttempts int, err error) string { + return fmt.Sprintf("Fetching manifests (attempt %d/%d): %v", attempt, maxAttempts, err) + }, + func() ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("getting manifests: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %s", resp.Status) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response data: %w", err) + } + + return data, nil + }, + ) +} + +func decodeManifests(data []byte) ([]unstructured.Unstructured, error) { + var out []unstructured.Unstructured + decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(data), 4096) + + for { + var obj unstructured.Unstructured + err := decoder.Decode(&obj) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("decoding object: %w", err) + } + if obj.Object == nil { + continue + } + out = append(out, obj) + } + + return out, nil +} diff --git a/e2e/framework/dummy_app.go b/e2e/framework/dummy_app.go new file mode 100644 index 000000000..26e45c674 --- /dev/null +++ b/e2e/framework/dummy_app.go @@ -0,0 +1,206 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "context" + "fmt" + "log" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" +) + +const ( + image = "registry.k8s.io/e2e-test-images/agnhost" + version = "2.39" +) + +// DeployDummyApp deploys a dummy backend application. If serverSecretName is +// non-empty the app is deployed with TLS, mounting the named secret. +func DeployDummyApp(ctx context.Context, l Logger, client *kubernetes.Clientset, name, namespace string, skipCleanup bool, serverSecretName string) (func(), error) { + return deployDummyApp(ctx, l, client, name, namespace, skipCleanup, serverSecretName) +} + +// Creates a dummy backend application for testing and returns a cleanup function. +func deployDummyApp(ctx context.Context, l Logger, client *kubernetes.Clientset, name, namespace string, skipCleanup bool, serverSecretName string) (func(), error) { + if err := createDummyAppDeployment(ctx, l, client, name, namespace, serverSecretName); err != nil { + return nil, fmt.Errorf("creating deployment: %w", err) + } + + if err := createDummyAppService(ctx, client, name, namespace, serverSecretName != ""); err != nil { + return nil, fmt.Errorf("creating service: %w", err) + } + + if err := waitForDummyApp(ctx, l, client, name, namespace); err != nil { + return nil, fmt.Errorf("waiting for dummy app: %w", err) + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + log.Printf("Skipping cleanup of dummy app") + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + log.Printf("Deleting dummy app %s", name) + err := client.CoreV1().Services(namespace).Delete(cleanupCtx, name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting service %s: %v", name, err) + } + + err = client.AppsV1().Deployments(namespace).Delete(cleanupCtx, name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting deployment %s: %v", name, err) + } + }, nil +} + +func createDummyAppDeployment(ctx context.Context, l Logger, client *kubernetes.Clientset, name, namespace, serverSecretName string) error { + labels := map[string]string{"app": name} + + l.Logf("Creating dummy app %s", name) + + containerArgs := []string{"netexec", "--http-port=8080"} + portName := "http" + if serverSecretName != "" { + containerArgs = append(containerArgs, + "--tls-cert-file=/etc/tls/tls.crt", + "--tls-private-key-file=/etc/tls/tls.key", + ) + portName = "https" + } + + volumeMount := corev1.VolumeMount{ + Name: "tls-certs", + MountPath: "/etc/tls", + ReadOnly: true, + } + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: name, + Image: fmt.Sprintf("%s:%s", image, version), + Args: containerArgs, + Ports: []corev1.ContainerPort{ + { + Name: portName, + ContainerPort: 8080, + }, + }, + }, + }, + }, + }, + }, + } + + if serverSecretName != "" { + deployment.Spec.Template.Spec.Containers[0].VolumeMounts = []corev1.VolumeMount{volumeMount} + deployment.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "tls-certs", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: serverSecretName, + }, + }, + }, + } + } + + if _, err := client.AppsV1().Deployments(namespace).Create(ctx, deployment, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("creating deployment: %w", err) + } + + return nil +} + +func createDummyAppService(ctx context.Context, client *kubernetes.Clientset, name, namespace string, useTLS bool) error { + servicePortName := "http" + var servicePort int32 = 80 + if useTLS { + servicePortName = "https" + servicePort = 443 + } + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": name}, + Ports: []corev1.ServicePort{ + { + Name: servicePortName, + Port: servicePort, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + + if _, err := client.CoreV1().Services(namespace).Create(ctx, service, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("creating service: %w", err) + } + + return nil +} + +func waitForDummyApp(ctx context.Context, l Logger, client *kubernetes.Clientset, name, namespace string) error { + l.Logf("Waiting for dummy app to be ready") + err := wait.PollUntilContextTimeout(ctx, 1*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + dep, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + //nolint:nilerr // Wait function - we deliberately return a nil error here + return false, nil + } + for _, cond := range dep.Status.Conditions { + if cond.Type == appsv1.DeploymentAvailable && cond.Status == corev1.ConditionTrue { + return true, nil + } + } + return false, nil + }) + if err != nil { + return fmt.Errorf("waiting for deployment: %w", err) + } + + return nil +} diff --git a/e2e/framework/gwapi.go b/e2e/framework/gwapi.go new file mode 100644 index 000000000..76c3703b8 --- /dev/null +++ b/e2e/framework/gwapi.go @@ -0,0 +1,540 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/apis/v1alpha2" + "sigs.k8s.io/gateway-api/apis/v1beta1" + gwclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" +) + +const ( + gatewayAPIVersion = "v1.5.0" + gatewayAPIInstallURL = "https://github.com/kgateway-dev/gateway-api/releases/download/" + gatewayAPIVersion + "/experimental-install.yaml" +) + +// Creates all Gateway API resources and returns a cleanup function. +func createGatewayResources( + ctx context.Context, + l Logger, + client *gwclientset.Clientset, + ns string, + res []i2gw.GatewayResources, + skipCleanup bool, +) (func(), error) { + var cleanupFuncs []func() + for _, r := range res { + cleanup, err := createGatewayClasses(ctx, l, client, r.GatewayClasses, skipCleanup) + if err != nil { + return nil, fmt.Errorf("creating gateway classes: %w", err) + } + cleanupFuncs = append(cleanupFuncs, cleanup) + + cleanup, err = createGateways(ctx, l, client, ns, r.Gateways, skipCleanup) + if err != nil { + return nil, fmt.Errorf("creating gateways: %w", err) + } + cleanupFuncs = append(cleanupFuncs, cleanup) + + cleanup, err = createHTTPRoutes(ctx, l, client, ns, r.HTTPRoutes, skipCleanup) + if err != nil { + return nil, fmt.Errorf("creating http routes: %w", err) + } + cleanupFuncs = append(cleanupFuncs, cleanup) + + cleanup, err = createGRPCRoutes(ctx, l, client, ns, r.GRPCRoutes, skipCleanup) + if err != nil { + return nil, fmt.Errorf("creating grpc routes: %w", err) + } + cleanupFuncs = append(cleanupFuncs, cleanup) + + cleanup, err = createTLSRoutes(ctx, l, client, ns, r.TLSRoutes, skipCleanup) + if err != nil { + return nil, fmt.Errorf("creating tls routes: %w", err) + } + cleanupFuncs = append(cleanupFuncs, cleanup) + + cleanup, err = createTCPRoutes(ctx, l, client, ns, r.TCPRoutes, skipCleanup) + if err != nil { + return nil, fmt.Errorf("creating tcp routes: %w", err) + } + cleanupFuncs = append(cleanupFuncs, cleanup) + + cleanup, err = createUDPRoutes(ctx, l, client, ns, r.UDPRoutes, skipCleanup) + if err != nil { + return nil, fmt.Errorf("creating udp routes: %w", err) + } + cleanupFuncs = append(cleanupFuncs, cleanup) + + cleanup, err = createBackendTLSPolicies(ctx, l, client, ns, r.BackendTLSPolicies, skipCleanup) + if err != nil { + return nil, fmt.Errorf("creating backend tls policies: %w", err) + } + cleanupFuncs = append(cleanupFuncs, cleanup) + + cleanup, err = createReferenceGrants(ctx, l, client, ns, r.ReferenceGrants, skipCleanup) + if err != nil { + return nil, fmt.Errorf("creating reference grants: %w", err) + } + cleanupFuncs = append(cleanupFuncs, cleanup) + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + l.Logf("Skipping cleanup of gateway resources") + return + } + for _, f := range cleanupFuncs { + f() + } + }, nil +} + +func createGateways(ctx context.Context, l Logger, client *gwclientset.Clientset, ns string, gws map[types.NamespacedName]gwapiv1.Gateway, skipCleanup bool) (func(), error) { + for name, gw := range gws { + // Ensure the namespace is set correctly. + if gw.Namespace == "" { + gw.Namespace = ns + } + + y, err := toYAML(&gw) + if err != nil { + return nil, fmt.Errorf("converting gateway to YAML: %w", err) + } + + l.Logf("Creating Gateway:\n%s", y) + + _, err = client.GatewayV1().Gateways(gw.Namespace).Create( + ctx, + &gw, + metav1.CreateOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("creating Gateway %s: %w", name.String(), err) + } + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, gw := range gws { + namespace := gw.Namespace + if namespace == "" { + namespace = ns + } + log.Printf("Deleting Gateway %s/%s", namespace, gw.Name) + err := client.GatewayV1().Gateways(namespace).Delete(cleanupCtx, gw.Name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting Gateway %s: %v", gw.Name, err) + } + } + }, nil +} + +func createGatewayClasses(ctx context.Context, l Logger, client *gwclientset.Clientset, gcs map[types.NamespacedName]gwapiv1.GatewayClass, skipCleanup bool) (func(), error) { + for name, gc := range gcs { + y, err := toYAML(&gc) + if err != nil { + return nil, fmt.Errorf("converting gateway class to YAML: %w", err) + } + + l.Logf("Creating GatewayClass:\n%s", y) + + _, err = client.GatewayV1().GatewayClasses().Create( + ctx, + &gc, + metav1.CreateOptions{}, + ) + if errors.IsAlreadyExists(err) { + _, err = client.GatewayV1().GatewayClasses().Update(ctx, &gc, metav1.UpdateOptions{}) + } + if err != nil { + return nil, fmt.Errorf("creating GatewayClass %s: %w", name.String(), err) + } + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, gc := range gcs { + log.Printf("Deleting GatewayClass %s", gc.Name) + err := client.GatewayV1().GatewayClasses().Delete(cleanupCtx, gc.Name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting GatewayClass %s: %v", gc.Name, err) + } + } + }, nil +} + +func createHTTPRoutes(ctx context.Context, l Logger, client *gwclientset.Clientset, ns string, routes map[types.NamespacedName]gwapiv1.HTTPRoute, skipCleanup bool) (func(), error) { + for name, route := range routes { + if route.Namespace == "" { + route.Namespace = ns + } + + y, err := toYAML(&route) + if err != nil { + return nil, fmt.Errorf("converting http route to YAML: %w", err) + } + + l.Logf("Creating HTTPRoute:\n%s", y) + + _, err = client.GatewayV1().HTTPRoutes(route.Namespace).Create( + ctx, + &route, + metav1.CreateOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("creating HTTPRoute %s: %w", name.String(), err) + } + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, route := range routes { + namespace := route.Namespace + if namespace == "" { + namespace = ns + } + log.Printf("Deleting HTTPRoute %s/%s", namespace, route.Name) + err := client.GatewayV1().HTTPRoutes(namespace).Delete(cleanupCtx, route.Name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting HTTPRoute %s: %v", route.Name, err) + } + } + }, nil +} + +func createGRPCRoutes(ctx context.Context, l Logger, client *gwclientset.Clientset, ns string, routes map[types.NamespacedName]gwapiv1.GRPCRoute, skipCleanup bool) (func(), error) { + for name, route := range routes { + if route.Namespace == "" { + route.Namespace = ns + } + + y, err := toYAML(&route) + if err != nil { + return nil, fmt.Errorf("converting grpc route to YAML: %w", err) + } + + l.Logf("Creating GRPCRoute:\n%s", y) + + _, err = client.GatewayV1().GRPCRoutes(route.Namespace).Create( + ctx, + &route, + metav1.CreateOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("creating GRPCRoute %s: %w", name.String(), err) + } + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, route := range routes { + namespace := route.Namespace + if namespace == "" { + namespace = ns + } + log.Printf("Deleting GRPCRoute %s/%s", namespace, route.Name) + err := client.GatewayV1().GRPCRoutes(namespace).Delete(cleanupCtx, route.Name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting GRPCRoute %s: %v", route.Name, err) + } + } + }, nil +} + +func createTLSRoutes(ctx context.Context, l Logger, client *gwclientset.Clientset, ns string, routes map[types.NamespacedName]v1alpha2.TLSRoute, skipCleanup bool) (func(), error) { + for name, route := range routes { + if route.Namespace == "" { + route.Namespace = ns + } + + y, err := toYAML(&route) + if err != nil { + return nil, fmt.Errorf("converting tls route to YAML: %w", err) + } + + l.Logf("Creating TLSRoute:\n%s", y) + + _, err = client.GatewayV1alpha2().TLSRoutes(route.Namespace).Create( + ctx, + &route, + metav1.CreateOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("creating TLSRoute %s: %w", name.String(), err) + } + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, route := range routes { + namespace := route.Namespace + if namespace == "" { + namespace = ns + } + log.Printf("Deleting TLSRoute %s/%s", namespace, route.Name) + err := client.GatewayV1alpha2().TLSRoutes(namespace).Delete(cleanupCtx, route.Name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting TLSRoute %s: %v", route.Name, err) + } + } + }, nil +} + +func createTCPRoutes(ctx context.Context, l Logger, client *gwclientset.Clientset, ns string, routes map[types.NamespacedName]v1alpha2.TCPRoute, skipCleanup bool) (func(), error) { + for name, route := range routes { + if route.Namespace == "" { + route.Namespace = ns + } + + y, err := toYAML(&route) + if err != nil { + return nil, fmt.Errorf("converting tcp route to YAML: %w", err) + } + + l.Logf("Creating TCPRoute:\n%s", y) + + _, err = client.GatewayV1alpha2().TCPRoutes(route.Namespace).Create( + ctx, + &route, + metav1.CreateOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("creating TCPRoute %s: %w", name.String(), err) + } + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, route := range routes { + namespace := route.Namespace + if namespace == "" { + namespace = ns + } + log.Printf("Deleting TCPRoute %s/%s", namespace, route.Name) + err := client.GatewayV1alpha2().TCPRoutes(namespace).Delete(cleanupCtx, route.Name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting TCPRoute %s: %v", route.Name, err) + } + } + }, nil +} + +func createUDPRoutes(ctx context.Context, l Logger, client *gwclientset.Clientset, ns string, routes map[types.NamespacedName]v1alpha2.UDPRoute, skipCleanup bool) (func(), error) { + for name, route := range routes { + if route.Namespace == "" { + route.Namespace = ns + } + + y, err := toYAML(&route) + if err != nil { + return nil, fmt.Errorf("converting udp route to YAML: %w", err) + } + + l.Logf("Creating UDPRoute:\n%s", y) + + _, err = client.GatewayV1alpha2().UDPRoutes(route.Namespace).Create( + ctx, + &route, + metav1.CreateOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("creating UDPRoute %s: %w", name.String(), err) + } + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, route := range routes { + namespace := route.Namespace + if namespace == "" { + namespace = ns + } + log.Printf("Deleting UDPRoute %s/%s", namespace, route.Name) + err := client.GatewayV1alpha2().UDPRoutes(namespace).Delete(cleanupCtx, route.Name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting UDPRoute %s: %v", route.Name, err) + } + } + }, nil +} + +func createBackendTLSPolicies(ctx context.Context, l Logger, client *gwclientset.Clientset, ns string, policies map[types.NamespacedName]gwapiv1.BackendTLSPolicy, skipCleanup bool) (func(), error) { + for name, policy := range policies { + if policy.Namespace == "" { + policy.Namespace = ns + } + + y, err := toYAML(&policy) + if err != nil { + return nil, fmt.Errorf("converting backend tls policy to YAML: %w", err) + } + + l.Logf("Creating BackendTLSPolicy:\n%s", y) + + _, err = client.GatewayV1().BackendTLSPolicies(policy.Namespace).Create( + ctx, + &policy, + metav1.CreateOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("creating BackendTLSPolicy %s: %w", name.String(), err) + } + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, policy := range policies { + namespace := policy.Namespace + if namespace == "" { + namespace = ns + } + log.Printf("Deleting BackendTLSPolicy %s/%s", namespace, policy.Name) + err := client.GatewayV1().BackendTLSPolicies(namespace).Delete(cleanupCtx, policy.Name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting BackendTLSPolicy %s: %v", policy.Name, err) + } + } + }, nil +} + +func createReferenceGrants(ctx context.Context, l Logger, client *gwclientset.Clientset, ns string, grants map[types.NamespacedName]v1beta1.ReferenceGrant, skipCleanup bool) (func(), error) { + for name, grant := range grants { + if grant.Namespace == "" { + grant.Namespace = ns + } + + y, err := toYAML(&grant) + if err != nil { + return nil, fmt.Errorf("converting reference grant to YAML: %w", err) + } + + l.Logf("Creating ReferenceGrant:\n%s", y) + + _, err = client.GatewayV1beta1().ReferenceGrants(grant.Namespace).Create( + ctx, + &grant, + metav1.CreateOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("creating ReferenceGrant %s: %w", name.String(), err) + } + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, grant := range grants { + namespace := grant.Namespace + if namespace == "" { + namespace = ns + } + log.Printf("Deleting ReferenceGrant %s/%s", namespace, grant.Name) + err := client.GatewayV1beta1().ReferenceGrants(namespace).Delete(cleanupCtx, grant.Name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting ReferenceGrant %s: %v", grant.Name, err) + } + } + }, nil +} + +// Extracts all Gateway resources from the specified GatewayResources slice and returns them as a +// map of namespaced names to Gateways. +func getGateways(res []i2gw.GatewayResources) map[types.NamespacedName]gwapiv1.Gateway { + gateways := make(map[types.NamespacedName]gwapiv1.Gateway) + + for _, r := range res { + for k, v := range r.Gateways { + gateways[k] = v + } + } + + return gateways +} diff --git a/e2e/framework/helm.go b/e2e/framework/helm.go new file mode 100644 index 000000000..858b26aac --- /dev/null +++ b/e2e/framework/helm.go @@ -0,0 +1,154 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "context" + "fmt" + "io" + "os" + "time" + + "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/chart/loader" + "helm.sh/helm/v4/pkg/cli" + "helm.sh/helm/v4/pkg/kube" + "helm.sh/helm/v4/pkg/registry" +) + +// InstallChart installs a Helm chart with the given configuration. +func InstallChart( + ctx context.Context, + log Logger, + settings *cli.EnvSettings, + repoURL string, + releaseName string, + chartName string, + version string, + namespace string, + createNamespace bool, + skipCRDs bool, + values map[string]interface{}, +) error { + cfg := new(action.Configuration) + if err := cfg.Init(settings.RESTClientGetter(), namespace, os.Getenv("HELM_DRIVER")); err != nil { + return fmt.Errorf("initializing helm config: %w", err) + } + + // Check if release already exists. + status := action.NewStatus(cfg) + if _, err := status.Run(releaseName); err == nil { + log.Logf("Release %q already exists, skipping installation", releaseName) + return nil + } + + install := action.NewInstall(cfg) + install.ReleaseName = releaseName + install.Namespace = namespace + install.CreateNamespace = createNamespace + install.SkipCRDs = skipCRDs + install.WaitStrategy = kube.StatusWatcherStrategy + install.Timeout = 5 * time.Minute + install.RepoURL = repoURL + install.Version = version + + if registry.IsOCI(chartName) { + registryClient, err := newRegistryClient(settings) + if err != nil { + return fmt.Errorf("creating registry client for OCI chart: %w", err) + } + install.SetRegistryClient(registryClient) + } + + cp, err := locateChart(ctx, log, install, chartName, settings) + if err != nil { + return fmt.Errorf("locating chart: %w", err) + } + + chartRequested, err := loader.Load(cp) + if err != nil { + return fmt.Errorf("loading chart: %w", err) + } + + _, err = install.RunWithContext(ctx, chartRequested, values) + if err != nil { + return fmt.Errorf("running install: %w", err) + } + + return nil +} + +// UninstallChart uninstalls a Helm release. +func UninstallChart(ctx context.Context, settings *cli.EnvSettings, releaseName, namespace string) error { + // Helm's Uninstall action doesn't support context so we can only check before starting. + if err := ctx.Err(); err != nil { + return fmt.Errorf("context canceled before uninstall: %w", err) + } + + cfg := new(action.Configuration) + + if err := cfg.Init(settings.RESTClientGetter(), namespace, os.Getenv("HELM_DRIVER")); err != nil { + return fmt.Errorf("Initializing helm config: %w", err) + } + + uninstall := action.NewUninstall(cfg) + uninstall.WaitStrategy = kube.StatusWatcherStrategy + // The default deletion propagation mode is "background", which may cause some resources to be + // left behind for a while, which in turn may lead to "stuck" namespace deletions after + // removing a release. + // Relevant issue: https://github.com/helm/helm/issues/31651 + uninstall.DeletionPropagation = "foreground" + uninstall.Timeout = 5 * time.Minute + + _, err := uninstall.Run(releaseName) + if err != nil { + return fmt.Errorf("Uninstalling %s: %w", releaseName, err) + } + + return nil +} + +func locateChart( + ctx context.Context, + log Logger, + install *action.Install, + chartName string, + settings *cli.EnvSettings, +) (string, error) { + // Helm masks the underlying HTTP errors and status codes so we can't easily distinguish + // transient errors (e.g. 503) from permanent errors (e.g. 404). Rather than relying on + // fragile string parsing, we treat all errors as transient failures. This isn't a big + // problem since the whole retry process is fairly short. + return retryWithData(ctx, log, defaultRetryConfig(), + func(attempt, maxAttempts int, err error) string { + return fmt.Sprintf("Locating chart (attempt %d/%d): %v", attempt, maxAttempts, err) + }, + func() (string, error) { + return install.ChartPathOptions.LocateChart(chartName, settings) + }, + ) +} + +func newRegistryClient(settings *cli.EnvSettings) (*registry.Client, error) { + opts := []registry.ClientOption{ + registry.ClientOptDebug(settings.Debug), + registry.ClientOptEnableCache(true), + registry.ClientOptWriter(io.Discard), + registry.ClientOptCredentialsFile(settings.RegistryConfig), + } + return registry.NewClient(opts...) +} diff --git a/e2e/framework/ingresses.go b/e2e/framework/ingresses.go new file mode 100644 index 000000000..477babdb0 --- /dev/null +++ b/e2e/framework/ingresses.go @@ -0,0 +1,74 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "context" + "fmt" + "log" + "time" + + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// Creates Kubernetes Ingress resources and returns a cleanup function. +func createIngresses(ctx context.Context, l Logger, client *kubernetes.Clientset, ns string, ingresses []*networkingv1.Ingress, skipCleanup bool) (func(), error) { + for _, ingress := range ingresses { + // Add ingress class label for admission webhook selectors. This allows ingress controllers + // to configure their admission webhooks to only validate ingresses with matching labels, + // avoiding cross-controller interference in parallel test scenarios. + if ingress.Spec.IngressClassName != nil { + if ingress.Labels == nil { + ingress.Labels = make(map[string]string) + } + ingress.Labels["app.kubernetes.io/ingress-class"] = *ingress.Spec.IngressClassName + } + + y, err := toYAML(ingress) + if err != nil { + return nil, fmt.Errorf("converting ingress to YAML: %w", err) + } + + l.Logf("Creating ingress:\n%s", y) + + _, err = client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("creating ingress: %w", err) + } + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + log.Printf("Skipping cleanup of ingresses") + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, ingress := range ingresses { + log.Printf("Deleting ingress %s", ingress.Name) + err := client.NetworkingV1().Ingresses(ns).Delete(cleanupCtx, ingress.Name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting ingress %s: %v", ingress.Name, err) + } + } + }, nil +} diff --git a/pkg/i2gw/providers/ingressnginx/notification.go b/e2e/framework/log.go similarity index 54% rename from pkg/i2gw/providers/ingressnginx/notification.go rename to e2e/framework/log.go index 316a31394..82ebb42e3 100644 --- a/pkg/i2gw/providers/ingressnginx/notification.go +++ b/e2e/framework/log.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,14 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -package ingressnginx +package framework -import ( - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -func notify(mType notifications.MessageType, message string, callingObject ...client.Object) { - newNotification := notifications.NewNotification(mType, message, callingObject...) - notifications.NotificationAggr.DispatchNotification(newNotification, string(Name)) +// Logger is an interface used by e2e test helpers. The testing.T type implements it. +type Logger interface { + Logf(format string, args ...interface{}) } diff --git a/e2e/framework/namespaces.go b/e2e/framework/namespaces.go new file mode 100644 index 000000000..a36f43062 --- /dev/null +++ b/e2e/framework/namespaces.go @@ -0,0 +1,113 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "context" + "fmt" + "log" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" +) + +// CreateNamespace creates a Kubernetes namespace and returns a cleanup function. +func CreateNamespace(ctx context.Context, l Logger, client *kubernetes.Clientset, ns string, skipCleanup bool) (func(), error) { + // Check if namespace already exists. This should be very rare since we use a random suffix, + // but we check just in case to avoid flaky tests due to conflicts. + _, err := client.CoreV1().Namespaces().Get(ctx, ns, metav1.GetOptions{}) + if err == nil { + return nil, fmt.Errorf("namespace %s already exists", ns) + } + + l.Logf("Creating namespace %s", ns) + _, err = client.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: ns, + }, + }, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("creating namespace %s: %w", ns, err) + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + log.Printf("Skipping cleanup of namespace %s", ns) + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + log.Printf("Cleaning up namespace %s", ns) + if err := DeleteNamespaceAndWait(cleanupCtx, client, ns); err != nil { + log.Printf("Deleting namespace %s: %v", ns, err) + } + }, nil +} + +// DeleteNamespaceAndWait deletes a namespace and waits for it to be fully removed. +func DeleteNamespaceAndWait(ctx context.Context, client *kubernetes.Clientset, ns string) error { + if err := client.CoreV1().Namespaces().Delete(ctx, ns, metav1.DeleteOptions{}); err != nil { + if errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("deleting namespace %s: %w", ns, err) + } + + lastNudge := time.Time{} + if err := wait.PollUntilContextTimeout(ctx, 1*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + nsObj, err := client.CoreV1().Namespaces().Get(ctx, ns, metav1.GetOptions{}) + if errors.IsNotFound(err) { + return true, nil + } + if err != nil { + return false, nil //nolint:nilerr // Keep polling through transient errors + } + + // If the namespace has been stuck in Terminating, periodically annotate it to trigger a + // watch event. The namespace controller enqueues namespaces via queue.Add (not + // AddRateLimited) on watch events, so this bypasses any exponential backoff the controller + // may be in after a transient error during finalization. We nudge every 10s because a + // single nudge may itself trigger a transient error, putting the controller back into + // backoff. + if nsObj.DeletionTimestamp != nil && + time.Since(nsObj.DeletionTimestamp.Time) > 10*time.Second && + time.Since(lastNudge) > 10*time.Second { + lastNudge = time.Now() + log.Printf("Namespace %s stuck in Terminating, nudging namespace controller", ns) + if nsObj.Annotations == nil { + nsObj.Annotations = map[string]string{} + } + nsObj.Annotations["e2e.ingress2gateway/nudge"] = time.Now().UTC().Format(time.RFC3339) + if _, updateErr := client.CoreV1().Namespaces().Update(ctx, nsObj, metav1.UpdateOptions{}); updateErr != nil { + log.Printf("Failed to nudge namespace %s: %v", ns, updateErr) + } + } + + return false, nil + }); err != nil { + return fmt.Errorf("waiting for namespace %s to delete: %w", ns, err) + } + + return nil +} diff --git a/e2e/framework/portforward.go b/e2e/framework/portforward.go new file mode 100644 index 000000000..12b48bdd5 --- /dev/null +++ b/e2e/framework/portforward.go @@ -0,0 +1,344 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/ingressnginx" + "golang.org/x/sync/semaphore" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" +) + +// Limits the number of concurrent port forward connections to avoid exhausting API server +// resources when running tests in parallel. +const maxConcurrentPortForwards = 50 + +// A package-level semaphore to limit concurrent port forwards. +var portForwardSem = semaphore.NewWeighted(maxConcurrentPortForwards) + +// Manages a port forward connection to a Kubernetes pod using client-go. +type portForwarder struct { + stopChan chan struct{} + pf *portforward.PortForwarder + localPort uint16 + releaseSem func() +} + +// Creates a port forward to a service's backing pod using client-go. The caller must call stop() +// when done to release resources. +func startPortForwardToService( + ctx context.Context, + client *kubernetes.Clientset, + restConfig *rest.Config, + namespace string, + serviceName string, + servicePort int, +) (*portForwarder, string, error) { + if err := portForwardSem.Acquire(ctx, 1); err != nil { + return nil, "", fmt.Errorf("acquiring port forward semaphore: %w", err) + } + + pf, addr, err := startPortForward(ctx, client, restConfig, namespace, serviceName, servicePort) + if err != nil { + portForwardSem.Release(1) + return nil, "", err + } + + pf.releaseSem = func() { portForwardSem.Release(1) } + return pf, addr, nil +} + +func startPortForward( + ctx context.Context, + client *kubernetes.Clientset, + restConfig *rest.Config, + namespace string, + serviceName string, + servicePort int, +) (*portForwarder, string, error) { + svc, err := client.CoreV1().Services(namespace).Get(ctx, serviceName, metav1.GetOptions{}) + if err != nil { + return nil, "", fmt.Errorf("getting service %s/%s: %w", namespace, serviceName, err) + } + + if len(svc.Spec.Selector) == 0 { + return nil, "", fmt.Errorf("service %s/%s has no selector", namespace, serviceName) + } + + // List pods for service. + pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: svc.Spec.Selector}), + }) + if err != nil { + return nil, "", fmt.Errorf("listing pods for service %s/%s: %w", namespace, serviceName, err) + } + + if len(pods.Items) == 0 { + return nil, "", fmt.Errorf("no pods found for service %s/%s", namespace, serviceName) + } + + // client-go doesn't support port forwarding to services, only to pods. Look for a pod we can + // forward to. + pod := findReadyPod(&pods.Items) + if pod == nil { + return nil, "", fmt.Errorf("no ready pods found for service %s/%s", namespace, serviceName) + } + + port, err := findTargetPort(svc, pod, servicePort) + if err != nil { + return nil, "", fmt.Errorf("finding target port: %w", err) + } + + return startPortForwardToPod(ctx, restConfig, namespace, pod.Name, port) +} + +func startPortForwardToPod( + ctx context.Context, + restConfig *rest.Config, + namespace string, + podName string, + podPort int, +) (*portForwarder, string, error) { + reqURL, err := url.Parse(fmt.Sprintf("%s/api/v1/namespaces/%s/pods/%s/portforward", + restConfig.Host, namespace, podName)) + if err != nil { + return nil, "", fmt.Errorf("parsing URL: %w", err) + } + + // Create SPDY transport. + transport, upgrader, err := spdy.RoundTripperFor(restConfig) + if err != nil { + return nil, "", fmt.Errorf("creating SPDY transport: %w", err) + } + + dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, reqURL) + + stopChan := make(chan struct{}, 1) + readyChan := make(chan struct{}, 1) + + // Use "0" as the local port to let the system assign an available port. + // This avoids race conditions when multiple tests request ports concurrently. + ports := []string{fmt.Sprintf("0:%d", podPort)} + + pf, err := portforward.New(dialer, ports, stopChan, readyChan, nil, nil) + if err != nil { + return nil, "", fmt.Errorf("creating port forwarder: %w", err) + } + + errChan := make(chan error, 1) + go func() { + errChan <- pf.ForwardPorts() + }() + + select { + case <-readyChan: + // Port forward is ready. + case pfErr := <-errChan: + return nil, "", fmt.Errorf("port forward failed: %w", pfErr) + case <-ctx.Done(): + close(stopChan) + return nil, "", ctx.Err() + } + + // Get the local port assigned by the system (we requested port 0 above). + forwardedPorts, err := pf.GetPorts() + if err != nil { + close(stopChan) + return nil, "", fmt.Errorf("getting forwarded ports: %w", err) + } + + if len(forwardedPorts) == 0 { + close(stopChan) + return nil, "", fmt.Errorf("no ports forwarded") + } + + localPort := forwardedPorts[0].Local + addr := fmt.Sprintf("127.0.0.1:%d", localPort) + + return &portForwarder{ + stopChan: stopChan, + pf: pf, + localPort: localPort, + }, addr, nil +} + +// Terminates the port forward connection. +func (pf *portForwarder) stop() { + if pf.stopChan != nil { + close(pf.stopChan) + pf.stopChan = nil + } + + if pf.releaseSem != nil { + pf.releaseSem() + pf.releaseSem = nil + } +} + +// Finds a pod that is running, ready, and not terminating. +func findReadyPod(pods *[]corev1.Pod) *corev1.Pod { + for i := range *pods { + pod := &(*pods)[i] + if pod.Status.Phase != corev1.PodRunning { + continue + } + if pod.DeletionTimestamp != nil { + continue // Pod is terminating + } + // Check Ready condition. + for _, cond := range pod.Status.Conditions { + if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue { + return pod + } + } + } + + return nil +} + +// Resolves the service port to a container port on the pod. +func findTargetPort(svc *corev1.Service, pod *corev1.Pod, servicePort int) (int, error) { + for _, sp := range svc.Spec.Ports { + if int(sp.Port) == servicePort { + // If targetPort is a number, use it directly. + if sp.TargetPort.IntValue() != 0 { + return sp.TargetPort.IntValue(), nil + } + // If targetPort is a name, find the container port. + portName := sp.TargetPort.String() + for _, container := range pod.Spec.Containers { + for _, cp := range container.Ports { + if cp.Name == portName { + return int(cp.ContainerPort), nil + } + } + } + return 0, fmt.Errorf("named port %q not found in pod", portName) + } + } + + return 0, fmt.Errorf("service port %d not found", servicePort) +} + +// Finds the service created by a Gateway API implementation for a Gateway object. +func findGatewayService( + ctx context.Context, + log Logger, + client *kubernetes.Clientset, + gatewayNamespace string, + gatewayName string, +) (*corev1.Service, error) { + var out *corev1.Service + + // The service may not exist immediately after Gateway creation. Wait for it. + err := wait.PollUntilContextTimeout(ctx, 2*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { + // Different implementations use different naming/labeling conventions, so we try multiple + // strategies. + + // Try the standard Gateway API label (used by Istio and others). + // See: https://gateway-api.sigs.k8s.io/geps/gep-1762/#resource-attachment + selector := fmt.Sprintf("gateway.networking.k8s.io/gateway-name=%s", gatewayName) + services, err := client.CoreV1().Services(gatewayNamespace).List(ctx, metav1.ListOptions{ + LabelSelector: selector, + }) + if err == nil && len(services.Items) > 0 { + if len(services.Items) > 1 { + log.Logf("WARNING: Found %d services for gateway %s - selecting the first one", + len(services.Items), gatewayName) + } + out = &services.Items[0] + return true, nil + } + + // Try other implementation-specific label selectors. + selectors := []string{ + fmt.Sprintf("gateway.envoyproxy.io/owning-gateway-name=%s", gatewayName), // Envoy Gateway + fmt.Sprintf("app.kubernetes.io/instance=%s", gatewayName), // NGINX Gateway Fabric + // TODO: Add labels for more implementations. + } + for _, s := range selectors { + services, err := client.CoreV1().Services(gatewayNamespace).List(ctx, metav1.ListOptions{ + LabelSelector: s, + }) + if err != nil { + continue + } + if len(services.Items) > 0 { + out = &services.Items[0] + return true, nil + } + } + + // Fallback: Istio names services as -. + // Common gateway class names: istio, istio-waypoint. + for _, suffix := range []string{"-istio", ""} { + svcName := gatewayName + suffix + svc, err := client.CoreV1().Services(gatewayNamespace).Get(ctx, svcName, metav1.GetOptions{}) + if err == nil { + out = svc + return true, nil + } + } + + return false, nil + }) + if err != nil { + return nil, fmt.Errorf("finding service for gateway %s/%s: %w", gatewayNamespace, gatewayName, err) + } + + return out, nil +} + +// Finds the service for an ingress controller. +func findIngressControllerService( + ctx context.Context, + client *kubernetes.Clientset, + namespace string, + controllerName string, +) (*corev1.Service, error) { + // Map controller names to their typical service names. + serviceNames := map[string][]string{ + ingressnginx.Name: {"ingress-nginx-controller"}, + } + + names, ok := serviceNames[controllerName] + if !ok { + return nil, fmt.Errorf("unknown ingress controller: %s", controllerName) + } + + var lastErr error + for _, name := range names { + svc, err := client.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{}) + if err == nil { + return svc, nil + } + lastErr = err + } + + return nil, fmt.Errorf("could not find service for ingress controller %s: %w", controllerName, lastErr) +} diff --git a/e2e/framework/resource_manager.go b/e2e/framework/resource_manager.go new file mode 100644 index 000000000..6b00492fd --- /dev/null +++ b/e2e/framework/resource_manager.go @@ -0,0 +1,168 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "sync" +) + +// GlobalResourceManager is a singleton ResourceManager for shared test resources. +var GlobalResourceManager = &ResourceManager{ + resources: make(map[string]*resourceState), +} + +// ResourceManager manages shared resources used by tests. It allows safe reuse of resources which +// have expensive setup and/or teardown by multiple concurrent tests. +type ResourceManager struct { + // The methods on this type are designed to return immediately: Any long-running operation + // should run asynchronously. The mutex is used only for thread-safe access to the internal + // state and is NOT designed to remain locked while a long-running resource operation is + // executing. + mu sync.Mutex + resources map[string]*resourceState +} + +// Acquire returns a shared resource identified by key. +// +// If the resource does not exist, install is called asynchronously to create it. The returned +// Resource allows callers to wait for installation to complete and to trigger cleanup. Subsequent +// calls with the same key return immediately without calling install again. +// +// Each caller MUST call the Cleanup() method on the returned Resource to ensure resource release +// takes place. +func (rm *ResourceManager) Acquire(key string, install InstallFunc) Resource { + for { + rm.mu.Lock() + state, exists := rm.resources[key] + if exists && state.cleaningUp != nil { + // Resource is being cleaned up - wait and retry. + cleaningUp := state.cleaningUp + rm.mu.Unlock() + <-cleaningUp + continue + } + + if !exists { + state = &resourceState{ + ready: make(chan struct{}), + count: 0, + } + rm.resources[key] = state + + // Run installation asynchronously. + go func() { + defer close(state.ready) + cleanup, err := install() + if err != nil { + state.err = err + return + } + state.cleanup = cleanup + }() + } + state.count++ + rm.mu.Unlock() + + done := make(chan struct{}) + var once sync.Once // Protect against multiple cleanups by same caller + + return Resource{ + Name: key, + Cleanup: func() <-chan struct{} { + once.Do(func() { + go func() { + <-rm.release(key) + close(done) + }() + }) + return done + }, + Wait: func() error { + <-state.ready + return state.err + }, + } + } +} + +// Decrements the reference count for a resource and triggers cleanup when the count reaches zero. +// Returns a channel that is closed when cleanup completes. +func (rm *ResourceManager) release(key string) <-chan struct{} { + done := make(chan struct{}) + + go func() { + defer close(done) + + rm.mu.Lock() + state, ok := rm.resources[key] + if !ok { + rm.mu.Unlock() + return + } + + state.count-- + if state.count <= 0 { + // Mark the resource as cleaning up before releasing the lock. This prevents new + // Acquire calls from using a resource that is being cleaned up. + state.cleaningUp = make(chan struct{}) + rm.mu.Unlock() + + // Wait for installation to complete before running cleanup. + <-state.ready + if state.cleanup != nil { + state.cleanup() + } + + // Remove the resource from the map and signal cleanup is done. + rm.mu.Lock() + delete(rm.resources, key) + close(state.cleaningUp) + rm.mu.Unlock() + } else { + rm.mu.Unlock() + } + }() + + return done +} + +// Resource represents a resource managed by the ResourceManager. +type Resource struct { + // A name for this resource. Useful for error messages. + Name string + // Releases the resource's underlying resources. + Cleanup func() <-chan struct{} + // Blocks until the resource is installed and ready for use. If there was an error during the + // installation, the error is returned. + Wait func() error +} + +// InstallFunc is a synchronous install function which returns a synchronous cleanup function or an +// installation error. +type InstallFunc func() (CleanupFunc, error) + +// CleanupFunc is a function which contains logic for cleaning up a resource. +type CleanupFunc func() + +// Tracks a shared resource's state. +type resourceState struct { + cleanup CleanupFunc + ready chan struct{} // Closed when installation completes + cleaningUp chan struct{} // Closed when cleanup completes + err error // An installation error + count int // Reference count +} diff --git a/e2e/framework/retry.go b/e2e/framework/retry.go new file mode 100644 index 000000000..fb0359e6b --- /dev/null +++ b/e2e/framework/retry.go @@ -0,0 +1,105 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "context" + "time" +) + +// Configures the behavior of a retry function. +type retryConfig struct { + // Maximum number of retry attempts. Must be at least 1. + maxAttempts int + // Time to wait between retry attempts. + delay time.Duration +} + +// Returns a default retryConfig. +func defaultRetryConfig() retryConfig { + return retryConfig{ + maxAttempts: 5, + delay: 2 * time.Second, + } +} + +// Executes the given function until it succeeds or the maximum number of attempts is reached. +// Respects context cancellation and logs each failed attempt using the provided logger. The +// attemptMsg function is called for each failed attempt to generate a log message. +func retry( + ctx context.Context, + log Logger, + cfg retryConfig, + attemptMsg func(attempt, maxAttempts int, err error) string, + fn func() error, +) error { + var err error + + for i := range cfg.maxAttempts { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + + err = fn() + if err == nil { + return nil + } + + log.Logf(attemptMsg(i+1, cfg.maxAttempts, err)) + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(cfg.delay): + } + } + + return err +} + +// Same as retry but returns generic data on success. +func retryWithData[T any]( + ctx context.Context, + log Logger, + cfg retryConfig, + attemptMsg func(attempt, maxAttempts int, err error) string, + fn func() (T, error), +) (T, error) { + var result T + var err error + + for i := range cfg.maxAttempts { + if ctxErr := ctx.Err(); ctxErr != nil { + return result, ctxErr + } + + result, err = fn() + if err == nil { + return result, nil + } + + log.Logf(attemptMsg(i+1, cfg.maxAttempts, err)) + + select { + case <-ctx.Done(): + return result, ctx.Err() + case <-time.After(cfg.delay): + } + } + + return result, err +} diff --git a/e2e/framework/secrets.go b/e2e/framework/secrets.go new file mode 100644 index 000000000..03eb7bcdd --- /dev/null +++ b/e2e/framework/secrets.go @@ -0,0 +1,247 @@ +/* +Copyright The Kubernetes Authors. + +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 framework + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "log" + "math/big" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// TLSTestSecret holds a TLS secret and its CA certificate for testing. +type TLSTestSecret struct { + Secret *corev1.Secret + CACert []byte +} + +// BackendTLSSecrets holds the secrets needed for backend TLS authentication testing. +type BackendTLSSecrets struct { + // ServerSecret contains the TLS cert+key for the HTTPS backend pod. + ServerSecret *corev1.Secret + // CASecret contains the CA certificate used to verify the backend, referenced by the + // proxy-ssl-secret annotation and later by BackendTLSPolicy. + CASecret *corev1.Secret // ca.crt for the proxy-ssl-secret / BackendTLSPolicy + CACertPEM []byte +} + +// GenerateSelfSignedTLSSecret creates a self-signed TLS secret for testing. +func GenerateSelfSignedTLSSecret(name, commonName string, hosts []string) (*TLSTestSecret, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("generating serial: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: commonName, + }, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IsCA: true, + BasicConstraintsValid: true, + DNSNames: hosts, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + return nil, fmt.Errorf("creating certificate: %w", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + keyBytes, err := x509.MarshalECPrivateKey(key) + if err != nil { + return nil, fmt.Errorf("marshaling private key: %w", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}) + + return &TLSTestSecret{ + Secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: certPEM, + corev1.TLSPrivateKeyKey: keyPEM, + }, + }, + CACert: certPEM, + }, nil +} + +// Creates Kubernetes Secret resources and returns a cleanup function. +func createSecrets(ctx context.Context, l Logger, client *kubernetes.Clientset, ns string, secrets []*corev1.Secret, skipCleanup bool) (func(), error) { + for _, secret := range secrets { + if secret.Namespace == "" { + secret.Namespace = ns + } + + y, err := toYAML(secret) + if err != nil { + return nil, fmt.Errorf("converting secret to YAML: %w", err) + } + + l.Logf("Creating secret:\n%s", y) + + _, err = client.CoreV1().Secrets(secret.Namespace).Create(ctx, secret, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("creating secret %s/%s: %w", secret.Namespace, secret.Name, err) + } + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + log.Printf("Skipping cleanup of secrets") + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, secret := range secrets { + namespace := secret.Namespace + if namespace == "" { + namespace = ns + } + log.Printf("Deleting secret %s/%s", namespace, secret.Name) + err := client.CoreV1().Secrets(namespace).Delete(cleanupCtx, secret.Name, metav1.DeleteOptions{}) + if err != nil { + log.Printf("Deleting secret %s: %v", secret.Name, err) + } + } + }, nil +} + +const ( + BackendServerSecretName = "tls-backend-server-cert" //nolint:gosec // Not a credential, just a resource name. + BackendCASecretName = "tls-backend-ca" //nolint:gosec // Not a credential, just a resource name. +) + +// GenerateBackendTLSSecrets creates a self-signed CA and a server certificate +// signed by that CA, returning them as Kubernetes TLS and Opaque secrets. +func GenerateBackendTLSSecrets(serverSecretName, caSecretName, namespace, serverHostname string) (*BackendTLSSecrets, error) { + // Generate CA key and self-signed CA cert. + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating CA key: %w", err) + } + caSerial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("generating CA serial: %w", err) + } + caTemplate := x509.Certificate{ + SerialNumber: caSerial, + Subject: pkix.Name{CommonName: "backend-tls-ca"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, &caTemplate, &caTemplate, &caKey.PublicKey, caKey) + if err != nil { + return nil, fmt.Errorf("creating CA certificate: %w", err) + } + caCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}) + caKeyBytes, err := x509.MarshalECPrivateKey(caKey) + if err != nil { + return nil, fmt.Errorf("marshaling CA key: %w", err) + } + caKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: caKeyBytes}) + caCert, err := x509.ParseCertificate(caDER) + if err != nil { + return nil, fmt.Errorf("parsing CA certificate: %w", err) + } + + // Generate server key and cert signed by the CA. + serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating server key: %w", err) + } + serverSerial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("generating server serial: %w", err) + } + serverTemplate := x509.Certificate{ + SerialNumber: serverSerial, + Subject: pkix.Name{CommonName: serverHostname}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{serverHostname}, + } + serverDER, err := x509.CreateCertificate(rand.Reader, &serverTemplate, caCert, &serverKey.PublicKey, caKey) + if err != nil { + return nil, fmt.Errorf("creating server certificate: %w", err) + } + + serverCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverDER}) + serverKeyBytes, err := x509.MarshalECPrivateKey(serverKey) + if err != nil { + return nil, fmt.Errorf("marshaling server key: %w", err) + } + serverKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: serverKeyBytes}) + + return &BackendTLSSecrets{ + ServerSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: serverSecretName, + Namespace: namespace, + }, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: serverCertPEM, + corev1.TLSPrivateKeyKey: serverKeyPEM, + }, + }, + CASecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: caSecretName, + Namespace: namespace, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "ca.crt": caCertPEM, // CA cert to verify the backend server + "tls.crt": caCertPEM, // client cert the proxy presents to the backend + "tls.key": caKeyPEM, // client key matching tls.crt + }, + }, + CACertPEM: caCertPEM, + }, nil +} diff --git a/e2e/framework/services.go b/e2e/framework/services.go new file mode 100644 index 000000000..0c8e8d624 --- /dev/null +++ b/e2e/framework/services.go @@ -0,0 +1,61 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "context" + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" +) + +// WaitForServiceReady waits until at least one of the pods backing the service is ready. +func WaitForServiceReady( + ctx context.Context, + client *kubernetes.Clientset, + namespace string, + serviceName string, +) error { + return wait.PollUntilContextTimeout(ctx, 2*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + svc, err := client.CoreV1().Services(namespace).Get(ctx, serviceName, metav1.GetOptions{}) + if err != nil { + // Service doesn't exist yet. Keep waiting. + //nolint:nilerr // Wait function - we deliberately return a nil error here + return false, nil + } + + if len(svc.Spec.Selector) == 0 { + return false, fmt.Errorf("service %s/%s has no selector", namespace, serviceName) + } + + selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: svc.Spec.Selector}) + pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: selector, + }) + if err != nil { + //nolint:nilerr // Wait function - we deliberately return a nil error here + return false, nil + } + + // Check if at least one pod is ready. + readyPod := findReadyPod(&pods.Items) + return readyPod != nil, nil + }) +} diff --git a/e2e/framework/testcase.go b/e2e/framework/testcase.go new file mode 100644 index 000000000..53ff3f0a0 --- /dev/null +++ b/e2e/framework/testcase.go @@ -0,0 +1,787 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/ingressnginx" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/utils/ptr" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/apis/v1alpha2" + "sigs.k8s.io/gateway-api/apis/v1beta1" + gwclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" +) + +// E2EPrefix is the prefix for all namespaces used in the e2e tests. +const E2EPrefix = "i2gw" + +// DummyAppName1 is the name of the first dummy app. +const DummyAppName1 = "dummy-app1" + +// DummyAppName2 is the name of the second dummy app. +const DummyAppName2 = "dummy-app2" + +// Backend describes a backend application to deploy for a test case. +type Backend struct { + // Name is the Kubernetes name for the deployment and service. + Name string + // ServerSecretName, when non-empty, deploys the app with TLS enabled, + // mounting the named secret as the server certificate. + ServerSecretName string +} + +// TestCase defines the configuration for a single e2e test case. +type TestCase struct { + // Backends to deploy. When nil, defaults to DummyAppName1 + DummyAppName2 + // (both plain HTTP). + Backends []Backend + Ingresses []*networkingv1.Ingress + Secrets []*corev1.Secret + ConfigMaps []*corev1.ConfigMap + Providers []string + ProviderFlags map[string]map[string]string + GatewayImplementation string + AllowExperimentalGWAPI bool + Emitter string + Verifiers map[string][]Verifier +} + +// TestEnv holds the runtime context created during test environment setup. +// Tests that need custom setup between environment creation and test execution +// can use the exported fields directly. +type TestEnv struct { + Ctx context.Context + T *testing.T + K8sClient *kubernetes.Clientset + Namespace string + SkipCleanup bool + + kubeconfig string + gwClient *gwclientset.Clientset + restConfig *rest.Config + randPrefix string +} + +// DeployProvidersFunc deploys ingress providers and returns their resources. +type DeployProvidersFunc func( + ctx context.Context, + t *testing.T, + k8sClient *kubernetes.Clientset, + gwClient *gwclientset.Clientset, + kubeconfig string, + providers []string, + gwImpl string, + skipCleanup bool, +) []Resource + +// DeployGatewayImplFunc deploys a gateway implementation and returns its resource. +type DeployGatewayImplFunc func( + ctx context.Context, + t *testing.T, + k8sClient *kubernetes.Clientset, + apiextClient *apiextensionsclientset.Clientset, + gwClient *gwclientset.Clientset, + kubeconfig string, + gwImpl string, + skipCleanup bool, +) Resource + +// SetupTestEnv creates the test namespace and deploys CRDs, providers and the gateway +// implementation. +func SetupTestEnv(t *testing.T, providers []string, gatewayImplementation string, deployProviders DeployProvidersFunc, deployGWImpl DeployGatewayImplFunc) *TestEnv { + t.Parallel() + + if len(providers) == 0 { + t.Fatal("At least one provider must be specified") + } + + if gatewayImplementation == "" { + t.Fatal("gatewayImplementation must be specified") + } + + ctx := t.Context() + + // We deliberately avoid setting a default kubeconfig so that we don't accidentally create e2e + // resources on a production cluster. + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + t.Fatal("Environment variable KUBECONFIG must be set") + } + + skipCleanup := os.Getenv("SKIP_CLEANUP") == "1" + + k8sClient, err := newClientFromKubeconfigPath(kubeconfig) + require.NoError(t, err) + + gwClient, err := newGatewayClientFromKubeconfigPath(kubeconfig) + require.NoError(t, err) + + apiextensionsClient, err := newAPIExtensionsClientFromKubeconfigPath(kubeconfig) + require.NoError(t, err) + + restConfig, err := newRestConfigFromKubeconfigPath(kubeconfig) + require.NoError(t, err) + + // Generate a random prefix to ensure unique namespaces and hostnames for each test case. + randPrefix, err := RandString() + require.NoError(t, err) + nsPrefix := fmt.Sprintf("%s-%s", E2EPrefix, randPrefix) + + appNS := fmt.Sprintf("%s-app", nsPrefix) + cleanupNS, err := CreateNamespace(ctx, t, k8sClient, appNS, skipCleanup) + require.NoError(t, err) + + crdResource := GlobalResourceManager.Acquire("gateway-api-crds", func() (CleanupFunc, error) { + return DeployCRDs(ctx, t, apiextensionsClient, gatewayAPIInstallURL, skipCleanup) + }) + t.Cleanup(func() { + <-crdResource.Cleanup() + }) + require.NoError(t, crdResource.Wait(), "Gateway API CRDs installation failed") + + deployedProviders := deployProviders(ctx, t, k8sClient, gwClient, kubeconfig, providers, gatewayImplementation, skipCleanup) + gwImpl := deployGWImpl(ctx, t, k8sClient, apiextensionsClient, gwClient, kubeconfig, gatewayImplementation, skipCleanup) + + resources := append(deployedProviders, gwImpl) + + // Clean up all providers and the GWAPI implementation in parallel. + t.Cleanup(func() { + var doneChans []<-chan struct{} + for _, r := range resources { + doneChans = append(doneChans, r.Cleanup()) + } + for _, ch := range doneChans { + <-ch + } + }) + + // Register namespace cleanup AFTER provider/implementation cleanup above. + // t.Cleanup runs in LIFO order, so the namespace is deleted FIRST — while CRDs are + // still registered and controllers are still running. This ensures the namespace + // controller can discover all resource types and that controllers (e.g., Envoy Gateway) + // can cleanly remove resources they created in the namespace (such as proxy deployments) + // before they themselves are torn down. + t.Cleanup(cleanupNS) + + for _, r := range resources { + require.NoError(t, r.Wait(), "resource installation failed: %s", r.Name) + } + + return &TestEnv{ + Ctx: ctx, + T: t, + K8sClient: k8sClient, + Namespace: appNS, + SkipCleanup: skipCleanup, + kubeconfig: kubeconfig, + gwClient: gwClient, + restConfig: restConfig, + randPrefix: randPrefix, + } +} + +// Run creates secrets, deploys backends, creates config maps and ingresses, +// verifies ingress traffic, runs ingress2gateway, creates gateway resources, +// and verifies gateway traffic. +func (env *TestEnv) Run(tc *TestCase) { + t := env.T + ctx := env.Ctx + + // Create secrets before deploying backends — TLS backends mount server + // secrets that must already exist when the pod is created. + if len(tc.Secrets) > 0 { + cleanupSecrets, secretsErr := createSecrets(ctx, t, env.K8sClient, env.Namespace, tc.Secrets, env.SkipCleanup) + require.NoError(t, secretsErr, "creating secrets") + t.Cleanup(cleanupSecrets) + } + + // Deploy backend apps. Default to one plain-HTTP dummy apps. + backends := tc.Backends + if backends == nil { + backends = []Backend{ + {Name: DummyAppName1}, + } + } + for _, b := range backends { + cleanup, err := deployDummyApp(ctx, t, env.K8sClient, b.Name, env.Namespace, env.SkipCleanup, b.ServerSecretName) + require.NoError(t, err, "creating backend %s", b.Name) + t.Cleanup(cleanup) + } + + // Populate ingress Host field if not specified in the test case. + for _, ing := range tc.Ingresses { + for i := range ing.Spec.Rules { + if ing.Spec.Rules[i].Host == "" { + ing.Spec.Rules[i].Host = fmt.Sprintf("%s.%s.%s.test", ing.Name, env.randPrefix, E2EPrefix) + } + } + } + + if len(tc.ConfigMaps) > 0 { + cleanupConfigMaps, cmErr := createConfigMaps(ctx, t, env.K8sClient, env.Namespace, tc.ConfigMaps, env.SkipCleanup) + require.NoError(t, cmErr, "creating configmaps") + t.Cleanup(cleanupConfigMaps) + } + + cleanupIngresses, err := createIngresses(ctx, t, env.K8sClient, env.Namespace, tc.Ingresses, env.SkipCleanup) + require.NoError(t, err) + t.Cleanup(cleanupIngresses) + + // Set up port forwarding to the ingress controllers for verification. + ingressPortForwarders, ingressAddresses := setUpIngressPortForwarding( + ctx, + t, + env.K8sClient, + env.restConfig, + tc.Providers, + testCaseNeedsHTTPS(tc), + ) + t.Cleanup(func() { + for _, pf := range ingressPortForwarders { + pf.stop() + } + }) + + verifyIngresses(ctx, t, tc, ingressAddresses) + + // Run the ingress2gateway binary to convert ingresses to Gateway API resources. + res := runI2GW( + ctx, + t, + env.kubeconfig, + env.Namespace, + tc.Providers, + tc.ProviderFlags, + tc.AllowExperimentalGWAPI, + tc.Emitter, + ) + + // TODO: Hack! Force correct gateway class since i2gw doesn't seem to infer that from the + // ingress at the moment. + for _, r := range res { + for k, v := range r.Gateways { + v.Spec.GatewayClassName = gwapiv1.ObjectName(tc.GatewayImplementation) + r.Gateways[k] = v + } + } + + cleanupGatewayResources, err := createGatewayResources(ctx, t, env.gwClient, env.Namespace, res, env.SkipCleanup) + require.NoError(t, err, "creating gateway resources") + t.Cleanup(cleanupGatewayResources) + + // Set up port forwarding to each gateway for verification. + gatewayPortForwarders, gwAddresses := setUpGatewayPortForwarding( + ctx, + t, + env.K8sClient, + env.restConfig, + getGateways(res), + env.Namespace, + testCaseNeedsHTTPS(tc), + ) + t.Cleanup(func() { + for _, pf := range gatewayPortForwarders { + pf.stop() + } + }) + + verifyGatewayResources(ctx, t, tc, gwAddresses) +} + +// Sets up port forwarders for all ingress providers. Returns the resulting portForwarders and a +// map of ingress class to address. +func setUpIngressPortForwarding( + ctx context.Context, + t *testing.T, + k8sClient *kubernetes.Clientset, + restConfig *rest.Config, + providers []string, + useHTTPS bool, +) ([]*portForwarder, map[string]addresses) { + var pfs []*portForwarder + pfAddresses := make(map[string]addresses) + + for _, p := range providers { + var ingressClass string + var ingressNS string + + switch p { + case ingressnginx.Name: + ingressNS = fmt.Sprintf("%s-ingress-nginx", E2EPrefix) + ingressClass = ingressnginx.NginxIngressClass + default: + t.Fatalf("Unknown ingress provider: %s", p) + } + + svc, err := findIngressControllerService(ctx, k8sClient, ingressNS, p) + require.NoError(t, err, "finding %s service", p) + + t.Logf("Waiting for ingress controller %s service %s/%s to have ready pods", p, svc.Namespace, svc.Name) + err = WaitForServiceReady(ctx, k8sClient, svc.Namespace, svc.Name) + require.NoError(t, err, "waiting for %s service to be ready", p) + + pf, addr, err := startPortForwardToService(ctx, k8sClient, restConfig, svc.Namespace, svc.Name, 80) + require.NoError(t, err, "starting port forward to %s", p) + pfs = append(pfs, pf) + pfAddresses[ingressClass] = addresses{http: addr} + if useHTTPS { + httpsPf, httpsAddr, err := startPortForwardToService(ctx, k8sClient, restConfig, svc.Namespace, svc.Name, 443) + require.NoError(t, err, "starting https port forward to %s", p) + pfs = append(pfs, httpsPf) + pfAddresses[ingressClass] = addresses{http: addr, https: httpsAddr} + } + t.Logf("Port forwarding ingress controller %s via %s", p, addr) + } + + return pfs, pfAddresses +} + +func setUpGatewayPortForwarding( + ctx context.Context, + t *testing.T, + k8sClient *kubernetes.Clientset, + restConfig *rest.Config, + gateways map[types.NamespacedName]gwapiv1.Gateway, + appNS string, + useHTTPS bool, +) ([]*portForwarder, map[string]addresses) { + var pfs []*portForwarder + pfAddresses := make(map[string]addresses) + + for gwName, gw := range gateways { + ns := gw.Namespace + if ns == "" { + ns = appNS + } + + // Find the service created by the gateway controller. + var svc *corev1.Service + var err error + svc, err = findGatewayService(ctx, t, k8sClient, ns, gw.Name) + require.NoError(t, err, "finding gateway service for %s", gwName) + + // Wait for at least one pod to be ready before port forwarding. + t.Logf("Waiting for gateway %s service %s/%s to have ready pods", gwName, svc.Namespace, svc.Name) + err = WaitForServiceReady(ctx, k8sClient, svc.Namespace, svc.Name) + require.NoError(t, err, "waiting for gateway service %s/%s to be ready", svc.Namespace, svc.Name) + + // Start port forward to the gateway service. + pf, addr, err := startPortForwardToService(ctx, k8sClient, restConfig, svc.Namespace, svc.Name, 80) + require.NoError(t, err, "starting port forward for gateway %s", gwName) + + pfs = append(pfs, pf) + pfAddresses[gwName.Name] = addresses{http: addr} + if useHTTPS { + httpsPf, httpsAddr, err := startPortForwardToService(ctx, k8sClient, restConfig, svc.Namespace, svc.Name, 443) + require.NoError(t, err, "starting https port forward for gateway %s", gwName) + pfs = append(pfs, httpsPf) + pfAddresses[gwName.Name] = addresses{http: addr, https: httpsAddr} + } + t.Logf("Port forwarding gateway %s via %s", gwName, addr) + } + + return pfs, pfAddresses +} + +func verifyIngresses(ctx context.Context, t *testing.T, tc *TestCase, ingressAddresses map[string]addresses) { + ingressByName := make(map[string]*networkingv1.Ingress, len(tc.Ingresses)) + for _, ing := range tc.Ingresses { + ingressByName[ing.Name] = ing + } + + for ingressName, verifiers := range tc.Verifiers { + ingress, ok := ingressByName[ingressName] + require.True(t, ok, "ingress %s not found in test case", ingressName) + + ingressClass := common.GetIngressClass(*ingress) + require.NotEmpty(t, ingressClass, "ingress %s has no ingress class", ingressName) + + addr, ok := ingressAddresses[ingressClass] + require.True(t, ok, "no address found for ingress class %s", ingressClass) + + var defaultHost string + if len(ingress.Spec.Rules) > 0 { + defaultHost = ingress.Spec.Rules[0].Host + } + + for _, v := range verifiers { + err := retry(ctx, t, retryConfig{maxAttempts: 60, delay: 1 * time.Second}, + func(attempt int, maxAttempts int, err error) string { + return fmt.Sprintf("Verifying ingress %s (attempt %d/%d): %v", ingressName, attempt, maxAttempts, err) + }, + func() error { + return v.verify(ctx, t, addr, defaultHost) + }, + ) + require.NoError(t, err, "ingress verification failed") + } + } +} + +func verifyGatewayResources(ctx context.Context, t *testing.T, tc *TestCase, gwAddresses map[string]addresses) { + for ingressName, verifiers := range tc.Verifiers { + // Find the ingress to determine the expected gateway name. + var ingress *networkingv1.Ingress + for _, ing := range tc.Ingresses { + if ing.Name == ingressName { + ingress = ing + break + } + } + if ingress == nil { + t.Fatalf("Ingress %s not found in test case", ingressName) + } + + // Gateway name is derived from ingress class. + gwName := common.GetIngressClass(*ingress) + if gwName == "" { + t.Fatalf("Ingress %s has no ingress class", ingressName) + } + + addr, ok := gwAddresses[gwName] + require.True(t, ok, "gateway %s not found in addresses", gwName) + + var defaultHost string + if len(ingress.Spec.Rules) > 0 { + defaultHost = ingress.Spec.Rules[0].Host + } + + for _, v := range verifiers { + err := retry(ctx, t, retryConfig{maxAttempts: 60, delay: 1 * time.Second}, + func(attempt int, maxAttempts int, err error) string { + return fmt.Sprintf("Verifying gateway %s (attempt %d/%d): %v", gwName, attempt, maxAttempts, err) + }, + func() error { + return v.verify(ctx, t, addr, defaultHost) + }, + ) + require.NoError(t, err, "gateway verification failed") + } + } +} + +func testCaseNeedsHTTPS(tc *TestCase) bool { + for _, verifiers := range tc.Verifiers { + for _, v := range verifiers { + if hv, ok := v.(*HTTPRequestVerifier); ok && hv.UseTLS { + return true + } + } + } + return false +} + +// Executes the ingress2gateway binary and returns the parsed Gateway API resources. +func runI2GW( + ctx context.Context, + t *testing.T, + kubeconfig string, + namespace string, + providers []string, + providerFlags map[string]map[string]string, + allowExperimental bool, + emitter string, +) []i2gw.GatewayResources { + binaryPath := os.Getenv("I2GW_BINARY_PATH") + require.NotEmpty(t, binaryPath, "environment variable I2GW_BINARY_PATH not set") + + args := []string{ + "print", + "--kubeconfig", kubeconfig, + "--namespace", namespace, + "--providers", strings.Join(providers, ","), + } + if allowExperimental { + args = append(args, "--allow-experimental-gw-api") + } + + if emitter != "" { + args = append(args, "--emitter", emitter) + } + + // Add provider-specific flags. + for provider, flags := range providerFlags { + for flagName, flagValue := range flags { + args = append(args, fmt.Sprintf("--%s-%s", provider, flagName), flagValue) + } + } + + t.Logf("Running ingress2gateway: %s %v", binaryPath, args) + + // #nosec G204 -- binaryPath is from trusted env var, args are constructed internally + cmd := exec.CommandContext(ctx, binaryPath, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + require.NoError(t, err, "ingress2gateway run failed\nstdout: %s\nstderr: %s", stdout.String(), stderr.String()) + + // Log any notifications from stderr. + if stderr.Len() > 0 { + t.Logf("Got stderr from ingress2gateway:\n%s", strings.TrimRight(stderr.String(), "\n")) + } + + return parseYAMLOutput(t, stdout.Bytes()) +} + +// Parses the YAML output from the ingress2gateway binary into Gateway API resources. +func parseYAMLOutput(t *testing.T, data []byte) []i2gw.GatewayResources { + res := i2gw.GatewayResources{ + Gateways: make(map[types.NamespacedName]gwapiv1.Gateway), + GatewayClasses: make(map[types.NamespacedName]gwapiv1.GatewayClass), + HTTPRoutes: make(map[types.NamespacedName]gwapiv1.HTTPRoute), + GRPCRoutes: make(map[types.NamespacedName]gwapiv1.GRPCRoute), + TLSRoutes: make(map[types.NamespacedName]v1alpha2.TLSRoute), + TCPRoutes: make(map[types.NamespacedName]v1alpha2.TCPRoute), + UDPRoutes: make(map[types.NamespacedName]v1alpha2.UDPRoute), + BackendTLSPolicies: make(map[types.NamespacedName]gwapiv1.BackendTLSPolicy), + ReferenceGrants: make(map[types.NamespacedName]v1beta1.ReferenceGrant), + } + + decoder := yaml.NewYAMLOrJSONDecoder(bufio.NewReader(bytes.NewReader(data)), 4096) + + for { + var rawObj map[string]interface{} + if err := decoder.Decode(&rawObj); err != nil { + if errors.Is(err, io.EOF) { + break + } + t.Fatalf("Failed to decode YAML: %v", err) + } + + if rawObj == nil { + continue + } + + apiVersion, _ := rawObj["apiVersion"].(string) + kind, _ := rawObj["kind"].(string) + metadata, _ := rawObj["metadata"].(map[string]interface{}) + name, _ := metadata["name"].(string) + namespace, _ := metadata["namespace"].(string) + + nn := types.NamespacedName{Namespace: namespace, Name: name} + + // Re-encode the object to JSON bytes for proper unmarshaling. + objBytes, err := json.Marshal(rawObj) + require.NoError(t, err, "failed to marshal object") + + switch { + case apiVersion == "gateway.networking.k8s.io/v1" && kind == "Gateway": + var gw gwapiv1.Gateway + err := json.Unmarshal(objBytes, &gw) + require.NoError(t, err, "failed to unmarshal Gateway") + res.Gateways[nn] = gw + case apiVersion == "gateway.networking.k8s.io/v1" && kind == "GatewayClass": + var gc gwapiv1.GatewayClass + err := json.Unmarshal(objBytes, &gc) + require.NoError(t, err, "failed to unmarshal GatewayClass") + res.GatewayClasses[nn] = gc + case apiVersion == "gateway.networking.k8s.io/v1" && kind == "HTTPRoute": + var hr gwapiv1.HTTPRoute + err := json.Unmarshal(objBytes, &hr) + require.NoError(t, err, "failed to unmarshal HTTPRoute") + res.HTTPRoutes[nn] = hr + case apiVersion == "gateway.networking.k8s.io/v1" && kind == "GRPCRoute": + var gr gwapiv1.GRPCRoute + err := json.Unmarshal(objBytes, &gr) + require.NoError(t, err, "failed to unmarshal GRPCRoute") + res.GRPCRoutes[nn] = gr + case apiVersion == "gateway.networking.k8s.io/v1alpha2" && kind == "TLSRoute": + var tr v1alpha2.TLSRoute + err := json.Unmarshal(objBytes, &tr) + require.NoError(t, err, "failed to unmarshal TLSRoute") + res.TLSRoutes[nn] = tr + case apiVersion == "gateway.networking.k8s.io/v1alpha2" && kind == "TCPRoute": + var tcpr v1alpha2.TCPRoute + err := json.Unmarshal(objBytes, &tcpr) + require.NoError(t, err, "failed to unmarshal TCPRoute") + res.TCPRoutes[nn] = tcpr + case apiVersion == "gateway.networking.k8s.io/v1alpha2" && kind == "UDPRoute": + var udpr v1alpha2.UDPRoute + err := json.Unmarshal(objBytes, &udpr) + require.NoError(t, err, "failed to unmarshal UDPRoute") + res.UDPRoutes[nn] = udpr + case apiVersion == "gateway.networking.k8s.io/v1" && kind == "BackendTLSPolicy": + var btls gwapiv1.BackendTLSPolicy + err := json.Unmarshal(objBytes, &btls) + require.NoError(t, err, "failed to unmarshal BackendTLSPolicy") + res.BackendTLSPolicies[nn] = btls + case apiVersion == "gateway.networking.k8s.io/v1beta1" && kind == "ReferenceGrant": + var rg v1beta1.ReferenceGrant + err := json.Unmarshal(objBytes, &rg) + require.NoError(t, err, "failed to unmarshal ReferenceGrant") + res.ReferenceGrants[nn] = rg + default: + // Keep implementation-specific resources as extensions. + var obj unstructured.Unstructured + err := json.Unmarshal(objBytes, &obj.Object) + require.NoError(t, err, "failed to unmarshal extension resource") + res.GatewayExtensions = append(res.GatewayExtensions, obj) + } + } + + return []i2gw.GatewayResources{res} +} + +// IngressBuilder provides a fluent interface for constructing Ingress resources in tests. +type IngressBuilder struct { + *networkingv1.Ingress +} + +// WithName sets the ingress name. +func (b *IngressBuilder) WithName(name string) *IngressBuilder { + b.ObjectMeta.Name = name + return b +} + +// WithIngressClass sets the ingress class name. +func (b *IngressBuilder) WithIngressClass(className string) *IngressBuilder { + b.Spec.IngressClassName = ptr.To(className) + return b +} + +// WithHost sets the Host field of the first rule in the ingress to the specified string. Does +// nothing if there are no rules. +func (b *IngressBuilder) WithHost(host string) *IngressBuilder { + if len(b.Spec.Rules) > 0 { + b.Spec.Rules[0].Host = host + } + return b +} + +// WithPath sets the path for all rules in the ingress. +func (b *IngressBuilder) WithPath(path string) *IngressBuilder { + for i := range b.Spec.Rules { + rule := b.Spec.Rules[i] + for j := range b.Spec.Rules[i].IngressRuleValue.HTTP.Paths { + p := rule.IngressRuleValue.HTTP.Paths[j] + p.Path = path + rule.IngressRuleValue.HTTP.Paths[j] = p + } + b.Spec.Rules[i] = rule + } + return b +} + +// WithBackend sets the backend service name for all rules in the ingress. +func (b *IngressBuilder) WithBackend(svc string) *IngressBuilder { + for i := range b.Spec.Rules { + rule := b.Spec.Rules[i] + for j := range b.Spec.Rules[i].IngressRuleValue.HTTP.Paths { + path := rule.IngressRuleValue.HTTP.Paths[j] + path.Backend.Service.Name = svc + rule.IngressRuleValue.HTTP.Paths[j] = path + } + b.Spec.Rules[i] = rule + } + return b +} + +func (b *IngressBuilder) WithBackendPort(port int32) *IngressBuilder { + for i := range b.Spec.Rules { + rule := b.Spec.Rules[i] + for j := range b.Spec.Rules[i].IngressRuleValue.HTTP.Paths { + path := rule.IngressRuleValue.HTTP.Paths[j] + path.Backend.Service.Port = networkingv1.ServiceBackendPort{Number: port} + rule.IngressRuleValue.HTTP.Paths[j] = path + } + b.Spec.Rules[i] = rule + } + return b +} + +// WithAnnotation adds an annotation to the ingress. +func (b *IngressBuilder) WithAnnotation(key, value string) *IngressBuilder { + if b.ObjectMeta.Annotations == nil { + b.ObjectMeta.Annotations = make(map[string]string) + } + b.ObjectMeta.Annotations[key] = value + return b +} + +// WithTLSSecret adds a TLS secret to the ingress. +func (b *IngressBuilder) WithTLSSecret(secretName string, hosts ...string) *IngressBuilder { + b.Spec.TLS = append(b.Spec.TLS, networkingv1.IngressTLS{ + SecretName: secretName, + Hosts: hosts, + }) + return b +} + +// Build returns the constructed Ingress resource. +func (b *IngressBuilder) Build() *networkingv1.Ingress { + return b.Ingress +} + +// BasicIngress returns an IngressBuilder with a minimal ingress configuration. +func BasicIngress() *IngressBuilder { + return &IngressBuilder{ + Ingress: &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Name: "foo"}, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: DummyAppName1, + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} diff --git a/e2e/framework/util.go b/e2e/framework/util.go new file mode 100644 index 000000000..9eec81a86 --- /dev/null +++ b/e2e/framework/util.go @@ -0,0 +1,53 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "crypto/rand" + + "sigs.k8s.io/yaml" +) + +// RandString generates a cryptographically random alphanumeric string of length 5. Uses +// crypto/rand to ensure uniqueness even when called from parallel tests. +func RandString() (string, error) { + n := 5 + const chars = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + + // Populate b with random alphanumeric characters by indexing chars with the remainder of + // (b[i] / len(chars)). This ensures we always select a valid element from chars. The byte() + // conversion is required because the % operator expects two operands of the same type. + for i := range b { + b[i] = chars[b[i]%byte(len(chars))] + } + + return string(b), nil +} + +// Converts a k8s object to a YAML string. +func toYAML(obj interface{}) (string, error) { + b, err := yaml.Marshal(obj) + if err != nil { + return "", err + } + + return string(b), nil +} diff --git a/e2e/framework/verifiers.go b/e2e/framework/verifiers.go new file mode 100644 index 000000000..712ca8629 --- /dev/null +++ b/e2e/framework/verifiers.go @@ -0,0 +1,236 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 framework + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net/http" + "regexp" + "slices" + "strings" + "time" +) + +// Verifier validates that a service is accessible and working correctly. The addr parameter is a +// "host:port" string representing the service endpoint. The defaultHost parameter is the host to +// use if the verifier does not have a host configured. +type Verifier interface { + verify(ctx context.Context, log Logger, addr addresses, defaultHost string) error +} + +// Holds HTTP and HTTPS addresses for a service. +type addresses struct { + http string + https string +} + +// CanaryVerifier runs a verifier multiple times and checks the success rate. +type CanaryVerifier struct { + Verifier Verifier + MinSuccesses float64 + MaxSuccesses float64 + Runs int +} + +func (v *CanaryVerifier) verify(ctx context.Context, log Logger, addr addresses, defaultHost string) error { + successes := 0 + for i := 0; i < v.Runs; i++ { + err := v.Verifier.verify(ctx, log, addr, defaultHost) + if err == nil { + log.Logf("Canary verifier run %d/%d for host %q succeeded", i+1, v.Runs, defaultHost) + successes++ + } + } + + successRate := float64(successes) / float64(v.Runs) + if successRate <= v.MinSuccesses || successRate >= v.MaxSuccesses { + return fmt.Errorf("canary verifier failed: success rate %.2f not in range [%.2f, %.2f]", successRate, v.MinSuccesses, v.MaxSuccesses) + } + return nil +} + +// HTTPRequestVerifier makes an HTTP or HTTPS request to the ingress and validates the response +// based on the provided configuration. The fields that check the response are optional but are +// ANDed together if set. +type HTTPRequestVerifier struct { + // Host is the Host header/SNI to use in the request. If empty, the verifier will attempt to + // infer it from the ingress rules. + Host string + // Path is the URL path to request (default "/") + Path string + // Method is the HTTP method to use (default GET) + Method string + // RequestHeaders are additional headers to include in the request + RequestHeaders map[string]string + // AllowedCodes are the expected HTTP status codes (default 200) + AllowedCodes []int + // HeaderMatches specifies headers that must be present and match at all of the provided regex + // patterns + HeaderMatches []HeaderMatch + // HeaderAbsent specifies headers that must not be present in the response + HeaderAbsent []string + // UseTLS indicates whether to use HTTPS instead of HTTP + UseTLS bool + // CACertPEM is the PEM-encoded CA certificate to trust for TLS verification (required if + // UseTLS is true) + CACertPEM []byte + // BodyRegex is an optional regex pattern that the response body must match + BodyRegex *regexp.Regexp +} + +// MaybeNegativePattern represents a regex pattern that can be negated. If Negate is true, the +// pattern must NOT match. +type MaybeNegativePattern struct { + Pattern *regexp.Regexp + Negate bool +} + +func (m MaybeNegativePattern) matches(s string) bool { + return m.Pattern.MatchString(s) != m.Negate +} + +// HeaderMatch specifies a header name and patterns that must match. +type HeaderMatch struct { + Name string + Patterns []*MaybeNegativePattern +} + +func (v *HTTPRequestVerifier) verify(ctx context.Context, log Logger, addr addresses, defaultHost string) error { + host := v.Host + if host == "" { + host = defaultHost + } + if host == "" { + return fmt.Errorf("no host specified: set httpRequestVerifier.host or provide a defaultHost") + } + + scheme := "http" + targetAddr := addr.http + if v.UseTLS { + scheme = "https" + targetAddr = addr.https + } + if targetAddr == "" { + return fmt.Errorf("no %s address available for verifier", scheme) + } + method := v.Method + if method == "" { + method = http.MethodGet + } + req, err := http.NewRequestWithContext(ctx, method, fmt.Sprintf("%s://%s%s", scheme, targetAddr, v.Path), nil) + if err != nil { + return fmt.Errorf("constructing HTTP request: %w", err) + } + + for name, value := range v.RequestHeaders { + req.Header.Set(name, value) + } + req.Host = host + + transport := http.DefaultTransport.(*http.Transport).Clone() + if v.UseTLS { + if len(v.CACertPEM) == 0 { + return fmt.Errorf("no CA cert provided for TLS verification") + } + certPool := x509.NewCertPool() + if ok := certPool.AppendCertsFromPEM(v.CACertPEM); !ok { + return fmt.Errorf("failed to parse CA cert PEM") + } + transport.TLSClientConfig = &tls.Config{ + RootCAs: certPool, + ServerName: host, + MinVersion: tls.VersionTLS12, + } + } + + client := http.Client{Timeout: 20 * time.Second, Transport: transport} + // Don't follow redirects, as some tests want to verify the redirect response itself (e.g. for TLS redirection) + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + + res, err := client.Do(req) + if err != nil { + return fmt.Errorf("doing request: %w", err) + } + defer func() { _ = res.Body.Close() }() + + allowedCodes := v.AllowedCodes + if len(allowedCodes) == 0 { + allowedCodes = []int{http.StatusOK} + } + if !slices.Contains(allowedCodes, res.StatusCode) { + return fmt.Errorf("unexpected HTTP status code: got %d, want one of %v", res.StatusCode, allowedCodes) + } + + for _, headerMatch := range v.HeaderMatches { + if headerMatch.Name == "" { + return fmt.Errorf("header match name cannot be empty") + } + if len(headerMatch.Patterns) == 0 { + return fmt.Errorf("header match patterns cannot be empty for %q", headerMatch.Name) + } + values := res.Header.Values(headerMatch.Name) + if len(values) == 0 { + return fmt.Errorf("missing header %q on response", headerMatch.Name) + } + for _, pattern := range headerMatch.Patterns { + matched := false + for _, value := range values { + if pattern.matches(value) { + matched = true + break + } + } + if !matched { + return fmt.Errorf( + "header %q did not match pattern %q with negation %v: header values were %q", + headerMatch.Name, + pattern.Pattern, + pattern.Negate, + strings.Join(values, ", "), + ) + } + } + } + + for _, headerName := range v.HeaderAbsent { + if headerName == "" { + return fmt.Errorf("header absent name cannot be empty") + } + if len(res.Header.Values(headerName)) > 0 { + return fmt.Errorf("unexpected header %q on response", headerName) + } + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("reading HTTP body: %w", err) + } + + log.Logf("Got a healthy response for host %q: %s", host, body) + + if v.BodyRegex != nil && !v.BodyRegex.MatchString(string(body)) { + return fmt.Errorf("unexpected HTTP body: does not match %v", v.BodyRegex) + } + + return nil +} diff --git a/e2e/helpers.go b/e2e/helpers.go new file mode 100644 index 000000000..1938df12b --- /dev/null +++ b/e2e/helpers.go @@ -0,0 +1,102 @@ +/* +Copyright The Kubernetes Authors. + +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 e2e + +import ( + "context" + "fmt" + "testing" + + "github.com/kgateway-dev/ingress2gateway/e2e/framework" + "github.com/kgateway-dev/ingress2gateway/e2e/implementation" + "github.com/kgateway-dev/ingress2gateway/e2e/provider" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/ingressnginx" + apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + "k8s.io/client-go/kubernetes" + gwclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" +) + +// setupTestEnv wraps framework.SetupTestEnv with the concrete deploy functions defined in this +// package. The returned TestEnv can be used for additional test-specific setup before calling Run(). +func setupTestEnv(t *testing.T, providers []string, gatewayImplementation string) *framework.TestEnv { + return framework.SetupTestEnv(t, providers, gatewayImplementation, deployProviders, deployGatewayImplementation) +} + +// runTestCase is a convenience wrapper for tests that don't need custom setup between environment +// creation and test execution. +func runTestCase(t *testing.T, tc *framework.TestCase) { + setupTestEnv(t, tc.Providers, tc.GatewayImplementation).Run(tc) +} + +func deployProviders( + ctx context.Context, + t *testing.T, + k8sClient *kubernetes.Clientset, + gwClient *gwclientset.Clientset, + kubeconfig string, + providers []string, + gwImpl string, + skipCleanup bool, +) []framework.Resource { + var resources []framework.Resource + + for _, p := range providers { + var r framework.Resource + switch p { + case ingressnginx.Name: + ns := fmt.Sprintf("%s-ingress-nginx", framework.E2EPrefix) + r = framework.GlobalResourceManager.Acquire(ingressnginx.Name, func() (framework.CleanupFunc, error) { + return provider.DeployIngressNginx(ctx, t, k8sClient, kubeconfig, ns, skipCleanup) + }) + default: + t.Fatalf("Unknown ingress provider: %s", p) + } + resources = append(resources, r) + } + + return resources +} + +func deployGatewayImplementation( + ctx context.Context, + t *testing.T, + k8sClient *kubernetes.Clientset, + apiextClient *apiextensionsclientset.Clientset, + gwClient *gwclientset.Clientset, + kubeconfig string, + gwImpl string, + skipCleanup bool, +) framework.Resource { + var r framework.Resource + + switch gwImpl { + case implementation.KgatewayName: + ns := fmt.Sprintf("%s-kgateway-system", framework.E2EPrefix) + r = framework.GlobalResourceManager.Acquire(implementation.KgatewayName, func() (framework.CleanupFunc, error) { + return implementation.DeployKgateway(ctx, t, k8sClient, kubeconfig, ns, skipCleanup) + }) + case implementation.AgentgatewayName: + ns := fmt.Sprintf("%s-agentgateway-system", framework.E2EPrefix) + r = framework.GlobalResourceManager.Acquire(implementation.AgentgatewayName, func() (framework.CleanupFunc, error) { + return implementation.DeployAgentgateway(ctx, t, k8sClient, gwClient, kubeconfig, ns, skipCleanup) + }) + default: + t.Fatalf("Unknown gateway implementation: %s", gwImpl) + } + + return r +} diff --git a/e2e/implementation/agentgateway.go b/e2e/implementation/agentgateway.go new file mode 100644 index 000000000..23f0985e6 --- /dev/null +++ b/e2e/implementation/agentgateway.go @@ -0,0 +1,116 @@ +/* +Copyright The Kubernetes Authors. + +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 implementation + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/kgateway-dev/ingress2gateway/e2e/framework" + "helm.sh/helm/v4/pkg/cli" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + gwclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" +) + +const ( + AgentgatewayName = "agentgateway" + agentgatewayVersion = "v1.0.0" + agentgatewayChart = "oci://cr.agentgateway.dev/charts/agentgateway" + agentgatewayCRDsChart = "oci://cr.agentgateway.dev/charts/agentgateway-crds" + agentgatewayReleaseName = "agentgateway" + agentgatewayCRDsRelease = "agentgateway-crds" +) + +func DeployAgentgateway( + ctx context.Context, + l framework.Logger, + client *kubernetes.Clientset, + gwClient *gwclientset.Clientset, + kubeconfigPath string, + namespace string, + skipCleanup bool, +) (func(), error) { + l.Logf("Deploying agentgateway %s", agentgatewayVersion) + + settings := cli.New() + settings.KubeConfig = kubeconfigPath + + // Install CRDs first to avoid races creating extension resources. + if err := framework.InstallChart( + ctx, + l, + settings, + "", + agentgatewayCRDsRelease, + agentgatewayCRDsChart, + agentgatewayVersion, + namespace, + true, + false, + nil, + ); err != nil { + return nil, fmt.Errorf("installing agentgateway CRDs chart: %w", err) + } + + if err := framework.InstallChart( + ctx, + l, + settings, + "", + agentgatewayReleaseName, + agentgatewayChart, + agentgatewayVersion, + namespace, + false, + false, + nil, + ); err != nil { + return nil, fmt.Errorf("installing agentgateway chart: %w", err) + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + log.Printf("Skipping cleanup of agentgateway") + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + log.Printf("Cleaning up agentgateway") + + log.Printf("Deleting GatewayClass %s", AgentgatewayName) + if err := gwClient.GatewayV1().GatewayClasses().Delete(cleanupCtx, AgentgatewayName, metav1.DeleteOptions{}); err != nil { + log.Printf("Deleting GatewayClass: %v", err) + } + + if err := framework.UninstallChart(cleanupCtx, settings, agentgatewayReleaseName, namespace); err != nil { + log.Printf("Uninstalling agentgateway chart: %v", err) + } + if err := framework.UninstallChart(cleanupCtx, settings, agentgatewayCRDsRelease, namespace); err != nil { + log.Printf("Uninstalling agentgateway CRDs chart: %v", err) + } + + if err := framework.DeleteNamespaceAndWait(cleanupCtx, client, namespace); err != nil { + log.Printf("Deleting namespace: %v", err) + } + }, nil +} diff --git a/e2e/implementation/kgateway.go b/e2e/implementation/kgateway.go new file mode 100644 index 000000000..ee14453b9 --- /dev/null +++ b/e2e/implementation/kgateway.go @@ -0,0 +1,110 @@ +/* +Copyright The Kubernetes Authors. + +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 implementation + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/kgateway-dev/ingress2gateway/e2e/framework" + "helm.sh/helm/v4/pkg/cli" + "k8s.io/client-go/kubernetes" +) + +const ( + // KgatewayName is the name used to identify the kgateway implementation. + KgatewayName = "kgateway" + kgatewayVersion = "v2.2.0" + kgatewayChart = "oci://ghcr.io/kgateway-dev/charts/kgateway" + kgatewayCRDsChart = "oci://ghcr.io/kgateway-dev/charts/kgateway-crds" + kgatewayReleaseName = "kgateway" + kgatewayCRDsRelease = "kgateway-crds" +) + +// DeployKgateway deploys kgateway as a Gateway API implementation via Helm and returns a cleanup +// function. +func DeployKgateway( + ctx context.Context, + l framework.Logger, + client *kubernetes.Clientset, + kubeconfigPath string, + namespace string, + skipCleanup bool, +) (func(), error) { + l.Logf("Deploying kgateway %s", kgatewayVersion) + + settings := cli.New() + settings.KubeConfig = kubeconfigPath + + // Install CRDs first to avoid races creating extension resources. + if err := framework.InstallChart( + ctx, + l, + settings, + "", + kgatewayCRDsRelease, + kgatewayCRDsChart, + kgatewayVersion, + namespace, + true, + false, + nil, + ); err != nil { + return nil, fmt.Errorf("installing kgateway CRDs chart: %w", err) + } + + if err := framework.InstallChart( + ctx, + l, + settings, + "", + kgatewayReleaseName, + kgatewayChart, + kgatewayVersion, + namespace, + false, + false, + nil, + ); err != nil { + return nil, fmt.Errorf("installing kgateway chart: %w", err) + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + log.Printf("Skipping cleanup of kgateway") + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + log.Printf("Cleaning up kgateway") + if err := framework.UninstallChart(cleanupCtx, settings, kgatewayReleaseName, namespace); err != nil { + log.Printf("Uninstalling kgateway chart: %v", err) + } + if err := framework.UninstallChart(cleanupCtx, settings, kgatewayCRDsRelease, namespace); err != nil { + log.Printf("Uninstalling kgateway CRDs chart: %v", err) + } + + if err := framework.DeleteNamespaceAndWait(cleanupCtx, client, namespace); err != nil { + log.Printf("Deleting namespace: %v", err) + } + }, nil +} diff --git a/e2e/implementation_test.go b/e2e/implementation_test.go new file mode 100644 index 000000000..7d7b093d7 --- /dev/null +++ b/e2e/implementation_test.go @@ -0,0 +1,81 @@ +/* +Copyright The Kubernetes Authors. + +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 e2e + +import ( + "testing" + + "github.com/kgateway-dev/ingress2gateway/e2e/framework" + "github.com/kgateway-dev/ingress2gateway/e2e/implementation" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/ingressnginx" + networkingv1 "k8s.io/api/networking/v1" +) + +// Implementation smoke tests: Table-driven tests across gateway implementations, all using +// ingress-nginx provider + standard emitter. + +func TestImplementations(t *testing.T) { + t.Parallel() + + implementations := []struct { + name string + }{ + {name: implementation.KgatewayName}, + {name: implementation.AgentgatewayName}, + } + + for _, impl := range implementations { + t.Run(impl.name, func(t *testing.T) { + t.Parallel() + t.Run("basic conversion", func(t *testing.T) { + runTestCase(t, &framework.TestCase{ + GatewayImplementation: impl.name, + Providers: []string{ingressnginx.Name}, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("foo"). + WithIngressClass(ingressnginx.NginxIngressClass). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "foo": {&framework.HTTPRequestVerifier{Path: "/"}}, + }, + }) + }) + t.Run("multiple ingresses", func(t *testing.T) { + runTestCase(t, &framework.TestCase{ + GatewayImplementation: impl.name, + Providers: []string{ingressnginx.Name}, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("foo"). + WithIngressClass(ingressnginx.NginxIngressClass). + Build(), + framework.BasicIngress(). + WithName("bar"). + WithIngressClass(ingressnginx.NginxIngressClass). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "foo": {&framework.HTTPRequestVerifier{Path: "/"}}, + "bar": {&framework.HTTPRequestVerifier{Path: "/"}}, + }, + }) + }) + }) + } +} diff --git a/e2e/provider/ingressnginx.go b/e2e/provider/ingressnginx.go new file mode 100644 index 000000000..2c55d64e1 --- /dev/null +++ b/e2e/provider/ingressnginx.go @@ -0,0 +1,139 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 provider + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/kgateway-dev/ingress2gateway/e2e/framework" + "helm.sh/helm/v4/pkg/cli" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" +) + +const ( + ingressNginxChartVersion = "4.14.3" + ingressNginxChartRepo = "https://kubernetes.github.io/ingress-nginx" +) + +// DeployIngressNginx deploys the ingress-nginx ingress controller via Helm and returns a cleanup +// function. +func DeployIngressNginx( + ctx context.Context, + l framework.Logger, + client *kubernetes.Clientset, + kubeconfigPath string, + namespace string, + skipCleanup bool, +) (func(), error) { + l.Logf("Deploying ingress-nginx %s", ingressNginxChartVersion) + + settings := cli.New() + settings.KubeConfig = kubeconfigPath + + // Configure the admission webhook to only validate ingresses with the nginx ingress class. + // Without this, the webhook intercepts ALL ingresses cluster-wide, which causes failures when + // other ingress controllers try to create ingresses before the nginx webhook service is ready. + values := map[string]interface{}{ + "controller": map[string]interface{}{ + "admissionWebhooks": map[string]interface{}{ + "objectSelector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app.kubernetes.io/ingress-class": "nginx", + }, + }, + }, + }, + } + + if err := framework.InstallChart( + ctx, + l, + settings, + ingressNginxChartRepo, + "ingress-nginx", + "ingress-nginx", + ingressNginxChartVersion, + namespace, + true, + false, + values, + ); err != nil { + return nil, fmt.Errorf("installing chart: %w", err) + } + + // Wait for the admission webhook service to be ready. The ValidatingWebhookConfiguration is + // registered cluster-wide immediately, but the admission controller pod takes time to start. + // Any Ingress creation will fail until the webhook service is ready to handle requests. + l.Logf("Waiting for ingress-nginx admission webhook to be ready") + if err := framework.WaitForServiceReady(ctx, client, namespace, "ingress-nginx-controller-admission"); err != nil { + return nil, fmt.Errorf("waiting for admission webhook service: %w", err) + } + + // Wait for the CA bundle to be propagated to the ValidatingWebhookConfiguration. The service + // being ready doesn't guarantee this, which can cause X.509 certificate errors. + l.Logf("Verifying ingress-nginx admission webhook has CA bundle") + if err := waitForAdmissionWebhookReady(ctx, client); err != nil { + return nil, fmt.Errorf("waiting for admission webhook CA bundle: %w", err) + } + + //nolint:contextcheck // Intentional background context in cleanup function + return func() { + if skipCleanup { + log.Printf("Skipping cleanup of ingress-nginx") + return + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + log.Printf("Cleaning up ingress-nginx") + if err := framework.UninstallChart(cleanupCtx, settings, "ingress-nginx", namespace); err != nil { + log.Printf("Uninstalling chart: %v", err) + } + + if err := framework.DeleteNamespaceAndWait(cleanupCtx, client, namespace); err != nil { + log.Printf("Deleting namespace: %v", err) + } + }, nil +} + +// Waits until the ingress-nginx ValidatingWebhookConfiguration has a CA bundle configured. The +// webhook service being ready doesn't guarantee the CA bundle has been propagated, which causes +// X.509 certificate verification errors. +func waitForAdmissionWebhookReady(ctx context.Context, client *kubernetes.Clientset) error { + return wait.PollUntilContextTimeout(ctx, 2*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { + vwc, err := client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Get( + ctx, "ingress-nginx-admission", metav1.GetOptions{}) + if err != nil { + //nolint:nilerr // Wait function - we deliberately return a nil error here + return false, nil + } + + for _, wh := range vwc.Webhooks { + if len(wh.ClientConfig.CABundle) == 0 { + return false, nil + } + } + + return true, nil + }) +} diff --git a/e2e/provider_ingressnginx_test.go b/e2e/provider_ingressnginx_test.go new file mode 100644 index 000000000..f33929194 --- /dev/null +++ b/e2e/provider_ingressnginx_test.go @@ -0,0 +1,1411 @@ +/* +Copyright The Kubernetes Authors. + +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 e2e + +import ( + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/kgateway-dev/ingress2gateway/e2e/framework" + "github.com/kgateway-dev/ingress2gateway/e2e/implementation" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/ingressnginx" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// backendTLSConfigMap builds a ConfigMap containing the CA certificate for +// BackendTLSPolicy verification. +func backendTLSConfigMap(namespace string, caCertPEM []byte) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: framework.BackendCASecretName, + Namespace: namespace, + }, + Data: map[string]string{ + "ca.crt": string(caCertPEM), + }, + } +} + +// ingress-nginx provider features: One test per feature, all using Istio + standard emitter. + +func TestIngressNGINXBackendTLS(t *testing.T) { + t.Parallel() + t.Run("to Istio", func(t *testing.T) { + t.Parallel() + + // Test 1: Valid backend TLS configuration – all required annotations present. + // The ingress2gateway tool should produce a BackendTLSPolicy for this ingress + // and the gateway should be able to reach the HTTPS backend. + t.Run("valid backend tls produces BackendTLSPolicy", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err, "creating host suffix") + host := "backend-tls-valid-" + suffix + ".example.com" + + providers := []string{ingressnginx.Name} + gwImpl := implementation.KgatewayName + env := setupTestEnv(t, providers, gwImpl) + svcHost := fmt.Sprintf("%s.%s.svc.cluster.local", framework.DummyAppName1, env.Namespace) + tlsSecrets, err := framework.GenerateBackendTLSSecrets(framework.BackendServerSecretName, framework.BackendCASecretName, env.Namespace, svcHost) + require.NoError(t, err, "generating backend TLS secrets") + + env.Run(&framework.TestCase{ + Providers: providers, + GatewayImplementation: gwImpl, + Backends: []framework.Backend{ + {Name: framework.DummyAppName1, ServerSecretName: framework.BackendServerSecretName}, + }, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Secrets: []*corev1.Secret{tlsSecrets.ServerSecret, tlsSecrets.CASecret}, + ConfigMaps: []*corev1.ConfigMap{backendTLSConfigMap(env.Namespace, tlsSecrets.CACertPEM)}, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("backend-tls-valid"). + WithHost(host). + WithIngressClass(ingressnginx.NginxIngressClass). + WithBackend(framework.DummyAppName1). + WithBackendPort(443). + WithAnnotation(ingressnginx.BackendProtocolAnnotation, "HTTPS"). + WithAnnotation(ingressnginx.ProxySSLVerifyAnnotation, "on"). + WithAnnotation(ingressnginx.ProxySSLSecretAnnotation, env.Namespace+"/"+framework.BackendCASecretName). + WithAnnotation(ingressnginx.ProxySSLServerNameAnnotation, "on"). + WithAnnotation(ingressnginx.ProxySSLNameAnnotation, svcHost). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "backend-tls-valid": { + // The request should reach the HTTPS backend through the gateway + // and return a 200 OK (agnhost netexec echoes back on /). + &framework.HTTPRequestVerifier{ + Host: host, + Path: "/", + }, + }, + }, + }) + }) + + // Test 2: Unsupported annotations produce warnings but don't block policy generation. + // proxy-ssl-verify-depth and proxy-ssl-protocols should emit warnings but a valid + // BackendTLSPolicy should still be produced when all required annotations are present. + t.Run("unsupported annotations emit warnings but policy still generated", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err, "creating host suffix") + host := "backend-tls-warn-" + suffix + ".example.com" + + providers := []string{ingressnginx.Name} + gwImpl := implementation.KgatewayName + env := setupTestEnv(t, providers, gwImpl) + svcHost := fmt.Sprintf("%s.%s.svc.cluster.local", framework.DummyAppName1, env.Namespace) + tlsSecrets, err := framework.GenerateBackendTLSSecrets(framework.BackendServerSecretName, framework.BackendCASecretName, env.Namespace, svcHost) + require.NoError(t, err, "generating backend TLS secrets") + + env.Run(&framework.TestCase{ + Providers: providers, + GatewayImplementation: gwImpl, + Backends: []framework.Backend{ + {Name: framework.DummyAppName1, ServerSecretName: framework.BackendServerSecretName}, + }, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Secrets: []*corev1.Secret{tlsSecrets.ServerSecret, tlsSecrets.CASecret}, + ConfigMaps: []*corev1.ConfigMap{backendTLSConfigMap(env.Namespace, tlsSecrets.CACertPEM)}, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("backend-tls-warn"). + WithHost(host). + WithIngressClass(ingressnginx.NginxIngressClass). + WithBackend(framework.DummyAppName1). + WithBackendPort(443). + WithAnnotation(ingressnginx.BackendProtocolAnnotation, "HTTPS"). + WithAnnotation(ingressnginx.ProxySSLVerifyAnnotation, "on"). + WithAnnotation(ingressnginx.ProxySSLSecretAnnotation, env.Namespace+"/"+framework.BackendCASecretName). + WithAnnotation(ingressnginx.ProxySSLServerNameAnnotation, "on"). + WithAnnotation(ingressnginx.ProxySSLNameAnnotation, svcHost). + // These two annotations are unsupported in Gateway API + // but should NOT prevent policy generation. + WithAnnotation(ingressnginx.ProxySSLVerifyDepthAnnotation, "3"). + WithAnnotation(ingressnginx.ProxySSLProtocolsAnnotation, "TLSv1.2 TLSv1.3"). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "backend-tls-warn": { + &framework.HTTPRequestVerifier{ + Host: host, + Path: "/", + }, + }, + }, + }) + }) + + // Test 3: Valid config with body response verification – ensure the request + // actually reaches the backend and we get a real response body. + t.Run("valid backend tls with body verification", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err, "creating host suffix") + host := "backend-tls-body-" + suffix + ".example.com" + + providers := []string{ingressnginx.Name} + gwImpl := implementation.KgatewayName + env := setupTestEnv(t, providers, gwImpl) + svcHost := fmt.Sprintf("%s.%s.svc.cluster.local", framework.DummyAppName1, env.Namespace) + tlsSecrets, err := framework.GenerateBackendTLSSecrets(framework.BackendServerSecretName, framework.BackendCASecretName, env.Namespace, svcHost) + require.NoError(t, err, "generating backend TLS secrets") + + env.Run(&framework.TestCase{ + Providers: providers, + GatewayImplementation: gwImpl, + Backends: []framework.Backend{ + {Name: framework.DummyAppName1, ServerSecretName: framework.BackendServerSecretName}, + }, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Secrets: []*corev1.Secret{tlsSecrets.ServerSecret, tlsSecrets.CASecret}, + ConfigMaps: []*corev1.ConfigMap{backendTLSConfigMap(env.Namespace, tlsSecrets.CACertPEM)}, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("backend-tls-body"). + WithHost(host). + WithIngressClass(ingressnginx.NginxIngressClass). + WithBackend(framework.DummyAppName1). + WithBackendPort(443). + WithAnnotation(ingressnginx.BackendProtocolAnnotation, "HTTPS"). + WithAnnotation(ingressnginx.ProxySSLVerifyAnnotation, "on"). + WithAnnotation(ingressnginx.ProxySSLSecretAnnotation, env.Namespace+"/"+framework.BackendCASecretName). + WithAnnotation(ingressnginx.ProxySSLServerNameAnnotation, "on"). + WithAnnotation(ingressnginx.ProxySSLNameAnnotation, svcHost). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "backend-tls-body": { + // agnhost netexec echoes back useful info on /hostname + &framework.HTTPRequestVerifier{ + Host: host, + Path: "/hostname", + BodyRegex: regexp.MustCompile(`.+`), + }, + }, + }, + }) + }) + }) +} + +func TestIngressNGINXCanary(t *testing.T) { + t.Parallel() + t.Run("base canary", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err) + host := fmt.Sprintf("canary-%s.com", suffix) + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + Backends: []framework.Backend{ + {Name: framework.DummyAppName1}, + {Name: framework.DummyAppName2}, + }, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("foo1"). + WithHost(host). + WithIngressClass(ingressnginx.NginxIngressClass). + Build(), + framework.BasicIngress(). + WithName("foo2"). + WithHost(host). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/canary", "true"). + WithAnnotation("nginx.ingress.kubernetes.io/canary-weight", "20"). + WithBackend(framework.DummyAppName2). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "foo1": { + &framework.CanaryVerifier{ + Verifier: &framework.HTTPRequestVerifier{ + Host: host, + Path: "/hostname", + BodyRegex: regexp.MustCompile("^dummy-app2"), + }, + Runs: 200, + MinSuccesses: 0.1, + MaxSuccesses: 0.3, + }, + }, + }, + }) + }) + t.Run("canary by header at path", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err) + host := fmt.Sprintf("canary-header-path-%s.com", suffix) + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + Backends: []framework.Backend{ + {Name: framework.DummyAppName1}, + {Name: framework.DummyAppName2}, + }, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("main"). + WithHost(host). + WithPath("/hostname"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithBackend(framework.DummyAppName1). + Build(), + framework.BasicIngress(). + WithName("canary-header"). + WithHost(host). + WithPath("/hostname"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/canary", "true"). + WithAnnotation("nginx.ingress.kubernetes.io/canary-by-header", "X-Canary"). + WithBackend(framework.DummyAppName2). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "main": { + // With the canary header set to "always", all requests at the + // canary path should go to the canary backend. + &framework.HTTPRequestVerifier{ + Host: host, + Path: "/hostname", + RequestHeaders: map[string]string{ + "X-Canary": "always", + }, + BodyRegex: regexp.MustCompile("^dummy-app2"), + }, + // Without the header, requests should go to the main backend. + &framework.HTTPRequestVerifier{ + Host: host, + Path: "/hostname", + BodyRegex: regexp.MustCompile("^dummy-app1"), + }, + }, + }, + }) + }) + t.Run("canary weight and header combined", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err) + host := fmt.Sprintf("canary-combined-%s.com", suffix) + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + Backends: []framework.Backend{ + {Name: framework.DummyAppName1}, + {Name: framework.DummyAppName2}, + }, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("prod"). + WithHost(host). + WithIngressClass(ingressnginx.NginxIngressClass). + WithBackend(framework.DummyAppName1). + Build(), + framework.BasicIngress(). + WithName("canary-combined"). + WithHost(host). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/canary", "true"). + WithAnnotation("nginx.ingress.kubernetes.io/canary-weight", "20"). + WithAnnotation("nginx.ingress.kubernetes.io/canary-by-header", "X-Canary"). + WithBackend(framework.DummyAppName2). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "prod": { + // With the canary header set to "always", 100% of requests + // should go to the canary backend regardless of weight. + &framework.HTTPRequestVerifier{ + Host: host, + Path: "/hostname", + RequestHeaders: map[string]string{ + "X-Canary": "always", + }, + BodyRegex: regexp.MustCompile("^dummy-app2"), + }, + // With the canary header set to "never", 0% of requests + // should go to the canary backend regardless of weight. + &framework.HTTPRequestVerifier{ + Host: host, + Path: "/hostname", + RequestHeaders: map[string]string{ + "X-Canary": "never", + }, + BodyRegex: regexp.MustCompile("^dummy-app1"), + }, + // Without any header, the canary-weight (20%) applies. + &framework.CanaryVerifier{ + Verifier: &framework.HTTPRequestVerifier{ + Host: host, + Path: "/hostname", + BodyRegex: regexp.MustCompile("^dummy-app2"), + }, + Runs: 200, + MinSuccesses: 0.1, + MaxSuccesses: 0.3, + }, + }, + }, + }) + }) +} + +func TestIngressNGINXCORS(t *testing.T) { + t.Parallel() + t.Run("typical cors annotations", func(t *testing.T) { + origin := "https://cors.example.com" + allowHeaders := "X-Requested-With, Content-Type" + allowMethods := "GET, POST, OPTIONS" + exposeHeaders := "X-Expose-1, X-Expose-2" + maxAge := "600" + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + AllowExperimentalGWAPI: true, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("cors"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/enable-cors", "true"). + WithAnnotation("nginx.ingress.kubernetes.io/cors-allow-origin", origin). + WithAnnotation("nginx.ingress.kubernetes.io/cors-allow-methods", allowMethods). + WithAnnotation("nginx.ingress.kubernetes.io/cors-allow-headers", allowHeaders). + WithAnnotation("nginx.ingress.kubernetes.io/cors-allow-credentials", "true"). + WithAnnotation("nginx.ingress.kubernetes.io/cors-max-age", maxAge). + WithAnnotation("nginx.ingress.kubernetes.io/cors-expose-headers", exposeHeaders). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "cors": { + &framework.HTTPRequestVerifier{ + Path: "/", + Method: http.MethodOptions, + AllowedCodes: []int{ + http.StatusOK, + http.StatusNoContent, + }, + RequestHeaders: map[string]string{ + "Origin": origin, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": allowHeaders, + }, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Access-Control-Allow-Origin", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^" + regexp.QuoteMeta(origin) + "$")}, + }, + }, + { + Name: "Access-Control-Allow-Methods", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`(?i).*GET.*`)}, + {Pattern: regexp.MustCompile(`(?i).*POST.*`)}, + {Pattern: regexp.MustCompile(`(?i).*OPTIONS.*`)}, + }, + }, + { + Name: "Access-Control-Allow-Headers", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`(?i).*X-Requested-With.*`)}, + {Pattern: regexp.MustCompile(`(?i).*Content-Type.*`)}, + }, + }, + { + Name: "Access-Control-Allow-Credentials", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`(?i)^true$`)}, + }, + }, + { + Name: "Access-Control-Max-Age", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^" + maxAge + "$")}, + }, + }, + }, + }, + &framework.HTTPRequestVerifier{ + Path: "/", + Method: http.MethodGet, + RequestHeaders: map[string]string{ + "Origin": origin, + }, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Access-Control-Allow-Origin", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^" + regexp.QuoteMeta(origin) + "$")}, + }, + }, + { + Name: "Access-Control-Allow-Credentials", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`(?i)^true$`)}, + }, + }, + { + Name: "Access-Control-Expose-Headers", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`(?i).*X-Expose-1.*`)}, + {Pattern: regexp.MustCompile(`(?i).*X-Expose-2.*`)}, + }, + }, + }, + }, + }, + }, + }) + }) + t.Run("cors defaults", func(t *testing.T) { + origin := "https://cors-defaults.example.com" + maxAge := "1728000" + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + AllowExperimentalGWAPI: true, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("cors-defaults"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/enable-cors", "true"). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "cors-defaults": { + &framework.HTTPRequestVerifier{ + Path: "/", + Method: http.MethodOptions, + AllowedCodes: []int{ + http.StatusOK, + http.StatusNoContent, + }, + RequestHeaders: map[string]string{ + "Origin": origin, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "X-Requested-With", + }, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Access-Control-Allow-Origin", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`^\*$|^` + regexp.QuoteMeta(origin) + `$`)}, + }, + }, + { + Name: "Access-Control-Allow-Methods", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`(?i).*GET.*`)}, + {Pattern: regexp.MustCompile(`(?i).*PUT.*`)}, + {Pattern: regexp.MustCompile(`(?i).*POST.*`)}, + {Pattern: regexp.MustCompile(`(?i).*DELETE.*`)}, + {Pattern: regexp.MustCompile(`(?i).*PATCH.*`)}, + {Pattern: regexp.MustCompile(`(?i).*OPTIONS.*`)}, + }, + }, + { + Name: "Access-Control-Allow-Headers", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`(?i).*DNT.*`)}, + {Pattern: regexp.MustCompile(`(?i).*Keep-Alive.*`)}, + {Pattern: regexp.MustCompile(`(?i).*User-Agent.*`)}, + {Pattern: regexp.MustCompile(`(?i).*X-Requested-With.*`)}, + {Pattern: regexp.MustCompile(`(?i).*If-Modified-Since.*`)}, + {Pattern: regexp.MustCompile(`(?i).*Cache-Control.*`)}, + {Pattern: regexp.MustCompile(`(?i).*Content-Type.*`)}, + {Pattern: regexp.MustCompile(`(?i).*Range.*`)}, + {Pattern: regexp.MustCompile(`(?i).*Authorization.*`)}, + }, + }, + { + Name: "Access-Control-Allow-Credentials", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`(?i)^true$`)}, + }, + }, + { + Name: "Access-Control-Max-Age", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^" + maxAge + "$")}, + }, + }, + }, + }, + &framework.HTTPRequestVerifier{ + Path: "/", + Method: http.MethodGet, + RequestHeaders: map[string]string{ + "Origin": origin, + }, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Access-Control-Allow-Origin", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`^\*$|^` + regexp.QuoteMeta(origin) + `$`)}, + }, + }, + { + Name: "Access-Control-Allow-Credentials", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`(?i)^true$`)}, + }, + }, + }, + }, + }, + }, + }) + }) + t.Run("cors denied origin", func(t *testing.T) { + allowedOrigin := "https://cors-allowed.example.com" + deniedOrigin := "https://cors-denied.example.com" + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + AllowExperimentalGWAPI: true, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("cors-denied"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/enable-cors", "true"). + WithAnnotation("nginx.ingress.kubernetes.io/cors-allow-origin", allowedOrigin). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "cors-denied": { + &framework.HTTPRequestVerifier{ + Path: "/", + Method: http.MethodOptions, + AllowedCodes: []int{ + http.StatusOK, + http.StatusNoContent, + }, + RequestHeaders: map[string]string{ + "Origin": deniedOrigin, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "X-Requested-With", + }, + HeaderAbsent: []string{"Access-Control-Allow-Origin"}, + }, + &framework.HTTPRequestVerifier{ + Path: "/", + Method: http.MethodGet, + RequestHeaders: map[string]string{ + "Origin": deniedOrigin, + }, + HeaderAbsent: []string{"Access-Control-Allow-Origin"}, + }, + }, + }, + }) + }) + t.Run("cors denied method and header", func(t *testing.T) { + origin := "https://cors-method.example.com" + allowedMethods := "GET, POST" + allowedHeaders := "X-Requested-With" + deniedMethod := "DELETE" + deniedHeader := "X-Not-Allowed" + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + AllowExperimentalGWAPI: true, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("cors-denied-method"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/enable-cors", "true"). + WithAnnotation("nginx.ingress.kubernetes.io/cors-allow-origin", origin). + WithAnnotation("nginx.ingress.kubernetes.io/cors-allow-methods", allowedMethods). + WithAnnotation("nginx.ingress.kubernetes.io/cors-allow-headers", allowedHeaders). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "cors-denied-method": { + &framework.HTTPRequestVerifier{ + Path: "/", + Method: http.MethodOptions, + AllowedCodes: []int{ + http.StatusOK, + http.StatusNoContent, + }, + RequestHeaders: map[string]string{ + "Origin": origin, + "Access-Control-Request-Method": deniedMethod, + "Access-Control-Request-Headers": deniedHeader, + }, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Access-Control-Allow-Origin", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^" + regexp.QuoteMeta(origin) + "$")}, + }, + }, + { + Name: "Access-Control-Allow-Methods", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`(?i).*GET.*`)}, + {Pattern: regexp.MustCompile(`(?i).*POST.*`)}, + }, + }, + { + Name: "Access-Control-Allow-Headers", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`(?i).*` + regexp.QuoteMeta(allowedHeaders) + `.*`)}, + }, + }, + { + Name: "Access-Control-Allow-Methods", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile(`(?i)` + deniedMethod), Negate: true}, + }, + }, + { + Name: "Access-Control-Allow-Headers", + Patterns: []*framework.MaybeNegativePattern{ + { + Pattern: regexp.MustCompile(`(?i)` + regexp.QuoteMeta(deniedHeader)), Negate: true, + }, + }, + }, + }, + }, + }, + }, + }) + }) +} + +func TestIngressNGINXPathRewrite(t *testing.T) { + t.Parallel() + t.Run("basic conversion", func(t *testing.T) { + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("foo1"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithPath("/abc"). + WithAnnotation("nginx.ingress.kubernetes.io/rewrite-target", "/header"). + WithAnnotation("nginx.ingress.kubernetes.io/x-forwarded-prefix", "/abc"). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "foo1": { + &framework.HTTPRequestVerifier{Path: "/abc", BodyRegex: regexp.MustCompile(`"X-Forwarded-Prefix":\["/abc"\]`)}, + }, + }, + }) + }) +} + +func TestIngressNGINXTLS(t *testing.T) { + t.Parallel() + t.Run("tls ingress and gateway", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err, "creating host suffix") + host := "tls-" + suffix + ".example.com" + tlsSecret, err := framework.GenerateSelfSignedTLSSecret("tls-cert-"+suffix, host, []string{host}) + if err != nil { + t.Fatalf("creating TLS secret: %v", err) + } + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Secrets: []*corev1.Secret{tlsSecret.Secret}, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("foo"). + WithHost(host). + WithIngressClass(ingressnginx.NginxIngressClass). + WithTLSSecret(tlsSecret.Secret.Name, host). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "foo": { + &framework.HTTPRequestVerifier{ + Host: host, + Path: "/", + UseTLS: true, + CACertPEM: tlsSecret.CACert, + }, + &framework.HTTPRequestVerifier{ + Host: host, + Path: "/", + AllowedCodes: []int{308}, + UseTLS: false, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Location", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^https://" + host + "/?$")}, + }, + }, + }, + }, + }, + }, + }) + }) + t.Run("ssl-redirect annotation", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err, "creating host suffix") + redirectHost := "tls-redirect-" + suffix + ".example.com" + noRedirectHost := "tls-noredirect-" + suffix + ".example.com" + redirectSecret, err := framework.GenerateSelfSignedTLSSecret("tls-redirect-"+suffix, redirectHost, []string{redirectHost}) + if err != nil { + t.Fatalf("creating redirect TLS secret: %v", err) + } + noRedirectSecret, err := framework.GenerateSelfSignedTLSSecret("tls-noredirect-"+suffix, noRedirectHost, []string{noRedirectHost}) + if err != nil { + t.Fatalf("creating no-redirect TLS secret: %v", err) + } + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Secrets: []*corev1.Secret{redirectSecret.Secret, noRedirectSecret.Secret}, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("redirect"). + WithHost(redirectHost). + WithIngressClass(ingressnginx.NginxIngressClass). + WithTLSSecret(redirectSecret.Secret.Name, redirectHost). + Build(), + framework.BasicIngress(). + WithName("no-redirect"). + WithHost(noRedirectHost). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/ssl-redirect", "false"). + WithTLSSecret(noRedirectSecret.Secret.Name, noRedirectHost). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "redirect": { + &framework.HTTPRequestVerifier{ + Host: redirectHost, + Path: "/", + UseTLS: true, + CACertPEM: redirectSecret.CACert, + }, + &framework.HTTPRequestVerifier{ + Host: redirectHost, + Path: "/", + AllowedCodes: []int{308}, + UseTLS: false, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Location", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^https://" + redirectHost + "/?$")}, + }, + }, + }, + }, + }, + "no-redirect": { + &framework.HTTPRequestVerifier{ + Host: noRedirectHost, + Path: "/", + UseTLS: true, + CACertPEM: noRedirectSecret.CACert, + }, + &framework.HTTPRequestVerifier{ + Host: noRedirectHost, + Path: "/", + UseTLS: false, + }, + }, + }, + }) + }) +} + +const slowShellPath = "/shell?cmd=sleep%204%3B%20echo%20done" +const verySlowShellPath = "/shell?cmd=sleep%2015%3B%20echo%20done" + +func TestIngressNGINXTimeouts(t *testing.T) { + t.Parallel() + t.Run("slow response allowed", func(t *testing.T) { + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("slow-allowed"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithPath("/shell"). + WithAnnotation(ingressnginx.ProxyConnectTimeoutAnnotation, "5"). + WithAnnotation(ingressnginx.ProxyReadTimeoutAnnotation, "5"). + WithAnnotation(ingressnginx.ProxySendTimeoutAnnotation, "5"). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "slow-allowed": { + &framework.HTTPRequestVerifier{ + Path: slowShellPath, + BodyRegex: regexp.MustCompile("done"), + }, + }, + }, + }) + }) + t.Run("short timeout", func(t *testing.T) { + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("short-timeout"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithPath("/shell"). + WithAnnotation(ingressnginx.ProxyConnectTimeoutAnnotation, "1"). + WithAnnotation(ingressnginx.ProxyReadTimeoutAnnotation, "1"). + WithAnnotation(ingressnginx.ProxySendTimeoutAnnotation, "1"). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "short-timeout": { + &framework.HTTPRequestVerifier{ + Path: verySlowShellPath, + AllowedCodes: []int{http.StatusGatewayTimeout, http.StatusInternalServerError}, + }, + }, + }, + }) + }) +} + +func TestIngressNGINXRedirect(t *testing.T) { + t.Parallel() + t.Run("permanent redirect", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err) + redirectURL := fmt.Sprintf("https://new-site-%s.example.com/new-path/", suffix) + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("permanent-redirect"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/permanent-redirect", redirectURL). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "permanent-redirect": { + &framework.HTTPRequestVerifier{ + Path: "/", + AllowedCodes: []int{ + http.StatusMovedPermanently, // 301 + }, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Location", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^" + regexp.QuoteMeta(redirectURL) + "$")}, + }, + }, + }, + }, + }, + }, + }) + }) + + t.Run("temporal redirect", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err) + redirectURL := fmt.Sprintf("https://temp-site-%s.example.com/temp-path/", suffix) + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("temporal-redirect"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/temporal-redirect", redirectURL). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "temporal-redirect": { + &framework.HTTPRequestVerifier{ + Path: "/", + AllowedCodes: []int{ + http.StatusFound, // 302 + }, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Location", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^" + regexp.QuoteMeta(redirectURL) + "$")}, + }, + }, + }, + }, + }, + }, + }) + }) + + t.Run("permanent redirect with supported custom code", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err) + redirectURL := fmt.Sprintf("https://custom-code-%s.example.com/path/", suffix) + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("permanent-redirect-301"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/permanent-redirect", redirectURL). + WithAnnotation("nginx.ingress.kubernetes.io/permanent-redirect-code", "301"). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "permanent-redirect-301": { + &framework.HTTPRequestVerifier{ + Path: "/", + AllowedCodes: []int{ + http.StatusMovedPermanently, // 301 + }, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Location", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^" + regexp.QuoteMeta(redirectURL) + "$")}, + }, + }, + }, + }, + }, + }, + }) + }) + + t.Run("temporal redirect with supported custom code", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err) + redirectURL := fmt.Sprintf("https://custom-temp-%s.example.com/path/", suffix) + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("temporal-redirect-302"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/temporal-redirect", redirectURL). + WithAnnotation("nginx.ingress.kubernetes.io/temporal-redirect-code", "302"). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "temporal-redirect-302": { + &framework.HTTPRequestVerifier{ + Path: "/", + AllowedCodes: []int{ + http.StatusFound, // 302 + }, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Location", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^" + regexp.QuoteMeta(redirectURL) + "$")}, + }, + }, + }, + }, + }, + }, + }) + }) + + t.Run("redirect with scheme and hostname only", func(t *testing.T) { + redirectURL := "https://another-domain.example.com/" + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("redirect-hostname-only"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/permanent-redirect", redirectURL). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "redirect-hostname-only": { + &framework.HTTPRequestVerifier{ + Path: "/some/path", + AllowedCodes: []int{ + http.StatusMovedPermanently, // 301 + }, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Location", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^" + regexp.QuoteMeta(redirectURL) + "$")}, + }, + }, + }, + }, + }, + }, + }) + }) + + t.Run("redirect with port", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err) + redirectURL := fmt.Sprintf("https://custom-port-%s.example.com:8443/secure/", suffix) + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("redirect-with-port"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/temporal-redirect", redirectURL). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "redirect-with-port": { + &framework.HTTPRequestVerifier{ + Path: "/", + AllowedCodes: []int{ + http.StatusFound, // 302 + }, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Location", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^" + regexp.QuoteMeta(redirectURL) + "$")}, + }, + }, + }, + }, + }, + }, + }) + }) + + t.Run("both redirect annotations - temporal takes priority", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err) + permanentURL := fmt.Sprintf("https://permanent-%s.example.com/path/", suffix) + temporalURL := fmt.Sprintf("https://temporal-%s.example.com/path/", suffix) + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("both-redirects"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithAnnotation("nginx.ingress.kubernetes.io/permanent-redirect", permanentURL). + WithAnnotation("nginx.ingress.kubernetes.io/temporal-redirect", temporalURL). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "both-redirects": { + &framework.HTTPRequestVerifier{ + Path: "/", + AllowedCodes: []int{ + http.StatusFound, // 302 - temporal takes priority + }, + HeaderMatches: []framework.HeaderMatch{ + { + Name: "Location", + Patterns: []*framework.MaybeNegativePattern{ + {Pattern: regexp.MustCompile("^" + regexp.QuoteMeta(temporalURL) + "$")}, + }, + }, + }, + }, + }, + }, + }) + }) +} + +func TestIngressNGINXRegex(t *testing.T) { + t.Parallel() + t.Run("host-level matching", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err) + regexHost := fmt.Sprintf("regex-host-%s.example.com", suffix) + implementationSpecific := networkingv1.PathTypeImplementationSpecific + exactPathType := networkingv1.PathTypeExact + + plain := framework.BasicIngress(). + WithName("plain"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithHost(regexHost). + WithPath("/hoSTn"). // Check for case-insensitivity of regex matching + Build() + // Exact becomes regex which are prefix + plain.Spec.Rules[0].HTTP.Paths[0].PathType = &exactPathType + + regex := framework.BasicIngress(). + WithName("regex"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithHost(regexHost). + WithPath("/cliEnt.+"). // Check for case-insensitivity of regex matching + WithAnnotation(ingressnginx.UseRegexAnnotation, "true"). + Build() + regex.Spec.Rules[0].HTTP.Paths[0].PathType = &implementationSpecific + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{plain, regex}, + Verifiers: map[string][]framework.Verifier{ + "plain": { + &framework.HTTPRequestVerifier{ + Host: regexHost, + Path: "/hostname", + }, + }, + "regex": { + &framework.HTTPRequestVerifier{ + Host: regexHost, + Path: "/clientip", + }, + }, + }, + }) + }) + t.Run("rewrite-target implies host-level matching", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err) + regexHost := fmt.Sprintf("rewrite-regex-host-%s.example.com", suffix) + implementationSpecific := networkingv1.PathTypeImplementationSpecific + exactPathType := networkingv1.PathTypeExact + + plain := framework.BasicIngress(). + WithName("plain"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithHost(regexHost). + WithPath("/hostn"). + Build() + plain.Spec.Rules[0].HTTP.Paths[0].PathType = &exactPathType + + rewriteRegex := framework.BasicIngress(). + WithName("rewrite-regex"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithHost(regexHost). + WithPath("/client.+"). + WithAnnotation(ingressnginx.RewriteTargetAnnotation, "/"). + Build() + rewriteRegex.Spec.Rules[0].HTTP.Paths[0].PathType = &implementationSpecific + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{plain, rewriteRegex}, + Verifiers: map[string][]framework.Verifier{ + "plain": { + &framework.HTTPRequestVerifier{ + Host: regexHost, + Path: "/hostname", + }, + }, + "rewrite-regex": { + &framework.HTTPRequestVerifier{ + Host: regexHost, + Path: "/clientip", + }, + }, + }, + }) + }) + t.Run("regex ending with dollar matches only exact path", func(t *testing.T) { + suffix, err := framework.RandString() + require.NoError(t, err) + host := fmt.Sprintf("regex-dollar-%s.example.com", suffix) + implementationSpecific := networkingv1.PathTypeImplementationSpecific + + ing := framework.BasicIngress(). + WithName("dollar"). + WithIngressClass(ingressnginx.NginxIngressClass). + WithHost(host). + WithPath("/$"). + WithAnnotation(ingressnginx.UseRegexAnnotation, "true"). + Build() + ing.Spec.Rules[0].HTTP.Paths[0].PathType = &implementationSpecific + + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{ingressnginx.Name}, + ProviderFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + Ingresses: []*networkingv1.Ingress{ing}, + Verifiers: map[string][]framework.Verifier{ + "dollar": { + &framework.HTTPRequestVerifier{ + Host: host, + Path: "/", + }, + &framework.HTTPRequestVerifier{ + Host: host, + Path: "/hostname", + AllowedCodes: []int{404}, + }, + &framework.HTTPRequestVerifier{ + Host: host, + Path: "/hostname/", + AllowedCodes: []int{404}, + }, + }, + }, + }) + }) +} diff --git a/e2e/provider_test.go b/e2e/provider_test.go new file mode 100644 index 000000000..5cb75da8d --- /dev/null +++ b/e2e/provider_test.go @@ -0,0 +1,94 @@ +/* +Copyright The Kubernetes Authors. + +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 e2e + +import ( + "testing" + + "github.com/kgateway-dev/ingress2gateway/e2e/framework" + "github.com/kgateway-dev/ingress2gateway/e2e/implementation" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/ingressnginx" + networkingv1 "k8s.io/api/networking/v1" +) + +// Provider basics: Table-driven tests across providers, all using Istio + standard emitter. + +func TestProviders(t *testing.T) { + t.Parallel() + + providers := []struct { + name string + ingressClass string + providerFlags map[string]map[string]string + }{ + { + name: ingressnginx.Name, + ingressClass: ingressnginx.NginxIngressClass, + providerFlags: map[string]map[string]string{ + ingressnginx.Name: { + ingressnginx.NginxIngressClassFlag: ingressnginx.NginxIngressClass, + }, + }, + }, + } + + for _, prov := range providers { + t.Run(prov.name, func(t *testing.T) { + t.Parallel() + t.Run("to Istio", func(t *testing.T) { + t.Parallel() + t.Run("basic conversion", func(t *testing.T) { + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{prov.name}, + ProviderFlags: prov.providerFlags, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("foo"). + WithIngressClass(prov.ingressClass). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "foo": {&framework.HTTPRequestVerifier{Path: "/"}}, + }, + }) + }) + t.Run("multiple ingresses", func(t *testing.T) { + runTestCase(t, &framework.TestCase{ + GatewayImplementation: implementation.KgatewayName, + Providers: []string{prov.name}, + ProviderFlags: prov.providerFlags, + Ingresses: []*networkingv1.Ingress{ + framework.BasicIngress(). + WithName("foo"). + WithIngressClass(prov.ingressClass). + Build(), + framework.BasicIngress(). + WithName("bar"). + WithIngressClass(prov.ingressClass). + Build(), + }, + Verifiers: map[string][]framework.Verifier{ + "foo": {&framework.HTTPRequestVerifier{Path: "/"}}, + "bar": {&framework.HTTPRequestVerifier{Path: "/"}}, + }, + }) + }) + }) + }) + } +} diff --git a/examples/glooedge/test_virtualservice.yaml b/examples/glooedge/test_virtualservice.yaml new file mode 100644 index 000000000..dce1aaa73 --- /dev/null +++ b/examples/glooedge/test_virtualservice.yaml @@ -0,0 +1,17 @@ +apiVersion: gateway.solo.io/v1 +kind: VirtualService +metadata: + name: example-vs + namespace: default +spec: + hosts: + - example.com + virtualHost: + routes: + - matchers: + - prefix: /api + routeAction: + single: + upstream: + name: my-service + namespace: default diff --git a/go.mod b/go.mod index 025198cc2..4f1412d64 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,15 @@ require ( github.com/agentgateway/agentgateway v1.0.1 github.com/google/go-cmp v0.7.0 github.com/kgateway-dev/kgateway/v2 v2.2.2 - github.com/olekukonko/tablewriter v0.0.5 github.com/samber/lo v1.39.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 go.yaml.in/yaml/v4 v4.0.0-rc.3 + golang.org/x/sync v0.20.0 google.golang.org/grpc v1.79.3 + helm.sh/helm/v4 v4.0.4 k8s.io/api v0.35.3 + k8s.io/apiextensions-apiserver v0.35.3 k8s.io/apimachinery v0.35.3 k8s.io/cli-runtime v0.35.1 k8s.io/client-go v0.35.3 @@ -46,38 +48,88 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect golang.org/x/mod v0.34.0 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect istio.io/api v1.29.0-alpha.0.0.20260315093121-ce7e56c13e1d // indirect - k8s.io/apiextensions-apiserver v0.35.3 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/chai2010/gettext-go v1.0.3 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/extism/go-sdk v1.7.1 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fluxcd/cli-utils v0.36.0-flux.14 // indirect + github.com/go-errors/errors v1.5.1 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/jsonreference v0.21.4 // indirect github.com/go-openapi/swag v0.25.4 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gosuri/uitable v0.0.4 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/rubenv/sql-migrate v1.8.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.42.0 // indirect @@ -89,8 +141,14 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect istio.io/client-go v1.29.0-alpha.0.0.20260315093321-a99807642da7 + k8s.io/apiserver v0.35.3 // indirect + k8s.io/component-base v0.35.3 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect + k8s.io/kubectl v0.35.1 // indirect + oras.land/oras-go/v2 v2.6.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect + sigs.k8s.io/kustomize/api v0.21.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect + sigs.k8s.io/yaml v1.6.0 ) diff --git a/go.sum b/go.sum index 1db36689b..2616f8bda 100644 --- a/go.sum +++ b/go.sum @@ -1,30 +1,100 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/GoogleCloudPlatform/gke-gateway-api v1.4.0 h1:GY5Ni3evpCoZSz7Qk1ZaPlHj2ou4UmwSv1jv0E4M8xA= github.com/GoogleCloudPlatform/gke-gateway-api v1.4.0/go.mod h1:IFDp1XhE20jjqWG3o2ocYoz33nCH6HC4rJ6Hdag4y1M= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/agentgateway/agentgateway v1.0.1 h1:G9Ovl0XjSwBPZhU0j3WJXXFZ+J485oDW7GbS3miJOE0= github.com/agentgateway/agentgateway v1.0.1/go.mod h1:73YJqkFWJ84as1AFv6wM12ATuE9S2zqr6AaMm4gr18E= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.3 h1:9liNh8t+u26xl5ddmWLmsOsdNLwkdRTg5AG+JnTiM80= +github.com/chai2010/gettext-go v1.0.3/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= +github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= +github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker-credential-helpers v0.9.4 h1:76ItO69/AP/V4yT9V4uuuItG0B1N8hvt0T0c0NN/DzI= +github.com/docker/docker-credential-helpers v0.9.4/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE= +github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= +github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= +github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluxcd/cli-utils v0.36.0-flux.14 h1:I//AMVUXTc+M04UtIXArMXQZCazGMwfemodV1j/yG8c= +github.com/fluxcd/cli-utils v0.36.0-flux.14/go.mod h1:uDo7BYOfbdmk/asnHuI0IQPl6u0FCgcN54AHDu3Y5As= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -65,10 +135,17 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxE github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -78,25 +155,65 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/v github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= +github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/golang-lru/arc/v2 v2.0.6 h1:4NU7uP5vSoK6TbaMj3NtY478TTAWLso/vL1gpNrInHg= +github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw= +github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kgateway-dev/kgateway/v2 v2.2.2 h1:+emL1IKfVqAPWMFS7gXfROkTa1LJz9F6Gl24QWK7yIs= github.com/kgateway-dev/kgateway/v2 v2.2.2/go.mod h1:BcC96EGMqMLjBidlqoDEM9K9idA8mfgUrRVyyaELRSw= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -107,35 +224,64 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= +github.com/redis/go-redis/extra/redisotel/v9 v9.7.1 h1:LJF39lvUagUpKfL2/gZIp5vHv3AwXt9zOZ/Xual/CzI= +github.com/redis/go-redis/extra/redisotel/v9 v9.7.1/go.mod h1:VAY1vDpD/dLwfw/wU5SsexXNhCO9DjhRoGkmJeFONoE= +github.com/redis/go-redis/v9 v9.14.1 h1:nDCrEiJmfOWhD76xlaw+HXT0c9hfNWeXgl0vIRYSDvQ= +github.com/redis/go-redis/v9 v9.14.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0= +github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -144,23 +290,68 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 h1:/Rij/t18Y7rUayNg7Id6rPrEnHgorxYabm2E6wUdPP4= +go.opentelemetry.io/contrib/bridges/prometheus v0.63.0/go.mod h1:AdyDPn6pkbkt2w01n3BubRVk7xAsCRq1Yg1mpfyA/0E= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 h1:QQqYw3lkrzwVsoEX0w//EhH/TCnpRdEenKBOOEIMjWc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0/go.mod h1:gSVQcr17jk2ig4jqJ2DX30IdWH251JcNAecvrqTxH1s= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 h1:Oe2z/BCg5q7k4iXC3cqJxKYg0ieRiOqF0cecFYdPTwk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0/go.mod h1:ZQM5lAJpOsKnYagGg/zV2krVqTtaVdYdDkhMoX6Oalg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= +go.opentelemetry.io/otel/exporters/prometheus v0.60.0 h1:cGtQxGvZbnrWdC2GyjZi0PDKVSLWP/Jocix3QWfXtbo= +go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4OWFGZcKQiIqg0RfuWExcPKFvepk= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 h1:B/g+qde6Mkzxbry5ZZag0l7QrQBCtVm7lVjaLgmpje8= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0/go.mod h1:mOJK8eMmgW6ocDJn6Bn11CcZ05gi3P8GylBXEkZtbgA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= +go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= +go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= +go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg= +go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM= go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8= go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= @@ -171,6 +362,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= @@ -182,6 +375,7 @@ golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= @@ -209,8 +403,13 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +helm.sh/helm/v4 v4.0.4 h1:5Lokr7XxCe6IW/NMtdECuAFW/0bTs/2831deUrlKqP8= +helm.sh/helm/v4 v4.0.4/go.mod h1:fMyG9onvVK6HOBjjkzhhHORAsgEWlRMqDY84lvX7GvY= istio.io/api v1.29.0-alpha.0.0.20260315093121-ce7e56c13e1d h1:4r9B7Ji85KizCHiVj61AFQQCRA+njLZVBxGfT0SsPyA= istio.io/api v1.29.0-alpha.0.0.20260315093121-ce7e56c13e1d/go.mod h1:+brQWcBHoROuyA6fv8rbgg8Kfn0RCGuqoY0duCMuSLA= istio.io/client-go v1.29.0-alpha.0.0.20260315093321-a99807642da7 h1:97bNUAf5WKMNahFEJNK7jEwGQtUTakX4mvfxvLdwUEs= @@ -221,16 +420,24 @@ k8s.io/apiextensions-apiserver v0.35.3 h1:2fQUhEO7P17sijylbdwt0nBdXP0TvHrHj0KeqH k8s.io/apiextensions-apiserver v0.35.3/go.mod h1:tK4Kz58ykRpwAEkXUb634HD1ZAegEElktz/B3jgETd8= k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.3 h1:D2eIcfJ05hEAEewoSDg+05e0aSRwx8Y4Agvd/wiomUI= +k8s.io/apiserver v0.35.3/go.mod h1:JI0n9bHYzSgIxgIrfe21dbduJ9NHzKJ6RchcsmIKWKY= k8s.io/cli-runtime v0.35.1 h1:uKcXFe8J7AMAM4Gm2JDK4mp198dBEq2nyeYtO+JfGJE= k8s.io/cli-runtime v0.35.1/go.mod h1:55/hiXIq1C8qIJ3WBrWxEwDLdHQYhBNRdZOz9f7yvTw= k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= +k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0= +k8s.io/component-base v0.35.3/go.mod h1:IZ8LEG30kPN4Et5NeC7vjNv5aU73ku5MS15iZyvyMYk= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kubectl v0.35.1 h1:zP3Er8C5i1dcAFUMh9Eva0kVvZHptXIn/+8NtRWMxwg= +k8s.io/kubectl v0.35.1/go.mod h1:cQ2uAPs5IO/kx8R5s5J3Ihv3VCYwrx0obCXum0CvnXo= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0= @@ -239,6 +446,10 @@ sigs.k8s.io/gateway-api/conformance v1.5.1 h1:5eruSMKcwKnkX42PFek8oO6BgPNBD5FbWb sigs.k8s.io/gateway-api/conformance v1.5.1/go.mod h1:mcvYR0Zll1i5hmcKn+jNbWdZTBls6s5GU+FPUFIceXw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= diff --git a/hack/boilerplate/boilerplate.go.txt b/hack/boilerplate/boilerplate.go.txt index 4b76f1fdd..0926592d3 100644 --- a/hack/boilerplate/boilerplate.go.txt +++ b/hack/boilerplate/boilerplate.go.txt @@ -1,5 +1,5 @@ /* -Copyright YEAR The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/hack/boilerplate/boilerplate.py b/hack/boilerplate/boilerplate.py index 73c7e0d15..2e82e1e17 100755 --- a/hack/boilerplate/boilerplate.py +++ b/hack/boilerplate/boilerplate.py @@ -14,10 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -# This file is copied from https://github.com/kubernetes/kubernetes/blob/04c2b1fbdc1289c9a72eda87cf7072346e60d241/hack/boilerplate/boilerplate.py - -from __future__ import print_function - import argparse import datetime import difflib @@ -28,23 +24,22 @@ parser = argparse.ArgumentParser() parser.add_argument( - "filenames", - help="list of files to check, all files if unspecified", - nargs='*') + "filenames", help="list of files to check, all files if unspecified", nargs="*" +) rootdir = os.path.dirname(__file__) + "/../../" rootdir = os.path.abspath(rootdir) -parser.add_argument( - "--rootdir", default=rootdir, help="root directory to examine") +parser.add_argument("--rootdir", default=rootdir, help="root directory to examine") default_boilerplate_dir = os.path.join(rootdir, "hack/boilerplate") -parser.add_argument( - "--boilerplate-dir", default=default_boilerplate_dir) +parser.add_argument("--boilerplate-dir", default=default_boilerplate_dir) parser.add_argument( - "-v", "--verbose", + "-v", + "--verbose", help="give verbose output regarding why a file does not pass", - action="store_true") + action="store_true", +) args = parser.parse_args() @@ -57,43 +52,32 @@ def get_refs(): for path in glob.glob(os.path.join(args.boilerplate_dir, "boilerplate.*.txt")): extension = os.path.basename(path).split(".")[1] - ref_file = open(path, 'r') - ref = ref_file.read().splitlines() - ref_file.close() - refs[extension] = ref + with open(path, "r") as ref_file: + refs[extension] = ref_file.read().splitlines() return refs -def is_generated_file(filename, data, regexs): - for d in skipped_ungenerated_files: - if d in filename: - return False - - p = regexs["generated"] - return p.search(data) +def is_generated_file(data, regexs): + return regexs["generated"].search(data) def file_passes(filename, refs, regexs): try: - f = open(filename, 'r') - except Exception as exc: - print("Unable to open %s: %s" % (filename, exc), file=verbose_out) + with open(filename) as stream: + data = stream.read() + except OSError as exc: + print(f"Unable to open {filename}: {exc}", file=verbose_out) return False - data = f.read() - f.close() - # determine if the file is automatically generated - generated = is_generated_file(filename, data, regexs) + generated = is_generated_file(data, regexs) basename = os.path.basename(filename) extension = file_extension(filename) if generated: if extension == "go": extension = "generatego" - elif extension == "bzl": - extension = "generatebzl" if extension != "": ref = refs[extension] @@ -101,51 +85,40 @@ def file_passes(filename, refs, regexs): ref = refs[basename] # remove extra content from the top of files - if extension == "go" or extension == "generatego": - p = regexs["go_build_constraints"] - (data, found) = p.subn("", data, 1) + if extension in ("go", "generatego"): + data, found = regexs["go_build_constraints"].subn("", data, 1) elif extension in ["sh", "py"]: - p = regexs["shebang"] - (data, found) = p.subn("", data, 1) + data, found = regexs["shebang"].subn("", data, 1) data = data.splitlines() # if our test file is smaller than the reference it surely fails! if len(ref) > len(data): - print('File %s smaller than reference (%d < %d)' % - (filename, len(data), len(ref)), - file=verbose_out) + print( + f"File {filename} smaller than reference ({len(data)} < {len(ref)})", + file=verbose_out, + ) return False # trim our file to the same number of lines as the reference file - data = data[:len(ref)] - - p = regexs["year"] - for d in data: - if p.search(d): - if generated: - print('File %s has the YEAR field, but it should not be in generated file' % - filename, file=verbose_out) - else: - print('File %s has the YEAR field, but missing the year of date' % - filename, file=verbose_out) - return False + data = data[: len(ref)] if not generated: - # Replace all occurrences of the regex "2014|2015|2016|2017|2018" with "YEAR" - p = regexs["date"] - for i, d in enumerate(data): - (data[i], found) = p.subn('YEAR', d) + # Remove all occurrences of the year (regex "Copyright (2014|2015|2016|2017|2018) ") + pattern = regexs["date"] + for i, line in enumerate(data): + data[i], found = pattern.subn("Copyright ", line) if found != 0: break # if we don't match the reference at this point, fail if ref != data: - print("Header in %s does not match reference, diff:" % - filename, file=verbose_out) + print(f"Header in {filename} does not match reference, diff:", file=verbose_out) if args.verbose: print(file=verbose_out) - for line in difflib.unified_diff(ref, data, 'reference', filename, lineterm=''): + for line in difflib.unified_diff( + ref, data, "reference", filename, lineterm="" + ): print(line, file=verbose_out) print(file=verbose_out) return False @@ -157,28 +130,23 @@ def file_extension(filename): return os.path.splitext(filename)[1].split(".")[-1].lower() -skipped_dirs = [ - 'cluster/env.sh', - '.git', - '_gopath', - 'hack/boilerplate/test', - '_output', - 'staging/src/k8s.io/kubectl/pkg/generated/bindata.go', - 'test/e2e/generated/bindata.go', - 'third_party', - 'vendor', - '.venv', +skipped_names = [ + "third_party", + "_output", + ".git", + "cluster/env.sh", + "vendor", + "testdata", + "test/e2e/generated/bindata.go", + "hack/boilerplate/test", + "staging/src/k8s.io/kubectl/pkg/generated/bindata.go", ] -# list all the files contain 'DO NOT EDIT', but are not generated -skipped_ungenerated_files = [ - 'hack/lib/swagger.sh', 'hack/boilerplate/boilerplate.py'] - def normalize_files(files): newfiles = [] for pathname in files: - if any(x in pathname for x in skipped_dirs): + if any(x in pathname for x in skipped_names): continue newfiles.append(pathname) for i, pathname in enumerate(newfiles): @@ -197,9 +165,13 @@ def get_files(extensions): # as we would prune these later in normalize_files(). But doing it # cuts down the amount of filesystem walking we do and cuts down # the size of the file list - for d in skipped_dirs: - if d in dirs: - dirs.remove(d) + for dname in skipped_names: + if dname in dirs: + dirs.remove(dname) + for dname in dirs: + # dirs that start with __ are ignored + if dname.startswith("__"): + dirs.remove(dname) for name in walkfiles: pathname = os.path.join(root, name) @@ -216,39 +188,39 @@ def get_files(extensions): def get_dates(): - years = datetime.datetime.now().year - return '(%s)' % '|'.join((str(year) for year in range(2014, years+1))) + # After 2025, we no longer allow new files to include the year in the copyright header. + final_year = 2025 + return " (%s) " % "|".join(str(year) for year in range(2014, final_year + 1)) def get_regexs(): regexs = {} # Search for "YEAR" which exists in the boilerplate, but shouldn't in the real thing - regexs["year"] = re.compile('YEAR') - # get_dates return 2014, 2015, 2016, 2017, or 2018 until the current year as a regex like: "(2014|2015|2016|2017|2018)"; - # company holder names can be anything - regexs["date"] = re.compile(get_dates()) + regexs["year"] = re.compile("YEAR") + # get_dates return 2014, 2015, 2016, 2017, ..., 2025 + # as a regex like: "(2014|2015|2016|2017|2018|...|2025)"; + regexs["date"] = re.compile("Copyright" + get_dates()) # strip the following build constraints/tags: # //go:build # // +build \n\n regexs["go_build_constraints"] = re.compile( - r"^(//(go:build| \+build).*\n)+\n", re.MULTILINE) + r"^(//(go:build| \+build).*\n)+\n", re.MULTILINE + ) # strip #!.* from scripts regexs["shebang"] = re.compile(r"^(#!.*\n)\n*", re.MULTILINE) # Search for generated files - regexs["generated"] = re.compile('DO NOT EDIT') + regexs["generated"] = re.compile(r"^[/*#]+ +.* DO NOT EDIT\.$", re.MULTILINE) return regexs def main(): regexs = get_regexs() refs = get_refs() - filenames = get_files(refs.keys()) + filenames = get_files(refs) for filename in filenames: if not file_passes(filename, refs, regexs): - print(filename, file=sys.stdout) - - print("Verified %d file headers match boilerplate" % (len(filenames),), file=sys.stderr) + print(filename) return 0 diff --git a/hack/boilerplate/boilerplate.py.txt b/hack/boilerplate/boilerplate.py.txt index 34cb349c4..c06e635b3 100644 --- a/hack/boilerplate/boilerplate.py.txt +++ b/hack/boilerplate/boilerplate.py.txt @@ -1,4 +1,4 @@ -# Copyright YEAR The Kubernetes Authors. +# Copyright The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/hack/boilerplate/boilerplate.sh.txt b/hack/boilerplate/boilerplate.sh.txt index 384f325ab..069e282bc 100644 --- a/hack/boilerplate/boilerplate.sh.txt +++ b/hack/boilerplate/boilerplate.sh.txt @@ -1,4 +1,4 @@ -# Copyright YEAR The Kubernetes Authors. +# Copyright The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/hack/verify-golint.sh b/hack/verify-golint.sh index 124058689..2767db4c0 100755 --- a/hack/verify-golint.sh +++ b/hack/verify-golint.sh @@ -18,7 +18,7 @@ set -o errexit set -o nounset set -o pipefail -readonly VERSION="v2.6.2" +readonly VERSION="v2.8.0" readonly KUBE_ROOT=$(dirname "${BASH_SOURCE}")/.. cd "${KUBE_ROOT}" diff --git a/pkg/i2gw/emitter.go b/pkg/i2gw/emitter.go index 4e1e009ff..f9d2b5e8a 100644 --- a/pkg/i2gw/emitter.go +++ b/pkg/i2gw/emitter.go @@ -18,6 +18,7 @@ package i2gw import ( emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" @@ -60,5 +61,7 @@ type EmitterName string type EmitterConstructor func(conf *EmitterConf) Emitter type EmitterConf struct { - // TODO: add fields as needed. + // AllowExperimentalGatewayAPI indicates whether Experimental Gateway API features (like URLRewrite) should be included in the output. + AllowExperimentalGatewayAPI bool + Report *notifications.Report } diff --git a/pkg/i2gw/emitter_intermediate/gce/gce.go b/pkg/i2gw/emitter_intermediate/gce/gce.go index f96b87442..3048b8a25 100644 --- a/pkg/i2gw/emitter_intermediate/gce/gce.go +++ b/pkg/i2gw/emitter_intermediate/gce/gce.go @@ -23,7 +23,6 @@ type GatewayIR struct { type SslPolicyConfig struct { Name string } -type HTTPRouteIR struct{} type ServiceIR struct { SessionAffinity *SessionAffinityConfig SecurityPolicy *SecurityPolicyConfig diff --git a/pkg/i2gw/emitter_intermediate/intermediate_representation.go b/pkg/i2gw/emitter_intermediate/intermediate_representation.go index d92ab8a33..d94814cc4 100644 --- a/pkg/i2gw/emitter_intermediate/intermediate_representation.go +++ b/pkg/i2gw/emitter_intermediate/intermediate_representation.go @@ -22,11 +22,39 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" ) +// ExtensionFeatureMetadata holds metadata about an Ingress extension feature, such as its source and any failure message if the feature is not supported. +type ExtensionFeatureMetadata struct { + source string + paths []*field.Path + failureMessage string +} + +func (e *ExtensionFeatureMetadata) Source() string { + return e.source +} + +func (e *ExtensionFeatureMetadata) Paths() []*field.Path { + return e.paths +} + +func (e *ExtensionFeatureMetadata) FailureMessage() string { + return e.failureMessage +} + +func NewExtensionFeatureMetadata(source string, paths []*field.Path, failureMessage string) ExtensionFeatureMetadata { + return ExtensionFeatureMetadata{ + source: source, + paths: paths, + failureMessage: failureMessage, + } +} + // EmitterIR holds specifications of Gateway Objects for supporting Ingress extensions, // annotations, and proprietary API features not supported as Gateway core // features. An EmitterIR field can be mapped to core Gateway-API fields, @@ -44,9 +72,29 @@ type EmitterIR struct { BackendTLSPolicies map[types.NamespacedName]BackendTLSPolicyContext ReferenceGrants map[types.NamespacedName]ReferenceGrantContext + Services map[types.NamespacedName]ServiceContext + GceServices map[types.NamespacedName]gce.ServiceIR } +type SessionAffinity struct { + Metadata ExtensionFeatureMetadata + Type string + CookieTTLSec *int64 +} + +type ServiceContext struct { + SessionAffinity *SessionAffinity +} + +func (s *ServiceContext) UnparsedExtensions() []*ExtensionFeatureMetadata { + var unparsedExtensions []*ExtensionFeatureMetadata + if s.SessionAffinity != nil { + unparsedExtensions = append(unparsedExtensions, &s.SessionAffinity.Metadata) + } + return unparsedExtensions +} + type GatewayContext struct { gatewayv1.Gateway // Emitter IR should be provider/emitter neutral, @@ -56,72 +104,157 @@ type GatewayContext struct { type HTTPRouteContext struct { gatewayv1.HTTPRoute + // RuleBackendSources[i][j] is the source of the jth backend in the ith + // element of HTTPRoute.Spec.Rules. + RuleBackendSources [][]BackendSource - // PoliciesBySourceIngressName stores feature policy data keyed by source Ingress name. + // PoliciesBySourceIngressName tracks ingress-nginx policy intent keyed by + // source Ingress name. PoliciesBySourceIngressName map[string]Policy - // RegexLocationForHost is true when regex location matching should be used for the route host. - RegexLocationForHost *bool - - // RegexForcedByUseRegex is true when RegexLocationForHost is driven by use-regex annotation. + // RegexLocationForHost indicates whether regex path matching was enabled for + // any ingress contributing to this merged host-group route. + RegexLocationForHost *bool RegexForcedByUseRegex bool + RegexForcedByRewrite bool - // RegexForcedByRewrite is true when RegexLocationForHost is driven by rewrite-target annotation. - RegexForcedByRewrite bool + // TCPTimeoutsByRuleIdx holds provider TCP-level timeouts by HTTPRoute rule index. + TCPTimeoutsByRuleIdx map[int]*TCPTimeouts - // RuleBackendSources tracks the source Ingress resources for each backend. - RuleBackendSources [][]BackendSource + // PathRewriteByRuleIdx maps HTTPRoute rule indices to path rewrite intent. + // This is provider-neutral and applied by the common emitter. + PathRewriteByRuleIdx map[int]*PathRewrite + + // BodySizeByRuleIdx maps HTTPRoute rule indices to body size intent. + // This is provider-neutral and applied by each custom emitter. + BodySizeByRuleIdx map[int]*BodySize + + // RateLimitByRuleIdx maps HTTPRoute rule indices to rate limit intent. + // This is provider-neutral and applied by each custom emitter. + RateLimitByRuleIdx map[int]*RateLimitPolicy + + // LoadBalancingByRuleIdx maps HTTPRoute rule indices to load balancing intent. + // This is provider-neutral and applied by each custom emitter. + LoadBalancingByRuleIdx map[int]*BackendLoadBalancingPolicy + + // EnableAccessLogByRuleIdx maps HTTPRoute rule indices to access log intent. + // This is provider-neutral and applied by each custom emitter. + EnableAccessLogByRuleIdx map[int]*AccessLog + + // CorsPolicyByRuleIdx maps HTTPRoute rule indices to CORS policy intent. + // This map is populated by providers that support CORS (e.g., via annotations) and is + // applied by the CommonEmitter. This separation allows the CORS logic to be provider-neutral + // and consistently applied across different providers, subject to feature gating. + CorsPolicyByRuleIdx map[int]*CORSConfig + + // IPRangeControlByRuleIdx maps HTTPRoute rule indices to IP range control intent. + // This is provider-neutral and applied by each custom emitter. + IPRangeControlByRuleIdx map[int]*IPRangeControl } -// BackendSource tracks the source Ingress resource that contributed -// a specific BackendRef to an HTTPRoute rule. -type BackendSource struct { - // Source Ingress that contributed this backend - Ingress *networkingv1.Ingress +func (h *HTTPRouteContext) UnparsedExtensions() []*ExtensionFeatureMetadata { + var unparsedExtensions []*ExtensionFeatureMetadata + for _, x := range h.BodySizeByRuleIdx { + if x != nil { + unparsedExtensions = append(unparsedExtensions, &x.Metadata) + } + } + for _, x := range h.RateLimitByRuleIdx { + if x != nil { + unparsedExtensions = append(unparsedExtensions, &x.Metadata) + } + } + for _, x := range h.LoadBalancingByRuleIdx { + if x != nil { + unparsedExtensions = append(unparsedExtensions, &x.Metadata) + } + } + for _, x := range h.EnableAccessLogByRuleIdx { + if x != nil { + unparsedExtensions = append(unparsedExtensions, &x.Metadata) + } + } + for _, x := range h.IPRangeControlByRuleIdx { + if x != nil { + unparsedExtensions = append(unparsedExtensions, &x.Metadata) + } + } + for _, x := range h.PathRewriteByRuleIdx { + if x != nil { + unparsedExtensions = append(unparsedExtensions, &x.Metadata) + } + } + return unparsedExtensions +} - // Exactly one of Path or DefaultBackend must be non-nil. - // Path points to the specific HTTPIngressPath that contributed this backend. - Path *networkingv1.HTTPIngressPath +// TCPTimeouts holds TCP-level timeout configuration for a single HTTPRoute rule. +type TCPTimeouts struct { + Connect *gatewayv1.Duration + Read *gatewayv1.Duration + Write *gatewayv1.Duration +} - // DefaultBackend points to the Ingress's spec.defaultBackend that contributed this backend. +// BackendSource tracks the source Ingress resource that contributed a specific +// BackendRef to an HTTPRoute rule. +type BackendSource struct { + Ingress *networkingv1.Ingress + Path *networkingv1.HTTPIngressPath DefaultBackend *networkingv1.IngressBackend } -type GatewayClassContext struct { - gatewayv1.GatewayClass +type PolicyIndex struct { + Rule int + Backend int } -type TLSRouteContext struct { - gatewayv1alpha2.TLSRoute +// PathRewrite represents provider-neutral path rewrite intent. +// For now it only supports full-path replacement; more fields may be added later. +type PathRewrite struct { + Metadata ExtensionFeatureMetadata + ReplaceFullPath string + // Headers to add on path rewrite. + Headers map[string]string + RegexCaptureGroupReferences bool } -type TCPRouteContext struct { - gatewayv1alpha2.TCPRoute +// BodySize represents provider-neutral body size intent. +type BodySize struct { + Metadata ExtensionFeatureMetadata + BufferSize *resource.Quantity + MaxSize *resource.Quantity } -type UDPRouteContext struct { - gatewayv1alpha2.UDPRoute -} +type RateLimitUnit string -type GRPCRouteContext struct { - gatewayv1.GRPCRoute +const ( + RateLimitUnitRPS RateLimitUnit = "rps" + RateLimitUnitRPM RateLimitUnit = "rpm" +) + +// RateLimitPolicy represents provider-neutral rate limit intent. +type RateLimitPolicy struct { + Metadata ExtensionFeatureMetadata + Limit int32 + Unit RateLimitUnit + BurstMultiplier int32 } -type BackendTLSPolicyContext struct { - gatewayv1.BackendTLSPolicy +type AccessLog struct { + Metadata ExtensionFeatureMetadata + Enabled bool } -type ReferenceGrantContext struct { - gatewayv1beta1.ReferenceGrant +// IPRangeControl represents provider-neutral IP range control intent. +type IPRangeControl struct { + Metadata ExtensionFeatureMetadata + AllowList []string + DenyList []string } -// PolicyIndex identifies a (rule, backend) pair within a merged HTTPRoute. -type PolicyIndex struct { - Rule int - Backend int +type CORSConfig struct { + gatewayv1.HTTPCORSFilter } -// CorsPolicy defines a CORS policy extracted from annotations. type CorsPolicy struct { Enable bool AllowOrigin []string @@ -132,36 +265,56 @@ type CorsPolicy struct { MaxAge *int32 } -// ExtAuthPolicy defines an external auth policy extracted from annotations. type ExtAuthPolicy struct { AuthURL string ResponseHeaders []string } -// BasicAuthPolicy defines a basic auth policy extracted from annotations. type BasicAuthPolicy struct { SecretName string AuthType string } -// SessionAffinityPolicy defines a session affinity policy extracted from annotations. type SessionAffinityPolicy struct { CookieName string CookiePath string CookieDomain string CookieSameSite string - CookieExpires *metav1.Duration + CookieExpires *int64 CookieSecure *bool } -// BackendTLSPolicy defines a backend TLS policy extracted from annotations. +type LoadBalancingStrategy string + +const ( + LoadBalancingStrategyRoundRobin LoadBalancingStrategy = "round_robin" +) + +type BackendLoadBalancingPolicy struct { + Metadata ExtensionFeatureMetadata + Strategy LoadBalancingStrategy +} + type BackendTLSPolicy struct { SecretName string Verify bool Hostname string } -// Policy describes per-Ingress policy knobs projected by providers. +type BackendProtocol string + +const ( + BackendProtocolGRPC BackendProtocol = "GRPC" +) + +type Backend struct { + Namespace string + Name string + Port int32 + Host string + Protocol *BackendProtocol +} + type Policy struct { ClientBodyBufferSize *resource.Quantity ProxyBodySize *resource.Quantity @@ -180,118 +333,59 @@ type Policy struct { SSLRedirect *bool RewriteTarget *string UseRegexPaths *bool - - // RuleBackendSources lists covered (rule, backend) pairs in the merged HTTPRoute. - RuleBackendSources []PolicyIndex - - // Backends holds all proxied backends that cannot be rendered as a standard k8s service. - Backends map[types.NamespacedName]Backend - - // ruleBackendIndexSet is an internal helper used to deduplicate RuleBackendSources entries. - ruleBackendIndexSet map[PolicyIndex]struct{} -} - -// BackendProtocol defines the L7 protocol used to talk to a Backend. -type BackendProtocol string - -// BackendProtocolGRPC is the gRPC protocol. -const BackendProtocolGRPC BackendProtocol = "grpc" - -// Backend defines a proxied backend that cannot be rendered as a standard k8s Service. -type Backend struct { - Namespace string - Name string - Port int32 - Host string - Protocol *BackendProtocol -} - -// RateLimitUnit defines the unit of rate limiting. -type RateLimitUnit string - -const ( - // RateLimitUnitRPS defines rate limit in requests per second. - RateLimitUnitRPS RateLimitUnit = "rps" - // RateLimitUnitRPM defines rate limit in requests per minute. - RateLimitUnitRPM RateLimitUnit = "rpm" -) - -// RateLimitPolicy defines a rate limiting policy derived from annotations. -type RateLimitPolicy struct { - Limit int32 - Unit RateLimitUnit - BurstMultiplier int32 -} - -// LoadBalancingStrategy represents upstream load-balancing mode. -type LoadBalancingStrategy string - -// LoadBalancingStrategyRoundRobin is the supported round_robin strategy. -const LoadBalancingStrategyRoundRobin LoadBalancingStrategy = "round_robin" - -// BackendLoadBalancingPolicy defines backend load-balancing policy. -type BackendLoadBalancingPolicy struct { - Strategy LoadBalancingStrategy + RuleBackendSources []PolicyIndex + Backends map[types.NamespacedName]Backend } -// AddRuleBackendSources returns a copy of p with idxs added to RuleBackendSources, -// ensuring each (rule, backend) pair is unique. func (p Policy) AddRuleBackendSources(idxs []PolicyIndex) Policy { - pCopy := p + if len(idxs) == 0 { + return p + } - if len(pCopy.RuleBackendSources) > 0 && pCopy.ruleBackendIndexSet == nil { - pCopy.ruleBackendIndexSet = make(map[PolicyIndex]struct{}, len(pCopy.RuleBackendSources)) - for _, existing := range pCopy.RuleBackendSources { - pCopy.ruleBackendIndexSet[existing] = struct{}{} + seen := make(map[PolicyIndex]struct{}, len(p.RuleBackendSources)+len(idxs)) + deduped := make([]PolicyIndex, 0, len(p.RuleBackendSources)+len(idxs)) + for _, idx := range p.RuleBackendSources { + if _, ok := seen[idx]; ok { + continue } + seen[idx] = struct{}{} + deduped = append(deduped, idx) } - if pCopy.ruleBackendIndexSet == nil { - pCopy.ruleBackendIndexSet = make(map[PolicyIndex]struct{}) - } - for _, idx := range idxs { - if _, exists := pCopy.ruleBackendIndexSet[idx]; exists { + if _, ok := seen[idx]; ok { continue } - pCopy.RuleBackendSources = append(pCopy.RuleBackendSources, idx) - pCopy.ruleBackendIndexSet[idx] = struct{}{} + seen[idx] = struct{}{} + deduped = append(deduped, idx) } - - return pCopy + p.RuleBackendSources = deduped + return p } -// Backward-compatibility aliases for older ingress-nginx-prefixed names. - -type IngressNginxPolicy = Policy - -type IngressNginxPolicyIndex = PolicyIndex - -type IngressNginxCorsPolicy = CorsPolicy - -type IngressNginxExtAuthPolicy = ExtAuthPolicy - -type IngressNginxBasicAuthPolicy = BasicAuthPolicy - -type IngressNginxSessionAffinityPolicy = SessionAffinityPolicy - -type IngressNginxBackendTLSPolicy = BackendTLSPolicy - -type IngressNginxBackendProtocol = BackendProtocol - -const IngressNginxBackendProtocolGRPC = BackendProtocolGRPC - -type IngressNginxBackend = Backend - -type IngressNginxRateLimitUnit = RateLimitUnit +type GatewayClassContext struct { + gatewayv1.GatewayClass +} -const IngressNginxRateLimitUnitRPS = RateLimitUnitRPS +type TLSRouteContext struct { + gatewayv1alpha2.TLSRoute +} -const IngressNginxRateLimitUnitRPM = RateLimitUnitRPM +type TCPRouteContext struct { + gatewayv1alpha2.TCPRoute +} -type IngressNginxRateLimitPolicy = RateLimitPolicy +type UDPRouteContext struct { + gatewayv1alpha2.UDPRoute +} -type IngressNginxLoadBalancingStrategy = LoadBalancingStrategy +type GRPCRouteContext struct { + gatewayv1.GRPCRoute +} -const IngressNginxLoadBalancingStrategyRoundRobin = LoadBalancingStrategyRoundRobin +type BackendTLSPolicyContext struct { + gatewayv1.BackendTLSPolicy +} -type IngressNginxBackendLoadBalancingPolicy = BackendLoadBalancingPolicy +type ReferenceGrantContext struct { + gatewayv1beta1.ReferenceGrant +} diff --git a/pkg/i2gw/emitters/agentgateway/access_log.go b/pkg/i2gw/emitters/agentgateway/access_log.go index b8b91be51..9a002766b 100644 --- a/pkg/i2gw/emitters/agentgateway/access_log.go +++ b/pkg/i2gw/emitters/agentgateway/access_log.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/pkg/i2gw/emitters/agentgateway/emitter.go b/pkg/i2gw/emitters/agentgateway/agentgateway.go similarity index 91% rename from pkg/i2gw/emitters/agentgateway/emitter.go rename to pkg/i2gw/emitters/agentgateway/agentgateway.go index 014afc477..0bbff671f 100644 --- a/pkg/i2gw/emitters/agentgateway/emitter.go +++ b/pkg/i2gw/emitters/agentgateway/agentgateway.go @@ -1,5 +1,5 @@ /* -Copyright 2025 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import ( "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitters/utils" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" agentgatewayv1alpha1 "github.com/agentgateway/agentgateway/controller/api/v1alpha1/agentgateway" "github.com/agentgateway/agentgateway/controller/api/v1alpha1/shared" @@ -32,17 +33,20 @@ import ( gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) -const gatewayClassName = "agentgateway" +const emitterName = "agentgateway" func init() { - i2gw.EmitterConstructorByName["agentgateway"] = NewEmitter + i2gw.EmitterConstructorByName[emitterName] = NewEmitter } -type Emitter struct{} +type Emitter struct { + notify notifications.NotifyFunc +} -// NewEmitter returns a new instance of AgentgatewayEmitter. -func NewEmitter(_ *i2gw.EmitterConf) i2gw.Emitter { - return &Emitter{} +func NewEmitter(conf *i2gw.EmitterConf) i2gw.Emitter { + return &Emitter{ + notify: conf.Report.Notifier(emitterName), + } } // Emit converts EmitterIR to Gateway API resources plus agentgateway-specific extensions. @@ -55,10 +59,26 @@ func (e *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.Err // Set GatewayClassName to "agentgateway" for all Gateways for key := range gatewayResources.Gateways { gateway := gatewayResources.Gateways[key] - gateway.Spec.GatewayClassName = gatewayClassName + gateway.Spec.GatewayClassName = emitterName gatewayResources.Gateways[key] = gateway } + errs = e.ToAgentgatewayResources(ir, &gatewayResources) + if len(errs) > 0 { + return gatewayResources, errs + } + + return gatewayResources, nil +} + +// ToAgentgatewayResources processes emitter IR and adds agentgateway-specific extensions +// to gatewayResources. +func (e *Emitter) ToAgentgatewayResources( + ir emitterir.EmitterIR, + gatewayResources *i2gw.GatewayResources, +) field.ErrorList { + var errs field.ErrorList + // Track agentgateway-specific resources var agentgatewayObjs []client.Object @@ -78,7 +98,8 @@ func (e *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.Err basicAuthSecretSeen := map[basicAuthSecretKey]struct{}{} for httpRouteKey, httpRouteContext := range ir.HTTPRoutes { - if len(httpRouteContext.PoliciesBySourceIngressName) == 0 { + effectivePolicies := effectivePoliciesWithPerRuleFeatures(httpRouteContext) + if len(effectivePolicies) == 0 { continue } @@ -86,14 +107,14 @@ func (e *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.Err // TODO: implement regex path matching if needed // deterministic policy iteration - policyNames := make([]string, 0, len(httpRouteContext.PoliciesBySourceIngressName)) - for name := range httpRouteContext.PoliciesBySourceIngressName { + policyNames := make([]string, 0, len(effectivePolicies)) + for name := range effectivePolicies { policyNames = append(policyNames, name) } sort.Strings(policyNames) for _, polSourceIngressName := range policyNames { - pol := httpRouteContext.PoliciesBySourceIngressName[polSourceIngressName] + pol := effectivePolicies[polSourceIngressName] // Normalize (rule, backend) coverage to unique pairs to avoid // generating duplicate filters on the same backendRef. @@ -184,6 +205,7 @@ func (e *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.Err touched = true // Emit an INFO notification with guidance about Secret key expectations. emitBasicAuthSecretNotifications( + e.notify, pol, polSourceIngressName, httpRouteKey.Namespace, @@ -383,5 +405,5 @@ func (e *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.Err gatewayResources.GatewayExtensions = append(gatewayResources.GatewayExtensions, *u) } - return gatewayResources, errs + return errs } diff --git a/pkg/i2gw/emitters/agentgateway/agentgateway_test.go b/pkg/i2gw/emitters/agentgateway/agentgateway_test.go new file mode 100644 index 000000000..dd594f3e7 --- /dev/null +++ b/pkg/i2gw/emitters/agentgateway/agentgateway_test.go @@ -0,0 +1,333 @@ +/* +Copyright The Kubernetes Authors. + +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 agentgateway + +import ( + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestEmit_Gateway(t *testing.T) { + e := &Emitter{notify: notifications.NoopNotify} + nn := types.NamespacedName{Namespace: "default", Name: "test-gateway"} + + gr, errs := e.Emit(emitterir.EmitterIR{ + Gateways: map[types.NamespacedName]emitterir.GatewayContext{ + nn: { + Gateway: gatewayv1.Gateway{ + Spec: gatewayv1.GatewaySpec{ + Listeners: []gatewayv1.Listener{{ + Name: "http", + Port: 80, + Protocol: gatewayv1.HTTPProtocolType, + Hostname: common.PtrTo(gatewayv1.Hostname("example.com")), + }}, + }, + }, + }, + }, + }) + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + + if gw, ok := gr.Gateways[nn]; !ok { + t.Fatalf("missing gateway %s", nn) + } else if gw.Spec.GatewayClassName != emitterName { + t.Errorf("unexpected GatewayClassName %q", gw.Spec.GatewayClassName) + } +} + +func TestEmit_BodySizeFromRuleIR(t *testing.T) { + nn := types.NamespacedName{Namespace: "default", Name: "test-http-route"} + + testHTTPRoute := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-http-route"}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{ + Name: gatewayv1.ObjectName("test-gateway"), + }}, + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{{ + Path: &gatewayv1.HTTPPathMatch{ + Type: common.PtrTo(gatewayv1.PathMatchPathPrefix), + Value: common.PtrTo("/"), + }, + }}, + BackendRefs: []gatewayv1.HTTPBackendRef{{ + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("test-service"), + Port: common.PtrTo(gatewayv1.PortNumber(80)), + }, + }, + }}, + }, + }, + }, + } + + e := &Emitter{notify: notifications.NoopNotify} + got, errs := e.Emit(emitterir.EmitterIR{ + HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{ + nn: { + HTTPRoute: testHTTPRoute, + BodySizeByRuleIdx: map[int]*emitterir.BodySize{ + 0: { + Metadata: emitterir.NewExtensionFeatureMetadata("default/ing-body-size", nil, ""), + BufferSize: common.PtrTo(resource.MustParse("1Mi")), + MaxSize: common.PtrTo(resource.MustParse("2Mi")), + }, + }, + }, + }, + }) + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if len(got.GatewayExtensions) != 1 { + t.Fatalf("want 1 GatewayExtension, got %d", len(got.GatewayExtensions)) + } + + ext := got.GatewayExtensions[0] + if ext.GetKind() != AgentgatewayPolicyGVK.Kind { + t.Fatalf("want kind %q, got %q", AgentgatewayPolicyGVK.Kind, ext.GetKind()) + } + if ext.GetName() != "ing-body-size" { + t.Fatalf("want policy name %q, got %q", "ing-body-size", ext.GetName()) + } + + targetRefs, found, err := unstructured.NestedSlice(ext.Object, "spec", "targetRefs") + if err != nil || !found || len(targetRefs) != 1 { + t.Fatalf("expected one targetRef, found=%v err=%v targetRefs=%#v", found, err, targetRefs) + } + targetRef, ok := targetRefs[0].(map[string]any) + if !ok { + t.Fatalf("expected targetRef map, got %#v", targetRefs[0]) + } + if targetRef["name"] != "test-http-route" { + t.Fatalf("want targetRef name %q, got %#v", "test-http-route", targetRef["name"]) + } + + maxBufferSize, found, err := unstructured.NestedInt64(ext.Object, "spec", "frontend", "http", "maxBufferSize") + if err != nil || !found { + t.Fatalf("expected frontend.http.maxBufferSize, found=%v err=%v", found, err) + } + if maxBufferSize != 2*1024*1024 { + t.Fatalf("want maxBufferSize %d, got %d", 2*1024*1024, maxBufferSize) + } +} + +func TestEmit_RateLimitFromRuleIR(t *testing.T) { + nn := types.NamespacedName{Namespace: "default", Name: "test-http-route"} + + testHTTPRoute := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-http-route"}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{ + Name: gatewayv1.ObjectName("test-gateway"), + }}, + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{{ + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("test-service"), + Port: common.PtrTo(gatewayv1.PortNumber(80)), + }, + }, + }}, + }, + }, + }, + } + + e := &Emitter{notify: notifications.NoopNotify} + got, errs := e.Emit(emitterir.EmitterIR{ + HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{ + nn: { + HTTPRoute: testHTTPRoute, + RateLimitByRuleIdx: map[int]*emitterir.RateLimitPolicy{ + 0: { + Metadata: emitterir.NewExtensionFeatureMetadata("default/ing-ratelimit", nil, ""), + Limit: 10, + Unit: emitterir.RateLimitUnitRPS, + }, + }, + }, + }, + }) + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if len(got.GatewayExtensions) != 1 { + t.Fatalf("want 1 GatewayExtension, got %d", len(got.GatewayExtensions)) + } + + ext := got.GatewayExtensions[0] + if ext.GetKind() != AgentgatewayPolicyGVK.Kind { + t.Fatalf("want kind %q, got %q", AgentgatewayPolicyGVK.Kind, ext.GetKind()) + } + if ext.GetName() != "ing-ratelimit" { + t.Fatalf("want policy name %q, got %q", "ing-ratelimit", ext.GetName()) + } + + targetRefs, found, err := unstructured.NestedSlice(ext.Object, "spec", "targetRefs") + if err != nil || !found || len(targetRefs) != 1 { + t.Fatalf("expected one targetRef, found=%v err=%v targetRefs=%#v", found, err, targetRefs) + } + targetRef, ok := targetRefs[0].(map[string]any) + if !ok { + t.Fatalf("expected targetRef map, got %#v", targetRefs[0]) + } + if targetRef["name"] != "test-http-route" { + t.Fatalf("want targetRef name %q, got %#v", "test-http-route", targetRef["name"]) + } + + localLimits, found, err := unstructured.NestedSlice(ext.Object, "spec", "traffic", "rateLimit", "local") + if err != nil || !found || len(localLimits) != 1 { + t.Fatalf("expected one local rate limit, found=%v err=%v local=%#v", found, err, localLimits) + } + localLimit, ok := localLimits[0].(map[string]any) + if !ok { + t.Fatalf("expected local rate limit map, got %#v", localLimits[0]) + } + if localLimit["requests"] != int64(10) { + t.Fatalf("want requests %d, got %#v", 10, localLimit["requests"]) + } + if localLimit["unit"] != string("Seconds") { + t.Fatalf("want unit %q, got %#v", "Seconds", localLimit["unit"]) + } +} + +func TestEmit_AccessLogFromRuleIR(t *testing.T) { + tests := []struct { + name string + ingress string + enabled bool + wantFilter any + }{ + { + name: "enabled access log emits empty config", + ingress: "ing-access-log-on", + enabled: true, + wantFilter: nil, + }, + { + name: "disabled access log emits false filter", + ingress: "ing-access-log-off", + enabled: false, + wantFilter: "false", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + nn := types.NamespacedName{Namespace: "default", Name: "test-http-route"} + testHTTPRoute := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-http-route"}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{ + Name: gatewayv1.ObjectName("test-gateway"), + }}, + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{{ + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("test-service"), + Port: common.PtrTo(gatewayv1.PortNumber(80)), + }, + }, + }}, + }, + }, + }, + } + + e := &Emitter{notify: notifications.NoopNotify} + got, errs := e.Emit(emitterir.EmitterIR{ + HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{ + nn: { + HTTPRoute: testHTTPRoute, + EnableAccessLogByRuleIdx: map[int]*emitterir.AccessLog{ + 0: { + Metadata: emitterir.NewExtensionFeatureMetadata("default/"+tt.ingress, nil, ""), + Enabled: tt.enabled, + }, + }, + }, + }, + }) + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if len(got.GatewayExtensions) != 1 { + t.Fatalf("want 1 GatewayExtension, got %d", len(got.GatewayExtensions)) + } + + ext := got.GatewayExtensions[0] + if ext.GetKind() != AgentgatewayPolicyGVK.Kind { + t.Fatalf("want kind %q, got %q", AgentgatewayPolicyGVK.Kind, ext.GetKind()) + } + if ext.GetName() != tt.ingress { + t.Fatalf("want policy name %q, got %q", tt.ingress, ext.GetName()) + } + + targetRefs, found, err := unstructured.NestedSlice(ext.Object, "spec", "targetRefs") + if err != nil || !found || len(targetRefs) != 1 { + t.Fatalf("expected one targetRef, found=%v err=%v targetRefs=%#v", found, err, targetRefs) + } + + accessLog, found, err := unstructured.NestedMap(ext.Object, "spec", "frontend", "accessLog") + if err != nil || !found { + t.Fatalf("expected accessLog config, found=%v err=%v", found, err) + } + filter, found, err := unstructured.NestedString(accessLog, "filter") + if err != nil { + t.Fatalf("unexpected filter error: %v", err) + } + if tt.wantFilter == nil { + if found { + t.Fatalf("expected no filter, got %q", filter) + } + } else { + if !found || filter != tt.wantFilter { + t.Fatalf("want filter %q, got found=%v value=%q", tt.wantFilter, found, filter) + } + } + }) + } +} diff --git a/pkg/i2gw/emitters/agentgateway/basic_auth.go b/pkg/i2gw/emitters/agentgateway/basic_auth.go index c02e0def2..fbf55903f 100644 --- a/pkg/i2gw/emitters/agentgateway/basic_auth.go +++ b/pkg/i2gw/emitters/agentgateway/basic_auth.go @@ -70,6 +70,7 @@ func applyBasicAuthPolicy( // emitBasicAuthSecretNotifications emits an INFO notification whenever the agentgateway emitter // projects BasicAuth into an AgentgatewayPolicy to warn users about theSecret key expectations. func emitBasicAuthSecretNotifications( + notify notifications.NotifyFunc, pol emitterir.Policy, sourceIngressName string, routeNamespace string, @@ -104,8 +105,5 @@ IMPORTANT: Secret key expectations differ by dataplane: pol.BasicAuth.SecretName, ) - notifications.NotificationAggr.DispatchNotification( - notifications.NewNotification(notifications.InfoNotification, msg), - "ingress-nginx", - ) + notify(notifications.InfoNotification, msg) } diff --git a/pkg/i2gw/emitters/agentgateway/emitter_integration_test.go b/pkg/i2gw/emitters/agentgateway/emitter_integration_test.go index 7290eb8e0..036f7a6d4 100644 --- a/pkg/i2gw/emitters/agentgateway/emitter_integration_test.go +++ b/pkg/i2gw/emitters/agentgateway/emitter_integration_test.go @@ -18,6 +18,7 @@ package agentgateway_test import ( "bytes" + "context" "errors" "io" "os" @@ -35,7 +36,7 @@ import ( func getModuleRoot(t *testing.T) string { t.Helper() - cmd := exec.Command("go", "env", "GOMOD") + cmd := exec.CommandContext(context.Background(), "go", "env", "GOMOD") out, err := cmd.Output() if err != nil { t.Fatalf("failed to run 'go env GOMOD': %v", err) @@ -128,7 +129,8 @@ func runGoldenTest(t *testing.T, inputRel, goldenRel string) { inputPath := filepath.Join(moduleRoot, inputRel) goldenPath := filepath.Join(moduleRoot, goldenRel) - cmd := exec.Command( + //nolint:gosec // G204: integration test runs the local module with fixed argv shape. + cmd := exec.CommandContext(context.Background(), "go", "run", ".", "print", "--providers=ingress-nginx", @@ -153,6 +155,7 @@ func runGoldenTest(t *testing.T, inputRel, goldenRel string) { // Golden file handling writeGolden := false + //nolint:gosec // G304: golden path is resolved under module root from known rel paths. goldenBytes, err := os.ReadFile(goldenPath) if os.IsNotExist(err) { writeGolden = true diff --git a/pkg/i2gw/emitters/agentgateway/external_auth.go b/pkg/i2gw/emitters/agentgateway/external_auth.go index c73f1a4af..a0a3218f3 100644 --- a/pkg/i2gw/emitters/agentgateway/external_auth.go +++ b/pkg/i2gw/emitters/agentgateway/external_auth.go @@ -20,6 +20,7 @@ import ( "fmt" "net" "net/url" + "strconv" "strings" agentgatewayv1alpha1 "github.com/agentgateway/agentgateway/controller/api/v1alpha1/agentgateway" @@ -173,8 +174,10 @@ func parseAuthURL(raw string, ingressNS string) (*parsedAuthURL, error) { // Port var port int32 if portStr != "" { - var parsed int - fmt.Sscanf(portStr, "%d", &parsed) + parsed, perr := strconv.ParseInt(portStr, 10, 32) + if perr != nil || parsed < 1 || parsed > 65535 { + return nil, fmt.Errorf("invalid port in auth-url %q", portStr) + } port = int32(parsed) } else { switch u.Scheme { diff --git a/pkg/i2gw/emitters/agentgateway/rate_limit.go b/pkg/i2gw/emitters/agentgateway/rate_limit.go index 3e88fab57..ad6ed0e64 100644 --- a/pkg/i2gw/emitters/agentgateway/rate_limit.go +++ b/pkg/i2gw/emitters/agentgateway/rate_limit.go @@ -17,6 +17,8 @@ limitations under the License. package agentgateway import ( + "strings" + agentgatewayv1alpha1 "github.com/agentgateway/agentgateway/controller/api/v1alpha1/agentgateway" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" ) @@ -99,3 +101,104 @@ func applyRateLimitPolicy( return true } + +func effectivePoliciesWithPerRuleFeatures(httpRouteCtx emitterir.HTTPRouteContext) map[string]emitterir.Policy { + effectivePolicies := make(map[string]emitterir.Policy, len(httpRouteCtx.PoliciesBySourceIngressName)) + for ingressName, policy := range httpRouteCtx.PoliciesBySourceIngressName { + effectivePolicies[ingressName] = policy + } + + for ruleIdx, rateLimit := range httpRouteCtx.RateLimitByRuleIdx { + if rateLimit == nil || rateLimit.Limit <= 0 { + continue + } + ingressName, ok := ingressNameFromFeatureSource(rateLimit.Metadata.Source()) + if !ok { + continue + } + + policy := effectivePolicies[ingressName] + policy.RateLimit = rateLimit + policy, ok = addRuleCoverage(policy, httpRouteCtx, ruleIdx) + if !ok { + continue + } + effectivePolicies[ingressName] = policy + } + + for ruleIdx, accessLog := range httpRouteCtx.EnableAccessLogByRuleIdx { + if accessLog == nil { + continue + } + ingressName, ok := ingressNameFromFeatureSource(accessLog.Metadata.Source()) + if !ok { + continue + } + + policy := effectivePolicies[ingressName] + enabled := accessLog.Enabled + policy.EnableAccessLog = &enabled + policy, ok = addRuleCoverage(policy, httpRouteCtx, ruleIdx) + if !ok { + continue + } + effectivePolicies[ingressName] = policy + } + + for ruleIdx, bodySize := range httpRouteCtx.BodySizeByRuleIdx { + if bodySize == nil || (bodySize.MaxSize == nil && bodySize.BufferSize == nil) { + continue + } + + ingressName, ok := ingressNameFromFeatureSource(bodySize.Metadata.Source()) + if !ok { + continue + } + + policy := effectivePolicies[ingressName] + policy.ProxyBodySize = bodySize.MaxSize + policy.ClientBodyBufferSize = bodySize.BufferSize + policy, ok = addRuleCoverage(policy, httpRouteCtx, ruleIdx) + if !ok { + continue + } + effectivePolicies[ingressName] = policy + } + + if len(effectivePolicies) == 0 { + return nil + } + return effectivePolicies +} + +func addRuleCoverage(policy emitterir.Policy, httpRouteCtx emitterir.HTTPRouteContext, ruleIdx int) (emitterir.Policy, bool) { + if ruleIdx == routeRuleAllIndex { + for idx, rule := range httpRouteCtx.Spec.Rules { + for backendIdx := range rule.BackendRefs { + policy = policy.AddRuleBackendSources([]emitterir.PolicyIndex{{ + Rule: idx, + Backend: backendIdx, + }}) + } + } + return policy, true + } + if ruleIdx < 0 || ruleIdx >= len(httpRouteCtx.Spec.Rules) { + return policy, false + } + for backendIdx := range httpRouteCtx.Spec.Rules[ruleIdx].BackendRefs { + policy = policy.AddRuleBackendSources([]emitterir.PolicyIndex{{ + Rule: ruleIdx, + Backend: backendIdx, + }}) + } + return policy, true +} + +func ingressNameFromFeatureSource(source string) (string, bool) { + _, ingressName, ok := strings.Cut(source, "/") + if !ok || ingressName == "" { + return "", false + } + return ingressName, true +} diff --git a/pkg/i2gw/emitters/agentgateway/service_upstream.go b/pkg/i2gw/emitters/agentgateway/service_upstream.go index 5722ffe66..0cca54328 100644 --- a/pkg/i2gw/emitters/agentgateway/service_upstream.go +++ b/pkg/i2gw/emitters/agentgateway/service_upstream.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/pkg/i2gw/emitters/agentgateway/ssl_redirect.go b/pkg/i2gw/emitters/agentgateway/ssl_redirect.go index 07d3194e3..3f5c660b7 100644 --- a/pkg/i2gw/emitters/agentgateway/ssl_redirect.go +++ b/pkg/i2gw/emitters/agentgateway/ssl_redirect.go @@ -58,18 +58,21 @@ func splitHTTPRouteForSSLRedirect( } for _, listener := range gatewayCtx.Spec.Listeners { - if listener.Protocol == gatewayv1.HTTPProtocolType { + switch listener.Protocol { + case gatewayv1.HTTPProtocolType: // Check if hostname matches if hostname == "" || (listener.Hostname != nil && string(*listener.Hostname) == hostname) { name := listener.Name httpListenerName = &name } - } else if listener.Protocol == gatewayv1.HTTPSProtocolType { + case gatewayv1.HTTPSProtocolType: // Check if hostname matches if hostname == "" || (listener.Hostname != nil && string(*listener.Hostname) == hostname) { name := listener.Name httpsListenerName = &name } + case gatewayv1.TLSProtocolType, gatewayv1.TCPProtocolType, gatewayv1.UDPProtocolType: + continue } } diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/input/buffer_body_size.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/input/buffer_body_size.yaml index bde378d1e..ad513c2a9 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/input/buffer_body_size.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/input/buffer_body_size.yaml @@ -2,8 +2,8 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: - nginx.ingress.kubernetes.io/client-body-buffer-size: "1Mi" - nginx.ingress.kubernetes.io/proxy-body-size: "2Mi" + nginx.ingress.kubernetes.io/client-body-buffer-size: "1m" + nginx.ingress.kubernetes.io/proxy-body-size: "2m" name: ingress-buffer-body-size namespace: default spec: diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/access_log.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/access_log.yaml index 4839f8c69..078c43d8f 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/access_log.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/access_log.yaml @@ -68,6 +68,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -91,5 +92,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/backend_protocol.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/backend_protocol.yaml index 8d57d546a..6d92e5f90 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/backend_protocol.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/backend_protocol.yaml @@ -49,5 +49,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/backend_tls.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/backend_tls.yaml index 7c3454438..01ef2f894 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/backend_tls.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/backend_tls.yaml @@ -34,6 +34,27 @@ status: ancestors: null --- apiVersion: gateway.networking.k8s.io/v1 +kind: BackendTLSPolicy +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + name: httpbin2-backend-tls + namespace: default +spec: + targetRefs: + - group: "" + kind: Service + name: httpbin2 + validation: + caCertificateRefs: + - group: "" + kind: ConfigMap + name: base-certificate-tls + hostname: tls.example.com +status: + ancestors: null +--- +apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: annotations: @@ -72,6 +93,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -95,5 +117,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/basic_auth.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/basic_auth.yaml index 88676afba..cc35aac9f 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/basic_auth.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/basic_auth.yaml @@ -92,6 +92,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -115,6 +116,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -138,5 +140,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/buffer_body_size.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/buffer_body_size.yaml index 8e70343d1..661f6b4f2 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/buffer_body_size.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/buffer_body_size.yaml @@ -49,5 +49,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/cors.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/cors.yaml index 4fca57b71..4753464cb 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/cors.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/cors.yaml @@ -40,12 +40,31 @@ spec: name: ingress-myserviceb-myserviceb-foo-org traffic: cors: + allowCredentials: true + allowHeaders: + - DNT + - Keep-Alive + - User-Agent + - X-Requested-With + - If-Modified-Since + - Cache-Control + - Content-Type + - Range + - Authorization + allowMethods: + - GET + - PUT + - POST + - DELETE + - PATCH + - OPTIONS allowOrigins: - https://example.com - https://another.com exposeHeaders: - '*' - X-CustomResponseHeader + maxAge: 1728000 status: ancestors: null --- @@ -85,6 +104,23 @@ spec: - name: myservicea port: 80 filters: + - cors: + allowCredentials: false + allowHeaders: + - X-Requested-With + - Content-Type + allowMethods: + - GET + - POST + - OPTIONS + allowOrigins: + - https://example.com + - https://another.com + exposeHeaders: + - X-Expose-One + - X-Expose-Two + maxAge: 600 + type: CORS - responseHeaderModifier: remove: - Access-Control-Allow-Origin @@ -98,6 +134,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -118,6 +155,33 @@ spec: - name: myserviceb port: 80 filters: + - cors: + allowCredentials: true + allowHeaders: + - DNT + - Keep-Alive + - User-Agent + - X-Requested-With + - If-Modified-Since + - Cache-Control + - Content-Type + - Range + - Authorization + allowMethods: + - GET + - PUT + - POST + - DELETE + - PATCH + - OPTIONS + allowOrigins: + - https://example.com + - https://another.com + exposeHeaders: + - '*' + - X-CustomResponseHeader + maxAge: 1728000 + type: CORS - responseHeaderModifier: remove: - Access-Control-Allow-Origin @@ -131,5 +195,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/external_auth.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/external_auth.yaml index 702dbc2eb..74f793584 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/external_auth.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/external_auth.yaml @@ -105,6 +105,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -128,6 +129,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -151,5 +153,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/load_balance.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/load_balance.yaml index 293bd6bcd..4c2406556 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/load_balance.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/load_balance.yaml @@ -33,5 +33,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/rate_limit.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/rate_limit.yaml index 959a2b19a..eaa3460e6 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/rate_limit.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/rate_limit.yaml @@ -96,6 +96,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -119,6 +120,7 @@ spec: - path: type: PathPrefix value: /rpm + name: rule-0 status: parents: [] --- @@ -142,5 +144,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/rewrite_target.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/rewrite_target.yaml index 0d46887a1..255a0a62c 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/rewrite_target.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/rewrite_target.yaml @@ -1,22 +1,3 @@ -apiVersion: agentgateway.dev/v1alpha1 -kind: AgentgatewayPolicy -metadata: - name: ingress-myservicea1 - namespace: default -spec: - targetRefs: - - group: gateway.networking.k8s.io - kind: HTTPRoute - name: ingress-myservicea1-myservicea-foo-org - traffic: - transformation: - request: - set: - - name: :path - value: '"/rewritten"' -status: - ancestors: null ---- apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: @@ -52,10 +33,17 @@ spec: - backendRefs: - name: myservicea port: 80 + filters: + - type: URLRewrite + urlRewrite: + path: + replaceFullPath: /rewritten + type: ReplaceFullPath matches: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -79,5 +67,6 @@ spec: - path: type: PathPrefix value: /path2 + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/service_upstream.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/service_upstream.yaml index 5e0765046..9cbebe1a8 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/service_upstream.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/service_upstream.yaml @@ -80,6 +80,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 - backendRefs: - name: myservicea port: 80 @@ -87,6 +88,7 @@ spec: - path: type: PathPrefix value: /2 + name: rule-1 status: parents: [] --- @@ -111,5 +113,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/ssl_redirect.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/ssl_redirect.yaml index a346a115f..f00f93f42 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/ssl_redirect.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/ssl_redirect.yaml @@ -18,7 +18,9 @@ spec: protocol: HTTPS tls: certificateRefs: - - name: force-redirect-tls + - group: "" + kind: Secret + name: force-redirect-tls - hostname: redirect.example name: redirect-example-http port: 80 @@ -29,31 +31,32 @@ spec: protocol: HTTPS tls: certificateRefs: - - name: redirect-tls + - group: "" + kind: Secret + name: redirect-tls --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-force-ssl-redirect-force-redirect-example-http-redirect + name: ingress-force-ssl-redirect-force-redirect-example namespace: default spec: hostnames: - force-redirect.example parentRefs: - name: nginx - sectionName: force-redirect-example-http + port: 443 rules: - - filters: - - requestRedirect: - scheme: https - statusCode: 301 - type: RequestRedirect + - backendRefs: + - name: myservice2 + port: 80 matches: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -62,66 +65,65 @@ kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-force-ssl-redirect-force-redirect-example-https + name: ingress-force-ssl-redirect-force-redirect-example-http namespace: default spec: hostnames: - force-redirect.example parentRefs: - name: nginx - sectionName: force-redirect-example-https + port: 80 rules: - - backendRefs: - - name: myservice2 - port: 80 + - filters: + - requestRedirect: + scheme: https + statusCode: 308 + type: RequestRedirect matches: - path: type: PathPrefix value: / status: - parents: [] + parents: null --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-ssl-redirect-redirect-example-http-redirect + name: ingress-ssl-redirect-redirect-example namespace: default spec: hostnames: - redirect.example parentRefs: - name: nginx - sectionName: redirect-example-http + port: 443 rules: - - filters: - - requestRedirect: - scheme: https - statusCode: 301 - type: RequestRedirect + - backendRefs: + - name: myservice + port: 80 matches: - path: type: PathPrefix value: / - - filters: - - requestRedirect: - scheme: https - statusCode: 301 - type: RequestRedirect + name: rule-0 + - backendRefs: + - name: myservice + port: 80 matches: - path: type: PathPrefix value: /api - - filters: - - requestRedirect: - scheme: https - statusCode: 301 - type: RequestRedirect + name: rule-1 + - backendRefs: + - name: myservice + port: 80 matches: - path: type: PathPrefix value: /web + name: rule-2 status: parents: [] --- @@ -130,35 +132,41 @@ kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-ssl-redirect-redirect-example-https + name: ingress-ssl-redirect-redirect-example-http namespace: default spec: hostnames: - redirect.example parentRefs: - name: nginx - sectionName: redirect-example-https + port: 80 rules: - - backendRefs: - - name: myservice - port: 80 + - filters: + - requestRedirect: + scheme: https + statusCode: 308 + type: RequestRedirect matches: - path: type: PathPrefix value: / - - backendRefs: - - name: myservice - port: 80 + - filters: + - requestRedirect: + scheme: https + statusCode: 308 + type: RequestRedirect matches: - path: type: PathPrefix value: /api - - backendRefs: - - name: myservice - port: 80 + - filters: + - requestRedirect: + scheme: https + statusCode: 308 + type: RequestRedirect matches: - path: type: PathPrefix value: /web status: - parents: [] + parents: null diff --git a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/timeouts.yaml b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/timeouts.yaml index 2bb78175b..2e159feb3 100644 --- a/pkg/i2gw/emitters/agentgateway/testing/testdata/output/timeouts.yaml +++ b/pkg/i2gw/emitters/agentgateway/testing/testdata/output/timeouts.yaml @@ -1,51 +1,3 @@ -apiVersion: agentgateway.dev/v1alpha1 -kind: AgentgatewayPolicy -metadata: - name: ingress-timeout-proxy-read - namespace: default -spec: - targetRefs: - - group: gateway.networking.k8s.io - kind: HTTPRoute - name: ingress-timeout-proxy-read-timeout-proxy-read-example-org - traffic: - timeouts: - request: 45s -status: - ancestors: null ---- -apiVersion: agentgateway.dev/v1alpha1 -kind: AgentgatewayPolicy -metadata: - name: ingress-timeout-proxy-send - namespace: default -spec: - targetRefs: - - group: gateway.networking.k8s.io - kind: HTTPRoute - name: ingress-timeout-proxy-send-timeout-proxy-send-example-org - traffic: - timeouts: - request: 1m0s -status: - ancestors: null ---- -apiVersion: agentgateway.dev/v1alpha1 -kind: AgentgatewayPolicy -metadata: - name: myservice-backend-connect-timeout - namespace: default -spec: - backend: - tcp: - connectTimeout: 15s - targetRefs: - - group: "" - kind: Service - name: myservice -status: - ancestors: null ---- apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: @@ -85,6 +37,9 @@ spec: - path: type: PathPrefix value: /read + name: rule-0 + timeouts: + request: 10m0s status: parents: [] --- @@ -108,5 +63,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/agentgateway/utils.go b/pkg/i2gw/emitters/agentgateway/utils.go index d7626537f..d3af86b86 100644 --- a/pkg/i2gw/emitters/agentgateway/utils.go +++ b/pkg/i2gw/emitters/agentgateway/utils.go @@ -1,5 +1,5 @@ /* -Copyright 2025 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,13 +19,17 @@ package agentgateway import ( agentgatewayv1alpha1 "github.com/agentgateway/agentgateway/controller/api/v1alpha1/agentgateway" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) +const ( + // routeRuleAllIndex is used to indicate a policy applies to all rules in an HTTPRoute. + routeRuleAllIndex = -1 +) + // uniquePolicyIndices returns a slice of PolicyIndex values with duplicates // removed. Uniqueness is defined by the (Rule, Backend) pair. func uniquePolicyIndices(indices []emitterir.PolicyIndex) []emitterir.PolicyIndex { diff --git a/pkg/i2gw/emitters/common_emitter/emitter.go b/pkg/i2gw/emitters/common_emitter/emitter.go index fa56303dc..5d8d1294f 100644 --- a/pkg/i2gw/emitters/common_emitter/emitter.go +++ b/pkg/i2gw/emitters/common_emitter/emitter.go @@ -17,20 +17,140 @@ limitations under the License. package common_emitter import ( - emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "time" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" "k8s.io/apimachinery/pkg/util/validation/field" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) -type Emitter struct{} +type EmitterConf struct { + AllowExperimentalGatewayAPI bool + Report *notifications.Report +} + +const tcpTimeoutMultiplier = 10 + +type Emitter struct { + conf *EmitterConf +} + +func NewEmitter(conf *EmitterConf) *Emitter { + return &Emitter{conf: conf} +} + +func applyPathRewrites(ir *emitterir.EmitterIR) { + for key, routeCtx := range ir.HTTPRoutes { + for ruleIdx, rewrite := range routeCtx.PathRewriteByRuleIdx { + if rewrite == nil || rewrite.RegexCaptureGroupReferences { + continue + } + fullPath := rewrite.ReplaceFullPath + routeCtx.Spec.Rules[ruleIdx].Filters = append(routeCtx.Spec.Rules[ruleIdx].Filters, gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterURLRewrite, + URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ + Path: &gatewayv1.HTTPPathModifier{ + Type: gatewayv1.FullPathHTTPPathModifier, + ReplaceFullPath: &fullPath, + }, + }, + }) + if len(rewrite.Headers) > 0 { + headerModifier := &gatewayv1.HTTPHeaderFilter{} + for headerName, headerValue := range rewrite.Headers { + headerModifier.Set = append(headerModifier.Set, gatewayv1.HTTPHeader{Name: gatewayv1.HTTPHeaderName(headerName), Value: headerValue}) + } + routeCtx.Spec.Rules[ruleIdx].Filters = append(routeCtx.Spec.Rules[ruleIdx].Filters, gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: headerModifier, + }) + } + routeCtx.PathRewriteByRuleIdx[ruleIdx] = nil + } -func NewEmitter() *Emitter { - return &Emitter{} + ir.HTTPRoutes[key] = routeCtx + } +} + +func (e *Emitter) applyCorsPolicies(ir *emitterir.EmitterIR) { + for key, routeCtx := range ir.HTTPRoutes { + for ruleIdx, policy := range routeCtx.CorsPolicyByRuleIdx { + if policy == nil { + continue + } + routeCtx.Spec.Rules[ruleIdx].Filters = append(routeCtx.Spec.Rules[ruleIdx].Filters, gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterCORS, + CORS: &policy.HTTPCORSFilter, + }) + routeCtx.CorsPolicyByRuleIdx[ruleIdx] = nil + } + ir.HTTPRoutes[key] = routeCtx + } } // Emit processes the IR to apply common logic (like deduplication) and returns the modified IR. // This ALWAYS runs after providers and before provider-specific emitters. // TODO: Implement common logic such as filtering by maturity status and/or individual features. func (e *Emitter) Emit(ir emitterir.EmitterIR) (emitterir.EmitterIR, field.ErrorList) { - return ir, nil + errs := applyTCPTimeouts(&ir) + applyPathRewrites(&ir) + e.applyCorsPolicies(&ir) + return ir, errs +} + +func applyTCPTimeouts(ir *emitterir.EmitterIR) field.ErrorList { + var errs field.ErrorList + for i, httpRouteContext := range ir.HTTPRoutes { + if httpRouteContext.TCPTimeoutsByRuleIdx == nil { + continue + } + + for ruleIdx, timeouts := range httpRouteContext.TCPTimeoutsByRuleIdx { + if timeouts == nil { + continue + } + if ruleIdx < 0 || ruleIdx >= len(httpRouteContext.Spec.Rules) { + errs = append(errs, field.Invalid( + field.NewPath("httpRoute", "spec", "rules").Index(ruleIdx), + ruleIdx, + "rule index out of range", + )) + continue + } + + rule := &httpRouteContext.Spec.Rules[ruleIdx] + if rule.Timeouts == nil { + rule.Timeouts = &gatewayv1.HTTPRouteTimeouts{} + } + maxTimeout, ok := maxParsedDuration(timeouts.Connect, timeouts.Read, timeouts.Write) + if ok { + requestTimeout := gatewayv1.Duration((maxTimeout * time.Duration(tcpTimeoutMultiplier)).String()) + rule.Timeouts.Request = &requestTimeout + } + } + + httpRouteContext.TCPTimeoutsByRuleIdx = nil + ir.HTTPRoutes[i] = httpRouteContext + } + return errs +} + +func maxParsedDuration(durations ...*gatewayv1.Duration) (time.Duration, bool) { + var maxDuration time.Duration + var found bool + for _, d := range durations { + if d == nil { + continue + } + parsed, err := time.ParseDuration(string(*d)) + if err != nil { + continue + } + if !found || parsed > maxDuration { + maxDuration = parsed + found = true + } + } + return maxDuration, found } diff --git a/pkg/i2gw/emitters/common_emitter/emitter_test.go b/pkg/i2gw/emitters/common_emitter/emitter_test.go new file mode 100644 index 000000000..c4c2323d0 --- /dev/null +++ b/pkg/i2gw/emitters/common_emitter/emitter_test.go @@ -0,0 +1,210 @@ +/* +Copyright The Kubernetes Authors. + +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 common_emitter + +import ( + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestApplyTCPTimeouts(t *testing.T) { + d := gatewayv1.Duration("10s") + tenSeconds := emitterir.TCPTimeouts{Connect: &d} + + testCases := []struct { + name string + ctx emitterir.HTTPRouteContext + wantSet bool + wantErr bool + }{ + { + name: "sets request timeout", + ctx: emitterir.HTTPRouteContext{ + HTTPRoute: gatewayv1.HTTPRoute{Spec: gatewayv1.HTTPRouteSpec{Rules: []gatewayv1.HTTPRouteRule{{}}}}, + TCPTimeoutsByRuleIdx: map[int]*emitterir.TCPTimeouts{0: &tenSeconds}, + }, + wantSet: true, + }, + { + name: "nil duration ignored", + ctx: emitterir.HTTPRouteContext{ + HTTPRoute: gatewayv1.HTTPRoute{Spec: gatewayv1.HTTPRouteSpec{Rules: []gatewayv1.HTTPRouteRule{{}}}}, + TCPTimeoutsByRuleIdx: map[int]*emitterir.TCPTimeouts{0: nil}, + }, + wantSet: false, + }, + { + name: "out of range rule index", + ctx: emitterir.HTTPRouteContext{ + HTTPRoute: gatewayv1.HTTPRoute{Spec: gatewayv1.HTTPRouteSpec{Rules: []gatewayv1.HTTPRouteRule{{}}}}, + TCPTimeoutsByRuleIdx: map[int]*emitterir.TCPTimeouts{1: &tenSeconds}, + }, + wantErr: true, + }, + } + + key := types.NamespacedName{Namespace: "ns", Name: "route"} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ir := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{key: tc.ctx}} + errList := applyTCPTimeouts(&ir) + + gotCtx := ir.HTTPRoutes[key] + if gotCtx.TCPTimeoutsByRuleIdx != nil { + t.Fatalf("expected TCPTimeoutsByRuleIdx to be nil after apply") + } + if tc.wantErr { + if len(errList) == 0 { + t.Fatalf("expected error") + } + return + } + if len(errList) > 0 { + t.Fatalf("expected no errors, got %v", errList) + } + + got := gotCtx.Spec.Rules[0].Timeouts + if tc.wantSet { + if got == nil || got.Request == nil { + t.Fatalf("expected request timeout to be set") + } + if *got.Request != gatewayv1.Duration("1m40s") { + t.Fatalf("expected %v, got %v", gatewayv1.Duration("1m40s"), *got.Request) + } + return + } + + if got != nil { + t.Fatalf("expected timeouts to be nil, got %v", got) + } + }) + } +} + +func TestEmitter_Emit_appliesPathRewriteReplaceFullPath(t *testing.T) { + key := types.NamespacedName{Namespace: "ns", Name: "route"} + + ir := emitterir.EmitterIR{ + HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{ + key: { + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{{}}, + }, + }, + PathRewriteByRuleIdx: map[int]*emitterir.PathRewrite{ + 0: {ReplaceFullPath: "/foo"}, + }, + }, + }, + } + + // Use allowAlpha=false as default, serves same purpose here + e := NewEmitter(nil) + gotIR, errs := e.Emit(ir) + if len(errs) != 0 { + t.Fatalf("expected no errors, got: %v", errs) + } + + got := gotIR.HTTPRoutes[key].Spec.Rules[0].Filters + if len(got) != 1 { + t.Fatalf("expected 1 filter, got %d: %#v", len(got), got) + } + + f := got[0] + if f.Type != gatewayv1.HTTPRouteFilterURLRewrite { + t.Fatalf("expected filter type %q, got %q", gatewayv1.HTTPRouteFilterURLRewrite, f.Type) + } + if f.URLRewrite == nil || f.URLRewrite.Path == nil { + t.Fatalf("expected URLRewrite.Path to be set, got: %#v", f.URLRewrite) + } + if f.URLRewrite.Path.Type != gatewayv1.FullPathHTTPPathModifier { + t.Fatalf("expected Path.Type %q, got %q", gatewayv1.FullPathHTTPPathModifier, f.URLRewrite.Path.Type) + } + if f.URLRewrite.Path.ReplaceFullPath == nil || *f.URLRewrite.Path.ReplaceFullPath != "/foo" { + t.Fatalf("expected ReplaceFullPath /foo, got: %#v", f.URLRewrite.Path.ReplaceFullPath) + } +} + +func TestEmitCORSFiltering(t *testing.T) { + testCases := []struct { + name string + allowExperimental bool + initialFilters []gatewayv1.HTTPRouteFilter + corsInSidecar *emitterir.CORSConfig + expectedFiltersCount int + }{ + { + name: "experimental allowed + cors in sidecar -> cors added", + allowExperimental: true, + corsInSidecar: &emitterir.CORSConfig{}, + expectedFiltersCount: 1, + }, + { + name: "experimental denied + cors in sidecar -> cors added", + allowExperimental: false, + corsInSidecar: &emitterir.CORSConfig{}, + expectedFiltersCount: 1, + }, + { + name: "other filters preserved regardless of flag", + allowExperimental: false, + initialFilters: []gatewayv1.HTTPRouteFilter{ + {Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier}, + }, + corsInSidecar: &emitterir.CORSConfig{}, + expectedFiltersCount: 2, // only header modifier + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + e := NewEmitter(&EmitterConf{ + AllowExperimentalGatewayAPI: tc.allowExperimental, + }) + + ir := emitterir.EmitterIR{ + HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{ + {Name: "test"}: { + HTTPRoute: gatewayv1.HTTPRoute{ + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + {Filters: tc.initialFilters}, + }, + }, + }, + CorsPolicyByRuleIdx: map[int]*emitterir.CORSConfig{ + 0: tc.corsInSidecar, + }, + }, + }, + } + + result, _ := e.Emit(ir) + filters := result.HTTPRoutes[types.NamespacedName{Name: "test"}].HTTPRoute.Spec.Rules[0].Filters + if len(filters) != tc.expectedFiltersCount { + t.Errorf("Expected %d filters, got %d", tc.expectedFiltersCount, len(filters)) + } + }) + } +} diff --git a/pkg/i2gw/emitters/envoygateway/utils.go b/pkg/i2gw/emitters/envoygateway/utils.go new file mode 100644 index 000000000..25fe0a2c4 --- /dev/null +++ b/pkg/i2gw/emitters/envoygateway/utils.go @@ -0,0 +1,70 @@ +/* +Copyright The Kubernetes Authors. + +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 envoygateway_emitter + +import ( + "reflect" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" +) + +const ( + // RouteRuleAllIndex is used to indicate a policy applies to all rules in an HTTPRoute. + RouteRuleAllIndex = -1 +) + +func MergeBodySizeIR(ctx *emitterir.HTTPRouteContext) { + if len(ctx.BodySizeByRuleIdx) != len(ctx.Spec.Rules) { + return + } + + var first *emitterir.BodySize + for _, bs := range ctx.BodySizeByRuleIdx { + if first == nil { + first = bs + continue + } + if !reflect.DeepEqual(first, bs) { + return + } + } + + ctx.BodySizeByRuleIdx = map[int]*emitterir.BodySize{ + RouteRuleAllIndex: first, + } +} + +func MergeIPRangeControlIR(ctx *emitterir.HTTPRouteContext) { + if len(ctx.IPRangeControlByRuleIdx) != len(ctx.Spec.Rules) { + return + } + + var first *emitterir.IPRangeControl + for _, iprc := range ctx.IPRangeControlByRuleIdx { + if first == nil { + first = iprc + continue + } + if !reflect.DeepEqual(first, iprc) { + return + } + } + + ctx.IPRangeControlByRuleIdx = map[int]*emitterir.IPRangeControl{ + RouteRuleAllIndex: first, + } +} diff --git a/pkg/i2gw/emitters/envoygateway/utils_test.go b/pkg/i2gw/emitters/envoygateway/utils_test.go new file mode 100644 index 000000000..34a1e4fb9 --- /dev/null +++ b/pkg/i2gw/emitters/envoygateway/utils_test.go @@ -0,0 +1,356 @@ +/* +Copyright The Kubernetes Authors. + +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 envoygateway_emitter + +import ( + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestMergeBodySizeIR(t *testing.T) { + maxSize10M := resource.MustParse("10M") + maxSize20M := resource.MustParse("20M") + bufferSize5M := resource.MustParse("5M") + bufferSize8M := resource.MustParse("8M") + + tests := []struct { + name string + numRules int + bodySizeMap map[int]*emitterir.BodySize + wantMerged bool + wantBodySize *emitterir.BodySize + }{ + { + name: "all rules have same body size - should merge", + numRules: 3, + bodySizeMap: map[int]*emitterir.BodySize{ + 0: {MaxSize: &maxSize10M, BufferSize: &bufferSize5M}, + 1: {MaxSize: &maxSize10M, BufferSize: &bufferSize5M}, + 2: {MaxSize: &maxSize10M, BufferSize: &bufferSize5M}, + }, + wantMerged: true, + wantBodySize: &emitterir.BodySize{MaxSize: &maxSize10M, BufferSize: &bufferSize5M}, + }, + { + name: "all rules have same max size only - should merge", + numRules: 2, + bodySizeMap: map[int]*emitterir.BodySize{ + 0: {MaxSize: &maxSize10M}, + 1: {MaxSize: &maxSize10M}, + }, + wantMerged: true, + wantBodySize: &emitterir.BodySize{MaxSize: &maxSize10M}, + }, + { + name: "all rules have same buffer size only - should merge", + numRules: 2, + bodySizeMap: map[int]*emitterir.BodySize{ + 0: {BufferSize: &bufferSize5M}, + 1: {BufferSize: &bufferSize5M}, + }, + wantMerged: true, + wantBodySize: &emitterir.BodySize{BufferSize: &bufferSize5M}, + }, + { + name: "different max size - should not merge", + numRules: 2, + bodySizeMap: map[int]*emitterir.BodySize{ + 0: {MaxSize: &maxSize10M}, + 1: {MaxSize: &maxSize20M}, + }, + wantMerged: false, + }, + { + name: "different buffer size - should not merge", + numRules: 2, + bodySizeMap: map[int]*emitterir.BodySize{ + 0: {BufferSize: &bufferSize5M}, + 1: {BufferSize: &bufferSize8M}, + }, + wantMerged: false, + }, + { + name: "one has buffer size, one doesn't - should not merge", + numRules: 2, + bodySizeMap: map[int]*emitterir.BodySize{ + 0: {MaxSize: &maxSize10M, BufferSize: &bufferSize5M}, + 1: {MaxSize: &maxSize10M}, + }, + wantMerged: false, + }, + { + name: "one has max size, one doesn't - should not merge", + numRules: 2, + bodySizeMap: map[int]*emitterir.BodySize{ + 0: {MaxSize: &maxSize10M}, + 1: {BufferSize: &bufferSize5M}, + }, + wantMerged: false, + }, + { + name: "body size map length doesn't match rules - should not merge", + numRules: 3, + bodySizeMap: map[int]*emitterir.BodySize{ + 0: {MaxSize: &maxSize10M}, + 1: {MaxSize: &maxSize10M}, + }, + wantMerged: false, + }, + { + name: "empty body size map - should not merge", + numRules: 2, + bodySizeMap: map[int]*emitterir.BodySize{}, + wantMerged: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create HTTPRouteContext with specified number of rules + ctx := &emitterir.HTTPRouteContext{ + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-route", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: make([]gatewayv1.HTTPRouteRule, tt.numRules), + }, + }, + BodySizeByRuleIdx: tt.bodySizeMap, + } + + // Call MergeBodySizeIR + MergeBodySizeIR(ctx) + + if tt.wantMerged { + // Should have merged to RouteRuleAllIndex + if len(ctx.BodySizeByRuleIdx) != 1 { + t.Errorf("expected BodySizeByRuleIdx to have 1 entry, got %d", len(ctx.BodySizeByRuleIdx)) + return + } + + merged, ok := ctx.BodySizeByRuleIdx[RouteRuleAllIndex] + if !ok { + t.Errorf("expected BodySizeByRuleIdx to have entry at RouteRuleAllIndex=%d", RouteRuleAllIndex) + return + } + + // Check MaxSize + if tt.wantBodySize.MaxSize == nil { + if merged.MaxSize != nil { + t.Errorf("expected MaxSize to be nil, got %v", merged.MaxSize) + } + } else { + if merged.MaxSize == nil { + t.Errorf("expected MaxSize to be %v, got nil", tt.wantBodySize.MaxSize) + } else if !merged.MaxSize.Equal(*tt.wantBodySize.MaxSize) { + t.Errorf("expected MaxSize %v, got %v", tt.wantBodySize.MaxSize, merged.MaxSize) + } + } + + // Check BufferSize + if tt.wantBodySize.BufferSize == nil { + if merged.BufferSize != nil { + t.Errorf("expected BufferSize to be nil, got %v", merged.BufferSize) + } + } else { + if merged.BufferSize == nil { + t.Errorf("expected BufferSize to be %v, got nil", tt.wantBodySize.BufferSize) + } else if !merged.BufferSize.Equal(*tt.wantBodySize.BufferSize) { + t.Errorf("expected BufferSize %v, got %v", tt.wantBodySize.BufferSize, merged.BufferSize) + } + } + } else { + // Should not have merged - BodySizeByRuleIdx should be unchanged + if len(ctx.BodySizeByRuleIdx) != len(tt.bodySizeMap) { + t.Errorf("expected BodySizeByRuleIdx length to remain %d, got %d", len(tt.bodySizeMap), len(ctx.BodySizeByRuleIdx)) + } + if _, exists := ctx.BodySizeByRuleIdx[RouteRuleAllIndex]; exists { + t.Errorf("expected no entry at RouteRuleAllIndex=%d, but found one", RouteRuleAllIndex) + } + } + }) + } +} + +func TestMergeIPRangeControlIR(t *testing.T) { + allowList1 := []string{"192.168.1.0/24", "10.0.0.0/8"} + allowList2 := []string{"172.16.0.0/12"} + denyList1 := []string{"203.0.113.0/24"} + denyList2 := []string{"198.51.100.0/24"} + + tests := []struct { + name string + numRules int + ipRangeControlMap map[int]*emitterir.IPRangeControl + wantMerged bool + wantIPRangeControl *emitterir.IPRangeControl + }{ + { + name: "all rules have same IP range control - should merge", + numRules: 3, + ipRangeControlMap: map[int]*emitterir.IPRangeControl{ + 0: {AllowList: allowList1, DenyList: denyList1}, + 1: {AllowList: allowList1, DenyList: denyList1}, + 2: {AllowList: allowList1, DenyList: denyList1}, + }, + wantMerged: true, + wantIPRangeControl: &emitterir.IPRangeControl{AllowList: allowList1, DenyList: denyList1}, + }, + { + name: "all rules have same allow list only - should merge", + numRules: 2, + ipRangeControlMap: map[int]*emitterir.IPRangeControl{ + 0: {AllowList: allowList1}, + 1: {AllowList: allowList1}, + }, + wantMerged: true, + wantIPRangeControl: &emitterir.IPRangeControl{AllowList: allowList1}, + }, + { + name: "all rules have same deny list only - should merge", + numRules: 2, + ipRangeControlMap: map[int]*emitterir.IPRangeControl{ + 0: {DenyList: denyList1}, + 1: {DenyList: denyList1}, + }, + wantMerged: true, + wantIPRangeControl: &emitterir.IPRangeControl{DenyList: denyList1}, + }, + { + name: "different allow list - should not merge", + numRules: 2, + ipRangeControlMap: map[int]*emitterir.IPRangeControl{ + 0: {AllowList: allowList1}, + 1: {AllowList: allowList2}, + }, + wantMerged: false, + }, + { + name: "different deny list - should not merge", + numRules: 2, + ipRangeControlMap: map[int]*emitterir.IPRangeControl{ + 0: {DenyList: denyList1}, + 1: {DenyList: denyList2}, + }, + wantMerged: false, + }, + { + name: "one has deny list, one doesn't - should not merge", + numRules: 2, + ipRangeControlMap: map[int]*emitterir.IPRangeControl{ + 0: {AllowList: allowList1, DenyList: denyList1}, + 1: {AllowList: allowList1}, + }, + wantMerged: false, + }, + { + name: "one has allow list, one doesn't - should not merge", + numRules: 2, + ipRangeControlMap: map[int]*emitterir.IPRangeControl{ + 0: {AllowList: allowList1}, + 1: {DenyList: denyList1}, + }, + wantMerged: false, + }, + { + name: "IP range control map length doesn't match rules - should not merge", + numRules: 3, + ipRangeControlMap: map[int]*emitterir.IPRangeControl{ + 0: {AllowList: allowList1}, + 1: {AllowList: allowList1}, + }, + wantMerged: false, + }, + { + name: "empty IP range control map - should not merge", + numRules: 2, + ipRangeControlMap: map[int]*emitterir.IPRangeControl{}, + wantMerged: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create HTTPRouteContext with specified number of rules + ctx := &emitterir.HTTPRouteContext{ + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-route", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: make([]gatewayv1.HTTPRouteRule, tt.numRules), + }, + }, + IPRangeControlByRuleIdx: tt.ipRangeControlMap, + } + + // Call MergeIPRangeControlIR + MergeIPRangeControlIR(ctx) + + if tt.wantMerged { + // Should have merged to RouteRuleAllIndex + if len(ctx.IPRangeControlByRuleIdx) != 1 { + t.Errorf("expected IPRangeControlByRuleIdx to have 1 entry, got %d", len(ctx.IPRangeControlByRuleIdx)) + return + } + + merged, ok := ctx.IPRangeControlByRuleIdx[RouteRuleAllIndex] + if !ok { + t.Errorf("expected IPRangeControlByRuleIdx to have entry at RouteRuleAllIndex=%d", RouteRuleAllIndex) + return + } + + // Check AllowList + if len(tt.wantIPRangeControl.AllowList) != len(merged.AllowList) { + t.Errorf("expected AllowList length %d, got %d", len(tt.wantIPRangeControl.AllowList), len(merged.AllowList)) + } else { + for i, cidr := range tt.wantIPRangeControl.AllowList { + if merged.AllowList[i] != cidr { + t.Errorf("expected AllowList[%d] = %s, got %s", i, cidr, merged.AllowList[i]) + } + } + } + + // Check DenyList + if len(tt.wantIPRangeControl.DenyList) != len(merged.DenyList) { + t.Errorf("expected DenyList length %d, got %d", len(tt.wantIPRangeControl.DenyList), len(merged.DenyList)) + } else { + for i, cidr := range tt.wantIPRangeControl.DenyList { + if merged.DenyList[i] != cidr { + t.Errorf("expected DenyList[%d] = %s, got %s", i, cidr, merged.DenyList[i]) + } + } + } + } else { + // Should not have merged - IPRangeControlByRuleIdx should be unchanged + if len(ctx.IPRangeControlByRuleIdx) != len(tt.ipRangeControlMap) { + t.Errorf("expected IPRangeControlByRuleIdx length to remain %d, got %d", len(tt.ipRangeControlMap), len(ctx.IPRangeControlByRuleIdx)) + } + if _, exists := ctx.IPRangeControlByRuleIdx[RouteRuleAllIndex]; exists { + t.Errorf("expected no entry at RouteRuleAllIndex=%d, but found one", RouteRuleAllIndex) + } + } + }) + } +} diff --git a/pkg/i2gw/emitters/gce/gce.go b/pkg/i2gw/emitters/gce/gce.go index 227c6988c..4b5410ce8 100644 --- a/pkg/i2gw/emitters/gce/gce.go +++ b/pkg/i2gw/emitters/gce/gce.go @@ -31,6 +31,8 @@ import ( gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" ) +const emitterName = "gce" + var ( GCPBackendPolicyGVK = schema.GroupVersionKind{ Group: "networking.gke.io", @@ -52,13 +54,17 @@ var ( ) func init() { - i2gw.EmitterConstructorByName["gce"] = NewEmitter + i2gw.EmitterConstructorByName[emitterName] = NewEmitter } -type Emitter struct{} +type Emitter struct { + notify notifications.NotifyFunc +} -func NewEmitter(_ *i2gw.EmitterConf) i2gw.Emitter { - return &Emitter{} +func NewEmitter(conf *i2gw.EmitterConf) i2gw.Emitter { + return &Emitter{ + notify: conf.Report.Notifier(emitterName), + } } func (c *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.ErrorList) { @@ -66,12 +72,26 @@ func (c *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.Err if len(errs) != 0 { return i2gw.GatewayResources{}, errs } - buildGceGatewayExtensions(ir, &gatewayResources) - buildGceServiceExtensions(ir, &gatewayResources) + buildGceGatewayExtensions(c.notify, ir, &gatewayResources) + buildGceServiceExtensions(c.notify, ir, &gatewayResources) + + removeHTTPRouteRuleNames(&gatewayResources) return gatewayResources, nil } -func buildGceGatewayExtensions(ir emitterir.EmitterIR, gatewayResources *i2gw.GatewayResources) { +func removeHTTPRouteRuleNames(gatewayResources *i2gw.GatewayResources) { + for k, route := range gatewayResources.HTTPRoutes { + rules := make([]gatewayv1.HTTPRouteRule, len(route.Spec.Rules)) + copy(rules, route.Spec.Rules) + for j := range rules { + rules[j].Name = nil + } + route.Spec.Rules = rules + gatewayResources.HTTPRoutes[k] = route + } +} + +func buildGceGatewayExtensions(notify notifications.NotifyFunc, ir emitterir.EmitterIR, gatewayResources *i2gw.GatewayResources) { for gwyKey, gatewayContext := range ir.Gateways { gwyPolicy := addGatewayPolicyIfConfigured(gwyKey, &gatewayContext) if gwyPolicy == nil { @@ -115,9 +135,26 @@ func addGatewayPolicyIfConfigured(gatewayNamespacedName types.NamespacedName, ga return &gcpGatewayPolicy } -func buildGceServiceExtensions(ir emitterir.EmitterIR, gatewayResources *i2gw.GatewayResources) { - for svcKey, gceServiceIR := range ir.GceServices { - bePolicy := addGCPBackendPolicyIfConfigured(svcKey, gceServiceIR) +func buildGceServiceExtensions(notify notifications.NotifyFunc, ir emitterir.EmitterIR, gatewayResources *i2gw.GatewayResources) { + svcKeys := make(map[types.NamespacedName]bool) + for k := range ir.GceServices { + svcKeys[k] = true + } + for k := range ir.Services { + svcKeys[k] = true + } + + for svcKey := range svcKeys { + var gceServiceIR *gce.ServiceIR + if gceIR, ok := ir.GceServices[svcKey]; ok { + gceServiceIR = &gceIR + } + var genericServiceIR *emitterir.ServiceContext + if genIR, ok := ir.Services[svcKey]; ok { + genericServiceIR = &genIR + } + + bePolicy := addGCPBackendPolicyIfConfigured(svcKey, genericServiceIR, gceServiceIR) if bePolicy != nil { obj, err := i2gw.CastToUnstructured(bePolicy) if err != nil { @@ -127,7 +164,7 @@ func buildGceServiceExtensions(ir emitterir.EmitterIR, gatewayResources *i2gw.Ga gatewayResources.GatewayExtensions = append(gatewayResources.GatewayExtensions, *obj) } - hcPolicy := addHealthCheckPolicyIfConfigured(svcKey, &gceServiceIR) + hcPolicy := addHealthCheckPolicyIfConfigured(svcKey, gceServiceIR) if hcPolicy != nil { obj, err := i2gw.CastToUnstructured(hcPolicy) if err != nil { @@ -139,9 +176,21 @@ func buildGceServiceExtensions(ir emitterir.EmitterIR, gatewayResources *i2gw.Ga } } -func addGCPBackendPolicyIfConfigured(serviceNamespacedName types.NamespacedName, gceServiceIR gce.ServiceIR) *gkegatewayv1.GCPBackendPolicy { +func addGCPBackendPolicyIfConfigured(serviceNamespacedName types.NamespacedName, genericServiceIR *emitterir.ServiceContext, gceServiceIR *gce.ServiceIR) *gkegatewayv1.GCPBackendPolicy { // If there is no specification related to GCPBackendPolicy feature, return nil. - if gceServiceIR.SessionAffinity == nil && gceServiceIR.SecurityPolicy == nil { + var hasSessionAffinity bool + if genericServiceIR != nil && genericServiceIR.SessionAffinity != nil { + hasSessionAffinity = true + } else if gceServiceIR != nil && gceServiceIR.SessionAffinity != nil { + hasSessionAffinity = true + } + + var hasSecurityPolicy bool + if gceServiceIR != nil && gceServiceIR.SecurityPolicy != nil { + hasSecurityPolicy = true + } + + if !hasSessionAffinity && !hasSecurityPolicy { return nil } @@ -161,11 +210,11 @@ func addGCPBackendPolicyIfConfigured(serviceNamespacedName types.NamespacedName, } gcpBackendPolicy.SetGroupVersionKind(GCPBackendPolicyGVK) - if gceServiceIR.SessionAffinity != nil { - gcpBackendPolicy.Spec.Default.SessionAffinity = BuildGCPBackendPolicySessionAffinityConfig(gceServiceIR) + if hasSessionAffinity { + gcpBackendPolicy.Spec.Default.SessionAffinity = BuildGCPBackendPolicySessionAffinityConfig(genericServiceIR, gceServiceIR) } - if gceServiceIR.SecurityPolicy != nil { - gcpBackendPolicy.Spec.Default.SecurityPolicy = BuildGCPBackendPolicySecurityPolicyConfig(gceServiceIR) + if hasSecurityPolicy { + gcpBackendPolicy.Spec.Default.SecurityPolicy = BuildGCPBackendPolicySecurityPolicyConfig(*gceServiceIR) } return &gcpBackendPolicy diff --git a/pkg/i2gw/emitters/gce/gce_test.go b/pkg/i2gw/emitters/gce/gce_test.go index cafe4099a..6daec7a8f 100644 --- a/pkg/i2gw/emitters/gce/gce_test.go +++ b/pkg/i2gw/emitters/gce/gce_test.go @@ -579,7 +579,8 @@ func getTestHealthCheckPolicyUnstrctured(serviceNamespace, serviceName, protocol }, }, } - if protocol == protocolHTTP { + switch protocol { + case protocolHTTP: hcPolicy.Spec.Default.Config = &gkegatewayv1.HealthCheck{ Type: gkegatewayv1.HTTP, HTTP: &gkegatewayv1.HTTPHealthCheck{ @@ -587,7 +588,7 @@ func getTestHealthCheckPolicyUnstrctured(serviceNamespace, serviceName, protocol CommonHTTPHealthCheck: commonHTTPHc, }, } - } else if protocol == protocolHTTPS { + case protocolHTTPS: hcPolicy.Spec.Default.Config = &gkegatewayv1.HealthCheck{ Type: gkegatewayv1.HTTPS, HTTPS: &gkegatewayv1.HTTPSHealthCheck{ @@ -595,7 +596,7 @@ func getTestHealthCheckPolicyUnstrctured(serviceNamespace, serviceName, protocol CommonHTTPHealthCheck: commonHTTPHc, }, } - } else if protocol == protocolHTTP2 { + case protocolHTTP2: hcPolicy.Spec.Default.Config = &gkegatewayv1.HealthCheck{ Type: gkegatewayv1.HTTP2, HTTP2: &gkegatewayv1.HTTP2HealthCheck{ @@ -603,7 +604,7 @@ func getTestHealthCheckPolicyUnstrctured(serviceNamespace, serviceName, protocol CommonHTTPHealthCheck: commonHTTPHc, }, } - } else { + default: return unstructured.Unstructured{} } hcPolicyUnstructured, err := i2gw.CastToUnstructured(&hcPolicy) diff --git a/pkg/i2gw/emitters/gce/output_extensions.go b/pkg/i2gw/emitters/gce/output_extensions.go index 2ae4353fe..136d12dd9 100644 --- a/pkg/i2gw/emitters/gce/output_extensions.go +++ b/pkg/i2gw/emitters/gce/output_extensions.go @@ -22,13 +22,28 @@ import ( "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate/gce" ) -func BuildGCPBackendPolicySessionAffinityConfig(gceServiceIR gce.ServiceIR) *gkegatewayv1.SessionAffinityConfig { - affinityType := gceServiceIR.SessionAffinity.AffinityType +func BuildGCPBackendPolicySessionAffinityConfig(genericServiceIR *emitterir.ServiceContext, gceServiceIR *gce.ServiceIR) *gkegatewayv1.SessionAffinityConfig { + var affinityType string + var cookieTTL *int64 + + if genericServiceIR != nil && genericServiceIR.SessionAffinity != nil { + affinityType = genericServiceIR.SessionAffinity.Type + if affinityType == "Cookie" { + affinityType = "GENERATED_COOKIE" + } + cookieTTL = genericServiceIR.SessionAffinity.CookieTTLSec + } else if gceServiceIR != nil && gceServiceIR.SessionAffinity != nil { + affinityType = gceServiceIR.SessionAffinity.AffinityType + cookieTTL = gceServiceIR.SessionAffinity.CookieTTLSec + } else { + return nil + } + saConfig := gkegatewayv1.SessionAffinityConfig{ Type: &affinityType, } if affinityType == "GENERATED_COOKIE" { - saConfig.CookieTTLSec = gceServiceIR.SessionAffinity.CookieTTLSec + saConfig.CookieTTLSec = cookieTTL } return &saConfig } diff --git a/pkg/i2gw/emitters/kgateway/auth.go b/pkg/i2gw/emitters/kgateway/auth.go index 97f09f4e9..8050d12eb 100644 --- a/pkg/i2gw/emitters/kgateway/auth.go +++ b/pkg/i2gw/emitters/kgateway/auth.go @@ -20,6 +20,8 @@ import ( "fmt" "net" "net/url" + "sort" + "strconv" "strings" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" @@ -27,6 +29,7 @@ import ( "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/shared" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) @@ -96,8 +99,10 @@ func parseAuthURL(raw string, ingressNS string) (*parsedAuthURL, error) { // Port var port int32 if portStr != "" { - var parsed int - fmt.Sscanf(portStr, "%d", &parsed) + parsed, perr := strconv.ParseInt(portStr, 10, 32) + if perr != nil || parsed < 1 || parsed > 65535 { + return nil, fmt.Errorf("invalid port in auth-url %q", portStr) + } port = int32(parsed) } else { switch u.Scheme { @@ -117,6 +122,73 @@ func parseAuthURL(raw string, ingressNS string) (*parsedAuthURL, error) { }, nil } +// EmitAuth projects ingress-nginx external-auth and basic-auth intent into +// Kgateway GatewayExtensions and TrafficPolicies. +func (e *Emitter) EmitAuth(ir emitterir.EmitterIR) { + for httpRouteKey, httpRouteCtx := range ir.HTTPRoutes { + if len(httpRouteCtx.PoliciesBySourceIngressName) == 0 { + continue + } + + policyNames := make([]string, 0, len(httpRouteCtx.PoliciesBySourceIngressName)) + for name := range httpRouteCtx.PoliciesBySourceIngressName { + policyNames = append(policyNames, name) + } + sort.Strings(policyNames) + + for _, ingressName := range policyNames { + pol := httpRouteCtx.PoliciesBySourceIngressName[ingressName] + applyExtAuthPolicy( + pol, + ingressName, + httpRouteKey.Name, + httpRouteKey.Namespace, + e.builderMap.TrafficPolicies, + e.builderMap.GatewayExtensions, + ) + applyBasicAuthPolicy( + pol, + ingressName, + httpRouteKey.Name, + httpRouteKey.Namespace, + e.builderMap.TrafficPolicies, + ) + } + } +} + +func ensureIngressTrafficPolicy( + tp map[types.NamespacedName]*kgateway.TrafficPolicy, + ingressName, namespace, routeName string, +) *kgateway.TrafficPolicy { + key := types.NamespacedName{ + Namespace: namespace, + Name: ingressName, + } + t, ok := tp[key] + if !ok { + t = &kgateway.TrafficPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: ingressName, + Namespace: namespace, + }, + Spec: kgateway.TrafficPolicySpec{}, + } + t.SetGroupVersionKind(TrafficPolicyGVK) + tp[key] = t + } + if len(t.Spec.TargetRefs) == 0 && routeName != "" { + t.Spec.TargetRefs = []shared.LocalPolicyTargetReferenceWithSectionName{{ + LocalPolicyTargetReference: shared.LocalPolicyTargetReference{ + Group: gatewayv1.Group("gateway.networking.k8s.io"), + Kind: gatewayv1.Kind("HTTPRoute"), + Name: gatewayv1.ObjectName(routeName), + }, + }} + } + return t +} + // applyExtAuthPolicy projects the ExtAuth IR policy into a GatewayExtension // and ExtAuthPolicy in TrafficPolicy. // @@ -127,9 +199,9 @@ func parseAuthURL(raw string, ingressNS string) (*parsedAuthURL, error) { // - An ExtAuthPolicy is added to TrafficPolicy that references the GatewayExtension. func applyExtAuthPolicy( pol emitterir.Policy, - ingressName, namespace string, - tp map[string]*kgateway.TrafficPolicy, - gatewayExtensions map[string]*kgateway.GatewayExtension, + ingressName, routeName, namespace string, + tp map[types.NamespacedName]*kgateway.TrafficPolicy, + gatewayExtensions map[types.NamespacedName]*kgateway.GatewayExtension, ) bool { if pol.ExtAuth == nil || pol.ExtAuth.AuthURL == "" { return false @@ -185,7 +257,7 @@ func applyExtAuthPolicy( ge.SetGroupVersionKind(GatewayExtensionGVK) // Add ExtAuthPolicy to TrafficPolicy. - t := ensureTrafficPolicy(tp, ingressName, namespace) + t := ensureIngressTrafficPolicy(tp, ingressName, namespace, routeName) t.Spec.ExtAuth = &kgateway.ExtAuthPolicy{ ExtensionRef: &shared.NamespacedObjectReference{ @@ -194,7 +266,7 @@ func applyExtAuthPolicy( }, } - gatewayExtensions[ingressName] = ge + gatewayExtensions[types.NamespacedName{Namespace: namespace, Name: ge.Name}] = ge return true } @@ -206,14 +278,14 @@ func applyExtAuthPolicy( // - If AuthType is "auth-file" (default), also set spec.basicAuth.secretRef.key to "auth". func applyBasicAuthPolicy( pol emitterir.Policy, - ingressName, namespace string, - tp map[string]*kgateway.TrafficPolicy, + ingressName, routeName, namespace string, + tp map[types.NamespacedName]*kgateway.TrafficPolicy, ) bool { if pol.BasicAuth == nil || pol.BasicAuth.SecretName == "" { return false } - t := ensureTrafficPolicy(tp, ingressName, namespace) + t := ensureIngressTrafficPolicy(tp, ingressName, namespace, routeName) secretRef := &kgateway.SecretReference{ Name: gatewayv1.ObjectName(pol.BasicAuth.SecretName), } diff --git a/pkg/i2gw/emitters/kgateway/backend_config.go b/pkg/i2gw/emitters/kgateway/backend_config.go index a09793696..7557c8413 100644 --- a/pkg/i2gw/emitters/kgateway/backend_config.go +++ b/pkg/i2gw/emitters/kgateway/backend_config.go @@ -17,6 +17,8 @@ limitations under the License. package kgateway import ( + "time" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/kgateway" @@ -27,90 +29,6 @@ import ( gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) -// applyProxyConnectTimeoutPolicy projects the ProxyConnectTimeout IR policy into one or more -// Kgateway BackendConfigPolicies. -func applyProxyConnectTimeoutPolicy( - pol emitterir.Policy, - ingressName string, - httpRouteKey types.NamespacedName, - httpRouteCtx emitterir.HTTPRouteContext, - backendCfg map[types.NamespacedName]*kgateway.BackendConfigPolicy, - svcTimeouts map[types.NamespacedName]map[string]*metav1.Duration, -) bool { - if pol.ProxyConnectTimeout == nil { - return false - } - - for _, idx := range pol.RuleBackendSources { - if idx.Rule >= len(httpRouteCtx.Spec.Rules) { - continue - } - rule := httpRouteCtx.Spec.Rules[idx.Rule] - if idx.Backend >= len(rule.BackendRefs) { - continue - } - - br := rule.BackendRefs[idx.Backend] - - if br.BackendRef.Group != nil && *br.BackendRef.Group != "" { - continue - } - if br.BackendRef.Kind != nil && *br.BackendRef.Kind != "Service" { - continue - } - - svcName := string(br.BackendRef.Name) - if svcName == "" { - continue - } - - svcKey := types.NamespacedName{ - Namespace: httpRouteKey.Namespace, - Name: svcName, - } - - // Track per-Service timeout contributors - if svcTimeouts[svcKey] == nil { - svcTimeouts[svcKey] = map[string]*metav1.Duration{} - } - svcTimeouts[svcKey][ingressName] = pol.ProxyConnectTimeout - - // Create or reuse BackendConfigPolicy per Service - bcp, exists := backendCfg[svcKey] - if !exists { - // Use a generic name that works for all backend config features - policyName := svcName + "-backend-config" - bcp = &kgateway.BackendConfigPolicy{ - ObjectMeta: metav1.ObjectMeta{ - Name: policyName, - Namespace: httpRouteKey.Namespace, - }, - Spec: kgateway.BackendConfigPolicySpec{ - TargetRefs: []shared.LocalPolicyTargetReference{ - { - Group: "", - Kind: "Service", - Name: gatewayv1.ObjectName(svcName), - }, - }, - ConnectTimeout: pol.ProxyConnectTimeout, - }, - } - bcp.SetGroupVersionKind(BackendConfigPolicyGVK) - backendCfg[svcKey] = bcp - } else { - // enforce "lowest timeout wins" - cur := bcp.Spec.ConnectTimeout.Duration - next := pol.ProxyConnectTimeout.Duration - if next < cur { - bcp.Spec.ConnectTimeout = pol.ProxyConnectTimeout - } - } - } - - return true -} - // applySessionAffinityPolicy projects the SessionAffinity IR policy into one or more // Kgateway BackendConfigPolicies. // @@ -195,7 +113,9 @@ func applySessionAffinityPolicy( } if sessionAffinity.CookieExpires != nil { - cookieHashPolicy.TTL = sessionAffinity.CookieExpires + cookieHashPolicy.TTL = &metav1.Duration{ + Duration: time.Duration(*sessionAffinity.CookieExpires) * time.Second, + } } if sessionAffinity.CookieSecure != nil { @@ -225,6 +145,30 @@ func applySessionAffinityPolicy( return true } +// EmitSessionAffinity projects per-route session affinity policy intent into +// Kgateway BackendConfigPolicies. This runs before load balancing so ring-hash +// affinity takes precedence over round-robin when both are present. +func (e *Emitter) EmitSessionAffinity(ir emitterir.EmitterIR) { + for httpRouteKey, httpRouteCtx := range ir.HTTPRoutes { + if len(httpRouteCtx.PoliciesBySourceIngressName) == 0 { + continue + } + + for _, pol := range httpRouteCtx.PoliciesBySourceIngressName { + if pol.SessionAffinity == nil { + continue + } + pol.RuleBackendSources = uniquePolicyIndices(pol.RuleBackendSources) + applySessionAffinityPolicy( + pol, + httpRouteKey, + httpRouteCtx, + e.builderMap.BackendConfigPolicies, + ) + } + } +} + // applyAccessLogPolicy projects the EnableAccessLog IR policy into one or more // Kgateway HTTPListenerPolicies. // @@ -300,3 +244,33 @@ func applyAccessLogPolicy( return true } + +// EmitAccessLog projects per-route access log policy intent into Kgateway +// HTTPListenerPolicies targeting the parent Gateway. +func (e *Emitter) EmitAccessLog(ir emitterir.EmitterIR) { + for httpRouteKey, httpRouteCtx := range ir.HTTPRoutes { + for ruleIdx, accessLog := range httpRouteCtx.EnableAccessLogByRuleIdx { + if accessLog == nil || !accessLog.Enabled || ruleIdx < 0 || ruleIdx >= len(httpRouteCtx.Spec.Rules) { + continue + } + + coverage := make([]emitterir.PolicyIndex, 0, len(httpRouteCtx.Spec.Rules[ruleIdx].BackendRefs)) + for backendIdx := range httpRouteCtx.Spec.Rules[ruleIdx].BackendRefs { + coverage = append(coverage, emitterir.PolicyIndex{ + Rule: ruleIdx, + Backend: backendIdx, + }) + } + + applyAccessLogPolicy( + emitterir.Policy{ + EnableAccessLog: ptr.To(true), + RuleBackendSources: coverage, + }, + httpRouteKey, + httpRouteCtx, + e.builderMap.HTTPListenerPolicies, + ) + } + } +} diff --git a/pkg/i2gw/emitters/kgateway/backend_protocol.go b/pkg/i2gw/emitters/kgateway/backend_protocol.go index 1deb66e7f..016559de3 100644 --- a/pkg/i2gw/emitters/kgateway/backend_protocol.go +++ b/pkg/i2gw/emitters/kgateway/backend_protocol.go @@ -17,6 +17,9 @@ limitations under the License. package kgateway import ( + "sort" + + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/kgateway" @@ -24,6 +27,63 @@ import ( gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) +// EmitBackendProtocol applies service-upstream/backend-protocol backend rewrites +// and projects backend protocol intent into Kgateway Backends. +func (e *Emitter) EmitBackendProtocol(ir emitterir.EmitterIR, gwResources *i2gw.GatewayResources) { + seenPatchNotifications := map[backendProtoPatchKey]struct{}{} + + for httpRouteKey, httpRouteCtx := range ir.HTTPRoutes { + if len(httpRouteCtx.PoliciesBySourceIngressName) == 0 { + continue + } + + policyNames := make([]string, 0, len(httpRouteCtx.PoliciesBySourceIngressName)) + for name := range httpRouteCtx.PoliciesBySourceIngressName { + policyNames = append(policyNames, name) + } + sort.Strings(policyNames) + + for _, ingressName := range policyNames { + pol := httpRouteCtx.PoliciesBySourceIngressName[ingressName] + if len(pol.Backends) == 0 { + continue + } + + pol.RuleBackendSources = uniquePolicyIndices(pol.RuleBackendSources) + applyServiceUpstream( + pol, + ingressName, + httpRouteKey, + &httpRouteCtx, + e.builderMap.Backends, + ) + + if pol.BackendProtocol == nil { + continue + } + + emitBackendProtocolPatchNotifications( + e.notify, + pol, + ingressName, + httpRouteKey, + httpRouteCtx, + seenPatchNotifications, + ) + applyBackendProtocol( + pol, + ingressName, + httpRouteKey, + &httpRouteCtx, + e.builderMap.Backends, + ) + } + + ir.HTTPRoutes[httpRouteKey] = httpRouteCtx + gwResources.HTTPRoutes[httpRouteKey] = httpRouteCtx.HTTPRoute + } +} + // applyBackendProtocol projects backend protocol metadata on IR Backends into // typed Kgateway Backend CRs and rewrites HTTPRoute backendRefs to reference // those Backends. diff --git a/pkg/i2gw/emitters/kgateway/backend_protocol_notifications.go b/pkg/i2gw/emitters/kgateway/backend_protocol_notifications.go index 81388ff31..a31008932 100644 --- a/pkg/i2gw/emitters/kgateway/backend_protocol_notifications.go +++ b/pkg/i2gw/emitters/kgateway/backend_protocol_notifications.go @@ -41,6 +41,7 @@ type backendProtoPatchKey struct { // - We also skip backends that have been rewritten to a kgateway Backend (service-upstream case), // because the generated Backend will carry appProtocol instead. func emitBackendProtocolPatchNotifications( + notify notifications.NotifyFunc, pol emitterir.Policy, sourceIngressName string, httpRouteKey types.NamespacedName, @@ -132,9 +133,6 @@ Apply the equivalent behavior by patching your existing Service port's appProtoc cmd, ) - notifications.NotificationAggr.DispatchNotification( - notifications.NewNotification(notifications.InfoNotification, msg), - "ingress-nginx", - ) + notify(notifications.InfoNotification, msg) } } diff --git a/pkg/i2gw/emitters/kgateway/backend_protocol_test.go b/pkg/i2gw/emitters/kgateway/backend_protocol_test.go index 1de1707dd..e24856d7d 100644 --- a/pkg/i2gw/emitters/kgateway/backend_protocol_test.go +++ b/pkg/i2gw/emitters/kgateway/backend_protocol_test.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -97,11 +97,8 @@ func TestApplyBackendProtocolProjectsBackendRefToKgatewayBackend(t *testing.T) { } func TestEmitBackendProtocolPatchNotificationsExplainsNoGRPCRouteProjection(t *testing.T) { - origNotifications := notifications.NotificationAggr.Notifications - defer func() { - notifications.NotificationAggr.Notifications = origNotifications - }() - notifications.NotificationAggr.Notifications = map[string][]notifications.Notification{} + report := notifications.NewReport(true) + notify := report.Notifier("ingress-nginx") grpcProtocol := emitterir.BackendProtocolGRPC backendRefPort := gatewayv1.PortNumber(9090) @@ -129,6 +126,7 @@ func TestEmitBackendProtocolPatchNotificationsExplainsNoGRPCRouteProjection(t *t } emitBackendProtocolPatchNotifications( + notify, policy, "ingress-grpc", httpRouteKey, @@ -136,11 +134,11 @@ func TestEmitBackendProtocolPatchNotificationsExplainsNoGRPCRouteProjection(t *t map[backendProtoPatchKey]struct{}{}, ) - got := notifications.NotificationAggr.Notifications["ingress-nginx"] - if len(got) != 1 { - t.Fatalf("expected 1 ingress-nginx notification, got %d", len(got)) + got := report.Render() + if strings.Count(got, "source: INGRESS-NGINX") != 1 { + t.Fatalf("expected 1 ingress-nginx notification, got:\n%s", got) } - if !strings.Contains(got[0].Message, "does not emit a GRPCRoute") { - t.Fatalf("expected message to explain GRPCRoute is not emitted; got:\n%s", got[0].Message) + if !strings.Contains(got, "does not emit a GRPCRoute") { + t.Fatalf("expected message to explain GRPCRoute is not emitted; got:\n%s", got) } } diff --git a/pkg/i2gw/emitters/kgateway/backend_tls.go b/pkg/i2gw/emitters/kgateway/backend_tls.go index e30d723b7..ef97031ba 100644 --- a/pkg/i2gw/emitters/kgateway/backend_tls.go +++ b/pkg/i2gw/emitters/kgateway/backend_tls.go @@ -17,8 +17,10 @@ limitations under the License. package kgateway import ( + "sort" "strings" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/kgateway" @@ -30,6 +32,40 @@ import ( gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) +// EmitBackendTLS projects backend TLS policy intent into Kgateway BackendConfigPolicies +// and suppresses core BackendTLSPolicy output for this emitter. +func (e *Emitter) EmitBackendTLS(ir emitterir.EmitterIR, gwResources *i2gw.GatewayResources) { + for httpRouteKey, httpRouteCtx := range ir.HTTPRoutes { + if len(httpRouteCtx.PoliciesBySourceIngressName) == 0 { + continue + } + + policyNames := make([]string, 0, len(httpRouteCtx.PoliciesBySourceIngressName)) + for name := range httpRouteCtx.PoliciesBySourceIngressName { + policyNames = append(policyNames, name) + } + sort.Strings(policyNames) + + for _, ingressName := range policyNames { + pol := httpRouteCtx.PoliciesBySourceIngressName[ingressName] + if pol.BackendTLS == nil { + continue + } + + pol.RuleBackendSources = uniquePolicyIndices(pol.RuleBackendSources) + applyBackendTLSPolicy( + pol, + httpRouteKey, + httpRouteCtx, + e.builderMap.BackendConfigPolicies, + ) + } + } + + // kgateway uses BackendConfigPolicy instead of core Gateway API BackendTLSPolicy. + gwResources.BackendTLSPolicies = nil +} + // applyBackendTLSPolicy projects the BackendTLS IR policy into one or more // Kgateway BackendConfigPolicies. // diff --git a/pkg/i2gw/emitters/kgateway/buffer.go b/pkg/i2gw/emitters/kgateway/buffer.go new file mode 100644 index 000000000..3e9f4d2f3 --- /dev/null +++ b/pkg/i2gw/emitters/kgateway/buffer.go @@ -0,0 +1,76 @@ +/* +Copyright The Kubernetes Authors. + +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 kgateway + +import ( + "fmt" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + envoygateway_emitter "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitters/envoygateway" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/kgateway" + "k8s.io/apimachinery/pkg/api/resource" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// EmitBuffer processes BodySizeByRuleIdx from emitterIR and creates TrafficPolicies with buffer configuration. +func (e *Emitter) EmitBuffer(ir emitterir.EmitterIR) { + for _, ctx := range ir.HTTPRoutes { + envoygateway_emitter.MergeBodySizeIR(&ctx) + + for idx, bs := range ctx.BodySizeByRuleIdx { + if bs == nil { + continue + } + + sectionName := e.getSectionName(ctx, idx) + trafficPolicy := e.getOrBuildTrafficPolicy(ctx, sectionName, idx) + + bufferVal := e.selectBufferValue(bs, &ctx.HTTPRoute) + if bufferVal == nil { + continue + } + + trafficPolicy.Spec.Buffer = &kgateway.Buffer{ + MaxRequestSize: bufferVal, + } + } + } +} + +// getSectionName returns the section name for the given rule index, or nil if it applies to all rules. +func (e *Emitter) getSectionName(ctx emitterir.HTTPRouteContext, idx int) *gatewayv1.SectionName { + if idx != RouteRuleAllIndex && idx < len(ctx.Spec.Rules) { + return ctx.Spec.Rules[idx].Name + } + return nil +} + +// selectBufferValue selects the buffer value, preferring MaxSize over BufferSize, and emits a warning if both are present. +func (e *Emitter) selectBufferValue(bs *emitterir.BodySize, httpRoute *gatewayv1.HTTPRoute) *resource.Quantity { + if bs.MaxSize != nil { + if bs.BufferSize != nil { + e.notify( + notifications.WarningNotification, + fmt.Sprintf("Body max size (%s) takes precedence; buffer size (%s) will be ignored", bs.MaxSize.String(), bs.BufferSize.String()), + httpRoute, + ) + } + return bs.MaxSize + } + return bs.BufferSize +} diff --git a/pkg/i2gw/emitters/kgateway/builder.go b/pkg/i2gw/emitters/kgateway/builder.go new file mode 100644 index 000000000..19175fd0a --- /dev/null +++ b/pkg/i2gw/emitters/kgateway/builder.go @@ -0,0 +1,85 @@ +/* +Copyright The Kubernetes Authors. + +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 kgateway + +import ( + "fmt" + + "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/kgateway" + "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/shared" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" +) + +type BuilderMap struct { + TrafficPolicies map[types.NamespacedName]*kgateway.TrafficPolicy + BackendConfigPolicies map[types.NamespacedName]*kgateway.BackendConfigPolicy + HTTPListenerPolicies map[types.NamespacedName]*kgateway.HTTPListenerPolicy + GatewayExtensions map[types.NamespacedName]*kgateway.GatewayExtension + Backends map[types.NamespacedName]*kgateway.Backend +} + +func NewBuilderMap() *BuilderMap { + return &BuilderMap{ + TrafficPolicies: make(map[types.NamespacedName]*kgateway.TrafficPolicy), + BackendConfigPolicies: make(map[types.NamespacedName]*kgateway.BackendConfigPolicy), + HTTPListenerPolicies: make(map[types.NamespacedName]*kgateway.HTTPListenerPolicy), + GatewayExtensions: make(map[types.NamespacedName]*kgateway.GatewayExtension), + Backends: make(map[types.NamespacedName]*kgateway.Backend), + } +} + +func (e *Emitter) getOrBuildTrafficPolicy(ctx emitterir.HTTPRouteContext, sectionName *gatewayv1.SectionName, ruleIdx int) *kgateway.TrafficPolicy { + name := fmt.Sprintf("%s-%d", ctx.Name, ruleIdx) + if ruleIdx == RouteRuleAllIndex { + name = ctx.Name + } + key := types.NamespacedName{ + Name: name, + Namespace: ctx.Namespace, + } + policy, exist := e.builderMap.TrafficPolicies[key] + if exist { + return policy + } + + trafficPolicy := &kgateway.TrafficPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ctx.Namespace, + }, + Spec: kgateway.TrafficPolicySpec{ + TargetRefs: []shared.LocalPolicyTargetReferenceWithSectionName{ + { + LocalPolicyTargetReference: shared.LocalPolicyTargetReference{ + Group: gatewayv1.Group("gateway.networking.k8s.io"), + Kind: gatewayv1.Kind("HTTPRoute"), + Name: gatewayv1.ObjectName(ctx.Name), + }, + SectionName: sectionName, + }, + }, + }, + } + trafficPolicy.SetGroupVersionKind(TrafficPolicyGVK) + + e.builderMap.TrafficPolicies[key] = trafficPolicy + return trafficPolicy +} diff --git a/pkg/i2gw/emitters/kgateway/cors.go b/pkg/i2gw/emitters/kgateway/cors.go index 0363e1544..60de032d3 100644 --- a/pkg/i2gw/emitters/kgateway/cors.go +++ b/pkg/i2gw/emitters/kgateway/cors.go @@ -19,27 +19,103 @@ package kgateway import ( "strings" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitters/utils" "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/kgateway" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) -// applyCorsPolicy projects the CORS policy IR into a Kgateway TrafficPolicy, -// returning true if it modified/created a TrafficPolicy for the given ingress. -func applyCorsPolicy( - pol emitterir.Policy, - ingressName, namespace string, - tp map[string]*kgateway.TrafficPolicy, -) bool { - if pol.Cors == nil || !pol.Cors.Enable || len(pol.Cors.AllowOrigin) == 0 { - return false +// EmitCors projects provider-neutral per-rule CORS intent into section-scoped +// Kgateway TrafficPolicies and strips upstream CORS response headers on the +// affected HTTPRoute rules. +func (e *Emitter) EmitCors(ir emitterir.EmitterIR, gwResources *i2gw.GatewayResources) { + for httpRouteKey, ctx := range ir.HTTPRoutes { + httpRoute := gwResources.HTTPRoutes[httpRouteKey] + appliedRules := map[int]struct{}{} + for idx := range httpRoute.Spec.Rules { + if idx < 0 || idx >= len(ctx.Spec.Rules) { + continue + } + + rule := &httpRoute.Spec.Rules[idx] + + var filter *gatewayv1.HTTPCORSFilter + newFilters := make([]gatewayv1.HTTPRouteFilter, 0, len(rule.Filters)) + for _, existingFilter := range rule.Filters { + if existingFilter.Type == gatewayv1.HTTPRouteFilterCORS && existingFilter.CORS != nil { + cfg := &emitterir.CORSConfig{HTTPCORSFilter: *existingFilter.CORS} + filter = buildCorsFilter(corsConfigToPolicy(cfg)) + continue + } + newFilters = append(newFilters, existingFilter) + } + + if filter == nil { + filter = buildCorsFilter(corsConfigToPolicy(ctx.CorsPolicyByRuleIdx[idx])) + } + if filter == nil { + continue + } + + trafficPolicy := e.getOrBuildTrafficPolicy(ctx, e.getSectionName(ctx, idx), idx) + trafficPolicy.Spec.Cors = &kgateway.CorsPolicy{ + HTTPCORSFilter: filter, + } + rule.Filters = newFilters + appliedRules[idx] = struct{}{} + } + + if len(appliedRules) == 0 { + continue + } + + utils.EnsureStripUpstreamCORSHeadersForRules(&httpRoute, appliedRules) + gwResources.HTTPRoutes[httpRouteKey] = httpRoute + } +} + +func corsConfigToPolicy(cfg *emitterir.CORSConfig) *emitterir.CorsPolicy { + if cfg == nil { + return nil + } + + policy := &emitterir.CorsPolicy{ + Enable: true, + } + for _, origin := range cfg.AllowOrigins { + policy.AllowOrigin = append(policy.AllowOrigin, string(origin)) + } + if cfg.AllowCredentials != nil { + value := *cfg.AllowCredentials + policy.AllowCredentials = &value + } + for _, header := range cfg.AllowHeaders { + policy.AllowHeaders = append(policy.AllowHeaders, string(header)) + } + for _, header := range cfg.ExposeHeaders { + policy.ExposeHeaders = append(policy.ExposeHeaders, string(header)) + } + for _, method := range cfg.AllowMethods { + policy.AllowMethods = append(policy.AllowMethods, string(method)) + } + if cfg.MaxAge > 0 { + value := cfg.MaxAge + policy.MaxAge = &value + } + return policy +} + +func buildCorsFilter(cors *emitterir.CorsPolicy) *gatewayv1.HTTPCORSFilter { + if cors == nil || !cors.Enable || len(cors.AllowOrigin) == 0 { + return nil } // AllowOrigins: dedupe while preserving order. - seenOrigins := make(map[string]struct{}, len(pol.Cors.AllowOrigin)) + seenOrigins := make(map[string]struct{}, len(cors.AllowOrigin)) var origins []gatewayv1.CORSOrigin - for _, o := range pol.Cors.AllowOrigin { + for _, o := range cors.AllowOrigin { o = strings.TrimSpace(o) if o == "" { continue @@ -51,14 +127,14 @@ func applyCorsPolicy( origins = append(origins, gatewayv1.CORSOrigin(o)) } if len(origins) == 0 { - return false + return nil } // AllowHeaders: dedupe (case-insensitive) and map to HTTPHeaderName. var allowHeaders []gatewayv1.HTTPHeaderName - if len(pol.Cors.AllowHeaders) > 0 { - seenHeaders := make(map[string]struct{}, len(pol.Cors.AllowHeaders)) - for _, h := range pol.Cors.AllowHeaders { + if len(cors.AllowHeaders) > 0 { + seenHeaders := make(map[string]struct{}, len(cors.AllowHeaders)) + for _, h := range cors.AllowHeaders { h = strings.TrimSpace(h) if h == "" { continue @@ -74,9 +150,9 @@ func applyCorsPolicy( // ExposeHeaders: dedupe (case-insensitive) and map to HTTPHeaderName. var exposeHeaders []gatewayv1.HTTPHeaderName - if len(pol.Cors.ExposeHeaders) > 0 { - seenHeaders := make(map[string]struct{}, len(pol.Cors.ExposeHeaders)) - for _, h := range pol.Cors.ExposeHeaders { + if len(cors.ExposeHeaders) > 0 { + seenHeaders := make(map[string]struct{}, len(cors.ExposeHeaders)) + for _, h := range cors.ExposeHeaders { h = strings.TrimSpace(h) if h == "" { continue @@ -92,9 +168,9 @@ func applyCorsPolicy( // AllowMethods: normalize to upper-case, filter to Gateway API enum, dedupe. var methods []gatewayv1.HTTPMethodWithWildcard - if len(pol.Cors.AllowMethods) > 0 { - seenMethods := make(map[string]struct{}, len(pol.Cors.AllowMethods)) - for _, m := range pol.Cors.AllowMethods { + if len(cors.AllowMethods) > 0 { + seenMethods := make(map[string]struct{}, len(cors.AllowMethods)) + for _, m := range cors.AllowMethods { m = strings.TrimSpace(m) if m == "" { continue @@ -123,36 +199,24 @@ func applyCorsPolicy( } } - t := ensureTrafficPolicy(tp, ingressName, namespace) - - if t.Spec.Cors == nil { - t.Spec.Cors = &kgateway.CorsPolicy{} + filter := &gatewayv1.HTTPCORSFilter{ + AllowOrigins: origins, } - if t.Spec.Cors.HTTPCORSFilter == nil { - t.Spec.Cors.HTTPCORSFilter = &gatewayv1.HTTPCORSFilter{} - } - - f := t.Spec.Cors.HTTPCORSFilter - - // Required-ish for nginx semantics: we only emit if we have at least one origin. - f.AllowOrigins = origins - - // Optional knobs: only set when present in the IR. - if pol.Cors.AllowCredentials != nil { - f.AllowCredentials = pol.Cors.AllowCredentials + if cors.AllowCredentials != nil { + filter.AllowCredentials = cors.AllowCredentials } if len(allowHeaders) > 0 { - f.AllowHeaders = allowHeaders + filter.AllowHeaders = allowHeaders } if len(exposeHeaders) > 0 { - f.ExposeHeaders = exposeHeaders + filter.ExposeHeaders = exposeHeaders } if len(methods) > 0 { - f.AllowMethods = methods + filter.AllowMethods = methods } - if pol.Cors.MaxAge != nil && *pol.Cors.MaxAge > 0 { - f.MaxAge = *pol.Cors.MaxAge + if cors.MaxAge != nil && *cors.MaxAge > 0 { + filter.MaxAge = *cors.MaxAge } - return true + return filter } diff --git a/pkg/i2gw/emitters/kgateway/emitter.go b/pkg/i2gw/emitters/kgateway/emitter.go index bcd7f56cb..0fce903c5 100644 --- a/pkg/i2gw/emitters/kgateway/emitter.go +++ b/pkg/i2gw/emitters/kgateway/emitter.go @@ -1,5 +1,5 @@ /* -Copyright 2025 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,398 +17,88 @@ limitations under the License. package kgateway import ( - "fmt" "sort" - "strings" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitters/utils" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" - - "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/kgateway" - "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/shared" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/controller-runtime/pkg/client" - gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) -const gatewayClassName = "kgateway" +const emitterName = "kgateway" func init() { - i2gw.EmitterConstructorByName["kgateway"] = NewEmitter + i2gw.EmitterConstructorByName[emitterName] = NewEmitter } -type Emitter struct{} +type Emitter struct { + builderMap *BuilderMap + notify notifications.NotifyFunc +} -// NewEmitter returns a new instance of KgatewayEmitter. -func NewEmitter(_ *i2gw.EmitterConf) i2gw.Emitter { - return &Emitter{} +// NewEmitter returns a new instance of KgatewayEmitter +func NewEmitter(conf *i2gw.EmitterConf) i2gw.Emitter { + return &Emitter{ + builderMap: NewBuilderMap(), + notify: conf.Report.Notifier(emitterName), + } } -// Emit converts EmitterIR to Gateway API resources plus kgateway-specific extensions. +// Emit converts EmitterIR to Gateway API resources plus kgateway-specific extensions func (e *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.ErrorList) { gatewayResources, errs := utils.ToGatewayResources(ir) - if len(errs) > 0 { - return gatewayResources, errs + if len(errs) != 0 { + return i2gw.GatewayResources{}, errs } // Set GatewayClassName to "kgateway" for all Gateways for key := range gatewayResources.Gateways { gateway := gatewayResources.Gateways[key] - gateway.Spec.GatewayClassName = gatewayClassName + gateway.Spec.GatewayClassName = emitterName gatewayResources.Gateways[key] = gateway } - // Track kgateway-specific resources - var kgatewayObjs []client.Object - - // One BackendConfigPolicy per Ingress name (per namespace), aggregating all - // Services that Ingress routes to, when BackendConfigPolicy applicable is set. - backendCfg := map[types.NamespacedName]*kgateway.BackendConfigPolicy{} - svcTimeouts := map[types.NamespacedName]map[string]*metav1.Duration{} - - // Track HTTPListenerPolicies per Gateway (for access logging). - httpListenerPolicies := map[types.NamespacedName]*kgateway.HTTPListenerPolicy{} - - // Track GatewayExtensions per ingress name (for external auth). - gatewayExtensions := map[string]*kgateway.GatewayExtension{} - - // Track Backends per (namespace, svcName) for backend-dependent features, i.e. service-upstream. - backends := map[types.NamespacedName]*kgateway.Backend{} - - // De-dupe backend-protocol patch notifications by (ns, svc, port, appProtocol). - backendProtoPatchSeen := map[backendProtoPatchKey]struct{}{} - - // Track HTTPRoutes that need SSL redirect splitting - routesToSplitForSSLRedirect := map[types.NamespacedName]bool{} - - for httpRouteKey, httpRouteContext := range ir.HTTPRoutes { - if len(httpRouteContext.PoliciesBySourceIngressName) == 0 { - continue - } - - // If any policy projects CORS for this route, strip upstream CORS headers. - routeCorsTouched := false - - // One TrafficPolicy per source Ingress name. - tp := map[string]*kgateway.TrafficPolicy{} - - // Apply host-wide regex enforcement first (so rule path regex is finalized) - applyRegexPathMatchingForHost(&httpRouteContext) - - // deterministic policy iteration - policyNames := make([]string, 0, len(httpRouteContext.PoliciesBySourceIngressName)) - for name := range httpRouteContext.PoliciesBySourceIngressName { - policyNames = append(policyNames, name) - } - sort.Strings(policyNames) - - // Rewrite-target pass: creates per-rule TPs and attaches filters itself. - for _, name := range policyNames { - pol := httpRouteContext.PoliciesBySourceIngressName[name] - applyRewriteTargetPolicies(pol, name, httpRouteKey.Namespace, &httpRouteContext, tp) - } - - for _, polSourceIngressName := range policyNames { - pol := httpRouteContext.PoliciesBySourceIngressName[polSourceIngressName] - // Normalize (rule, backend) coverage to unique pairs to avoid - // generating duplicate filters on the same backendRef. - coverage := uniquePolicyIndices(pol.RuleBackendSources) - - // Apply feature-specific projections (buffer, CORS, etc.). - touched := false - - if applyBufferPolicy(pol, polSourceIngressName, httpRouteKey.Namespace, tp) { - touched = true - } - if applyCorsPolicy(pol, polSourceIngressName, httpRouteKey.Namespace, tp) { - touched = true - routeCorsTouched = true - } - if applyRateLimitPolicy(pol, polSourceIngressName, httpRouteKey.Namespace, tp) { - touched = true - } - if applyTimeoutPolicy(pol, polSourceIngressName, httpRouteKey.Namespace, tp) { - touched = true - } - - // Apply proxy-connect-timeout via BackendConfigPolicy. - // Note: "touched" is not updated here, as this does not affect TrafficPolicy. - applyProxyConnectTimeoutPolicy( - pol, - polSourceIngressName, - httpRouteKey, - httpRouteContext, - backendCfg, - svcTimeouts, - ) - - // Apply session affinity via BackendConfigPolicy. - // Note: "touched" is not updated here, as this does not affect TrafficPolicy. - applySessionAffinityPolicy( - pol, - httpRouteKey, - httpRouteContext, - backendCfg, - ) - - // Apply explicit round_robin load balancing via BackendConfigPolicy. - // Note: This must come AFTER applySessionAffinityPolicy so ring-hash - // (session affinity) always takes precedence for a given Service. - applyLoadBalancingPolicy( - pol, - httpRouteKey, - httpRouteContext, - backendCfg, - ) - - // Apply backend TLS via BackendConfigPolicy. - // Note: "touched" is not updated here, as this does not affect TrafficPolicy. - applyBackendTLSPolicy( - pol, - httpRouteKey, - httpRouteContext, - backendCfg, - ) - - // backend-protocol: do NOT emit/patch Services. - // Instead, emit an INFO notification with a safe kubectl patch command for the user - // (and skip when service-upstream rewrote the backendRef to a kgateway Backend). - emitBackendProtocolPatchNotifications( - pol, - polSourceIngressName, - httpRouteKey, - httpRouteContext, - backendProtoPatchSeen, - ) - - // Apply service-upstream via Backend and HTTPRoute backendRef rewrites. - applyServiceUpstream( - pol, - polSourceIngressName, - httpRouteKey, - &httpRouteContext, - backends, - ) - - // Apply backend-protocol via Backend and HTTPRoute backendRef rewrites. - applyBackendProtocol( - pol, - polSourceIngressName, - httpRouteKey, - &httpRouteContext, - backends, - ) - - // Apply enable-access-log via HTTPListenerPolicy. - applyAccessLogPolicy( - pol, - httpRouteKey, - httpRouteContext, - httpListenerPolicies, - ) - - // Apply auth-url via GatewayExtension and ExtAuthPolicy. - if applyExtAuthPolicy(pol, polSourceIngressName, httpRouteKey.Namespace, tp, gatewayExtensions) { - touched = true - } - - // Apply basic auth via TrafficPolicy. - if applyBasicAuthPolicy(pol, polSourceIngressName, httpRouteKey.Namespace, tp) { - touched = true - } - - // Check if SSL redirect is enabled but don't apply it yet (will split route later). - if applySSLRedirectPolicy(pol) { - // Mark this HTTPRoute for SSL redirect splitting - routesToSplitForSSLRedirect[httpRouteKey] = true - } - - if !touched { - // No TrafficPolicy fields set for this policy; skip coverage wiring. - continue - } - - t := tp[polSourceIngressName] - if t == nil { - // Should not happen, but guard just in case. - continue - } - - // Coverage logic is shared across all features: - // - If this policy covers all route backends, attach via targetRefs. - // - Otherwise, attach via ExtensionRef filters on the covered backendRefs. - total := numRules(httpRouteContext.HTTPRoute) - covered := len(coverage) + e.ToKgatewayResources(ir, &gatewayResources) - // Some ingress-nginx features are recorded at the Ingress scope (not per rule/backend pair). - // In that case RuleBackendSources may be empty; treat this as "applies to all backends". - // This avoids silently generating a TrafficPolicy that never gets attached. - if covered == 0 { - covered = total - } - - if covered == total { - // Full coverage via targetRefs. - t.Spec.TargetRefs = []shared.LocalPolicyTargetReferenceWithSectionName{{ - LocalPolicyTargetReference: shared.LocalPolicyTargetReference{ - Group: gatewayv1.Group("gateway.networking.k8s.io"), - Kind: gatewayv1.Kind("HTTPRoute"), - Name: gatewayv1.ObjectName(httpRouteKey.Name), - }, - }} - } else { - // Partial coverage via ExtensionRef filters on backendRefs. - for _, idx := range coverage { - httpRouteContext.Spec.Rules[idx.Rule].BackendRefs[idx.Backend].Filters = - append( - httpRouteContext.Spec.Rules[idx.Rule].BackendRefs[idx.Backend].Filters, - gatewayv1.HTTPRouteFilter{ - Type: gatewayv1.HTTPRouteFilterExtensionRef, - ExtensionRef: &gatewayv1.LocalObjectReference{ - Group: gatewayv1.Group(TrafficPolicyGVK.Group), - Kind: gatewayv1.Kind(TrafficPolicyGVK.Kind), - Name: gatewayv1.ObjectName(t.Name), - }, - }, - ) - } - } - } - - // Prevent upstream/backends from leaking permissive CORS headers. - // We do this once per route if ANY CORS policy was projected. - if routeCorsTouched { - utils.EnsureStripUpstreamCORSHeaders(&httpRouteContext.HTTPRoute) - } - - // Write back the mutated HTTPRouteContext into the IR. - ir.HTTPRoutes[httpRouteKey] = httpRouteContext - - // Update gatewayResources with modified HTTPRoute - gatewayResources.HTTPRoutes[httpRouteKey] = httpRouteContext.HTTPRoute - - // Collect TrafficPolicies for this HTTPRoute. - for _, tp := range tp { - kgatewayObjs = append(kgatewayObjs, tp) - } - } - - // Split HTTPRoutes that have SSL redirect enabled - for httpRouteKey := range routesToSplitForSSLRedirect { - httpRouteContext, exists := ir.HTTPRoutes[httpRouteKey] - if !exists { - continue - } - - // Get the Gateway for this HTTPRoute - var gatewayCtx *emitterir.GatewayContext - if len(httpRouteContext.Spec.ParentRefs) > 0 { - parentRef := httpRouteContext.Spec.ParentRefs[0] - gatewayNamespace := httpRouteKey.Namespace - if parentRef.Namespace != nil { - gatewayNamespace = string(*parentRef.Namespace) - } - gatewayName := string(parentRef.Name) - if gatewayName != "" { - gatewayKey := types.NamespacedName{ - Namespace: gatewayNamespace, - Name: gatewayName, - } - if gw, ok := ir.Gateways[gatewayKey]; ok { - gatewayCtx = &gw - } - } - } - - if gatewayCtx == nil { - continue - } - - // Split the route - httpRedirectRoute, httpsBackendRoute, success := splitHTTPRouteForSSLRedirect( - httpRouteContext, - httpRouteKey, - gatewayCtx, - ) - - if success { - // Remove the original route - delete(ir.HTTPRoutes, httpRouteKey) - delete(gatewayResources.HTTPRoutes, httpRouteKey) - - // Add the HTTP redirect route - httpRedirectKey := types.NamespacedName{ - Namespace: httpRedirectRoute.Namespace, - Name: httpRedirectRoute.Name, - } - ir.HTTPRoutes[httpRedirectKey] = *httpRedirectRoute - gatewayResources.HTTPRoutes[httpRedirectKey] = httpRedirectRoute.HTTPRoute - - // Add the HTTPS backend route if it was created - if httpsBackendRoute != nil { - httpsBackendKey := types.NamespacedName{ - Namespace: httpsBackendRoute.Namespace, - Name: httpsBackendRoute.Name, - } - ir.HTTPRoutes[httpsBackendKey] = *httpsBackendRoute - gatewayResources.HTTPRoutes[httpsBackendKey] = httpsBackendRoute.HTTPRoute - } - } - } + utils.LogUnparsedErrors(ir, e.notify) + return gatewayResources, nil +} - // Collect all static Backends computed across HTTPRoutes. - for _, b := range backends { - kgatewayObjs = append(kgatewayObjs, b) +// ToKgatewayResources processes emitterIR and adds kgateway-specific extensions to gatewayResources +func (e *Emitter) ToKgatewayResources(ir emitterir.EmitterIR, gwResources *i2gw.GatewayResources) { + e.EmitBackendProtocol(ir, gwResources) + e.EmitBackendTLS(ir, gwResources) + e.EmitBuffer(ir) + // e.EmitTimeouts(ir) + e.EmitRateLimit(ir) + e.EmitCors(ir, gwResources) + e.EmitPathRewrite(ir, gwResources) + e.EmitSessionAffinity(ir) + e.EmitLoadBalancing(ir) + e.EmitAccessLog(ir) + e.EmitAuth(ir) + + // Collect all TrafficPolicies and convert to unstructured + var kgatewayObjs []client.Object + for _, gatewayExtension := range e.builderMap.GatewayExtensions { + kgatewayObjs = append(kgatewayObjs, gatewayExtension) } - - // Collect all BackendConfigPolicies computed across HTTPRoutes. - for _, bcp := range backendCfg { - kgatewayObjs = append(kgatewayObjs, bcp) + for _, backendConfigPolicy := range e.builderMap.BackendConfigPolicies { + kgatewayObjs = append(kgatewayObjs, backendConfigPolicy) } - - // Collect all HTTPListenerPolicies computed across HTTPRoutes. - for _, hlp := range httpListenerPolicies { - kgatewayObjs = append(kgatewayObjs, hlp) + for _, httpListenerPolicy := range e.builderMap.HTTPListenerPolicies { + kgatewayObjs = append(kgatewayObjs, httpListenerPolicy) } - - // Collect all GatewayExtensions computed across HTTPRoutes. - for _, ge := range gatewayExtensions { - kgatewayObjs = append(kgatewayObjs, ge) + for _, trafficPolicy := range e.builderMap.TrafficPolicies { + kgatewayObjs = append(kgatewayObjs, trafficPolicy) } - - // Emit warnings for conflicting service timeouts - for svc, ingressMap := range svcTimeouts { - if len(ingressMap) <= 1 { - continue - } - - // Build message - parts := []string{} - for ing, d := range ingressMap { - parts = append(parts, fmt.Sprintf("%s=%s", ing, d.Duration)) - } - - msg := fmt.Sprintf( - "Multiple Ingresses set conflicting proxy-connect-timeout for Service %s/%s. Using lowest value. Values: %s", - svc.Namespace, - svc.Name, - strings.Join(parts, ", "), - ) - - notifications.NotificationAggr.DispatchNotification( - notifications.NewNotification( - notifications.WarningNotification, - msg, - ), - "ingress-nginx", - ) + for _, backend := range e.builderMap.Backends { + kgatewayObjs = append(kgatewayObjs, backend) } - // Sort by Kind, then Namespace, then Name to make output deterministic for testing. + // Sort by Kind, then Namespace, then Name to make output deterministic for testing sort.SliceStable(kgatewayObjs, func(i, j int) bool { oi, oj := kgatewayObjs[i], kgatewayObjs[j] @@ -430,13 +120,11 @@ func (e *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.Err // Convert kgateway objects to unstructured and add to GatewayExtensions for _, obj := range kgatewayObjs { - u, err := toUnstructured(obj) + u, err := i2gw.CastToUnstructured(obj) if err != nil { - errs = append(errs, field.InternalError(field.NewPath("kgateway"), err)) + e.notify(notifications.ErrorNotification, "Failed to cast TrafficPolicy to unstructured", obj) continue } - gatewayResources.GatewayExtensions = append(gatewayResources.GatewayExtensions, *u) + gwResources.GatewayExtensions = append(gwResources.GatewayExtensions, *u) } - - return gatewayResources, errs } diff --git a/pkg/i2gw/emitters/kgateway/emitter_integration_test.go b/pkg/i2gw/emitters/kgateway/emitter_integration_test.go index 249852300..cf55ae905 100644 --- a/pkg/i2gw/emitters/kgateway/emitter_integration_test.go +++ b/pkg/i2gw/emitters/kgateway/emitter_integration_test.go @@ -18,6 +18,7 @@ package kgateway_test import ( "bytes" + "context" "errors" "io" "os" @@ -35,7 +36,7 @@ import ( func getModuleRoot(t *testing.T) string { t.Helper() - cmd := exec.Command("go", "env", "GOMOD") + cmd := exec.CommandContext(context.Background(), "go", "env", "GOMOD") out, err := cmd.Output() if err != nil { t.Fatalf("failed to run 'go env GOMOD': %v", err) @@ -128,7 +129,8 @@ func runGoldenTest(t *testing.T, inputRel, goldenRel string) { inputPath := filepath.Join(moduleRoot, inputRel) goldenPath := filepath.Join(moduleRoot, goldenRel) - cmd := exec.Command( + //nolint:gosec // G204: integration test runs the local module with fixed argv shape. + cmd := exec.CommandContext(context.Background(), "go", "run", ".", "print", "--providers=ingress-nginx", @@ -153,6 +155,7 @@ func runGoldenTest(t *testing.T, inputRel, goldenRel string) { // Golden file handling writeGolden := false + //nolint:gosec // G304: golden path is resolved under module root from known rel paths. goldenBytes, err := os.ReadFile(goldenPath) if os.IsNotExist(err) { writeGolden = true @@ -333,6 +336,15 @@ func TestKgatewayIngressNginxIntegration_Golden(t *testing.T) { "pkg", "i2gw", "emitters", "kgateway", "testing", "testdata", "output", "session_affinity.yaml", ), }, + { + name: "timeouts", + inputRel: filepath.Join( + "pkg", "i2gw", "emitters", "kgateway", "testing", "testdata", "input", "timeouts.yaml", + ), + goldenRel: filepath.Join( + "pkg", "i2gw", "emitters", "kgateway", "testing", "testdata", "output", "timeouts.yaml", + ), + }, } for _, tt := range tests { diff --git a/pkg/i2gw/emitters/kgateway/load_balance.go b/pkg/i2gw/emitters/kgateway/load_balance.go index 01fb894aa..0ebe0c608 100644 --- a/pkg/i2gw/emitters/kgateway/load_balance.go +++ b/pkg/i2gw/emitters/kgateway/load_balance.go @@ -26,6 +26,35 @@ import ( gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) +// EmitLoadBalancing projects per-rule load balancing IR into Kgateway BackendConfigPolicies. +func (e *Emitter) EmitLoadBalancing(ir emitterir.EmitterIR) { + for httpRouteKey, httpRouteCtx := range ir.HTTPRoutes { + for ruleIdx, lb := range httpRouteCtx.LoadBalancingByRuleIdx { + if lb == nil || ruleIdx < 0 || ruleIdx >= len(httpRouteCtx.Spec.Rules) { + continue + } + + coverage := make([]emitterir.PolicyIndex, 0, len(httpRouteCtx.Spec.Rules[ruleIdx].BackendRefs)) + for backendIdx := range httpRouteCtx.Spec.Rules[ruleIdx].BackendRefs { + coverage = append(coverage, emitterir.PolicyIndex{ + Rule: ruleIdx, + Backend: backendIdx, + }) + } + + applyLoadBalancingPolicy( + emitterir.Policy{ + LoadBalancing: lb, + RuleBackendSources: coverage, + }, + httpRouteKey, + httpRouteCtx, + e.builderMap.BackendConfigPolicies, + ) + } + } +} + // applyLoadBalancingPolicy projects the LoadBalancing IR policy into one or more // Kgateway BackendConfigPolicies. // diff --git a/pkg/i2gw/emitters/kgateway/policies.go b/pkg/i2gw/emitters/kgateway/policies.go index 55ee3f369..aa8e6fd80 100644 --- a/pkg/i2gw/emitters/kgateway/policies.go +++ b/pkg/i2gw/emitters/kgateway/policies.go @@ -22,143 +22,57 @@ import ( emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/kgateway" - "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/shared" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// applyBufferPolicy projects the buffer-related policy IR into a Kgateway TrafficPolicy, -// returning true if it modified/created a TrafficPolicy for this ingress. -// -// Semantics are as follows: -// - If the "nginx.ingress.kubernetes.io/proxy-body-size" annotation is present, that value -// is used as the effective max request size. -// - Otherwise, if the "nginx.ingress.kubernetes.io/client-body-buffer-size" annotation is present, -// that value is used. -// - If neither is set, no Kgateway Buffer policy is emitted. -// -// Note: Kgateway's Buffer.MaxRequestSize has "max body size" semantics (413 on exceed), -// which matches NGINX's proxy-body-size more directly. client-body-buffer-size is -// treated as a fallback when proxy-body-size is not configured. -func applyBufferPolicy( - pol emitterir.Policy, - ingressName, namespace string, - tp map[string]*kgateway.TrafficPolicy, -) bool { - if pol.ClientBodyBufferSize == nil && pol.ProxyBodySize == nil { - return false +// EmitRateLimit projects provider-neutral per-rule rate limit intent into +// section-scoped Kgateway TrafficPolicies. +func (e *Emitter) EmitRateLimit(ir emitterir.EmitterIR) { + for _, ctx := range ir.HTTPRoutes { + for idx, rl := range ctx.RateLimitByRuleIdx { + if rl == nil || idx < 0 || idx >= len(ctx.Spec.Rules) || rl.Limit <= 0 { + continue + } + + var ( + maxTokens int32 + tokensPerFill int32 + fillInterval metav1.Duration + ) + + burstMult := rl.BurstMultiplier + if burstMult <= 0 { + burstMult = 1 + } + + switch rl.Unit { + case emitterir.RateLimitUnitRPS: + tokensPerFill = rl.Limit + maxTokens = rl.Limit * burstMult + fillInterval = metav1.Duration{Duration: time.Second} + case emitterir.RateLimitUnitRPM: + tokensPerFill = rl.Limit + maxTokens = rl.Limit * burstMult + fillInterval = metav1.Duration{Duration: time.Minute} + default: + continue + } + + sectionName := e.getSectionName(ctx, idx) + trafficPolicy := e.getOrBuildTrafficPolicy(ctx, sectionName, idx) + if trafficPolicy.Spec.RateLimit == nil { + trafficPolicy.Spec.RateLimit = &kgateway.RateLimit{} + } + if trafficPolicy.Spec.RateLimit.Local == nil { + trafficPolicy.Spec.RateLimit.Local = &kgateway.LocalRateLimitPolicy{} + } + trafficPolicy.Spec.RateLimit.Local.TokenBucket = &kgateway.TokenBucket{ + MaxTokens: maxTokens, + TokensPerFill: int32Ptr(tokensPerFill), + FillInterval: fillInterval, + } + } } - - // Prefer proxy-body-size if present; otherwise fall back to client-body-buffer-size. - size := pol.ProxyBodySize - if size == nil { - size = pol.ClientBodyBufferSize - } - if size == nil { - return false - } - - t := ensureTrafficPolicy(tp, ingressName, namespace) - t.Spec.Buffer = &kgateway.Buffer{ - MaxRequestSize: size, - } - return true } -// applyRateLimitPolicy projects the rate limit policy IR into a Kgateway TrafficPolicy. -// returning true if it modified/created a TrafficPolicy for this ingress. -func applyRateLimitPolicy( - pol emitterir.Policy, - ingressName, namespace string, - tp map[string]*kgateway.TrafficPolicy, -) bool { - if pol.RateLimit == nil { - return false - } - - rl := pol.RateLimit - if rl.Limit <= 0 { - return false - } - - // Default burst multiplier to 1 if unset/zero. - burstMult := rl.BurstMultiplier - if burstMult <= 0 { - burstMult = 1 - } - - var ( - maxTokens int32 - tokensPerFill int32 - fillInterval metav1.Duration - ) - - switch rl.Unit { - case emitterir.RateLimitUnitRPS: - // Requests per second. - tokensPerFill = rl.Limit - maxTokens = rl.Limit * burstMult - fillInterval = metav1.Duration{Duration: time.Second} - case emitterir.RateLimitUnitRPM: - // Requests per minute. - tokensPerFill = rl.Limit - maxTokens = rl.Limit * burstMult - fillInterval = metav1.Duration{Duration: time.Minute} - default: - // Unknown unit; ignore for now. - return false - } - - t := ensureTrafficPolicy(tp, ingressName, namespace) - - if t.Spec.RateLimit == nil { - t.Spec.RateLimit = &kgateway.RateLimit{} - } - if t.Spec.RateLimit.Local == nil { - t.Spec.RateLimit.Local = &kgateway.LocalRateLimitPolicy{} - } - - // Helper to create *int32 without extra imports. - int32Ptr := func(v int32) *int32 { return &v } - - t.Spec.RateLimit.Local.TokenBucket = &kgateway.TokenBucket{ - MaxTokens: maxTokens, - TokensPerFill: int32Ptr(tokensPerFill), - FillInterval: fillInterval, - } - - return true -} - -// applyTimeoutPolicy projects the timeout-related policy IR into a Kgateway TrafficPolicy, -// returning true if it modified/created a TrafficPolicy for this ingress. -// -// Semantics: -// - If ProxySendTimeout is set, it is mapped to the Request timeout in Kgateway. -// - If ProxyReadTimeout is set, it is mapped to the StreamIdle timeout in Kgateway. -func applyTimeoutPolicy( - pol emitterir.Policy, - ingressName, namespace string, - tp map[string]*kgateway.TrafficPolicy, -) bool { - if pol.ProxySendTimeout == nil && pol.ProxyReadTimeout == nil { - return false - } - - t := ensureTrafficPolicy(tp, ingressName, namespace) - - if t.Spec.Timeouts == nil { - t.Spec.Timeouts = &shared.Timeouts{} - } - - // Map proxy-send-timeout → Timeouts.Request - if pol.ProxySendTimeout != nil { - t.Spec.Timeouts.Request = pol.ProxySendTimeout - } - - // Map proxy-read-timeout → Timeouts.StreamIdle - if pol.ProxyReadTimeout != nil { - t.Spec.Timeouts.StreamIdle = pol.ProxyReadTimeout - } - - return true -} +func int32Ptr(v int32) *int32 { return &v } diff --git a/pkg/i2gw/emitters/kgateway/rewrite_target.go b/pkg/i2gw/emitters/kgateway/rewrite_target.go index 3bc489865..c2942ac4a 100644 --- a/pkg/i2gw/emitters/kgateway/rewrite_target.go +++ b/pkg/i2gw/emitters/kgateway/rewrite_target.go @@ -17,104 +17,49 @@ limitations under the License. package kgateway import ( - "fmt" - "sort" - + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/kgateway" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) -// applyRewriteTargetPolicies projects ingress-nginx rewrite-target into *per-rule* Kgateway TrafficPolicies -// and attaches them via ExtensionRef filters to the covered backendRefs. -// -// Why per-rule? -// - The regex rewrite pattern must align with the rule's path regex so capture groups ($1, $2, ...) -// behave like ingress-nginx. -// -// Assumptions: -// - applyRegexPathMatchingForHost(...) has already run (if host-wide regex location mode is enabled), -// so rule path matches will already be RegularExpression where needed. -func applyRewriteTargetPolicies( - pol emitterir.Policy, - sourceIngressName, namespace string, - httpRouteCtx *emitterir.HTTPRouteContext, - tp map[string]*kgateway.TrafficPolicy, -) { - if pol.RewriteTarget == nil || *pol.RewriteTarget == "" { - return - } - if httpRouteCtx == nil { - return - } - - // Group covered backendRefs by rule index. - byRule := map[int]map[int]struct{}{} - for _, idx := range pol.RuleBackendSources { - if idx.Rule < 0 || idx.Backend < 0 { - continue - } - if _, ok := byRule[idx.Rule]; !ok { - byRule[idx.Rule] = map[int]struct{}{} - } - byRule[idx.Rule][idx.Backend] = struct{}{} - } - if len(byRule) == 0 { - return - } - - // Deterministic iteration for stable goldens. - ruleIdxs := make([]int, 0, len(byRule)) - for r := range byRule { - ruleIdxs = append(ruleIdxs, r) - } - sort.Ints(ruleIdxs) +// EmitPathRewrite projects provider-neutral rewrite intent into either native +// HTTPRoute filters or section-scoped Kgateway TrafficPolicies for regex capture +// group rewrites. +func (e *Emitter) EmitPathRewrite(ir emitterir.EmitterIR, gwResources *i2gw.GatewayResources) { + for httpRouteKey, ctx := range ir.HTTPRoutes { + changed := false - for _, ruleIdx := range ruleIdxs { - if ruleIdx >= len(httpRouteCtx.Spec.Rules) { - continue - } - - // Regex rewrite only when use-regex=true. - if pol.UseRegexPaths != nil && *pol.UseRegexPaths { - pattern := deriveRulePathRegexPattern(httpRouteCtx.Spec.Rules[ruleIdx]) - tpName := fmt.Sprintf("%s-rewrite-%d", sourceIngressName, ruleIdx) - t := ensureTrafficPolicy(tp, tpName, namespace) - t.Spec.UrlRewrite = &kgateway.URLRewrite{ - PathRegex: &kgateway.PathRegexRewrite{ - Pattern: pattern, - Substitution: *pol.RewriteTarget, - }, + for idx, rewrite := range ctx.PathRewriteByRuleIdx { + if rewrite == nil || idx < 0 || idx >= len(ctx.Spec.Rules) { + continue } - backendSet := byRule[ruleIdx] - backendIdxs := make([]int, 0, len(backendSet)) - for b := range backendSet { - backendIdxs = append(backendIdxs, b) - } - sort.Ints(backendIdxs) - for _, backendIdx := range backendIdxs { - if backendIdx >= len(httpRouteCtx.Spec.Rules[ruleIdx].BackendRefs) { - continue - } - httpRouteCtx.Spec.Rules[ruleIdx].BackendRefs[backendIdx].Filters = append( - httpRouteCtx.Spec.Rules[ruleIdx].BackendRefs[backendIdx].Filters, - gatewayv1.HTTPRouteFilter{ - Type: gatewayv1.HTTPRouteFilterExtensionRef, - ExtensionRef: &gatewayv1.LocalObjectReference{ - Group: gatewayv1.Group(TrafficPolicyGVK.Group), - Kind: gatewayv1.Kind(TrafficPolicyGVK.Kind), - Name: gatewayv1.ObjectName(t.Name), - }, + if rewrite.RegexCaptureGroupReferences { + sectionName := e.getSectionName(ctx, idx) + trafficPolicy := e.getOrBuildTrafficPolicy(ctx, sectionName, idx) + trafficPolicy.Spec.UrlRewrite = &kgateway.URLRewrite{ + PathRegex: &kgateway.PathRegexRewrite{ + Pattern: deriveRulePathRegexPattern(ctx.Spec.Rules[idx]), + Substitution: rewrite.ReplaceFullPath, }, - ) + } + } else { + ensureRuleURLRewriteReplaceFullPath(&ctx.Spec.Rules[idx], rewrite.ReplaceFullPath) + changed = true + } + + if len(rewrite.Headers) > 0 { + ensureRuleRequestHeaderModifierSet(&ctx.Spec.Rules[idx], rewrite.Headers) + changed = true } - continue } - // Non-regex: use native Gateway API URLRewrite/ReplaceFullPath at the rule level. - ensureRuleURLRewriteReplaceFullPath(&httpRouteCtx.Spec.Rules[ruleIdx], *pol.RewriteTarget) + if changed { + ir.HTTPRoutes[httpRouteKey] = ctx + gwResources.HTTPRoutes[httpRouteKey] = ctx.HTTPRoute + } } } @@ -180,3 +125,42 @@ func ensureRuleURLRewriteReplaceFullPath(rule *gatewayv1.HTTPRouteRule, replaceF }, }) } + +func ensureRuleRequestHeaderModifierSet(rule *gatewayv1.HTTPRouteRule, headers map[string]string) { + for i := range rule.Filters { + if rule.Filters[i].Type != gatewayv1.HTTPRouteFilterRequestHeaderModifier { + continue + } + if rule.Filters[i].RequestHeaderModifier == nil { + rule.Filters[i].RequestHeaderModifier = &gatewayv1.HTTPHeaderFilter{} + } + upsertRequestHeaders(rule.Filters[i].RequestHeaderModifier, headers) + return + } + + filter := &gatewayv1.HTTPHeaderFilter{} + upsertRequestHeaders(filter, headers) + rule.Filters = append(rule.Filters, gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: filter, + }) +} + +func upsertRequestHeaders(filter *gatewayv1.HTTPHeaderFilter, headers map[string]string) { + for name, value := range headers { + found := false + for i := range filter.Set { + if string(filter.Set[i].Name) == name { + filter.Set[i].Value = value + found = true + break + } + } + if !found { + filter.Set = append(filter.Set, gatewayv1.HTTPHeader{ + Name: gatewayv1.HTTPHeaderName(name), + Value: value, + }) + } + } +} diff --git a/pkg/i2gw/emitters/kgateway/ssl_redirect.go b/pkg/i2gw/emitters/kgateway/ssl_redirect.go deleted file mode 100644 index 8d5ec537c..000000000 --- a/pkg/i2gw/emitters/kgateway/ssl_redirect.go +++ /dev/null @@ -1,162 +0,0 @@ -/* -Copyright 2023 The Kubernetes Authors. - -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 kgateway - -import ( - "fmt" - - emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" - - "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" - gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" -) - -// applySSLRedirectPolicy marks rules that need SSL redirect handling. -// The actual route splitting happens later in the emitter. -// -// Semantics: -// - If SSLRedirect is enabled, mark the HTTPRoute for later splitting -// - Returns true if SSL redirect is enabled for this policy -func applySSLRedirectPolicy(pol emitterir.Policy) bool { - if pol.SSLRedirect == nil || !*pol.SSLRedirect { - return false - } - // SSL redirect will be handled by splitting the route later - return true -} - -// splitHTTPRouteForSSLRedirect splits an HTTPRoute into two routes when SSL redirect is enabled: -// 1. HTTP redirect route: bound to HTTP listener, has RequestRedirect filter, no backendRefs -// 2. HTTPS backend route: bound to HTTPS listener, has backendRefs, no redirect filter -// -// Returns the HTTP redirect route, HTTPS backend route, and whether splitting was successful. -func splitHTTPRouteForSSLRedirect( - httpRouteContext emitterir.HTTPRouteContext, - httpRouteKey types.NamespacedName, - gatewayCtx *emitterir.GatewayContext, -) (*emitterir.HTTPRouteContext, *emitterir.HTTPRouteContext, bool) { - // Find HTTP and HTTPS listeners by hostname - var httpListenerName, httpsListenerName *gatewayv1.SectionName - hostname := "" - if len(httpRouteContext.Spec.Hostnames) > 0 { - hostname = string(httpRouteContext.Spec.Hostnames[0]) - } - - for _, listener := range gatewayCtx.Spec.Listeners { - if listener.Protocol == gatewayv1.HTTPProtocolType { - // Check if hostname matches - if hostname == "" || (listener.Hostname != nil && string(*listener.Hostname) == hostname) { - name := listener.Name - httpListenerName = &name - } - } else if listener.Protocol == gatewayv1.HTTPSProtocolType { - // Check if hostname matches - if hostname == "" || (listener.Hostname != nil && string(*listener.Hostname) == hostname) { - name := listener.Name - httpsListenerName = &name - } - } - } - - // If HTTPS listener doesn't exist, we can't create the HTTPS route - // Still create HTTP redirect route though - if httpsListenerName == nil { - // Only create HTTP redirect route if HTTP listener exists - if httpListenerName == nil { - return nil, nil, false - } - } - - // Create HTTP redirect route - httpRedirectRoute := emitterir.HTTPRouteContext{ - HTTPRoute: *httpRouteContext.HTTPRoute.DeepCopy(), - PoliciesBySourceIngressName: httpRouteContext.PoliciesBySourceIngressName, - RegexLocationForHost: httpRouteContext.RegexLocationForHost, - RegexForcedByUseRegex: httpRouteContext.RegexForcedByUseRegex, - RegexForcedByRewrite: httpRouteContext.RegexForcedByRewrite, - RuleBackendSources: httpRouteContext.RuleBackendSources, - } - httpRedirectRoute.ObjectMeta.Name = fmt.Sprintf("%s-http-redirect", httpRouteKey.Name) - httpRedirectRoute.ObjectMeta.Namespace = httpRouteKey.Namespace - - // Update parentRefs to bind to HTTP listener - if len(httpRedirectRoute.Spec.ParentRefs) > 0 && httpListenerName != nil { - httpRedirectRoute.Spec.ParentRefs[0].SectionName = httpListenerName - } - - // Add RequestRedirect filter and remove backendRefs from all rules - for i := range httpRedirectRoute.Spec.Rules { - // Add RequestRedirect filter - hasRedirect := false - for _, filter := range httpRedirectRoute.Spec.Rules[i].Filters { - if filter.Type == gatewayv1.HTTPRouteFilterRequestRedirect { - hasRedirect = true - break - } - } - if !hasRedirect { - httpRedirectRoute.Spec.Rules[i].Filters = append( - httpRedirectRoute.Spec.Rules[i].Filters, - gatewayv1.HTTPRouteFilter{ - Type: gatewayv1.HTTPRouteFilterRequestRedirect, - RequestRedirect: &gatewayv1.HTTPRequestRedirectFilter{ - Scheme: ptr.To("https"), - StatusCode: ptr.To(301), - }, - }, - ) - } - // Remove backendRefs (RequestRedirect filters cannot coexist with backendRefs) - httpRedirectRoute.Spec.Rules[i].BackendRefs = nil - } - - // Create HTTPS backend route (only if HTTPS listener exists) - var httpsBackendRoute *emitterir.HTTPRouteContext - if httpsListenerName != nil { - route := emitterir.HTTPRouteContext{ - HTTPRoute: *httpRouteContext.HTTPRoute.DeepCopy(), - PoliciesBySourceIngressName: httpRouteContext.PoliciesBySourceIngressName, - RegexLocationForHost: httpRouteContext.RegexLocationForHost, - RegexForcedByUseRegex: httpRouteContext.RegexForcedByUseRegex, - RegexForcedByRewrite: httpRouteContext.RegexForcedByRewrite, - RuleBackendSources: httpRouteContext.RuleBackendSources, - } - route.ObjectMeta.Name = fmt.Sprintf("%s-https", httpRouteKey.Name) - route.ObjectMeta.Namespace = httpRouteKey.Namespace - httpsBackendRoute = &route - - // Update parentRefs to bind to HTTPS listener - if len(httpsBackendRoute.Spec.ParentRefs) > 0 { - httpsBackendRoute.Spec.ParentRefs[0].SectionName = httpsListenerName - } - - // Remove any RequestRedirect filters from HTTPS route - for i := range httpsBackendRoute.Spec.Rules { - var filtersWithoutRedirect []gatewayv1.HTTPRouteFilter - for _, filter := range httpsBackendRoute.Spec.Rules[i].Filters { - if filter.Type != gatewayv1.HTTPRouteFilterRequestRedirect { - filtersWithoutRedirect = append(filtersWithoutRedirect, filter) - } - } - httpsBackendRoute.Spec.Rules[i].Filters = filtersWithoutRedirect - } - // Keep backendRefs for HTTPS route - } - - return &httpRedirectRoute, httpsBackendRoute, true -} diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/input/timeouts.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/input/timeouts.yaml index 68604bc14..1de87c66f 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/input/timeouts.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/input/timeouts.yaml @@ -2,9 +2,9 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: - nginx.ingress.kubernetes.io/proxy-send-timeout: "30s" - nginx.ingress.kubernetes.io/proxy-read-timeout: "45s" - nginx.ingress.kubernetes.io/proxy-connect-timeout: "30s" + nginx.ingress.kubernetes.io/proxy-send-timeout: "30" + nginx.ingress.kubernetes.io/proxy-read-timeout: "45" + nginx.ingress.kubernetes.io/proxy-connect-timeout: "30" name: ingress-myservicea1 namespace: default spec: @@ -25,9 +25,9 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: - nginx.ingress.kubernetes.io/proxy-send-timeout: "60" # No "s" suffix, should still be parsed correctly - nginx.ingress.kubernetes.io/proxy-read-timeout: "60" # No "s" suffix, should still be parsed correctly - nginx.ingress.kubernetes.io/proxy-connect-timeout: "60" # Will be ignored since it's higher than ingress-myservicea1 Ingress + nginx.ingress.kubernetes.io/proxy-send-timeout: "60" + nginx.ingress.kubernetes.io/proxy-read-timeout: "60" + nginx.ingress.kubernetes.io/proxy-connect-timeout: "60" name: ingress-myservicea2 namespace: default spec: @@ -49,8 +49,8 @@ kind: Ingress metadata: annotations: nginx.ingress.kubernetes.io/proxy-body-size: "100m" - nginx.ingress.kubernetes.io/proxy-send-timeout: "90s" - nginx.ingress.kubernetes.io/proxy-read-timeout: "90s" + nginx.ingress.kubernetes.io/proxy-send-timeout: "90" + nginx.ingress.kubernetes.io/proxy-read-timeout: "90" nginx.ingress.kubernetes.io/proxy-connect-timeout: "120" name: ingress-myserviceb namespace: default diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/backend_protocol.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/backend_protocol.yaml index 50c249fee..797c150be 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/backend_protocol.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/backend_protocol.yaml @@ -1,3 +1,19 @@ +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: Backend +metadata: + labels: + ingress2gateway.kubernetes.io/source-ingress: ingress-myserviceb + name: myserviceb-service-upstream + namespace: default +spec: + static: + appProtocol: grpc + hosts: + - host: myserviceb.default.svc.cluster.local + port: 80 + type: Static +status: {} +--- apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: @@ -8,14 +24,14 @@ metadata: spec: gatewayClassName: kgateway listeners: - - hostname: myservicea.foo.org - name: myservicea-foo-org-http - port: 80 - protocol: HTTP - - hostname: myserviceb.foo.org - name: myserviceb-foo-org-http - port: 80 - protocol: HTTP + - hostname: myservicea.foo.org + name: myservicea-foo-org-http + port: 80 + protocol: HTTP + - hostname: myserviceb.foo.org + name: myserviceb-foo-org-http + port: 80 + protocol: HTTP --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute @@ -26,24 +42,26 @@ metadata: namespace: default spec: hostnames: - - myservicea.foo.org + - myservicea.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: myservicea - port: 80 - matches: - - path: - type: PathPrefix - value: / - - backendRefs: - - name: myservicea - port: 80 - matches: - - path: - type: PathPrefix - value: /2 + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: PathPrefix + value: / + name: rule-0 + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: PathPrefix + value: /2 + name: rule-1 status: parents: [] --- @@ -56,33 +74,18 @@ metadata: namespace: default spec: hostnames: - - myserviceb.foo.org + - myserviceb.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - group: gateway.kgateway.dev - kind: Backend - name: myserviceb-service-upstream - matches: - - path: - type: PathPrefix - value: / + - backendRefs: + - group: gateway.kgateway.dev + kind: Backend + name: myserviceb-service-upstream + matches: + - path: + type: PathPrefix + value: / + name: rule-0 status: parents: [] ---- -apiVersion: gateway.kgateway.dev/v1alpha1 -kind: Backend -metadata: - labels: - ingress2gateway.kubernetes.io/source-ingress: ingress-myserviceb - name: myserviceb-service-upstream - namespace: default -spec: - type: Static - static: - hosts: - - host: myserviceb.default.svc.cluster.local - port: 80 - appProtocol: grpc -status: {} diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/backend_tls.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/backend_tls.yaml index c01badbb7..d702fb58f 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/backend_tls.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/backend_tls.yaml @@ -70,6 +70,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -93,5 +94,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/basic.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/basic.yaml index 177934ba9..bd00ba111 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/basic.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/basic.yaml @@ -7,10 +7,10 @@ metadata: spec: gatewayClassName: kgateway listeners: - - hostname: demo.localdev.me - name: demo-localdev-me-http - port: 80 - protocol: HTTP + - hostname: demo.localdev.me + name: demo-localdev-me-http + port: 80 + protocol: HTTP --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute @@ -20,16 +20,17 @@ metadata: name: demo-localhost-demo-localdev-me spec: hostnames: - - demo.localdev.me + - demo.localdev.me parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: echo-backend - port: 8080 - matches: - - path: - type: PathPrefix - value: / + - backendRefs: + - name: echo-backend + port: 8080 + matches: + - path: + type: PathPrefix + value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/basic_auth.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/basic_auth.yaml index 391d38e46..d405c9661 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/basic_auth.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/basic_auth.yaml @@ -41,6 +41,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -64,6 +65,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -87,6 +89,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/cors.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/cors.yaml index d2bd25606..74fc34645 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/cors.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/cors.yaml @@ -8,14 +8,14 @@ metadata: spec: gatewayClassName: kgateway listeners: - - hostname: myservicea.foo.org - name: myservicea-foo-org-http - port: 80 - protocol: HTTP - - hostname: myserviceb.foo.org - name: myserviceb-foo-org-http - port: 80 - protocol: HTTP + - hostname: myservicea.foo.org + name: myservicea-foo-org-http + port: 80 + protocol: HTTP + - hostname: myserviceb.foo.org + name: myserviceb-foo-org-http + port: 80 + protocol: HTTP --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute @@ -26,56 +26,46 @@ metadata: namespace: default spec: hostnames: - - myservicea.foo.org + - myservicea.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - filters: - - extensionRef: - group: gateway.kgateway.dev - kind: TrafficPolicy - name: ingress-myservicea1 - type: ExtensionRef - name: myservicea - port: 80 - filters: - - responseHeaderModifier: - remove: - - Access-Control-Allow-Origin - - Access-Control-Allow-Methods - - Access-Control-Allow-Headers - - Access-Control-Expose-Headers - - Access-Control-Max-Age - - Access-Control-Allow-Credentials - type: ResponseHeaderModifier - matches: - - path: - type: PathPrefix - value: / - - backendRefs: - - filters: - - extensionRef: - group: gateway.kgateway.dev - kind: TrafficPolicy - name: ingress-myservicea2 - type: ExtensionRef - name: myservicea - port: 80 - filters: - - responseHeaderModifier: - remove: - - Access-Control-Allow-Origin - - Access-Control-Allow-Methods - - Access-Control-Allow-Headers - - Access-Control-Expose-Headers - - Access-Control-Max-Age - - Access-Control-Allow-Credentials - type: ResponseHeaderModifier - matches: - - path: - type: PathPrefix - value: /2 + - backendRefs: + - name: myservicea + port: 80 + filters: + - responseHeaderModifier: + remove: + - Access-Control-Allow-Origin + - Access-Control-Allow-Methods + - Access-Control-Allow-Headers + - Access-Control-Expose-Headers + - Access-Control-Max-Age + - Access-Control-Allow-Credentials + type: ResponseHeaderModifier + matches: + - path: + type: PathPrefix + value: / + name: rule-0 + - backendRefs: + - name: myservicea + port: 80 + filters: + - responseHeaderModifier: + remove: + - Access-Control-Allow-Origin + - Access-Control-Allow-Methods + - Access-Control-Allow-Headers + - Access-Control-Expose-Headers + - Access-Control-Max-Age + - Access-Control-Allow-Credentials + type: ResponseHeaderModifier + matches: + - path: + type: PathPrefix + value: /2 + name: rule-1 status: parents: [] --- @@ -88,84 +78,134 @@ metadata: namespace: default spec: hostnames: - - myserviceb.foo.org + - myserviceb.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: myserviceb - port: 80 - filters: - - responseHeaderModifier: - remove: - - Access-Control-Allow-Origin - - Access-Control-Allow-Methods - - Access-Control-Allow-Headers - - Access-Control-Expose-Headers - - Access-Control-Max-Age - - Access-Control-Allow-Credentials - type: ResponseHeaderModifier - matches: - - path: - type: PathPrefix - value: / + - backendRefs: + - name: myserviceb + port: 80 + filters: + - responseHeaderModifier: + remove: + - Access-Control-Allow-Origin + - Access-Control-Allow-Methods + - Access-Control-Allow-Headers + - Access-Control-Expose-Headers + - Access-Control-Max-Age + - Access-Control-Allow-Credentials + type: ResponseHeaderModifier + matches: + - path: + type: PathPrefix + value: / + name: rule-0 status: parents: [] --- apiVersion: gateway.kgateway.dev/v1alpha1 kind: TrafficPolicy metadata: - name: ingress-myservicea1 + name: ingress-myservicea1-myservicea-foo-org-0 namespace: default spec: cors: allowCredentials: false allowHeaders: - - X-Requested-With - - Content-Type + - X-Requested-With + - Content-Type allowMethods: - - GET - - POST - - OPTIONS + - GET + - POST + - OPTIONS allowOrigins: - - https://example.com - - https://another.com + - https://example.com + - https://another.com exposeHeaders: - - X-Expose-One - - X-Expose-Two + - X-Expose-One + - X-Expose-Two maxAge: 600 + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: ingress-myservicea1-myservicea-foo-org + sectionName: rule-0 status: ancestors: null --- apiVersion: gateway.kgateway.dev/v1alpha1 kind: TrafficPolicy metadata: - name: ingress-myservicea2 + name: ingress-myservicea1-myservicea-foo-org-1 namespace: default spec: cors: + allowCredentials: true + allowHeaders: + - DNT + - Keep-Alive + - User-Agent + - X-Requested-With + - If-Modified-Since + - Cache-Control + - Content-Type + - Range + - Authorization + allowMethods: + - GET + - PUT + - POST + - DELETE + - PATCH + - OPTIONS allowOrigins: - - https://example.com - - https://another.com + - https://example.com + - https://another.com + maxAge: 1728000 + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: ingress-myservicea1-myservicea-foo-org + sectionName: rule-1 status: ancestors: null --- apiVersion: gateway.kgateway.dev/v1alpha1 kind: TrafficPolicy metadata: - name: ingress-myserviceb + name: ingress-myserviceb-myserviceb-foo-org-0 namespace: default spec: cors: + allowCredentials: true + allowHeaders: + - DNT + - Keep-Alive + - User-Agent + - X-Requested-With + - If-Modified-Since + - Cache-Control + - Content-Type + - Range + - Authorization + allowMethods: + - GET + - PUT + - POST + - DELETE + - PATCH + - OPTIONS allowOrigins: - - https://example.com - - https://another.com + - https://example.com + - https://another.com exposeHeaders: - - '*' - - X-CustomResponseHeader + - '*' + - X-CustomResponseHeader + maxAge: 1728000 targetRefs: - - group: gateway.networking.k8s.io - kind: HTTPRoute - name: ingress-myserviceb-myserviceb-foo-org + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: ingress-myserviceb-myserviceb-foo-org + sectionName: rule-0 status: ancestors: null diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/external_auth.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/external_auth.yaml index 4f673becf..6fcd9951d 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/external_auth.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/external_auth.yaml @@ -88,6 +88,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -111,6 +112,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -134,6 +136,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/golden.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/golden.yaml index adca39458..20b2bdcab 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/golden.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/golden.yaml @@ -1,3 +1,20 @@ +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: BackendConfigPolicy +metadata: + name: httpbin2-backend-config + namespace: default +spec: + targetRefs: + - group: "" + kind: Service + name: httpbin2 + tls: + secretRef: + name: base-certificate-tls + sni: tls.example.com +status: + ancestors: null +--- apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: @@ -8,18 +25,35 @@ metadata: spec: gatewayClassName: kgateway listeners: - - hostname: myservicea.foo.org - name: myservicea-foo-org-http - port: 80 - protocol: HTTP - - hostname: myserviceb.foo.org - name: myserviceb-foo-org-http - port: 80 - protocol: HTTP - - hostname: tls.example.org - name: tls-example-org-http - port: 80 - protocol: HTTP + - hostname: myservicea.foo.org + name: myservicea-foo-org-http + port: 80 + protocol: HTTP + - hostname: myserviceb.foo.org + name: myserviceb-foo-org-http + port: 80 + protocol: HTTP + - hostname: tls.example.org + name: tls-example-org-http + port: 80 + protocol: HTTP +--- +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: HTTPListenerPolicy +metadata: + name: nginx-access-log + namespace: default +spec: + accessLog: + - fileSink: + path: /dev/stdout + stringFormat: '[%START_TIME%] "%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%" %RESPONSE_CODE% %RESPONSE_FLAGS% %BYTES_RECEIVED% %BYTES_SENT% %DURATION% %RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)% "%REQ(X-FORWARDED-FOR)%" "%REQ(USER-AGENT)%" "%REQ(X-REQUEST-ID)%" "%REQ(:AUTHORITY)%" "%UPSTREAM_HOST%"%n' + targetRefs: + - group: "" + kind: Gateway + name: nginx +status: + ancestors: null --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute @@ -30,17 +64,18 @@ metadata: namespace: default spec: hostnames: - - tls.example.org + - tls.example.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: httpbin2 - port: 80 - matches: - - path: - type: PathPrefix - value: / + - backendRefs: + - name: httpbin2 + port: 80 + matches: + - path: + type: PathPrefix + value: / + name: rule-0 status: parents: [] --- @@ -53,36 +88,26 @@ metadata: namespace: default spec: hostnames: - - myservicea.foo.org + - myservicea.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - filters: - - extensionRef: - group: gateway.kgateway.dev - kind: TrafficPolicy - name: ingress-myservicea1 - type: ExtensionRef - name: myservicea - port: 80 - matches: - - path: - type: PathPrefix - value: / - - backendRefs: - - filters: - - extensionRef: - group: gateway.kgateway.dev - kind: TrafficPolicy - name: ingress-myservicea2 - type: ExtensionRef - name: myservicea - port: 80 - matches: - - path: - type: PathPrefix - value: /2 + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: PathPrefix + value: / + name: rule-0 + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: PathPrefix + value: /2 + name: rule-1 status: parents: [] --- @@ -95,99 +120,86 @@ metadata: namespace: default spec: hostnames: - - myserviceb.foo.org + - myserviceb.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: myserviceb - port: 80 - matches: - - path: - type: PathPrefix - value: / + - backendRefs: + - name: myserviceb + port: 80 + matches: + - path: + type: PathPrefix + value: / + name: rule-0 status: parents: [] --- apiVersion: gateway.kgateway.dev/v1alpha1 -kind: BackendConfigPolicy -metadata: - name: httpbin2-backend-config - namespace: default -spec: - targetRefs: - - group: "" - kind: Service - name: httpbin2 - tls: - secretRef: - name: base-certificate-tls - sni: tls.example.com -status: - ancestors: null ---- -apiVersion: gateway.kgateway.dev/v1alpha1 -kind: HTTPListenerPolicy -metadata: - name: nginx-access-log - namespace: default -spec: - accessLog: - - fileSink: - path: /dev/stdout - stringFormat: '[%START_TIME%] "%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% - %PROTOCOL%" %RESPONSE_CODE% %RESPONSE_FLAGS% %BYTES_RECEIVED% %BYTES_SENT% - %DURATION% %RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)% "%REQ(X-FORWARDED-FOR)%" - "%REQ(USER-AGENT)%" "%REQ(X-REQUEST-ID)%" "%REQ(:AUTHORITY)%" "%UPSTREAM_HOST%"%n' - targetRefs: - - group: "" - kind: Gateway - name: nginx -status: - ancestors: null ---- -apiVersion: gateway.kgateway.dev/v1alpha1 kind: TrafficPolicy metadata: - name: ingress-myservicea1 + name: ingress-myservicea1-myservicea-foo-org-0 namespace: default spec: buffer: - maxRequestSize: 10m + maxRequestSize: 10Mi rateLimit: local: tokenBucket: fillInterval: 1m0s maxTokens: 600 tokensPerFill: 600 + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: ingress-myservicea1-myservicea-foo-org + sectionName: rule-0 status: ancestors: null --- apiVersion: gateway.kgateway.dev/v1alpha1 kind: TrafficPolicy metadata: - name: ingress-myservicea2 + name: ingress-myservicea1-myservicea-foo-org-1 namespace: default spec: buffer: - maxRequestSize: 20m + maxRequestSize: 20Mi rateLimit: local: tokenBucket: fillInterval: 1s maxTokens: 10 tokensPerFill: 10 + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: ingress-myservicea1-myservicea-foo-org + sectionName: rule-1 status: ancestors: null --- apiVersion: gateway.kgateway.dev/v1alpha1 kind: TrafficPolicy metadata: - name: ingress-myserviceb + name: ingress-myserviceb-myserviceb-foo-org namespace: default spec: buffer: - maxRequestSize: 100m + maxRequestSize: 100Mi + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: ingress-myserviceb-myserviceb-foo-org +status: + ancestors: null +--- +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: ingress-myserviceb-myserviceb-foo-org-0 + namespace: default +spec: rateLimit: local: tokenBucket: @@ -195,8 +207,9 @@ spec: maxTokens: 250 tokensPerFill: 50 targetRefs: - - group: gateway.networking.k8s.io - kind: HTTPRoute - name: ingress-myserviceb-myserviceb-foo-org + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: ingress-myserviceb-myserviceb-foo-org + sectionName: rule-0 status: ancestors: null diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/load_balance.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/load_balance.yaml index 6e6a27fcd..3c6d0c56c 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/load_balance.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/load_balance.yaml @@ -67,6 +67,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 - backendRefs: - name: myservicea port: 80 @@ -74,6 +75,7 @@ spec: - path: type: PathPrefix value: /2 + name: rule-1 status: parents: [] --- @@ -97,5 +99,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/rewrite_target.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/rewrite_target.yaml index 2b84616b0..b49b8528a 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/rewrite_target.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/rewrite_target.yaml @@ -8,14 +8,14 @@ metadata: spec: gatewayClassName: kgateway listeners: - - hostname: myservicea.foo.org - name: myservicea-foo-org-http - port: 80 - protocol: HTTP - - hostname: myserviceb.foo.org - name: myserviceb-foo-org-http - port: 80 - protocol: HTTP + - hostname: myservicea.foo.org + name: myservicea-foo-org-http + port: 80 + protocol: HTTP + - hostname: myserviceb.foo.org + name: myserviceb-foo-org-http + port: 80 + protocol: HTTP --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute @@ -26,30 +26,32 @@ metadata: namespace: default spec: hostnames: - - myservicea.foo.org + - myservicea.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: myservicea - port: 80 - filters: - - type: URLRewrite - urlRewrite: - path: - replaceFullPath: /rewritten - type: ReplaceFullPath - matches: - - path: - type: PathPrefix - value: / - - backendRefs: - - name: myservicea - port: 80 - matches: - - path: - type: PathPrefix - value: /2 + - backendRefs: + - name: myservicea + port: 80 + filters: + - type: URLRewrite + urlRewrite: + path: + replaceFullPath: /rewritten + type: ReplaceFullPath + matches: + - path: + type: PathPrefix + value: / + name: rule-0 + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: PathPrefix + value: /2 + name: rule-1 status: parents: [] --- @@ -62,16 +64,17 @@ metadata: namespace: default spec: hostnames: - - myserviceb.foo.org + - myserviceb.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: myserviceb - port: 80 - matches: - - path: - type: PathPrefix - value: / + - backendRefs: + - name: myserviceb + port: 80 + matches: + - path: + type: PathPrefix + value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/rewrite_target_use_regex.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/rewrite_target_use_regex.yaml index 1ff56d8f5..a48a58ca9 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/rewrite_target_use_regex.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/rewrite_target_use_regex.yaml @@ -8,14 +8,14 @@ metadata: spec: gatewayClassName: kgateway listeners: - - hostname: myservicea.foo.org - name: myservicea-foo-org-http - port: 80 - protocol: HTTP - - hostname: myserviceb.foo.org - name: myserviceb-foo-org-http - port: 80 - protocol: HTTP + - hostname: myservicea.foo.org + name: myservicea-foo-org-http + port: 80 + protocol: HTTP + - hostname: myserviceb.foo.org + name: myserviceb-foo-org-http + port: 80 + protocol: HTTP --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute @@ -26,30 +26,26 @@ metadata: namespace: default spec: hostnames: - - myservicea.foo.org + - myservicea.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - filters: - - extensionRef: - group: gateway.kgateway.dev - kind: TrafficPolicy - name: ingress-myservicea1-rewrite-0 - type: ExtensionRef - name: myservicea - port: 80 - matches: - - path: - type: RegularExpression - value: ^/foo/(.*) - - backendRefs: - - name: myservicea - port: 80 - matches: - - path: - type: PathPrefix - value: /v1.+ + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: RegularExpression + value: (?i)/foo/(.*).* + name: rule-0 + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: RegularExpression + value: (?i)/v1.+.* + name: rule-1 status: parents: [] --- @@ -62,35 +58,41 @@ metadata: namespace: default spec: hostnames: - - myserviceb.foo.org + - myserviceb.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: myserviceb - port: 80 - filters: - - type: URLRewrite - urlRewrite: - path: - replaceFullPath: /rewritten - type: ReplaceFullPath - matches: - - path: - type: PathPrefix - value: / + - backendRefs: + - name: myserviceb + port: 80 + filters: + - type: URLRewrite + urlRewrite: + path: + replaceFullPath: /rewritten + type: ReplaceFullPath + matches: + - path: + type: PathPrefix + value: / + name: rule-0 status: parents: [] --- apiVersion: gateway.kgateway.dev/v1alpha1 kind: TrafficPolicy metadata: - name: ingress-myservicea1-rewrite-0 + name: ingress-myservicea1-myservicea-foo-org-0 namespace: default spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: ingress-myservicea1-myservicea-foo-org + sectionName: rule-0 urlRewrite: pathRegex: - pattern: ^/foo/(.*) + pattern: (?i)/foo/(.*).* substitution: /$1 status: ancestors: null diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/service_upstream.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/service_upstream.yaml index 54d54559f..73d4909d1 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/service_upstream.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/service_upstream.yaml @@ -68,6 +68,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 - backendRefs: - name: myservicea port: 80 @@ -75,6 +76,7 @@ spec: - path: type: PathPrefix value: /2 + name: rule-1 status: parents: [] --- @@ -99,5 +101,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/session_affinity.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/session_affinity.yaml index bcb4c2b60..3d8b9e948 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/session_affinity.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/session_affinity.yaml @@ -1,3 +1,25 @@ +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: BackendConfigPolicy +metadata: + name: httpbin-backend-config + namespace: default +spec: + loadBalancer: + ringHash: + hashPolicies: + - cookie: + name: session-id + path: /api + sameSite: Strict + secure: true + ttl: 168h0m0s + targetRefs: + - group: "" + kind: Service + name: httpbin +status: + ancestors: null +--- apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: @@ -8,10 +30,10 @@ metadata: spec: gatewayClassName: kgateway listeners: - - hostname: sessionaffinity.example.org - name: sessionaffinity-example-org-http - port: 80 - protocol: HTTP + - hostname: sessionaffinity.example.org + name: sessionaffinity-example-org-http + port: 80 + protocol: HTTP --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute @@ -22,38 +44,17 @@ metadata: namespace: default spec: hostnames: - - sessionaffinity.example.org + - sessionaffinity.example.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: httpbin - port: 80 - matches: - - path: - type: PathPrefix - value: /path/one + - backendRefs: + - name: httpbin + port: 80 + matches: + - path: + type: PathPrefix + value: /path/one + name: rule-0 status: parents: [] ---- -apiVersion: gateway.kgateway.dev/v1alpha1 -kind: BackendConfigPolicy -metadata: - name: httpbin-backend-config - namespace: default -spec: - loadBalancer: - ringHash: - hashPolicies: - - cookie: - name: session-id - path: /api - sameSite: Strict - secure: true - ttl: 168h0m0s - targetRefs: - - group: "" - kind: Service - name: httpbin -status: - ancestors: null diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/ssl_redirect.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/ssl_redirect.yaml index f0c2e0423..8e89753d4 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/ssl_redirect.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/ssl_redirect.yaml @@ -18,7 +18,9 @@ spec: protocol: HTTPS tls: certificateRefs: - - name: force-redirect-tls + - group: "" + kind: Secret + name: force-redirect-tls - hostname: redirect.example name: redirect-example-http port: 80 @@ -29,31 +31,32 @@ spec: protocol: HTTPS tls: certificateRefs: - - name: redirect-tls + - group: "" + kind: Secret + name: redirect-tls --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-force-ssl-redirect-force-redirect-example-http-redirect + name: ingress-force-ssl-redirect-force-redirect-example namespace: default spec: hostnames: - force-redirect.example parentRefs: - name: nginx - sectionName: force-redirect-example-http + port: 443 rules: - - filters: - - requestRedirect: - scheme: https - statusCode: 301 - type: RequestRedirect + - backendRefs: + - name: myservice2 + port: 80 matches: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -62,66 +65,65 @@ kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-force-ssl-redirect-force-redirect-example-https + name: ingress-force-ssl-redirect-force-redirect-example-http namespace: default spec: hostnames: - force-redirect.example parentRefs: - name: nginx - sectionName: force-redirect-example-https + port: 80 rules: - - backendRefs: - - name: myservice2 - port: 80 + - filters: + - requestRedirect: + scheme: https + statusCode: 308 + type: RequestRedirect matches: - path: type: PathPrefix value: / status: - parents: [] + parents: null --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-ssl-redirect-redirect-example-http-redirect + name: ingress-ssl-redirect-redirect-example namespace: default spec: hostnames: - redirect.example parentRefs: - name: nginx - sectionName: redirect-example-http + port: 443 rules: - - filters: - - requestRedirect: - scheme: https - statusCode: 301 - type: RequestRedirect + - backendRefs: + - name: myservice + port: 80 matches: - path: type: PathPrefix value: / - - filters: - - requestRedirect: - scheme: https - statusCode: 301 - type: RequestRedirect + name: rule-0 + - backendRefs: + - name: myservice + port: 80 matches: - path: type: PathPrefix value: /api - - filters: - - requestRedirect: - scheme: https - statusCode: 301 - type: RequestRedirect + name: rule-1 + - backendRefs: + - name: myservice + port: 80 matches: - path: type: PathPrefix value: /web + name: rule-2 status: parents: [] --- @@ -130,35 +132,41 @@ kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-ssl-redirect-redirect-example-https + name: ingress-ssl-redirect-redirect-example-http namespace: default spec: hostnames: - redirect.example parentRefs: - name: nginx - sectionName: redirect-example-https + port: 80 rules: - - backendRefs: - - name: myservice - port: 80 + - filters: + - requestRedirect: + scheme: https + statusCode: 308 + type: RequestRedirect matches: - path: type: PathPrefix value: / - - backendRefs: - - name: myservice - port: 80 + - filters: + - requestRedirect: + scheme: https + statusCode: 308 + type: RequestRedirect matches: - path: type: PathPrefix value: /api - - backendRefs: - - name: myservice - port: 80 + - filters: + - requestRedirect: + scheme: https + statusCode: 308 + type: RequestRedirect matches: - path: type: PathPrefix value: /web status: - parents: [] + parents: null diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/timeouts.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/timeouts.yaml index 94063f929..89f9e20ea 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/timeouts.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/timeouts.yaml @@ -8,35 +8,48 @@ metadata: spec: gatewayClassName: kgateway listeners: - - hostname: myservicea.foo.org - name: myservicea-foo-org-http - port: 80 - protocol: HTTP - - hostname: myserviceb.foo.org - name: myserviceb-foo-org-http - port: 80 - protocol: HTTP + - hostname: myservicea.foo.org + name: myservicea-foo-org-http + port: 80 + protocol: HTTP + - hostname: myserviceb.foo.org + name: myserviceb-foo-org-http + port: 80 + protocol: HTTP --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-myserviceb-myserviceb-foo-org + name: ingress-myservicea1-myservicea-foo-org namespace: default spec: hostnames: - - myserviceb.foo.org + - myservicea.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: myserviceb - port: 80 - matches: - - path: - type: PathPrefix - value: / + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: PathPrefix + value: / + name: rule-0 + timeouts: + request: 7m30s + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: PathPrefix + value: /2 + name: rule-1 + timeouts: + request: 10m0s status: parents: [] --- @@ -45,109 +58,38 @@ kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-myservicea1-myservicea-foo-org + name: ingress-myserviceb-myserviceb-foo-org namespace: default spec: hostnames: - - myservicea.foo.org + - myserviceb.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - filters: - - extensionRef: - group: gateway.kgateway.dev - kind: TrafficPolicy - name: ingress-myservicea1 - type: ExtensionRef - name: myservicea - port: 80 - matches: - - path: - type: PathPrefix - value: / - - backendRefs: - - filters: - - extensionRef: - group: gateway.kgateway.dev - kind: TrafficPolicy - name: ingress-myservicea2 - type: ExtensionRef - name: myservicea - port: 80 - matches: - - path: - type: PathPrefix - value: /2 + - backendRefs: + - name: myserviceb + port: 80 + matches: + - path: + type: PathPrefix + value: / + name: rule-0 + timeouts: + request: 20m0s status: parents: [] --- apiVersion: gateway.kgateway.dev/v1alpha1 -kind: BackendConfigPolicy -metadata: - name: myservicea-backend-config - namespace: default -spec: - connectTimeout: 30s - targetRefs: - - group: "" - kind: Service - name: myservicea -status: - ancestors: null ---- -apiVersion: gateway.kgateway.dev/v1alpha1 -kind: BackendConfigPolicy -metadata: - name: myserviceb-backend-config - namespace: default -spec: - connectTimeout: 2m0s - targetRefs: - - group: "" - kind: Service - name: myserviceb -status: - ancestors: null ---- -apiVersion: gateway.kgateway.dev/v1alpha1 kind: TrafficPolicy metadata: - name: ingress-myservicea1 - namespace: default -spec: - timeouts: - request: 30s - streamIdle: 45s -status: - ancestors: null ---- -apiVersion: gateway.kgateway.dev/v1alpha1 -kind: TrafficPolicy -metadata: - name: ingress-myservicea2 - namespace: default -spec: - timeouts: - request: 1m0s - streamIdle: 1m0s -status: - ancestors: null ---- -apiVersion: gateway.kgateway.dev/v1alpha1 -kind: TrafficPolicy -metadata: - name: ingress-myserviceb + name: ingress-myserviceb-myserviceb-foo-org namespace: default spec: buffer: - maxRequestSize: 100m + maxRequestSize: 100Mi targetRefs: - - group: gateway.networking.k8s.io - kind: HTTPRoute - name: ingress-myserviceb-myserviceb-foo-org - timeouts: - request: 1m30s - streamIdle: 1m30s + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: ingress-myserviceb-myserviceb-foo-org status: ancestors: null diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/use_regex.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/use_regex.yaml index b9068ea60..6a571fc10 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/use_regex.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/use_regex.yaml @@ -8,14 +8,14 @@ metadata: spec: gatewayClassName: kgateway listeners: - - hostname: myservicea.foo.org - name: myservicea-foo-org-http - port: 80 - protocol: HTTP - - hostname: myserviceb.foo.org - name: myserviceb-foo-org-http - port: 80 - protocol: HTTP + - hostname: myservicea.foo.org + name: myservicea-foo-org-http + port: 80 + protocol: HTTP + - hostname: myserviceb.foo.org + name: myserviceb-foo-org-http + port: 80 + protocol: HTTP --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute @@ -26,24 +26,26 @@ metadata: namespace: default spec: hostnames: - - myservicea.foo.org + - myservicea.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: myservicea - port: 80 - matches: - - path: - type: RegularExpression - value: ^/path/one - - backendRefs: - - name: myservicea - port: 80 - matches: - - path: - type: PathPrefix - value: /path/two + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: RegularExpression + value: (?i)/path/one.* + name: rule-0 + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: RegularExpression + value: (?i)/path/two.* + name: rule-1 status: parents: [] --- @@ -56,16 +58,17 @@ metadata: namespace: default spec: hostnames: - - myserviceb.foo.org + - myserviceb.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: myserviceb - port: 80 - matches: - - path: - type: PathPrefix - value: / + - backendRefs: + - name: myserviceb + port: 80 + matches: + - path: + type: PathPrefix + value: / + name: rule-0 status: parents: [] diff --git a/pkg/i2gw/emitters/kgateway/testing/testdata/output/use_regex_session_affinity.yaml b/pkg/i2gw/emitters/kgateway/testing/testdata/output/use_regex_session_affinity.yaml index aafe64435..014551123 100644 --- a/pkg/i2gw/emitters/kgateway/testing/testdata/output/use_regex_session_affinity.yaml +++ b/pkg/i2gw/emitters/kgateway/testing/testdata/output/use_regex_session_affinity.yaml @@ -1,3 +1,22 @@ +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: BackendConfigPolicy +metadata: + name: myservicea-backend-config + namespace: default +spec: + loadBalancer: + ringHash: + hashPolicies: + - cookie: + name: session-id + path: /api + targetRefs: + - group: "" + kind: Service + name: myservicea +status: + ancestors: null +--- apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: @@ -8,14 +27,14 @@ metadata: spec: gatewayClassName: kgateway listeners: - - hostname: myservicea.foo.org - name: myservicea-foo-org-http - port: 80 - protocol: HTTP - - hostname: myserviceb.foo.org - name: myserviceb-foo-org-http - port: 80 - protocol: HTTP + - hostname: myservicea.foo.org + name: myservicea-foo-org-http + port: 80 + protocol: HTTP + - hostname: myserviceb.foo.org + name: myserviceb-foo-org-http + port: 80 + protocol: HTTP --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute @@ -26,24 +45,26 @@ metadata: namespace: default spec: hostnames: - - myservicea.foo.org + - myservicea.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: myservicea - port: 80 - matches: - - path: - type: RegularExpression - value: ^/path/one - - backendRefs: - - name: myservicea - port: 80 - matches: - - path: - type: RegularExpression - value: ^/path/two + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: RegularExpression + value: (?i)/path/one.* + name: rule-0 + - backendRefs: + - name: myservicea + port: 80 + matches: + - path: + type: RegularExpression + value: (?i)/path/two.* + name: rule-1 status: parents: [] --- @@ -56,35 +77,17 @@ metadata: namespace: default spec: hostnames: - - myserviceb.foo.org + - myserviceb.foo.org parentRefs: - - name: nginx + - name: nginx rules: - - backendRefs: - - name: myserviceb - port: 80 - matches: - - path: - type: PathPrefix - value: / + - backendRefs: + - name: myserviceb + port: 80 + matches: + - path: + type: PathPrefix + value: / + name: rule-0 status: parents: [] ---- -apiVersion: gateway.kgateway.dev/v1alpha1 -kind: BackendConfigPolicy -metadata: - name: myservicea-backend-config - namespace: default -spec: - loadBalancer: - ringHash: - hashPolicies: - - cookie: - name: session-id - path: /api - targetRefs: - - group: "" - kind: Service - name: myservicea -status: - ancestors: null diff --git a/pkg/i2gw/emitters/kgateway/types.go b/pkg/i2gw/emitters/kgateway/types.go index 23766b781..f5bd23cf6 100644 --- a/pkg/i2gw/emitters/kgateway/types.go +++ b/pkg/i2gw/emitters/kgateway/types.go @@ -18,6 +18,13 @@ package kgateway import "k8s.io/apimachinery/pkg/runtime/schema" +const ( + // RouteRuleAllIndex is used to indicate a policy applies to all rules in an HTTPRoute. + RouteRuleAllIndex = -1 + + sourceIngressAnnotation = "ingress2gateway.kubernetes.io/source-ingress" +) + var ( // TrafficPolicyGVK is the GroupVersionKind for TrafficPolicy. TrafficPolicyGVK = schema.GroupVersionKind{ @@ -49,5 +56,3 @@ var ( Kind: "Backend", } ) - -const sourceIngressAnnotation = "ingress2gateway.kubernetes.io/source-ingress" diff --git a/pkg/i2gw/emitters/kgateway/use_regex.go b/pkg/i2gw/emitters/kgateway/use_regex.go deleted file mode 100644 index ffcb1492a..000000000 --- a/pkg/i2gw/emitters/kgateway/use_regex.go +++ /dev/null @@ -1,104 +0,0 @@ -/* -Copyright 2024 The Kubernetes Authors. - -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 kgateway - -import ( - emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" - - gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" -) - -// applyRegexPathMatchingForHost mutates the HTTPRouteContext in-place to use -// Gateway API RegularExpression path matches when the provider indicates that -// ingress-nginx "regex location modifier" semantics are enforced for the host. -// -// This is the emitter-side realization of host-wide regex enforcement driven by: -// - nginx.ingress.kubernetes.io/use-regex=true -// -// Behavior: -// - If RegexLocationForHost is true, convert any PathPrefix/Exact matches into RegularExpression matches. -// - The regex is anchored to mimic NGINX-ish location behavior: -// - PathPrefix "/foo" -> "^/foo" -// - Exact "/foo" -> "^/foo$" -// - Existing RegularExpression matches are preserved. -func applyRegexPathMatchingForHost( - httpRouteCtx *emitterir.HTTPRouteContext, -) bool { - if httpRouteCtx.RegexLocationForHost == nil || !*httpRouteCtx.RegexLocationForHost { - return false - } - - // Rules contributed by an ingress with use-regex=true should NOT be escaped. - userRegexRule := map[int]bool{} - for _, pol := range httpRouteCtx.PoliciesBySourceIngressName { - if pol.UseRegexPaths != nil && *pol.UseRegexPaths { - for _, idx := range pol.RuleBackendSources { - userRegexRule[idx.Rule] = true - } - } - } - - // If nothing is actually marked use-regex, do not mutate any matches. - if len(userRegexRule) == 0 { - return false - } - - mutated := false - for ri := range httpRouteCtx.Spec.Rules { - // Only rewrite rules that originated from a use-regex ingress. - if !userRegexRule[ri] { - continue - } - - rule := &httpRouteCtx.Spec.Rules[ri] - for mi := range rule.Matches { - m := &rule.Matches[mi] - if m.Path == nil || m.Path.Value == nil || *m.Path.Value == "" { - continue - } - - // Preserve explicitly-regex matches. - if m.Path.Type != nil && *m.Path.Type == gatewayv1.PathMatchRegularExpression { - continue - } - - // Default match type is PathPrefix if nil. - matchType := gatewayv1.PathMatchPathPrefix - if m.Path.Type != nil { - matchType = *m.Path.Type - } - - val := *m.Path.Value - var re string - switch matchType { - case gatewayv1.PathMatchExact: - re = "^" + val + "$" - case gatewayv1.PathMatchPathPrefix: - re = "^" + val - default: - continue - } - - t := gatewayv1.PathMatchRegularExpression - m.Path.Type = &t - m.Path.Value = &re - mutated = true - } - } - - return mutated -} diff --git a/pkg/i2gw/emitters/kgateway/utils.go b/pkg/i2gw/emitters/kgateway/utils.go index 1c4a96b1b..fb7f0f20a 100644 --- a/pkg/i2gw/emitters/kgateway/utils.go +++ b/pkg/i2gw/emitters/kgateway/utils.go @@ -18,12 +18,6 @@ package kgateway import ( emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" - - "github.com/kgateway-dev/kgateway/v2/api/v1alpha1/kgateway" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) // uniquePolicyIndices returns a slice of PolicyIndex values with duplicates @@ -45,43 +39,3 @@ func uniquePolicyIndices(indices []emitterir.PolicyIndex) []emitterir.PolicyInde } return out } - -// ensureTrafficPolicy returns the TrafficPolicy for the given ingressName, -// creating and initializing it if needed. -func ensureTrafficPolicy( - tp map[string]*kgateway.TrafficPolicy, - ingressName, namespace string, -) *kgateway.TrafficPolicy { - if existing, ok := tp[ingressName]; ok { - return existing - } - - newTP := &kgateway.TrafficPolicy{ - ObjectMeta: metav1.ObjectMeta{ - Name: ingressName, - Namespace: namespace, - }, - Spec: kgateway.TrafficPolicySpec{}, - } - newTP.SetGroupVersionKind(TrafficPolicyGVK) - - tp[ingressName] = newTP - return newTP -} - -func numRules(hr gatewayv1.HTTPRoute) int { - n := 0 - for _, r := range hr.Spec.Rules { - n += len(r.BackendRefs) - } - return n -} - -// toUnstructured converts a runtime.Object to unstructured.Unstructured -func toUnstructured(obj runtime.Object) (*unstructured.Unstructured, error) { - unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) - if err != nil { - return nil, err - } - return &unstructured.Unstructured{Object: unstructuredObj}, nil -} diff --git a/pkg/i2gw/emitters/standard/standard.go b/pkg/i2gw/emitters/standard/standard.go index d2ba3fa31..876c53e10 100644 --- a/pkg/i2gw/emitters/standard/standard.go +++ b/pkg/i2gw/emitters/standard/standard.go @@ -20,22 +20,32 @@ import ( "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitters/utils" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" "k8s.io/apimachinery/pkg/util/validation/field" ) +const emitterName = "standard_emitter" + func init() { i2gw.EmitterConstructorByName["standard"] = NewEmitter } -type Emitter struct{} +type Emitter struct { + notify notifications.NotifyFunc +} // Emitter is the standard emitter that converts the intermediate representation // to Gateway API resources without any provider-specific modifications. -func NewEmitter(_ *i2gw.EmitterConf) i2gw.Emitter { - return &Emitter{} +func NewEmitter(conf *i2gw.EmitterConf) i2gw.Emitter { + return &Emitter{ + notify: conf.Report.Notifier(emitterName), + } } // Emit converts the provider intermediate representation to Gateway API resources. func (e *Emitter) Emit(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.ErrorList) { - return utils.ToGatewayResources(ir) + utils.LogUnparsedErrors(ir, e.notify) + e.notify(notifications.WarningNotification, "Gateway API does not support configuring URL normalization (RFC 3986, Section 6). Please check if this matters for your use case and consult implementation-specific details.") + resources, err := utils.ToGatewayResources(ir) + return resources, err } diff --git a/pkg/i2gw/emitters/standard/standard_test.go b/pkg/i2gw/emitters/standard/standard_test.go index f62570364..2d615a855 100644 --- a/pkg/i2gw/emitters/standard/standard_test.go +++ b/pkg/i2gw/emitters/standard/standard_test.go @@ -23,6 +23,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" apiequality "k8s.io/apimachinery/pkg/api/equality" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -288,7 +289,7 @@ func Test_ToGatewayResources(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - emitter := Emitter{} + emitter := Emitter{notify: notifications.NoopNotify} gatewayResouces, errs := emitter.Emit(tc.ir) if len(errs) != len(tc.expectedErrors) { diff --git a/pkg/i2gw/emitters/utils/utils.go b/pkg/i2gw/emitters/utils/utils.go index 607385559..ca02fdec4 100644 --- a/pkg/i2gw/emitters/utils/utils.go +++ b/pkg/i2gw/emitters/utils/utils.go @@ -18,12 +18,16 @@ package utils import ( "fmt" + "strings" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" - + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" @@ -37,8 +41,8 @@ type uniqueBackendRefsKey struct { Kind gatewayv1.Kind } -// RemoveBackendRefsDuplicates removes duplicate backendRefs from a list of backendRefs. -func RemoveBackendRefsDuplicates(backendRefs []gatewayv1.HTTPBackendRef) []gatewayv1.HTTPBackendRef { +// removeBackendRefsDuplicates removes duplicate backendRefs from a list of backendRefs. +func removeBackendRefsDuplicates(backendRefs []gatewayv1.HTTPBackendRef) []gatewayv1.HTTPBackendRef { uniqueBackendRefs := map[uniqueBackendRefsKey]*gatewayv1.HTTPBackendRef{} for _, backendRef := range backendRefs { @@ -99,7 +103,7 @@ func ToGatewayResources(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.Er gatewayResources.HTTPRoutes[key] = httpRouteContext.HTTPRoute hr := gatewayResources.HTTPRoutes[key] for i := range hr.Spec.Rules { - hr.Spec.Rules[i].BackendRefs = RemoveBackendRefsDuplicates(hr.Spec.Rules[i].BackendRefs) + hr.Spec.Rules[i].BackendRefs = removeBackendRefsDuplicates(hr.Spec.Rules[i].BackendRefs) } gatewayResources.HTTPRoutes[key] = hr } @@ -124,11 +128,65 @@ func ToGatewayResources(ir emitterir.EmitterIR) (i2gw.GatewayResources, field.Er for key, val := range ir.ReferenceGrants { gatewayResources.ReferenceGrants[key] = val.ReferenceGrant } + return gatewayResources, nil +} - // Dedupe TLS certificateRefs within each Gateway listener. - dedupeGatewayListenerCertificateRefs(&gatewayResources) +func LogUnparsedErrors(ir emitterir.EmitterIR, notify notifications.NotifyFunc) { + // currently, we only really have unparsed errors in the HTTPRouteContext, but we can expand this function as needed if we have unparsed errors in other contexts in the future. + for _, httpRouteContext := range ir.HTTPRoutes { + for _, unparsedExtension := range httpRouteContext.UnparsedExtensions() { + if unparsedExtension == nil { + continue + } + source := unparsedExtension.Source() + paths := strings.Builder{} + for _, p := range unparsedExtension.Paths() { + paths.WriteString(p.String()) + paths.WriteString(", ") + } - return gatewayResources, nil + message := unparsedExtension.FailureMessage() + + notify(notifications.WarningNotification, + fmt.Sprintf("Failed to apply %s from %s: %s", strings.TrimSuffix(paths.String(), ", "), source, message), + &httpRouteContext.HTTPRoute, + ) + } + } + + for svcKey, serviceContext := range ir.Services { + for _, unparsedExtension := range serviceContext.UnparsedExtensions() { + if unparsedExtension == nil { + continue + } + meta := unparsedExtension + + source := meta.Source() + paths := strings.Builder{} + for _, p := range meta.Paths() { + paths.WriteString(p.String()) + paths.WriteString(", ") + } + + message := meta.FailureMessage() + + svc := corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: svcKey.Name, + Namespace: svcKey.Namespace, + }, + } + + notify(notifications.WarningNotification, + fmt.Sprintf("failed to parse %s from Ingress %s: %s", source, strings.TrimSuffix(paths.String(), ", "), message), + &svc, + ) + } + } } func dedupeGatewayListenerCertificateRefs(gr *i2gw.GatewayResources) { diff --git a/pkg/i2gw/ingress2gateway.go b/pkg/i2gw/ingress2gateway.go index 08b646933..16f40c3c3 100644 --- a/pkg/i2gw/ingress2gateway.go +++ b/pkg/i2gw/ingress2gateway.go @@ -17,8 +17,10 @@ limitations under the License. package i2gw import ( + "bytes" "context" "fmt" + "io" "sort" common_emitter "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitters/common_emitter" @@ -37,10 +39,10 @@ const GeneratorAnnotationKey = "gateway.networking.k8s.io/generator" // Examples: "v0.4.0", "v0.4.0-5-gabcdef", "v0.4.0-5-gabcdef-dirty" var Version = "dev" // Default value if not built with linker flags -func ToGatewayAPIResources(ctx context.Context, namespace string, inputFile string, providers []string, emitterName string, providerSpecificFlags map[string]map[string]string) ([]GatewayResources, map[string]string, error) { +func ToGatewayAPIResources(ctx context.Context, namespace string, reader io.Reader, providers []string, emitterName string, providerSpecificFlags map[string]map[string]string, allowExperimentalGatewayAPI bool, noColor bool) ([]GatewayResources, *notifications.Report, error) { var clusterClient client.Client - if inputFile == "" { + if reader == nil { conf, err := config.GetConfig() if err != nil { return nil, nil, fmt.Errorf("failed to get client config: %w", err) @@ -53,17 +55,20 @@ func ToGatewayAPIResources(ctx context.Context, namespace string, inputFile stri clusterClient = client.NewNamespacedClient(cl, namespace) } + report := notifications.NewReport(noColor) + providerByName, err := constructProviders(&ProviderConf{ Client: clusterClient, Namespace: namespace, ProviderSpecificFlags: providerSpecificFlags, + Report: report, }, providers) if err != nil { return nil, nil, err } - if inputFile != "" { - if err = readProviderResourcesFromFile(ctx, providerByName, inputFile); err != nil { + if reader != nil { + if err = readProviderResourcesFromFile(ctx, providerByName, reader); err != nil { return nil, nil, err } } else { @@ -72,13 +77,19 @@ func ToGatewayAPIResources(ctx context.Context, namespace string, inputFile stri } } - emitterConf := &EmitterConf{} + emitterConf := &EmitterConf{ + AllowExperimentalGatewayAPI: allowExperimentalGatewayAPI, + Report: report, + } newEmitterFunc, ok := EmitterConstructorByName[EmitterName(emitterName)] if !ok { return nil, nil, fmt.Errorf("%s is not a supported emitter", emitterName) } emitter := newEmitterFunc(emitterConf) - commonEmitter := common_emitter.NewEmitter() + commonEmitter := common_emitter.NewEmitter(&common_emitter.EmitterConf{ + AllowExperimentalGatewayAPI: emitterConf.AllowExperimentalGatewayAPI, + Report: report, + }) var ( gatewayResources []GatewayResources @@ -95,18 +106,22 @@ func ToGatewayAPIResources(ctx context.Context, namespace string, inputFile stri errs = append(errs, conversionErrs...) gatewayResources = append(gatewayResources, providerGatewayResources) } - notificationTablesMap := notifications.NotificationAggr.CreateNotificationTables() if len(errs) > 0 { - return nil, notificationTablesMap, aggregatedErrs(errs) + return nil, report, aggregatedErrs(errs) } - return gatewayResources, notificationTablesMap, nil + return gatewayResources, report, nil } -func readProviderResourcesFromFile(ctx context.Context, providerByName map[ProviderName]Provider, inputFile string) error { +func readProviderResourcesFromFile(ctx context.Context, providerByName map[ProviderName]Provider, reader io.Reader) error { + data, err := io.ReadAll(reader) + if err != nil { + return fmt.Errorf("failed to read input manifests: %w", err) + } + for name, provider := range providerByName { - if err := provider.ReadResourcesFromFile(ctx, inputFile); err != nil { - return fmt.Errorf("failed to read %s resources from file: %w", name, err) + if err := provider.ReadResourcesFromFile(ctx, bytes.NewReader(data)); err != nil { + return fmt.Errorf("failed to read %s resources from input: %w", name, err) } } return nil diff --git a/pkg/i2gw/ingress2gateway_test.go b/pkg/i2gw/ingress2gateway_test.go index e65a1cf10..85dc664ff 100644 --- a/pkg/i2gw/ingress2gateway_test.go +++ b/pkg/i2gw/ingress2gateway_test.go @@ -62,7 +62,7 @@ func Test_constructProviders(t *testing.T) { t.Errorf("Expected no error but got %v", err) } if len(tc.expectedProviders) != len(providerByName) { - t.Errorf("Expected contructed providers num is %d but got %d", len(tc.providers), len(providerByName)) + t.Errorf("Expected constructed providers num is %d but got %d", len(tc.providers), len(providerByName)) } for _, provider := range tc.expectedProviders { if _, ok := providerByName[ProviderName(provider)]; !ok { @@ -82,7 +82,7 @@ func Test_GetSupportedProviders(t *testing.T) { t.Run("Test GetSupportedProviders", func(t *testing.T) { allProviders := GetSupportedProviders() if len(allProviders) != len(ProviderConstructorByName) { - t.Errorf("The acutal number of the providers we supported is %d but we got the number is: %d", + t.Errorf("The actual number of the providers we supported is %d but we got the number is: %d", len(ProviderConstructorByName), len(allProviders)) } for _, provider := range allProviders { diff --git a/pkg/i2gw/notifications/notifications.go b/pkg/i2gw/notifications/notifications.go index 881d599c6..32451d097 100644 --- a/pkg/i2gw/notifications/notifications.go +++ b/pkg/i2gw/notifications/notifications.go @@ -17,19 +17,11 @@ limitations under the License. package notifications import ( - "fmt" "strings" - "sync" "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/olekukonko/tablewriter" ) -func init() { - NotificationAggr = NotificationAggregator{Notifications: map[string][]Notification{}} -} - const ( // InfoNotification represents an informational message type. InfoNotification MessageType = "INFO" @@ -49,66 +41,15 @@ type Notification struct { CallingObjects []client.Object } -// NotificationAggregator aggregates notifications from different providers. -type NotificationAggregator struct { - mutex sync.Mutex - Notifications map[string][]Notification -} - -// NotificationAggr is a global instance of NotificationAggregator used to collect notifications. -var NotificationAggr NotificationAggregator - -// DispatchNotification is used to send a notification to the NotificationAggregator -func (na *NotificationAggregator) DispatchNotification(notification Notification, ProviderName string) { - na.mutex.Lock() - na.Notifications[ProviderName] = append(na.Notifications[ProviderName], notification) - na.mutex.Unlock() -} - -// CreateNotificationTables takes all generated notifications and returns a map[string]string -// that displays the notifications in a tabular format based on provider -func (na *NotificationAggregator) CreateNotificationTables() map[string]string { - notificationTablesMap := make(map[string]string) - - na.mutex.Lock() - defer na.mutex.Unlock() - - for provider, msgs := range na.Notifications { - providerTable := strings.Builder{} - - t := tablewriter.NewWriter(&providerTable) - t.SetHeader([]string{"Message Type", "Notification", "Calling Object"}) - t.SetColWidth(200) - t.SetRowLine(true) - - for _, n := range msgs { - row := []string{string(n.Type), n.Message, convertObjectsToStr(n.CallingObjects)} - t.Append(row) - } - - providerTable.WriteString(fmt.Sprintf("Notifications from %v:\n", strings.ToUpper(provider))) - t.Render() - notificationTablesMap[provider] = providerTable.String() - } - - return notificationTablesMap -} - -func convertObjectsToStr(ob []client.Object) string { - var sb strings.Builder +func objectsToStr(ob []client.Object) string { + strs := make([]string, 0, len(ob)) - for i, o := range ob { - if i > 0 { - sb.WriteString(", ") + for _, o := range ob { + if o == nil { + continue } - object := o.GetObjectKind().GroupVersionKind().Kind + ": " + client.ObjectKeyFromObject(o).String() - sb.WriteString(object) + strs = append(strs, o.GetObjectKind().GroupVersionKind().Kind+": "+client.ObjectKeyFromObject(o).String()) } - return sb.String() -} - -// NewNotification constructs and returns a Notification. -func NewNotification(mType MessageType, message string, callingObject ...client.Object) Notification { - return Notification{Type: mType, Message: message, CallingObjects: callingObject} + return strings.Join(strs, ", ") } diff --git a/pkg/i2gw/notifications/notifications_test.go b/pkg/i2gw/notifications/notifications_test.go index c9accf818..28a5d9364 100644 --- a/pkg/i2gw/notifications/notifications_test.go +++ b/pkg/i2gw/notifications/notifications_test.go @@ -17,182 +17,17 @@ limitations under the License. package notifications import ( - "sync" "testing" "github.com/stretchr/testify/assert" - istioclientv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) -func TestCreateNotificationsTables(t *testing.T) { - testCases := []struct { - name string - notifications map[string][]Notification - wantedTables map[string]string - }{ - { - name: "no notifications", - notifications: map[string][]Notification{}, - wantedTables: map[string]string{}, - }, - { - name: "single provider with one notification", - notifications: map[string][]Notification{ - "provider1": { - { - Type: WarningNotification, - Message: "warning message", - }, - }, - }, - wantedTables: map[string]string{ - "provider1": `Notifications from PROVIDER1: -+--------------+-----------------+----------------+ -| MESSAGE TYPE | NOTIFICATION | CALLING OBJECT | -+--------------+-----------------+----------------+ -| WARNING | warning message | | -+--------------+-----------------+----------------+ -`, - }, - }, - { - name: "two providers each with two notifications", - notifications: map[string][]Notification{ - "istio": { - { - Type: WarningNotification, - Message: "a very long warning notification generated by VirtualService ns/test from the provider istio", - CallingObjects: []client.Object{ - &istioclientv1beta1.VirtualService{ - TypeMeta: metav1.TypeMeta{ - Kind: "VirtualService", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "ns", - }, - }, - }, - }, - { - Type: InfoNotification, - Message: "successfully converted VirtualService ns/test to HTTPRoute", - CallingObjects: []client.Object{ - &istioclientv1beta1.VirtualService{ - TypeMeta: metav1.TypeMeta{ - Kind: "VirtualService", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "ns", - }, - }, - }, - }, - }, - "kong": { - { - Type: InfoNotification, - Message: "informational notification from kong provider", - CallingObjects: []client.Object{ - &networkingv1.Ingress{ - TypeMeta: metav1.TypeMeta{ - Kind: "Ingress", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "ingress-kong", - Namespace: "default", - }, - }, - }, - }, - { - Type: ErrorNotification, - Message: "error notification genereated by kong", - }, - }, - }, - wantedTables: map[string]string{ - "istio": `Notifications from ISTIO: -+--------------+----------------------------------------------------------------------------------------------+-------------------------+ -| MESSAGE TYPE | NOTIFICATION | CALLING OBJECT | -+--------------+----------------------------------------------------------------------------------------------+-------------------------+ -| WARNING | a very long warning notification generated by VirtualService ns/test from the provider istio | VirtualService: ns/test | -+--------------+----------------------------------------------------------------------------------------------+-------------------------+ -| INFO | successfully converted VirtualService ns/test to HTTPRoute | VirtualService: ns/test | -+--------------+----------------------------------------------------------------------------------------------+-------------------------+ -`, - "kong": `Notifications from KONG: -+--------------+-----------------------------------------------+-------------------------------+ -| MESSAGE TYPE | NOTIFICATION | CALLING OBJECT | -+--------------+-----------------------------------------------+-------------------------------+ -| INFO | informational notification from kong provider | Ingress: default/ingress-kong | -+--------------+-----------------------------------------------+-------------------------------+ -| ERROR | error notification genereated by kong | | -+--------------+-----------------------------------------------+-------------------------------+ -`, - }, - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - na := NotificationAggregator{ - Notifications: tc.notifications, - } - result := na.CreateNotificationTables() - for provider, table := range result { - assert.Equal(t, tc.wantedTables[provider], table) - } - }) - } -} - -// TestNotificationAggregatorRace checks for data races in NotificationAggregator when accessed -// concurrently. -func TestNotificationAggregatorRace(_ *testing.T) { - na := NotificationAggregator{Notifications: map[string][]Notification{}} - - providers := []string{"provider1", "provider2", "provider3", "provider4"} - var wg sync.WaitGroup - - // Start multiple goroutines that dispatch notifications concurrently. - for _, provider := range providers { - wg.Add(1) - go func(p string) { - for range 100 { - na.DispatchNotification( - Notification{ - Type: WarningNotification, - Message: "concurrent warning", - }, - p, - ) - } - wg.Done() - }(provider) - } - - // Concurrently read from the aggregator while writes are happening. - wg.Add(1) - go func() { - for range 100 { - _ = na.CreateNotificationTables() - } - wg.Done() - }() - - // Wait for all goroutines to complete. - wg.Wait() -} - -func TestConvertObjectsToStr(t *testing.T) { +func TestObjectsToStr(t *testing.T) { testCases := []struct { name string objects []client.Object @@ -242,7 +77,7 @@ func TestConvertObjectsToStr(t *testing.T) { for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { - result := convertObjectsToStr(tc.objects) + result := objectsToStr(tc.objects) assert.Equal(t, tc.want, result) }) } diff --git a/pkg/i2gw/notifications/report.go b/pkg/i2gw/notifications/report.go new file mode 100644 index 000000000..510dc8bd9 --- /dev/null +++ b/pkg/i2gw/notifications/report.go @@ -0,0 +1,166 @@ +/* +Copyright The Kubernetes Authors. + +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 notifications + +import ( + "fmt" + "maps" + "slices" + "strings" + "sync" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ANSI color codes for terminal output. +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorYellow = "\033[33m" + colorGray = "\033[90m" + colorCyan = "\033[36m" + colorBrightGreen = "\033[92m" + colorBrightPurple = "\033[95m" +) + +// Number of dash characters in the top border after the level label. +const boxDashes = 40 + +// Report collects user-facing notifications during a single conversion run. A nil *Report is safe +// to use - calls to Add and Notifier become no-ops. +type Report struct { + mu sync.Mutex + notifications map[string][]Notification + noColor bool +} + +// NewReport creates a new Report and returns a pointer to it. Set noColor to true to disable ANSI +// color codes in the output. +func NewReport(noColor bool) *Report { + return &Report{ + notifications: make(map[string][]Notification), + noColor: noColor, + } +} + +// Add records a notification under the given source name. +func (r *Report) Add(source string, n Notification) { + if r == nil { + return + } + + r.mu.Lock() + r.notifications[source] = append(r.notifications[source], n) + r.mu.Unlock() +} + +// Notifier returns a convenience function scoped to a single source name, eliminating the need for +// per-package boilerplate. +func (r *Report) Notifier(source string) NotifyFunc { + return func(mt MessageType, msg string, objs ...client.Object) { + r.Add(source, Notification{ + Type: mt, + Message: msg, + CallingObjects: objs, + }) + } +} + +// Render returns notifications as a single human-readable string of colored boxes. Called once +// after conversion completes. Notifications are sorted by source name for deterministic output. +// Returns "" when r is nil or there are no notifications. +func (r *Report) Render() string { + if r == nil { + return "" + } + + r.mu.Lock() + defer r.mu.Unlock() + + sources := slices.Sorted(maps.Keys(r.notifications)) + + // Returns the ANSI code, or "" when color is disabled. + c := func(code string) string { + if r.noColor { + return "" + } + return code + } + + var buf strings.Builder + + for _, source := range sources { + for _, n := range r.notifications[source] { + label, lcolor := levelLabel(n.Type) + + // Top border with level. + fmt.Fprintf(&buf, "%s┌─ %s%s%s %s%s\n", + c(colorGray), c(lcolor), label, c(colorGray), + strings.Repeat("─", boxDashes), c(colorReset)) + + // Message. + fmt.Fprintf(&buf, "%s│%s %s\n", + c(colorGray), c(colorReset), n.Message) + + // Source attribute. + fmt.Fprintf(&buf, "%s│%s %ssource:%s %s%s%s\n", + c(colorGray), c(colorReset), + c(colorGray), c(colorReset), + c(colorBrightPurple), strings.ToUpper(source), c(colorReset)) + + // Calling objects. + if len(n.CallingObjects) > 0 { + key := "object" + if len(n.CallingObjects) > 1 { + key = "objects" + } + fmt.Fprintf(&buf, "%s│%s %s%s:%s %s%s%s\n", + c(colorGray), c(colorReset), + c(colorGray), key, c(colorReset), + c(colorBrightGreen), objectsToStr(n.CallingObjects), c(colorReset)) + } + + // Bottom border. + fmt.Fprintf(&buf, "%s└─%s\n", + c(colorGray), c(colorReset)) + } + } + + return buf.String() +} + +// levelLabel returns a display label and its ANSI color for the given MessageType. Labels for +// WARN and INFO include a trailing space so all labels are 5 characters wide. +func levelLabel(mt MessageType) (string, string) { + switch mt { + case ErrorNotification: + return "ERROR", colorRed + case WarningNotification: + return "WARN ", colorYellow + case InfoNotification: + return "INFO ", colorCyan + default: + return "INFO ", colorCyan + } +} + +// NotifyFunc is the signature for a scoped notification callback. Used by providers and emitters +// to record user-facing information related to conversions. +type NotifyFunc func(MessageType, string, ...client.Object) + +// NoopNotify is a no-op NotifyFunc used when no Report is configured (typically in tests). +func NoopNotify(_ MessageType, _ string, _ ...client.Object) {} diff --git a/pkg/i2gw/notifications/report_test.go b/pkg/i2gw/notifications/report_test.go new file mode 100644 index 000000000..d2a9db96f --- /dev/null +++ b/pkg/i2gw/notifications/report_test.go @@ -0,0 +1,231 @@ +/* +Copyright The Kubernetes Authors. + +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 notifications + +import ( + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + + istioclientv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestReportRender(t *testing.T) { + testCases := []struct { + name string + setup func(r *Report) + expected string + }{ + { + name: "no notifications", + setup: func(_ *Report) {}, + expected: "", + }, + { + name: "single source with one notification", + setup: func(r *Report) { + r.Add("provider1", Notification{ + Type: WarningNotification, + Message: "warning message", + }) + }, + expected: `┌─ WARN ──────────────────────────────────────── +│ warning message +│ source: PROVIDER1 +└─ +`, + }, + { + name: "two sources each with two notifications", + setup: func(r *Report) { + r.Add("istio", Notification{ + Type: WarningNotification, + Message: "a very long warning notification generated by VirtualService ns/test from the provider istio", + CallingObjects: []client.Object{ + &istioclientv1beta1.VirtualService{ + TypeMeta: metav1.TypeMeta{ + Kind: "VirtualService", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "ns", + }, + }, + }, + }) + r.Add("istio", Notification{ + Type: InfoNotification, + Message: "successfully converted VirtualService ns/test to HTTPRoute", + CallingObjects: []client.Object{ + &istioclientv1beta1.VirtualService{ + TypeMeta: metav1.TypeMeta{ + Kind: "VirtualService", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "ns", + }, + }, + }, + }) + r.Add("kong", Notification{ + Type: InfoNotification, + Message: "informational notification from kong provider", + CallingObjects: []client.Object{ + &networkingv1.Ingress{ + TypeMeta: metav1.TypeMeta{ + Kind: "Ingress", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-kong", + Namespace: "default", + }, + }, + }, + }) + r.Add("kong", Notification{ + Type: ErrorNotification, + Message: "error notification genereated by kong", + }) + }, + expected: `┌─ WARN ──────────────────────────────────────── +│ a very long warning notification generated by VirtualService ns/test from the provider istio +│ source: ISTIO +│ object: VirtualService: ns/test +└─ +┌─ INFO ──────────────────────────────────────── +│ successfully converted VirtualService ns/test to HTTPRoute +│ source: ISTIO +│ object: VirtualService: ns/test +└─ +┌─ INFO ──────────────────────────────────────── +│ informational notification from kong provider +│ source: KONG +│ object: Ingress: default/ingress-kong +└─ +┌─ ERROR ──────────────────────────────────────── +│ error notification genereated by kong +│ source: KONG +└─ +`, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + r := NewReport(true) + tc.setup(r) + result := r.Render() + assert.Equal(t, tc.expected, result) + }) + } +} + +// TestReportConcurrentAdd checks for data races in Report when accessed concurrently. +func TestReportConcurrentAdd(t *testing.T) { + r := NewReport(true) + + providers := []string{"provider1", "provider2", "provider3", "provider4"} + var wg sync.WaitGroup + + // Start multiple goroutines that add notifications concurrently. + for _, provider := range providers { + wg.Go(func() { + for range 100 { + r.Add(provider, Notification{ + Type: WarningNotification, + Message: "concurrent warning", + }) + } + }) + } + + // Concurrently read from the report while writes are happening. + wg.Go(func() { + for range 100 { + _ = r.Render() + } + }) + + wg.Wait() + + // Verify all notifications were recorded. + result := r.Render() + for _, provider := range providers { + assert.Equal(t, 100, strings.Count(result, "source: "+strings.ToUpper(provider))) + } +} + +func TestReportNotifier(t *testing.T) { + r := NewReport(true) + notify := r.Notifier("test-provider") + + ingress := &networkingv1.Ingress{ + TypeMeta: metav1.TypeMeta{Kind: "Ingress"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress", + Namespace: "default", + }, + } + + gateway := &gatewayv1.Gateway{ + TypeMeta: metav1.TypeMeta{Kind: "Gateway"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gw", + Namespace: "prod", + }, + } + + notify(WarningNotification, "host not supported") + notify(InfoNotification, "converted successfully", ingress) + notify(ErrorNotification, "multiple objects involved", ingress, gateway) + + result := r.Render() + assert.Contains(t, result, "source: TEST-PROVIDER") + assert.Contains(t, result, "WARN") + assert.Contains(t, result, "host not supported") + assert.Contains(t, result, "INFO") + assert.Contains(t, result, "converted successfully") + assert.Contains(t, result, "Ingress: default/my-ingress") + assert.Contains(t, result, "ERROR") + assert.Contains(t, result, "multiple objects involved") + assert.Contains(t, result, "Gateway: prod/my-gw") +} + +// Ensure notifications don't "leak" between sources. +func TestReportNotifierScoping(t *testing.T) { + r := NewReport(true) + + notifyA := r.Notifier("providerA") + notifyB := r.Notifier("providerB") + + notifyA(InfoNotification, "msg from A") + notifyB(WarningNotification, "msg from B") + + result := r.Render() + assert.Contains(t, result, "msg from A") + assert.Contains(t, result, "source: PROVIDERA") + assert.Contains(t, result, "msg from B") + assert.Contains(t, result, "source: PROVIDERB") +} diff --git a/pkg/i2gw/provider.go b/pkg/i2gw/provider.go index 4e88308a6..63c18246a 100644 --- a/pkg/i2gw/provider.go +++ b/pkg/i2gw/provider.go @@ -18,9 +18,11 @@ package i2gw import ( "context" + "io" "sync" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/types" @@ -47,6 +49,7 @@ type ProviderConf struct { Client client.Client Namespace string ProviderSpecificFlags map[string]map[string]string + Report *notifications.Report } // The Provider interface specifies the required functionality which needs to be @@ -65,7 +68,7 @@ type CustomResourceReader interface { // ReadResourcesFromFile reads custom resources associated with // the underlying Provider implementation from the file. - ReadResourcesFromFile(ctx context.Context, filename string) error + ReadResourcesFromFile(ctx context.Context, reader io.Reader) error } // The ResourcesToIRConverter interface specifies conversion functions from Ingress @@ -91,7 +94,7 @@ type ProviderImplementationSpecificOptions struct { // // Different FeatureParsers will run in undetermined order. The function must // modify / create only the required fields of the IR and nothing else. -type FeatureParser func([]networkingv1.Ingress, map[types.NamespacedName]map[string]int32, *providerir.ProviderIR) field.ErrorList +type FeatureParser func(notifications.NotifyFunc, []networkingv1.Ingress, map[types.NamespacedName]map[string]int32, *providerir.ProviderIR) field.ErrorList var providerSpecificFlagDefinitions = providerSpecificFlags{ flags: make(map[ProviderName]map[string]ProviderSpecificFlag), diff --git a/pkg/i2gw/provider_intermediate/conversion.go b/pkg/i2gw/provider_intermediate/conversion.go index 0fdcf1460..e10a9c067 100644 --- a/pkg/i2gw/provider_intermediate/conversion.go +++ b/pkg/i2gw/provider_intermediate/conversion.go @@ -18,10 +18,10 @@ package providerir import ( emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate/gce" "k8s.io/apimachinery/pkg/types" ) -// ToEmitterIR converts a ProviderIR to an EmitterIR. func ToEmitterIR(pIR ProviderIR) emitterir.EmitterIR { eIR := emitterir.EmitterIR{ Gateways: make(map[types.NamespacedName]emitterir.GatewayContext), @@ -33,15 +33,20 @@ func ToEmitterIR(pIR ProviderIR) emitterir.EmitterIR { GRPCRoutes: make(map[types.NamespacedName]emitterir.GRPCRouteContext), BackendTLSPolicies: make(map[types.NamespacedName]emitterir.BackendTLSPolicyContext), ReferenceGrants: make(map[types.NamespacedName]emitterir.ReferenceGrantContext), + Services: make(map[types.NamespacedName]emitterir.ServiceContext), + GceServices: make(map[types.NamespacedName]gce.ServiceIR), } for k, v := range pIR.Gateways { - eIR.Gateways[k] = emitterir.GatewayContext{Gateway: v.Gateway} + ctx := emitterir.GatewayContext{Gateway: v.Gateway} + if v.ProviderSpecificIR.Gce != nil { + ctx.Gce = v.ProviderSpecificIR.Gce + } + eIR.Gateways[k] = ctx } for k, v := range pIR.HTTPRoutes { - httpRouteContext := emitterir.HTTPRouteContext{HTTPRoute: v.HTTPRoute} - applyProviderSpecificHTTPRouteIR(&httpRouteContext, v) - eIR.HTTPRoutes[k] = httpRouteContext + ctx := emitterir.HTTPRouteContext{HTTPRoute: v.HTTPRoute} + eIR.HTTPRoutes[k] = ctx } for k, v := range pIR.GatewayClasses { eIR.GatewayClasses[k] = emitterir.GatewayClassContext{GatewayClass: v} @@ -56,7 +61,7 @@ func ToEmitterIR(pIR ProviderIR) emitterir.EmitterIR { eIR.UDPRoutes[k] = emitterir.UDPRouteContext{UDPRoute: v} } for k, v := range pIR.GRPCRoutes { - eIR.GRPCRoutes[k] = emitterir.GRPCRouteContext{GRPCRoute: v} + eIR.GRPCRoutes[k] = emitterir.GRPCRouteContext{GRPCRoute: v.GRPCRoute} } for k, v := range pIR.BackendTLSPolicies { eIR.BackendTLSPolicies[k] = emitterir.BackendTLSPolicyContext{BackendTLSPolicy: v} @@ -64,6 +69,14 @@ func ToEmitterIR(pIR ProviderIR) emitterir.EmitterIR { for k, v := range pIR.ReferenceGrants { eIR.ReferenceGrants[k] = emitterir.ReferenceGrantContext{ReferenceGrant: v} } + for k, v := range pIR.Services { + eIR.Services[k] = emitterir.ServiceContext{ + SessionAffinity: v.SessionAffinity, + } + if v.Gce != nil { + eIR.GceServices[k] = *v.Gce + } + } return eIR } diff --git a/pkg/i2gw/provider_intermediate/conversion_http_route.go b/pkg/i2gw/provider_intermediate/conversion_http_route.go index ef34f338d..f5d08c2b9 100644 --- a/pkg/i2gw/provider_intermediate/conversion_http_route.go +++ b/pkg/i2gw/provider_intermediate/conversion_http_route.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,197 +16,192 @@ limitations under the License. package providerir -import ( - emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" - "k8s.io/apimachinery/pkg/types" -) - -func applyProviderSpecificHTTPRouteIR(out *emitterir.HTTPRouteContext, in HTTPRouteContext) { - out.RuleBackendSources = convertBackendSources(in.RuleBackendSources) - - ingx := in.ProviderSpecificIR.IngressNginx - if ingx == nil { - return - } - - out.RegexLocationForHost = ingx.RegexLocationForHost - out.RegexForcedByUseRegex = ingx.RegexForcedByUseRegex - out.RegexForcedByRewrite = ingx.RegexForcedByRewrite - - if ingx.Policies != nil { - out.PoliciesBySourceIngressName = make(map[string]emitterir.Policy, len(ingx.Policies)) - for ingressName, policy := range ingx.Policies { - out.PoliciesBySourceIngressName[ingressName] = convertIngressNginxPolicy(policy) - } - } -} - -func convertBackendSources(in [][]BackendSource) [][]emitterir.BackendSource { - if in == nil { - return nil - } - out := make([][]emitterir.BackendSource, len(in)) - for i, ruleSources := range in { - out[i] = make([]emitterir.BackendSource, len(ruleSources)) - for j, src := range ruleSources { - out[i][j] = emitterir.BackendSource{ - Ingress: src.Ingress, - Path: src.Path, - DefaultBackend: src.DefaultBackend, - } - } - } - return out -} - -func convertIngressNginxPolicy(in IngressNginxPolicy) emitterir.Policy { - return emitterir.Policy{ - ClientBodyBufferSize: in.ClientBodyBufferSize, - ProxyBodySize: in.ProxyBodySize, - Cors: convertIngressNginxCorsPolicy(in.Cors), - RateLimit: convertIngressNginxRateLimitPolicy(in.RateLimit), - ProxySendTimeout: in.ProxySendTimeout, - ProxyReadTimeout: in.ProxyReadTimeout, - ProxyConnectTimeout: in.ProxyConnectTimeout, - EnableAccessLog: in.EnableAccessLog, - ExtAuth: convertIngressNginxExtAuthPolicy(in.ExtAuth), - BasicAuth: convertIngressNginxBasicAuthPolicy(in.BasicAuth), - SessionAffinity: convertIngressNginxSessionAffinityPolicy(in.SessionAffinity), - LoadBalancing: convertIngressNginxBackendLoadBalancingPolicy(in.LoadBalancing), - BackendTLS: convertIngressNginxBackendTLSPolicy(in.BackendTLS), - BackendProtocol: convertIngressNginxBackendProtocol(in.BackendProtocol), - SSLRedirect: in.SSLRedirect, - RewriteTarget: in.RewriteTarget, - UseRegexPaths: in.UseRegexPaths, - RuleBackendSources: convertIngressNginxPolicyIndices(in.RuleBackendSources), - Backends: convertIngressNginxBackends(in.Backends), - } -} - -func convertIngressNginxPolicyIndices(in []IngressNginxPolicyIndex) []emitterir.PolicyIndex { - if in == nil { - return nil - } - out := make([]emitterir.PolicyIndex, len(in)) - for i := range in { - out[i] = emitterir.PolicyIndex{ - Rule: in[i].Rule, - Backend: in[i].Backend, - } - } - return out -} - -func convertIngressNginxCorsPolicy(in *IngressNginxCorsPolicy) *emitterir.CorsPolicy { - if in == nil { - return nil - } - return &emitterir.CorsPolicy{ - Enable: in.Enable, - AllowOrigin: cloneStringSlice(in.AllowOrigin), - AllowCredentials: in.AllowCredentials, - AllowHeaders: cloneStringSlice(in.AllowHeaders), - ExposeHeaders: cloneStringSlice(in.ExposeHeaders), - AllowMethods: cloneStringSlice(in.AllowMethods), - MaxAge: in.MaxAge, - } -} - -func convertIngressNginxExtAuthPolicy(in *IngressNginxExtAuthPolicy) *emitterir.ExtAuthPolicy { - if in == nil { - return nil - } - return &emitterir.ExtAuthPolicy{ - AuthURL: in.AuthURL, - ResponseHeaders: cloneStringSlice(in.ResponseHeaders), - } -} - -func convertIngressNginxBasicAuthPolicy(in *IngressNginxBasicAuthPolicy) *emitterir.BasicAuthPolicy { - if in == nil { - return nil - } - return &emitterir.BasicAuthPolicy{ - SecretName: in.SecretName, - AuthType: in.AuthType, - } -} - -func convertIngressNginxSessionAffinityPolicy(in *IngressNginxSessionAffinityPolicy) *emitterir.SessionAffinityPolicy { - if in == nil { - return nil - } - return &emitterir.SessionAffinityPolicy{ - CookieName: in.CookieName, - CookiePath: in.CookiePath, - CookieDomain: in.CookieDomain, - CookieSameSite: in.CookieSameSite, - CookieExpires: in.CookieExpires, - CookieSecure: in.CookieSecure, - } -} - -func convertIngressNginxBackendTLSPolicy(in *IngressNginxBackendTLSPolicy) *emitterir.BackendTLSPolicy { - if in == nil { - return nil - } - return &emitterir.BackendTLSPolicy{ - SecretName: in.SecretName, - Verify: in.Verify, - Hostname: in.Hostname, - } -} - -func convertIngressNginxBackendLoadBalancingPolicy(in *IngressNginxBackendLoadBalancingPolicy) *emitterir.BackendLoadBalancingPolicy { - if in == nil { - return nil - } - return &emitterir.BackendLoadBalancingPolicy{ - Strategy: emitterir.LoadBalancingStrategy(in.Strategy), - } -} - -func convertIngressNginxRateLimitPolicy(in *IngressNginxRateLimitPolicy) *emitterir.RateLimitPolicy { - if in == nil { - return nil - } - return &emitterir.RateLimitPolicy{ - Limit: in.Limit, - Unit: emitterir.RateLimitUnit(in.Unit), - BurstMultiplier: in.BurstMultiplier, - } -} - -func convertIngressNginxBackendProtocol(in *IngressNginxBackendProtocol) *emitterir.BackendProtocol { - if in == nil { - return nil - } - value := emitterir.BackendProtocol(*in) - return &value -} - -func convertIngressNginxBackends(in map[types.NamespacedName]IngressNginxBackend) map[types.NamespacedName]emitterir.Backend { - if in == nil { - return nil - } - out := make(map[types.NamespacedName]emitterir.Backend, len(in)) - for key, backend := range in { - out[key] = emitterir.Backend{ - Namespace: backend.Namespace, - Name: backend.Name, - Port: backend.Port, - Host: backend.Host, - Protocol: convertIngressNginxBackendProtocol(backend.Protocol), - } - } - return out -} - -func cloneStringSlice(in []string) []string { - if in == nil { - return nil - } - out := make([]string, len(in)) - copy(out, in) - return out -} +// func applyProviderSpecificHTTPRouteIR(out *emitterir.HTTPRouteContext, in HTTPRouteContext) { +// out.RuleBackendSources = convertBackendSources(in.RuleBackendSources) + +// ingx := in.ProviderSpecificIR.IngressNginx +// if ingx == nil { +// return +// } + +// out.RegexLocationForHost = ingx.RegexLocationForHost +// out.RegexForcedByUseRegex = ingx.RegexForcedByUseRegex +// out.RegexForcedByRewrite = ingx.RegexForcedByRewrite + +// if ingx.Policies != nil { +// out.PoliciesBySourceIngressName = make(map[string]emitterir.Policy, len(ingx.Policies)) +// for ingressName, policy := range ingx.Policies { +// out.PoliciesBySourceIngressName[ingressName] = convertIngressNginxPolicy(policy) +// } +// } +// } + +// func convertBackendSources(in [][]BackendSource) [][]emitterir.BackendSource { +// if in == nil { +// return nil +// } +// out := make([][]emitterir.BackendSource, len(in)) +// for i, ruleSources := range in { +// out[i] = make([]emitterir.BackendSource, len(ruleSources)) +// for j, src := range ruleSources { +// out[i][j] = emitterir.BackendSource{ +// Ingress: src.Ingress, +// Path: src.Path, +// DefaultBackend: src.DefaultBackend, +// } +// } +// } +// return out +// } + +// func convertIngressNginxPolicy(in IngressNginxPolicy) emitterir.Policy { +// return emitterir.Policy{ +// ClientBodyBufferSize: in.ClientBodyBufferSize, +// ProxyBodySize: in.ProxyBodySize, +// Cors: convertIngressNginxCorsPolicy(in.Cors), +// RateLimit: convertIngressNginxRateLimitPolicy(in.RateLimit), +// ProxySendTimeout: in.ProxySendTimeout, +// ProxyReadTimeout: in.ProxyReadTimeout, +// ProxyConnectTimeout: in.ProxyConnectTimeout, +// EnableAccessLog: in.EnableAccessLog, +// ExtAuth: convertIngressNginxExtAuthPolicy(in.ExtAuth), +// BasicAuth: convertIngressNginxBasicAuthPolicy(in.BasicAuth), +// SessionAffinity: convertIngressNginxSessionAffinityPolicy(in.SessionAffinity), +// LoadBalancing: convertIngressNginxBackendLoadBalancingPolicy(in.LoadBalancing), +// BackendTLS: convertIngressNginxBackendTLSPolicy(in.BackendTLS), +// BackendProtocol: convertIngressNginxBackendProtocol(in.BackendProtocol), +// SSLRedirect: in.SSLRedirect, +// RewriteTarget: in.RewriteTarget, +// UseRegexPaths: in.UseRegexPaths, +// RuleBackendSources: convertIngressNginxPolicyIndices(in.RuleBackendSources), +// Backends: convertIngressNginxBackends(in.Backends), +// } +// } + +// func convertIngressNginxPolicyIndices(in []IngressNginxPolicyIndex) []emitterir.PolicyIndex { +// if in == nil { +// return nil +// } +// out := make([]emitterir.PolicyIndex, len(in)) +// for i := range in { +// out[i] = emitterir.PolicyIndex{ +// Rule: in[i].Rule, +// Backend: in[i].Backend, +// } +// } +// return out +// } + +// func convertIngressNginxCorsPolicy(in *IngressNginxCorsPolicy) *emitterir.CorsPolicy { +// if in == nil { +// return nil +// } +// return &emitterir.CorsPolicy{ +// Enable: in.Enable, +// AllowOrigin: cloneStringSlice(in.AllowOrigin), +// AllowCredentials: in.AllowCredentials, +// AllowHeaders: cloneStringSlice(in.AllowHeaders), +// ExposeHeaders: cloneStringSlice(in.ExposeHeaders), +// AllowMethods: cloneStringSlice(in.AllowMethods), +// MaxAge: in.MaxAge, +// } +// } + +// func convertIngressNginxExtAuthPolicy(in *IngressNginxExtAuthPolicy) *emitterir.ExtAuthPolicy { +// if in == nil { +// return nil +// } +// return &emitterir.ExtAuthPolicy{ +// AuthURL: in.AuthURL, +// ResponseHeaders: cloneStringSlice(in.ResponseHeaders), +// } +// } + +// func convertIngressNginxBasicAuthPolicy(in *IngressNginxBasicAuthPolicy) *emitterir.BasicAuthPolicy { +// if in == nil { +// return nil +// } +// return &emitterir.BasicAuthPolicy{ +// SecretName: in.SecretName, +// AuthType: in.AuthType, +// } +// } + +// func convertIngressNginxSessionAffinityPolicy(in *IngressNginxSessionAffinityPolicy) *emitterir.SessionAffinityPolicy { +// if in == nil { +// return nil +// } +// return &emitterir.SessionAffinityPolicy{ +// CookieName: in.CookieName, +// CookiePath: in.CookiePath, +// CookieDomain: in.CookieDomain, +// CookieSameSite: in.CookieSameSite, +// CookieExpires: in.CookieExpires, +// CookieSecure: in.CookieSecure, +// } +// } + +// func convertIngressNginxBackendTLSPolicy(in *IngressNginxBackendTLSPolicy) *emitterir.BackendTLSPolicy { +// if in == nil { +// return nil +// } +// return &emitterir.BackendTLSPolicy{ +// SecretName: in.SecretName, +// Verify: in.Verify, +// Hostname: in.Hostname, +// } +// } + +// func convertIngressNginxBackendLoadBalancingPolicy(in *IngressNginxBackendLoadBalancingPolicy) *emitterir.BackendLoadBalancingPolicy { +// if in == nil { +// return nil +// } +// return &emitterir.BackendLoadBalancingPolicy{ +// Strategy: emitterir.LoadBalancingStrategy(in.Strategy), +// } +// } + +// func convertIngressNginxRateLimitPolicy(in *IngressNginxRateLimitPolicy) *emitterir.RateLimitPolicy { +// if in == nil { +// return nil +// } +// return &emitterir.RateLimitPolicy{ +// Limit: in.Limit, +// Unit: emitterir.RateLimitUnit(in.Unit), +// BurstMultiplier: in.BurstMultiplier, +// } +// } + +// func convertIngressNginxBackendProtocol(in *IngressNginxBackendProtocol) *emitterir.BackendProtocol { +// if in == nil { +// return nil +// } +// value := emitterir.BackendProtocol(*in) +// return &value +// } + +// func convertIngressNginxBackends(in map[types.NamespacedName]IngressNginxBackend) map[types.NamespacedName]emitterir.Backend { +// if in == nil { +// return nil +// } +// out := make(map[types.NamespacedName]emitterir.Backend, len(in)) +// for key, backend := range in { +// out[key] = emitterir.Backend{ +// Namespace: backend.Namespace, +// Name: backend.Name, +// Port: backend.Port, +// Host: backend.Host, +// Protocol: convertIngressNginxBackendProtocol(backend.Protocol), +// } +// } +// return out +// } + +// func cloneStringSlice(in []string) []string { +// if in == nil { +// return nil +// } +// out := make([]string, len(in)) +// copy(out, in) +// return out +// } diff --git a/pkg/i2gw/provider_intermediate/conversion_test.go b/pkg/i2gw/provider_intermediate/conversion_test.go index d1425506b..c1d3a742e 100644 --- a/pkg/i2gw/provider_intermediate/conversion_test.go +++ b/pkg/i2gw/provider_intermediate/conversion_test.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,122 +16,111 @@ limitations under the License. package providerir -import ( - "testing" +// func TestToEmitterIRConvertsIngressNginxPolicy(t *testing.T) { +// routeKey := types.NamespacedName{Namespace: "default", Name: "route"} +// backendKey := types.NamespacedName{Namespace: "default", Name: "backend-a"} +// useRegex := true +// backendProtocol := BackendProtocolGRPC - emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" - networkingv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" - gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" -) +// sourceIR := ProviderIR{ +// HTTPRoutes: map[types.NamespacedName]HTTPRouteContext{ +// routeKey: { +// HTTPRoute: gatewayv1.HTTPRoute{ +// ObjectMeta: metav1.ObjectMeta{Namespace: routeKey.Namespace, Name: routeKey.Name}, +// Spec: gatewayv1.HTTPRouteSpec{ +// Rules: []gatewayv1.HTTPRouteRule{{ +// BackendRefs: []gatewayv1.HTTPBackendRef{{ +// BackendRef: gatewayv1.BackendRef{ +// BackendObjectReference: gatewayv1.BackendObjectReference{ +// Name: "backend-a", +// }, +// }, +// }}, +// }}, +// }, +// }, +// ProviderSpecificIR: ProviderSpecificHTTPRouteIR{ +// IngressNginx: &IngressNginxHTTPRouteIR{ +// Policies: map[string]Policy{ +// "ing-a": { +// Cors: &CorsPolicy{ +// Enable: true, +// AllowOrigin: []string{"https://example.com"}, +// }, +// RateLimit: &RateLimitPolicy{ +// Limit: 10, +// Unit: RateLimitUnitRPM, +// BurstMultiplier: 3, +// }, +// UseRegexPaths: &useRegex, +// RuleBackendSources: []PolicyIndex{ +// {Rule: 0, Backend: 0}, +// }, +// Backends: map[types.NamespacedName]Backend{ +// backendKey: { +// Namespace: backendKey.Namespace, +// Name: backendKey.Name, +// Port: 8080, +// Host: "backend-a.default.svc.cluster.local", +// Protocol: &backendProtocol, +// }, +// }, +// }, +// }, +// RegexLocationForHost: ptr.To(true), +// RegexForcedByUseRegex: true, +// }, +// }, +// RuleBackendSources: [][]BackendSource{ +// {{ +// Ingress: &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "ing-a"}}, +// }}, +// }, +// }, +// }, +// } -func TestToEmitterIRConvertsIngressNginxPolicy(t *testing.T) { - routeKey := types.NamespacedName{Namespace: "default", Name: "route"} - backendKey := types.NamespacedName{Namespace: "default", Name: "backend-a"} - useRegex := true - backendProtocol := BackendProtocolGRPC +// converted := ToEmitterIR(sourceIR) +// routeCtx, ok := converted.HTTPRoutes[routeKey] +// if !ok { +// t.Fatalf("expected converted HTTPRoute %v", routeKey) +// } +// if routeCtx.RegexLocationForHost == nil || !*routeCtx.RegexLocationForHost { +// t.Fatalf("expected RegexLocationForHost=true, got %#v", routeCtx.RegexLocationForHost) +// } - sourceIR := ProviderIR{ - HTTPRoutes: map[types.NamespacedName]HTTPRouteContext{ - routeKey: { - HTTPRoute: gatewayv1.HTTPRoute{ - ObjectMeta: metav1.ObjectMeta{Namespace: routeKey.Namespace, Name: routeKey.Name}, - Spec: gatewayv1.HTTPRouteSpec{ - Rules: []gatewayv1.HTTPRouteRule{{ - BackendRefs: []gatewayv1.HTTPBackendRef{{ - BackendRef: gatewayv1.BackendRef{ - BackendObjectReference: gatewayv1.BackendObjectReference{ - Name: "backend-a", - }, - }, - }}, - }}, - }, - }, - ProviderSpecificIR: ProviderSpecificHTTPRouteIR{ - IngressNginx: &IngressNginxHTTPRouteIR{ - Policies: map[string]Policy{ - "ing-a": { - Cors: &CorsPolicy{ - Enable: true, - AllowOrigin: []string{"https://example.com"}, - }, - RateLimit: &RateLimitPolicy{ - Limit: 10, - Unit: RateLimitUnitRPM, - BurstMultiplier: 3, - }, - UseRegexPaths: &useRegex, - RuleBackendSources: []PolicyIndex{ - {Rule: 0, Backend: 0}, - }, - Backends: map[types.NamespacedName]Backend{ - backendKey: { - Namespace: backendKey.Namespace, - Name: backendKey.Name, - Port: 8080, - Host: "backend-a.default.svc.cluster.local", - Protocol: &backendProtocol, - }, - }, - }, - }, - RegexLocationForHost: ptr.To(true), - RegexForcedByUseRegex: true, - }, - }, - RuleBackendSources: [][]BackendSource{ - {{ - Ingress: &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "ing-a"}}, - }}, - }, - }, - }, - } +// pol, ok := routeCtx.PoliciesBySourceIngressName["ing-a"] +// if !ok { +// t.Fatalf("expected policy for ingress ing-a") +// } +// if pol.RateLimit == nil || pol.RateLimit.Unit != emitterir.RateLimitUnitRPM { +// t.Fatalf("expected rate limit unit %q, got %#v", emitterir.RateLimitUnitRPM, pol.RateLimit) +// } +// if pol.UseRegexPaths == nil || !*pol.UseRegexPaths { +// t.Fatalf("expected UseRegexPaths=true") +// } +// if len(pol.RuleBackendSources) != 1 || pol.RuleBackendSources[0].Rule != 0 || pol.RuleBackendSources[0].Backend != 0 { +// t.Fatalf("unexpected RuleBackendSources: %#v", pol.RuleBackendSources) +// } +// backend, ok := pol.Backends[backendKey] +// if !ok { +// t.Fatalf("expected backend %v", backendKey) +// } +// if backend.Protocol == nil || *backend.Protocol != emitterir.BackendProtocolGRPC { +// t.Fatalf("expected backend protocol %q, got %#v", emitterir.BackendProtocolGRPC, backend.Protocol) +// } - converted := ToEmitterIR(sourceIR) - routeCtx, ok := converted.HTTPRoutes[routeKey] - if !ok { - t.Fatalf("expected converted HTTPRoute %v", routeKey) - } - if routeCtx.RegexLocationForHost == nil || !*routeCtx.RegexLocationForHost { - t.Fatalf("expected RegexLocationForHost=true, got %#v", routeCtx.RegexLocationForHost) - } +// // Ensure slice/map fields are copied, not shared. +// sourcePol := sourceIR.HTTPRoutes[routeKey].ProviderSpecificIR.IngressNginx.Policies["ing-a"] +// sourcePol.Cors.AllowOrigin[0] = "https://mutated.example.com" +// sourceIR.HTTPRoutes[routeKey].ProviderSpecificIR.IngressNginx.Policies["ing-a"] = sourcePol +// if got := pol.Cors.AllowOrigin[0]; got != "https://example.com" { +// t.Fatalf("expected converted policy to retain original allow origin, got %q", got) +// } - pol, ok := routeCtx.PoliciesBySourceIngressName["ing-a"] - if !ok { - t.Fatalf("expected policy for ingress ing-a") - } - if pol.RateLimit == nil || pol.RateLimit.Unit != emitterir.RateLimitUnitRPM { - t.Fatalf("expected rate limit unit %q, got %#v", emitterir.RateLimitUnitRPM, pol.RateLimit) - } - if pol.UseRegexPaths == nil || !*pol.UseRegexPaths { - t.Fatalf("expected UseRegexPaths=true") - } - if len(pol.RuleBackendSources) != 1 || pol.RuleBackendSources[0].Rule != 0 || pol.RuleBackendSources[0].Backend != 0 { - t.Fatalf("unexpected RuleBackendSources: %#v", pol.RuleBackendSources) - } - backend, ok := pol.Backends[backendKey] - if !ok { - t.Fatalf("expected backend %v", backendKey) - } - if backend.Protocol == nil || *backend.Protocol != emitterir.BackendProtocolGRPC { - t.Fatalf("expected backend protocol %q, got %#v", emitterir.BackendProtocolGRPC, backend.Protocol) - } - - // Ensure slice/map fields are copied, not shared. - sourcePol := sourceIR.HTTPRoutes[routeKey].ProviderSpecificIR.IngressNginx.Policies["ing-a"] - sourcePol.Cors.AllowOrigin[0] = "https://mutated.example.com" - sourceIR.HTTPRoutes[routeKey].ProviderSpecificIR.IngressNginx.Policies["ing-a"] = sourcePol - if got := pol.Cors.AllowOrigin[0]; got != "https://example.com" { - t.Fatalf("expected converted policy to retain original allow origin, got %q", got) - } - - // Ensure converted policies retain dedupe behavior for later emitter updates. - updated := pol.AddRuleBackendSources([]emitterir.PolicyIndex{{Rule: 0, Backend: 0}, {Rule: 1, Backend: 0}}) - if len(updated.RuleBackendSources) != 2 { - t.Fatalf("expected deduped rule/backend sources length 2, got %d", len(updated.RuleBackendSources)) - } -} +// // Ensure converted policies retain dedupe behavior for later emitter updates. +// updated := pol.AddRuleBackendSources([]emitterir.PolicyIndex{{Rule: 0, Backend: 0}, {Rule: 1, Backend: 0}}) +// if len(updated.RuleBackendSources) != 2 { +// t.Fatalf("expected deduped rule/backend sources length 2, got %d", len(updated.RuleBackendSources)) +// } +// } diff --git a/pkg/i2gw/provider_intermediate/intermediate_representation.go b/pkg/i2gw/provider_intermediate/intermediate_representation.go index adb40d8b1..061589ff6 100644 --- a/pkg/i2gw/provider_intermediate/intermediate_representation.go +++ b/pkg/i2gw/provider_intermediate/intermediate_representation.go @@ -17,10 +17,9 @@ limitations under the License. package providerir import ( + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate/gce" networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" @@ -40,7 +39,7 @@ type ProviderIR struct { TLSRoutes map[types.NamespacedName]gatewayv1alpha2.TLSRoute TCPRoutes map[types.NamespacedName]gatewayv1alpha2.TCPRoute UDPRoutes map[types.NamespacedName]gatewayv1alpha2.UDPRoute - GRPCRoutes map[types.NamespacedName]gatewayv1.GRPCRoute + GRPCRoutes map[types.NamespacedName]GRPCRouteContext BackendTLSPolicies map[types.NamespacedName]gatewayv1.BackendTLSPolicy ReferenceGrants map[types.NamespacedName]gatewayv1beta1.ReferenceGrant @@ -57,8 +56,7 @@ type GatewayContext struct { } type ProviderSpecificGatewayIR struct { - Gce *gce.GatewayIR - IngressNginx *IngressNginxGatewayIR + Gce *gce.GatewayIR } // HTTPRouteContext contains the Gateway-API HTTPRoute object and HTTPRouteIR, @@ -75,349 +73,26 @@ type HTTPRouteContext struct { } type ProviderSpecificHTTPRouteIR struct { - Gce *gce.HTTPRouteIR - IngressNginx *IngressNginxHTTPRouteIR } -// ProviderSpecificServiceIR contains a dedicated field for each provider to specify their -// extension features on Service. -type ProviderSpecificServiceIR struct { - Gce *gce.ServiceIR - IngressNginx *IngressNginxServiceIR -} - -// IngressNginxGatewayIR is the provider-specific IR for ingress-nginx. -type IngressNginxGatewayIR struct{} - -// IngressNginxHTTPRouteIR contains ingress-nginx-specific fields for HTTPRoute. -type IngressNginxHTTPRouteIR struct { - // Policies keyed by source Ingress name. - Policies map[string]IngressNginxPolicy - - // RegexLocationForHost is true when ingress-nginx would enforce the "~*" (case-insensitive) - // regex location modifier for ALL paths under a host. - // - // Per nginx semantics, this becomes true if ANY ingress for the host has either of the - // following: annotations: - // - // - nginx.ingress.kubernetes.io/use-regex: "true" - // - nginx.ingress.kubernetes.io/rewrite-target set to any value - RegexLocationForHost *bool - - // RegexForcedByUseRegex is true when RegexLocationForHost is true specifically - // because of the nginx.ingress.kubernetes.io/use-regex annotation. - RegexForcedByUseRegex bool - - // RegexForcedByRewrite is true when RegexLocationForHost is true specifically - // because of the nginx.ingress.kubernetes.io/rewrite-target annotation. - RegexForcedByRewrite bool -} - -// IngressNginxServiceIR contains ingress-nginx-specific fields for Service. -type IngressNginxServiceIR struct{} - -// IngressNginxPolicyIndex identifies a (rule, backend) pair within a merged HTTPRoute. -type IngressNginxPolicyIndex struct { - Rule int - Backend int -} - -// IngressNginxCorsPolicy defines a CORS policy that has been extracted from ingress-nginx annotations. -type IngressNginxCorsPolicy struct { - // Enable corresponds to nginx.ingress.kubernetes.io/enable-cors and indicates whether CORS - // is enabled. - Enable bool - - // AllowOrigin corresponds to nginx.ingress.kubernetes.io/cors-allow-origin and controls what - // is the accepted Origin for CORS. - AllowOrigin []string - - // AllowCredentials corresponds to nginx.ingress.kubernetes.io/cors-allow-credentials and controls - // if credentials can be passed during CORS operations. When nil, the provider has not specified a value. - AllowCredentials *bool - - // AllowHeaders corresponds to nginx.ingress.kubernetes.io/cors-allow-headers and controls which - // headers are accepted. Values are stored as raw header names; case-insensitivity is handled by consumers. - AllowHeaders []string - - // ExposeHeaders corresponds to nginx.ingress.kubernetes.io/cors-expose-headers. - // Values are header names as they appeared in the annotation, trimmed of - // surrounding whitespace but otherwise case-preserving. - ExposeHeaders []string - - // AllowMethods corresponds to nginx.ingress.kubernetes.io/cors-allow-methods and controls which methods - // are accepted. Values are stored as raw method names; consumers can normalize/validate. - AllowMethods []string - - // MaxAge corresponds to nginx.ingress.kubernetes.io/cors-max-age, in seconds and controls how long preflight - // requests can be cached. When nil, the provider has not specified a value. - MaxAge *int32 -} - -// IngressNginxExtAuthPolicy defines an external authentication policy that has been extracted from ingress-nginx annotations. -type IngressNginxExtAuthPolicy struct { - // AuthURL defines the URL of an external authentication service. - AuthURL string - // ResponseHeaders defines the headers to pass to backend once authentication request completes. - ResponseHeaders []string -} - -// IngressNginxBasicAuthPolicy defines a basic authentication policy that has been extracted from ingress-nginx annotations. -type IngressNginxBasicAuthPolicy struct { - // SecretName defines the name of the secret containing basic auth credentials. - SecretName string - // AuthType defines the format of the secret: "auth-file" (default) or "auth-map". - // For "auth-file", the secret contains an htpasswd file in a specific key. - // For "auth-map", the keys of the secret are usernames and values are hashed passwords. - AuthType string -} - -// IngressNginxSessionAffinityPolicy defines a session affinity policy that has been extracted from ingress-nginx annotations. -type IngressNginxSessionAffinityPolicy struct { - // CookieName defines the name of the cookie used for session affinity. - CookieName string - // CookiePath defines the path that will be set on the cookie. - CookiePath string - // CookieDomain defines the Domain attribute of the sticky cookie. - CookieDomain string - // CookieSameSite defines the SameSite attribute of the sticky cookie (None, Lax, Strict). - CookieSameSite string - // CookieExpires defines the TTL/expiration time for the cookie. - CookieExpires *metav1.Duration - // CookieSecure defines whether the Secure flag is set on the cookie. - CookieSecure *bool -} - -// IngressNginxBackendTLSPolicy defines a backend TLS policy that has been extracted from ingress-nginx annotations. -type IngressNginxBackendTLSPolicy struct { - // SecretName defines the name of the secret containing client certificate (tls.crt), - // client key (tls.key), and CA certificate (ca.crt) in PEM format. - // Format: "namespace/secretName" - SecretName string - // Verify enables or disables verification of the proxied HTTPS server certificate. - // Default: false (off) - Verify bool - // Hostname allows overriding the server name used to verify the certificate of the proxied HTTPS server. - // This value is also used for SNI when a connection is established. - // In Gateway API, setting Hostname enables SNI automatically. - Hostname string -} - -// IngressNginxPolicy describes all per-Ingress policy knobs that ingress-nginx projects into the -// IR (buffer, CORS, etc.). -type IngressNginxPolicy struct { - // ClientBodyBufferSize defines the size of the buffer used for client request bodies. - ClientBodyBufferSize *resource.Quantity - - // ProxyBodySize defines the maximum allowed size of the client request body. - ProxyBodySize *resource.Quantity - - // Cors defines the CORS policy derived from ingress-nginx annotations. - Cors *IngressNginxCorsPolicy - - // RateLimit is a generic rate limit policy derived from ingress-nginx annotations. - RateLimit *IngressNginxRateLimitPolicy - - // ProxySendTimeout defines the timeout for transmitting a request to the proxied server. - ProxySendTimeout *metav1.Duration - - // ProxyReadTimeout defines the timeout for reading a response from a proxied server. - ProxyReadTimeout *metav1.Duration - - // ProxyConnectTimeout defines the timeout for establishing a connection to a proxied server. - ProxyConnectTimeout *metav1.Duration - - // EnableAccessLog defines whether access logging is enabled for the ingress. - EnableAccessLog *bool - - // ExtAuth defines the external authentication policy. - ExtAuth *IngressNginxExtAuthPolicy - - // BasicAuth defines the basic authentication policy. - BasicAuth *IngressNginxBasicAuthPolicy - - // SessionAffinity defines the session affinity policy. - SessionAffinity *IngressNginxSessionAffinityPolicy - - // LoadBalancing controls the upstream load-balancing algorithm. Only round_robin is supported; - // other values are ignored. - LoadBalancing *IngressNginxBackendLoadBalancingPolicy - - // BackendTLS defines the backend TLS policy. - BackendTLS *IngressNginxBackendTLSPolicy - - // BackendProtocol defines the upstream application protocol to use when communicating with - // backend Services covered by this policy. - BackendProtocol *IngressNginxBackendProtocol - - // SSLRedirect indicates whether SSL redirect is enabled, corresponding to - // nginx.ingress.kubernetes.io/ssl-redirect. When true, requests should be - // redirected to HTTPS. - SSLRedirect *bool - - // RewriteTarget corresponds to nginx.ingress.kubernetes.io/rewrite-target annotation and rewrites the - // path in the request to the path expected by the service. - RewriteTarget *string - - // UseRegexPaths corresponds to nginx.ingress.kubernetes.io/use-regex. - // When true (and host-wide regex mode is enabled), paths contributed by this ingress - // must be treated as regex patterns (i.e. NOT escaped as literals). - UseRegexPaths *bool - - // RuleBackendSources lists the (rule, backend) pairs within a merged HTTPRoute - // that this policy applies to. - // - // Each entry is a IngressNginxPolicyIndex struct identifying a (rule, backend) pair. - // - // This slice may contain duplicates; use AddRuleBackendSources to add entries - // while ensuring uniqueness. - RuleBackendSources []IngressNginxPolicyIndex - - // Backends holds all proxied backends that cannot be rendered as a standard k8s service, i.e. kgateway Backend. - Backends map[types.NamespacedName]IngressNginxBackend - - // ruleBackendIndexSet is an internal helper used to deduplicate RuleBackendSources entries. - ruleBackendIndexSet map[IngressNginxPolicyIndex]struct{} -} - -// IngressNginxBackendProtocol defines the L7 protocol used to talk to a Backend. -type IngressNginxBackendProtocol string - -// IngressNginxBackendProtocolGRPC is the gRPC protocol. -const IngressNginxBackendProtocolGRPC IngressNginxBackendProtocol = "grpc" - -// IngressNginxBackend defines a proxied backend that cannot be rendered as a standard k8s Service. -type IngressNginxBackend struct { - // Namespace defines the namespace of the backend. - Namespace string - - // Name defines the name of the backend. - Name string - - // Port defines the port of the backend. - Port int32 - - // Host defines the host (IP or DNS name) of the backend. - Host string - - // Protocol defines the application protocol used to communicate with the backend. - // When nil, the default HTTP/1.x semantics should be assumed by consumers. - Protocol *IngressNginxBackendProtocol -} - -// IngressNginxRateLimitUnit defines the unit of rate limiting. -type IngressNginxRateLimitUnit string - -const ( - // IngressNginxRateLimitUnitRPS defines rate limit in requests per second. - IngressNginxRateLimitUnitRPS IngressNginxRateLimitUnit = "rps" - // IngressNginxRateLimitUnitRPM defines rate limit in requests per minute. - IngressNginxRateLimitUnitRPM IngressNginxRateLimitUnit = "rpm" -) - -// IngressNginxRateLimitPolicy defines a rate limiting policy derived from ingress-nginx annotations. -type IngressNginxRateLimitPolicy struct { - // Exactly one of RPS/RPM should be set by the provider. - Limit int32 // normalized numeric limit - Unit IngressNginxRateLimitUnit // "rps" or "rpm" - - // BurstMultiplier is applied on top of the base limit to compute the bucket size. - // If zero, treat as 1. - BurstMultiplier int32 -} - -// IngressNginxLoadBalancingStrategy represents the upstream load-balancing mode requested by the Ingress NGINX annotations. -// Currently only round_robin is supported; other values are ignored. -type IngressNginxLoadBalancingStrategy string - -const IngressNginxLoadBalancingStrategyRoundRobin IngressNginxLoadBalancingStrategy = "round_robin" - -type IngressNginxBackendLoadBalancingPolicy struct { - Strategy IngressNginxLoadBalancingStrategy +// GRPCRouteContext contains the Gateway-API GRPCRoute object and GRPCRouteIR, +// which has a dedicated field for each provider to specify their extension +// features on GRPCRoutes. +// The IR will contain necessary information to construct the GRPCRoute +// extensions, but not the extensions themselves. +type GRPCRouteContext struct { + gatewayv1.GRPCRoute + // RuleBackendSources[i][j] is the source of the jth backend in the ith element of GRPCRoute.Spec.Rules. + RuleBackendSources [][]BackendSource } -// AddRuleBackendSources returns a copy of p with idxs added to -// RuleBackendSources, ensuring each (Rule, Backend) pair is unique. -func (p IngressNginxPolicy) AddRuleBackendSources(idxs []IngressNginxPolicyIndex) IngressNginxPolicy { - pCopy := p - - // Initialize the internal set from any existing slice contents. - if len(pCopy.RuleBackendSources) > 0 && pCopy.ruleBackendIndexSet == nil { - pCopy.ruleBackendIndexSet = make(map[IngressNginxPolicyIndex]struct{}, len(pCopy.RuleBackendSources)) - for _, existing := range pCopy.RuleBackendSources { - pCopy.ruleBackendIndexSet[existing] = struct{}{} - } - } - if pCopy.ruleBackendIndexSet == nil { - pCopy.ruleBackendIndexSet = make(map[IngressNginxPolicyIndex]struct{}) - } - - for _, idx := range idxs { - if _, exists := pCopy.ruleBackendIndexSet[idx]; exists { - continue - } - pCopy.RuleBackendSources = append(pCopy.RuleBackendSources, idx) - pCopy.ruleBackendIndexSet[idx] = struct{}{} - } - - return pCopy +// ServiceIR contains a dedicated field for each provider to specify their +// extension features on Service. +type ProviderSpecificServiceIR struct { + SessionAffinity *emitterir.SessionAffinity + Gce *gce.ServiceIR } -// Type aliases for backward compatibility with existing provider code. -// These allow providers to use shorter names while maintaining the prefixed names -// for clarity in the IR structure. - -// Policy is an alias for IngressNginxPolicy -type Policy = IngressNginxPolicy - -// PolicyIndex is an alias for IngressNginxPolicyIndex -type PolicyIndex = IngressNginxPolicyIndex - -// CorsPolicy is an alias for IngressNginxCorsPolicy -type CorsPolicy = IngressNginxCorsPolicy - -// ExtAuthPolicy is an alias for IngressNginxExtAuthPolicy -type ExtAuthPolicy = IngressNginxExtAuthPolicy - -// BasicAuthPolicy is an alias for IngressNginxBasicAuthPolicy -type BasicAuthPolicy = IngressNginxBasicAuthPolicy - -// SessionAffinityPolicy is an alias for IngressNginxSessionAffinityPolicy -type SessionAffinityPolicy = IngressNginxSessionAffinityPolicy - -// BackendTLSPolicy is an alias for IngressNginxBackendTLSPolicy -type BackendTLSPolicy = IngressNginxBackendTLSPolicy - -// BackendProtocol is an alias for IngressNginxBackendProtocol -type BackendProtocol = IngressNginxBackendProtocol - -// BackendProtocolGRPC is an alias for IngressNginxBackendProtocolGRPC -const BackendProtocolGRPC = IngressNginxBackendProtocolGRPC - -// Backend is an alias for IngressNginxBackend -type Backend = IngressNginxBackend - -// RateLimitUnit is an alias for IngressNginxRateLimitUnit -type RateLimitUnit = IngressNginxRateLimitUnit - -// RateLimitUnitRPS is an alias for IngressNginxRateLimitUnitRPS -const RateLimitUnitRPS = IngressNginxRateLimitUnitRPS - -// RateLimitUnitRPM is an alias for IngressNginxRateLimitUnitRPM -const RateLimitUnitRPM = IngressNginxRateLimitUnitRPM - -// RateLimitPolicy is an alias for IngressNginxRateLimitPolicy -type RateLimitPolicy = IngressNginxRateLimitPolicy - -// LoadBalancingStrategy is an alias for IngressNginxLoadBalancingStrategy -type LoadBalancingStrategy = IngressNginxLoadBalancingStrategy - -// LoadBalancingStrategyRoundRobin is an alias for IngressNginxLoadBalancingStrategyRoundRobin -const LoadBalancingStrategyRoundRobin = IngressNginxLoadBalancingStrategyRoundRobin - -// BackendLoadBalancingPolicy is an alias for IngressNginxBackendLoadBalancingPolicy -type BackendLoadBalancingPolicy = IngressNginxBackendLoadBalancingPolicy - // BackendSource tracks the source Ingress resource that contributed // a specific BackendRef to an HTTPRoute rule. type BackendSource struct { diff --git a/pkg/i2gw/provider_intermediate/utils.go b/pkg/i2gw/provider_intermediate/utils.go index d363705ed..97849ff84 100644 --- a/pkg/i2gw/provider_intermediate/utils.go +++ b/pkg/i2gw/provider_intermediate/utils.go @@ -32,10 +32,10 @@ import ( // - GatewayClasses, Routes, and ReferenceGrants are grouped into the same maps // - Gateways may have the same NamespaceName even if they come from different // ingresses, as they have a their GatewayClass' name as name. For this reason, -// if there are mutiple gateways named the same, their listeners are merged into +// if there are multiple gateways named the same, their listeners are merged into // a unique Gateway. // -// This behavior is likely to change after https://github.com/kubernetes-sigs/gateway-api/pull/1863 takes place. +// This behavior is likely to change after https://github.com/kgateway-dev/gateway-api/pull/1863 takes place. func MergeIRs(irs ...ProviderIR) (ProviderIR, field.ErrorList) { mergedIRs := ProviderIR{ Gateways: make(map[types.NamespacedName]GatewayContext), @@ -44,7 +44,7 @@ func MergeIRs(irs ...ProviderIR) (ProviderIR, field.ErrorList) { TLSRoutes: make(map[types.NamespacedName]gatewayv1alpha2.TLSRoute), TCPRoutes: make(map[types.NamespacedName]gatewayv1alpha2.TCPRoute), UDPRoutes: make(map[types.NamespacedName]gatewayv1alpha2.UDPRoute), - GRPCRoutes: make(map[types.NamespacedName]gatewayv1.GRPCRoute), + GRPCRoutes: make(map[types.NamespacedName]GRPCRouteContext), BackendTLSPolicies: make(map[types.NamespacedName]gatewayv1.BackendTLSPolicy), ReferenceGrants: make(map[types.NamespacedName]gatewayv1beta1.ReferenceGrant), } diff --git a/pkg/i2gw/providers/common/converter.go b/pkg/i2gw/providers/common/converter.go index 06db50d69..063bbe4c7 100644 --- a/pkg/i2gw/providers/common/converter.go +++ b/pkg/i2gw/providers/common/converter.go @@ -29,41 +29,58 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" ) +type Protocol string + +const ( + HTTP Protocol = "http" + GRPC Protocol = "grpc" +) + // ToIR converts the received ingresses to providerir.ProviderIR without taking into // consideration any provider specific logic. -func ToIR(ingresses []networkingv1.Ingress, servicePorts map[types.NamespacedName]map[string]int32, options i2gw.ProviderImplementationSpecificOptions) (providerir.ProviderIR, field.ErrorList) { +func ToIR(httpIngresses []networkingv1.Ingress, grpcIngresses []networkingv1.Ingress, servicePorts map[types.NamespacedName]map[string]int32, options i2gw.ProviderImplementationSpecificOptions) (providerir.ProviderIR, field.ErrorList) { aggregator := ingressAggregator{ ruleGroups: map[ruleGroupKey]*ingressRuleGroup{}, servicePorts: servicePorts, } var errs field.ErrorList - for _, ingress := range ingresses { - aggregator.addIngress(ingress) + for _, ingress := range httpIngresses { + aggregator.addIngress(ingress, HTTP) } - if len(errs) > 0 { - return providerir.ProviderIR{}, errs + for _, ingress := range grpcIngresses { + aggregator.addIngress(ingress, GRPC) } - routes, gateways, errs := aggregator.toHTTPRoutesAndGateways(options) + httproutes, grpcroutes, gateways, errs := aggregator.toRoutesAndGateways(options) if len(errs) > 0 { return providerir.ProviderIR{}, errs } - routeByKey := make(map[types.NamespacedName]providerir.HTTPRouteContext) - for _, routeWithSources := range routes { + httpRouteByKey := make(map[types.NamespacedName]providerir.HTTPRouteContext) + for _, routeWithSources := range httproutes { key := types.NamespacedName{Namespace: routeWithSources.route.Namespace, Name: routeWithSources.route.Name} - routeByKey[key] = providerir.HTTPRouteContext{ + httpRouteByKey[key] = providerir.HTTPRouteContext{ HTTPRoute: routeWithSources.route, RuleBackendSources: routeWithSources.sources, } } + grpcRouteByKey := make(map[types.NamespacedName]providerir.GRPCRouteContext) + for _, routeWithSources := range grpcroutes { + key := types.NamespacedName{Namespace: routeWithSources.route.Namespace, Name: routeWithSources.route.Name} + grpcRouteByKey[key] = providerir.GRPCRouteContext{ + GRPCRoute: routeWithSources.route, + RuleBackendSources: routeWithSources.sources, + } + } + gatewayByKey := make(map[types.NamespacedName]providerir.GatewayContext) for _, gateway := range gateways { key := types.NamespacedName{Namespace: gateway.Namespace, Name: gateway.Name} @@ -72,13 +89,13 @@ func ToIR(ingresses []networkingv1.Ingress, servicePorts map[types.NamespacedNam return providerir.ProviderIR{ Gateways: gatewayByKey, - HTTPRoutes: routeByKey, + HTTPRoutes: httpRouteByKey, Services: make(map[types.NamespacedName]providerir.ProviderSpecificServiceIR), GatewayClasses: make(map[types.NamespacedName]gatewayv1.GatewayClass), TLSRoutes: make(map[types.NamespacedName]gatewayv1alpha2.TLSRoute), TCPRoutes: make(map[types.NamespacedName]gatewayv1alpha2.TCPRoute), UDPRoutes: make(map[types.NamespacedName]gatewayv1alpha2.UDPRoute), - GRPCRoutes: make(map[types.NamespacedName]gatewayv1.GRPCRoute), + GRPCRoutes: grpcRouteByKey, BackendTLSPolicies: make(map[types.NamespacedName]gatewayv1.BackendTLSPolicy), ReferenceGrants: make(map[types.NamespacedName]gatewayv1beta1.ReferenceGrant), }, nil @@ -114,6 +131,12 @@ var ( Version: "v1beta1", Kind: "ReferenceGrant", } + + GRPCRouteGVK = schema.GroupVersionKind{ + Group: "gateway.networking.k8s.io", + Version: "v1", + Kind: "GRPCRoute", + } ) type ruleGroupKey string @@ -133,6 +156,7 @@ type ingressRuleGroup struct { host string tls []networkingv1.IngressTLS rules []ingressRule + protocol Protocol } type ingressRule struct { @@ -159,10 +183,10 @@ type ingressPath struct { sourceIngress *networkingv1.Ingress } -func (a *ingressAggregator) addIngress(ingress networkingv1.Ingress) { +func (a *ingressAggregator) addIngress(ingress networkingv1.Ingress, protocol Protocol) { ingressClass := GetIngressClass(ingress) for _, rule := range ingress.Spec.Rules { - a.addIngressRule(ingress, ingressClass, rule) + a.addIngressRule(ingress, ingressClass, rule, protocol) } if ingress.Spec.DefaultBackend != nil { a.defaultBackends = append(a.defaultBackends, ingressDefaultBackend{ @@ -175,8 +199,8 @@ func (a *ingressAggregator) addIngress(ingress networkingv1.Ingress) { } } -func (a *ingressAggregator) addIngressRule(ingress networkingv1.Ingress, ingressClass string, rule networkingv1.IngressRule) { - rgKey := ruleGroupKey(fmt.Sprintf("%s/%s/%s", ingress.Namespace, ingressClass, rule.Host)) +func (a *ingressAggregator) addIngressRule(ingress networkingv1.Ingress, ingressClass string, rule networkingv1.IngressRule, protocol Protocol) { + rgKey := ruleGroupKey(fmt.Sprintf("%s/%s/%s/%s", ingress.Namespace, ingressClass, rule.Host, protocol)) rg, ok := a.ruleGroups[rgKey] if !ok { rg = &ingressRuleGroup{ @@ -184,6 +208,7 @@ func (a *ingressAggregator) addIngressRule(ingress networkingv1.Ingress, ingress name: ingress.Name, ingressClass: ingressClass, host: rule.Host, + protocol: protocol, } a.ruleGroups[rgKey] = rg } @@ -201,8 +226,14 @@ type httpRouteWithSources struct { sources [][]providerir.BackendSource } -func (a *ingressAggregator) toHTTPRoutesAndGateways(options i2gw.ProviderImplementationSpecificOptions) ([]httpRouteWithSources, []gatewayv1.Gateway, field.ErrorList) { +type grpcRouteWithSources struct { + route gatewayv1.GRPCRoute + sources [][]providerir.BackendSource +} + +func (a *ingressAggregator) toRoutesAndGateways(options i2gw.ProviderImplementationSpecificOptions) ([]httpRouteWithSources, []grpcRouteWithSources, []gatewayv1.Gateway, field.ErrorList) { var httpRoutes []httpRouteWithSources + var grpcRoutes []grpcRouteWithSources var errors field.ErrorList listenersByNamespacedGateway := map[string][]gatewayv1.Listener{} @@ -227,15 +258,29 @@ func (a *ingressAggregator) toHTTPRoutesAndGateways(options i2gw.ProviderImpleme if len(rg.tls) > 0 { listener.TLS = &gatewayv1.ListenerTLSConfig{} } + certNames := map[string]struct{}{} for _, tls := range rg.tls { + certNames[tls.SecretName] = struct{}{} + } + for certName := range certNames { listener.TLS.CertificateRefs = append(listener.TLS.CertificateRefs, - gatewayv1.SecretObjectReference{Name: gatewayv1.ObjectName(tls.SecretName)}) + gatewayv1.SecretObjectReference{ + Group: ptr.To(gatewayv1.Group("")), + Kind: ptr.To(gatewayv1.Kind("Secret")), + Name: gatewayv1.ObjectName(certName), + }) } gwKey := fmt.Sprintf("%s/%s", rg.namespace, rg.ingressClass) listenersByNamespacedGateway[gwKey] = append(listenersByNamespacedGateway[gwKey], listener) - httpRoute, sources, errs := rg.toHTTPRoute(a.servicePorts, options) - httpRoutes = append(httpRoutes, httpRouteWithSources{route: httpRoute, sources: sources}) - errors = append(errors, errs...) + if rg.protocol == HTTP { + httpRoute, sources, errs := rg.toHTTPRoute(a.servicePorts, options) + httpRoutes = append(httpRoutes, httpRouteWithSources{route: httpRoute, sources: sources}) + errors = append(errors, errs...) + } else { + grpcRoute, sources, errs := rg.toGRPCRoute(a.servicePorts, options) + grpcRoutes = append(grpcRoutes, grpcRouteWithSources{route: grpcRoute, sources: sources}) + errors = append(errors, errs...) + } } for i, db := range a.defaultBackends { @@ -268,6 +313,9 @@ func (a *ingressAggregator) toHTTPRoutesAndGateways(options i2gw.ProviderImpleme BackendRefs: []gatewayv1.HTTPBackendRef{{BackendRef: *backendRef}}, }) } + for idx := range httpRoute.Spec.Rules { + httpRoute.Spec.Rules[idx].Name = ptr.To(gatewayv1.SectionName(fmt.Sprintf("rule-%d", idx))) + } // Set the single source for this default backend. sources := [][]providerir.BackendSource{ { @@ -301,28 +349,61 @@ func (a *ingressAggregator) toHTTPRoutesAndGateways(options i2gw.ProviderImpleme gateway.SetGroupVersionKind(GatewayGVK) gatewaysByKey[gwKey] = gateway } - for _, listener := range listeners { + uniqueListeners := make(map[gatewayv1.SectionName]*gatewayv1.Listener) + var orderedNames []gatewayv1.SectionName + + for _, l := range listeners { var listenerNamePrefix string - if listener.Hostname != nil && *listener.Hostname != "" { - listenerNamePrefix = fmt.Sprintf("%s-", NameFromHost(string(*listener.Hostname))) + if l.Hostname != nil && *l.Hostname != "" { + listenerNamePrefix = fmt.Sprintf("%s-", NameFromHost(string(*l.Hostname))) } - gateway.Spec.Listeners = append(gateway.Spec.Listeners, gatewayv1.Listener{ - Name: gatewayv1.SectionName(fmt.Sprintf("%shttp", listenerNamePrefix)), - Hostname: listener.Hostname, - Port: 80, - Protocol: gatewayv1.HTTPProtocolType, - }) - if listener.TLS != nil { - gateway.Spec.Listeners = append(gateway.Spec.Listeners, gatewayv1.Listener{ - Name: gatewayv1.SectionName(fmt.Sprintf("%shttps", listenerNamePrefix)), - Hostname: listener.Hostname, - Port: 443, - Protocol: gatewayv1.HTTPSProtocolType, - TLS: listener.TLS, - }) + // Add/Update HTTP listener + httpName := gatewayv1.SectionName(fmt.Sprintf("%shttp", listenerNamePrefix)) + if _, exists := uniqueListeners[httpName]; !exists { + uniqueListeners[httpName] = &gatewayv1.Listener{ + Name: httpName, + Hostname: l.Hostname, + Port: 80, + Protocol: gatewayv1.HTTPProtocolType, + } + orderedNames = append(orderedNames, httpName) + } + + // Add/Update HTTPS listener + if l.TLS != nil { + httpsName := gatewayv1.SectionName(fmt.Sprintf("%shttps", listenerNamePrefix)) + if _, exists := uniqueListeners[httpsName]; !exists { + uniqueListeners[httpsName] = &gatewayv1.Listener{ + Name: httpsName, + Hostname: l.Hostname, + Port: 443, + Protocol: gatewayv1.HTTPSProtocolType, + TLS: &gatewayv1.ListenerTLSConfig{}, + } + orderedNames = append(orderedNames, httpsName) + } + // Merge CertificateRefs + uniqueListeners[httpsName].TLS.CertificateRefs = append(uniqueListeners[httpsName].TLS.CertificateRefs, l.TLS.CertificateRefs...) } } + + for _, name := range orderedNames { + l := uniqueListeners[name] + // Final deduplication of certificates for this listener + if l.TLS != nil && len(l.TLS.CertificateRefs) > 0 { + uniqueRefs := make(map[gatewayv1.ObjectName]bool) + var certificates []gatewayv1.SecretObjectReference + for _, ref := range l.TLS.CertificateRefs { + if !uniqueRefs[ref.Name] { + certificates = append(certificates, ref) + uniqueRefs[ref.Name] = true + } + } + l.TLS.CertificateRefs = certificates + } + gateway.Spec.Listeners = append(gateway.Spec.Listeners, *l) + } } var gateways []gatewayv1.Gateway @@ -330,7 +411,7 @@ func (a *ingressAggregator) toHTTPRoutesAndGateways(options i2gw.ProviderImpleme gateways = append(gateways, *gw) } - return httpRoutes, gateways, errors + return httpRoutes, grpcRoutes, gateways, errors } func (rg *ingressRuleGroup) toHTTPRoute(servicePorts map[types.NamespacedName]map[string]int32, options i2gw.ProviderImplementationSpecificOptions) (gatewayv1.HTTPRoute, [][]providerir.BackendSource, field.ErrorList) { @@ -380,6 +461,10 @@ func (rg *ingressRuleGroup) toHTTPRoute(servicePorts map[types.NamespacedName]ma allRuleBackendSources = append(allRuleBackendSources, sources) } + for idx := range httpRoute.Spec.Rules { + httpRoute.Spec.Rules[idx].Name = ptr.To(gatewayv1.SectionName(fmt.Sprintf("rule-%d", idx))) + } + return httpRoute, allRuleBackendSources, errors } @@ -448,3 +533,92 @@ func toHTTPRouteMatch(routePath networkingv1.HTTPIngressPath, path *field.Path, return match, nil } + +func (rg *ingressRuleGroup) toGRPCRoute(servicePorts map[types.NamespacedName]map[string]int32, _ i2gw.ProviderImplementationSpecificOptions) (gatewayv1.GRPCRoute, [][]providerir.BackendSource, field.ErrorList) { + // Parse paths to create proper GRPCRouteMatches + ingressPathsByMatchKey := groupIngressPathsByMatchKey(rg.rules) + + grpcRoute := gatewayv1.GRPCRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: RouteName(rg.name, rg.host), + Namespace: rg.namespace, + }, + Spec: gatewayv1.GRPCRouteSpec{}, + } + grpcRoute.SetGroupVersionKind(GRPCRouteGVK) + + if rg.ingressClass != "" { + grpcRoute.Spec.ParentRefs = []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName(rg.ingressClass)}} + } + if rg.host != "" { + grpcRoute.Spec.Hostnames = []gatewayv1.Hostname{gatewayv1.Hostname(rg.host)} + } + + var errors field.ErrorList + var allRuleBackendSources [][]providerir.BackendSource + + for _, key := range ingressPathsByMatchKey.keys { + paths := ingressPathsByMatchKey.data[key] + path := paths[0] + fieldPath := field.NewPath("spec", "rules").Index(path.ruleIdx).Child(path.ruleType).Child("paths").Index(path.pathIdx) + + // Parse the path to create a GRPCRouteMatch + match := toGRPCRouteMatch(path.path, fieldPath) + + grpcRule := gatewayv1.GRPCRouteRule{} + // Only add matches if there's actually something to match (service or method) + if match.Method != nil { + grpcRule.Matches = []gatewayv1.GRPCRouteMatch{*match} + } + + backendRefs, sources, errs := rg.configureGRPCBackendRef(servicePorts, paths) + errors = append(errors, errs...) + grpcRule.BackendRefs = backendRefs + + grpcRoute.Spec.Rules = append(grpcRoute.Spec.Rules, grpcRule) + allRuleBackendSources = append(allRuleBackendSources, sources) + } + + return grpcRoute, allRuleBackendSources, errors +} + +func (rg *ingressRuleGroup) configureGRPCBackendRef(servicePorts map[types.NamespacedName]map[string]int32, paths []ingressPath) ([]gatewayv1.GRPCBackendRef, []providerir.BackendSource, field.ErrorList) { + var errors field.ErrorList + var backendRefs []gatewayv1.GRPCBackendRef + var sources []providerir.BackendSource + + for i, path := range paths { + backendRef, err := ToBackendRef(rg.namespace, path.path.Backend, servicePorts, field.NewPath("paths", "backends").Index(i)) + if err != nil { + errors = append(errors, err) + continue + } + backendRefs = append(backendRefs, gatewayv1.GRPCBackendRef{BackendRef: *backendRef}) + + // Track source for this backend + sources = append(sources, providerir.BackendSource{ + Ingress: path.sourceIngress, + Path: &path.path, + }) + } + + // keep duplicates as they might have different sources. + return backendRefs, sources, errors +} + +func toGRPCRouteMatch(routePath networkingv1.HTTPIngressPath, _ *field.Path) *gatewayv1.GRPCRouteMatch { + // Parse the path to extract service and method + // Example: /hello.HelloService/SayHello -> service="hello.HelloService", method="SayHello" + service, method := ParseGRPCServiceMethod(routePath.Path) + match := &gatewayv1.GRPCRouteMatch{} + if service != "" || method != "" { + match.Method = &gatewayv1.GRPCMethodMatch{} + if service != "" { + match.Method.Service = &service + } + if method != "" { + match.Method.Method = &method + } + } + return match +} diff --git a/pkg/i2gw/providers/common/converter_test.go b/pkg/i2gw/providers/common/converter_test.go index 3e91514b5..903abaf13 100644 --- a/pkg/i2gw/providers/common/converter_test.go +++ b/pkg/i2gw/providers/common/converter_test.go @@ -29,6 +29,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) @@ -41,6 +42,7 @@ func Test_ToIR(t *testing.T) { testCases := []struct { name string ingresses []networkingv1.Ingress + grpcIngresses []networkingv1.Ingress servicePorts map[types.NamespacedName]map[string]int32 expectedIR providerir.ProviderIR expectedErrors field.ErrorList @@ -109,6 +111,7 @@ func Test_ToIR(t *testing.T) { }, Hostnames: []gatewayv1.Hostname{"example.com"}, Rules: []gatewayv1.HTTPRouteRule{{ + Name: ptr.To(gatewayv1.SectionName("rule-0")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -182,7 +185,9 @@ func Test_ToIR(t *testing.T) { Hostname: PtrTo(gatewayv1.Hostname("example.com")), TLS: &gatewayv1.ListenerTLSConfig{ CertificateRefs: []gatewayv1.SecretObjectReference{{ - Name: "example-cert", + Group: ptr.To(gatewayv1.Group("")), + Kind: ptr.To(gatewayv1.Kind("Secret")), + Name: "example-cert", }}, }, }}, @@ -202,6 +207,7 @@ func Test_ToIR(t *testing.T) { }, Hostnames: []gatewayv1.Hostname{"example.com"}, Rules: []gatewayv1.HTTPRouteRule{{ + Name: ptr.To(gatewayv1.SectionName("rule-0")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -224,6 +230,108 @@ func Test_ToIR(t *testing.T) { }, expectedErrors: field.ErrorList{}, }, + { + name: "ingress with duplicate TLS secret names deduplicates CertificateRefs", + ingresses: []networkingv1.Ingress{{ + ObjectMeta: metav1.ObjectMeta{Name: "dup-tls", Namespace: "test"}, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{ + { + Hosts: []string{"foo.example.com"}, + SecretName: "shared-cert", + }, + { + Hosts: []string{"bar.example.com"}, + SecretName: "shared-cert", + }, + }, + Rules: []networkingv1.IngressRule{{ + Host: "foo.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: &iPrefix, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "foo-svc", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }}, + }, + }, + }}, + IngressClassName: PtrTo("dup-tls"), + }, + }}, + servicePorts: map[types.NamespacedName]map[string]int32{}, + expectedIR: providerir.ProviderIR{ + Gateways: map[types.NamespacedName]providerir.GatewayContext{ + {Namespace: "test", Name: "dup-tls"}: { + Gateway: gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "dup-tls", Namespace: "test"}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "dup-tls", + Listeners: []gatewayv1.Listener{{ + Name: "foo-example-com-http", + Port: 80, + Protocol: gatewayv1.HTTPProtocolType, + Hostname: PtrTo(gatewayv1.Hostname("foo.example.com")), + }, { + Name: "foo-example-com-https", + Port: 443, + Protocol: gatewayv1.HTTPSProtocolType, + Hostname: PtrTo(gatewayv1.Hostname("foo.example.com")), + TLS: &gatewayv1.ListenerTLSConfig{ + CertificateRefs: []gatewayv1.SecretObjectReference{{ + Group: ptr.To(gatewayv1.Group("")), + Kind: ptr.To(gatewayv1.Kind("Secret")), + Name: "shared-cert", + }}, + }, + }}, + }, + }, + }, + }, + HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{ + {Namespace: "test", Name: "dup-tls-foo-example-com"}: { + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "dup-tls-foo-example-com", Namespace: "test"}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{ + Name: "dup-tls", + }}, + }, + Hostnames: []gatewayv1.Hostname{"foo.example.com"}, + Rules: []gatewayv1.HTTPRouteRule{{ + Name: ptr.To(gatewayv1.SectionName("rule-0")), + Matches: []gatewayv1.HTTPRouteMatch{{ + Path: &gatewayv1.HTTPPathMatch{ + Type: &gPathPrefix, + Value: PtrTo("/"), + }, + }}, + BackendRefs: []gatewayv1.HTTPBackendRef{{ + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "foo-svc", + Port: PtrTo(gatewayv1.PortNumber(80)), + }, + }, + }}, + }}, + }, + }, + }, + }, + }, + expectedErrors: field.ErrorList{}, + }, { name: "ingress with custom and default backend", ingresses: []networkingv1.Ingress{{ @@ -288,6 +396,7 @@ func Test_ToIR(t *testing.T) { }, Hostnames: []gatewayv1.Hostname{"example.net"}, Rules: []gatewayv1.HTTPRouteRule{{ + Name: ptr.To(gatewayv1.SectionName("rule-0")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gExact, @@ -317,6 +426,7 @@ func Test_ToIR(t *testing.T) { }}, }, Rules: []gatewayv1.HTTPRouteRule{{ + Name: ptr.To(gatewayv1.SectionName("rule-0")), BackendRefs: []gatewayv1.HTTPBackendRef{{ BackendRef: gatewayv1.BackendRef{ BackendObjectReference: gatewayv1.BackendObjectReference{ @@ -393,6 +503,7 @@ func Test_ToIR(t *testing.T) { }, Hostnames: []gatewayv1.Hostname{"example.com"}, Rules: []gatewayv1.HTTPRouteRule{{ + Name: ptr.To(gatewayv1.SectionName("rule-0")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -451,12 +562,169 @@ func Test_ToIR(t *testing.T) { }, expectedErrors: field.ErrorList{field.Invalid(field.NewPath(""), "", "")}, }, + { + name: "simple grpc ingress", + grpcIngresses: []networkingv1.Ingress{{ + ObjectMeta: metav1.ObjectMeta{Name: "grpc", Namespace: "test"}, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "grpc.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/grpc.service/Method", + PathType: &iPrefix, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "grpc-service", + Port: networkingv1.ServiceBackendPort{ + Number: 50051, + }, + }, + }, + }}, + }, + }, + }}, + IngressClassName: PtrTo("grpc-class"), + }, + }}, + servicePorts: map[types.NamespacedName]map[string]int32{}, + expectedIR: providerir.ProviderIR{ + Gateways: map[types.NamespacedName]providerir.GatewayContext{ + {Namespace: "test", Name: "grpc-class"}: { + Gateway: gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "grpc-class", Namespace: "test"}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "grpc-class", + Listeners: []gatewayv1.Listener{{ + Name: "grpc-example-com-http", + Port: 80, + Protocol: gatewayv1.HTTPProtocolType, + Hostname: PtrTo(gatewayv1.Hostname("grpc.example.com")), + }}, + }, + }, + }, + }, + GRPCRoutes: map[types.NamespacedName]providerir.GRPCRouteContext{ + {Namespace: "test", Name: "grpc-grpc-example-com"}: { + GRPCRoute: gatewayv1.GRPCRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "grpc-grpc-example-com", Namespace: "test"}, + Spec: gatewayv1.GRPCRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{ + Name: "grpc-class", + }}, + }, + Hostnames: []gatewayv1.Hostname{"grpc.example.com"}, + Rules: []gatewayv1.GRPCRouteRule{{ + Matches: []gatewayv1.GRPCRouteMatch{{ + Method: &gatewayv1.GRPCMethodMatch{ + Service: PtrTo("grpc.service"), + Method: PtrTo("Method"), + }, + }}, + BackendRefs: []gatewayv1.GRPCBackendRef{{ + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "grpc-service", + Port: PtrTo(gatewayv1.PortNumber(50051)), + }, + }, + }}, + }}, + }, + }, + }, + }, + }, + expectedErrors: field.ErrorList{}, + }, + { + name: "grpc ingress with service path", + grpcIngresses: []networkingv1.Ingress{{ + ObjectMeta: metav1.ObjectMeta{Name: "grpcbin", Namespace: "default"}, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "grpcbin.local", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/hello.HelloService/", + PathType: &iPrefix, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "grpcbin", + Port: networkingv1.ServiceBackendPort{ + Number: 9000, + }, + }, + }, + }}, + }, + }, + }}, + IngressClassName: PtrTo("nginx"), + }, + }}, + servicePorts: map[types.NamespacedName]map[string]int32{}, + expectedIR: providerir.ProviderIR{ + Gateways: map[types.NamespacedName]providerir.GatewayContext{ + {Namespace: "default", Name: "nginx"}: { + Gateway: gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx", Namespace: "default"}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "nginx", + Listeners: []gatewayv1.Listener{{ + Name: "grpcbin-local-http", + Port: 80, + Protocol: gatewayv1.HTTPProtocolType, + Hostname: PtrTo(gatewayv1.Hostname("grpcbin.local")), + }}, + }, + }, + }, + }, + GRPCRoutes: map[types.NamespacedName]providerir.GRPCRouteContext{ + {Namespace: "default", Name: "grpcbin-grpcbin-local"}: { + GRPCRoute: gatewayv1.GRPCRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "grpcbin-grpcbin-local", Namespace: "default"}, + Spec: gatewayv1.GRPCRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{ + Name: "nginx", + }}, + }, + Hostnames: []gatewayv1.Hostname{"grpcbin.local"}, + Rules: []gatewayv1.GRPCRouteRule{{ + Matches: []gatewayv1.GRPCRouteMatch{{ + Method: &gatewayv1.GRPCMethodMatch{ + Service: PtrTo("hello.HelloService"), + }, + }}, + BackendRefs: []gatewayv1.GRPCBackendRef{{ + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "grpcbin", + Port: PtrTo(gatewayv1.PortNumber(9000)), + }, + }, + }}, + }}, + }, + }, + }, + }, + }, + expectedErrors: field.ErrorList{}, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ir, errs := ToIR(tc.ingresses, tc.servicePorts, i2gw.ProviderImplementationSpecificOptions{}) + ir, errs := ToIR(tc.ingresses, tc.grpcIngresses, tc.servicePorts, i2gw.ProviderImplementationSpecificOptions{}) if len(ir.HTTPRoutes) != len(tc.expectedIR.HTTPRoutes) { t.Errorf("Expected %d HTTPRoutes, got %d: %+v", @@ -472,6 +740,20 @@ func Test_ToIR(t *testing.T) { } } + if len(ir.GRPCRoutes) != len(tc.expectedIR.GRPCRoutes) { + t.Errorf("Expected %d GRPCRoutes, got %d: %+v", + len(tc.expectedIR.GRPCRoutes), len(ir.GRPCRoutes), ir.GRPCRoutes) + } else { + for i, gotGRPCRouteContext := range ir.GRPCRoutes { + key := types.NamespacedName{Namespace: gotGRPCRouteContext.GRPCRoute.Namespace, Name: gotGRPCRouteContext.GRPCRoute.Name} + wantGRPCRouteContext := tc.expectedIR.GRPCRoutes[key] + wantGRPCRouteContext.GRPCRoute.SetGroupVersionKind(GRPCRouteGVK) + if !apiequality.Semantic.DeepEqual(gotGRPCRouteContext.GRPCRoute, wantGRPCRouteContext.GRPCRoute) { + t.Errorf("Expected GRPCRoute %s to be %+v\n Got: %+v\n Diff: %s", i, wantGRPCRouteContext.GRPCRoute, gotGRPCRouteContext.GRPCRoute, cmp.Diff(wantGRPCRouteContext.GRPCRoute, gotGRPCRouteContext.GRPCRoute)) + } + } + } + if len(ir.Gateways) != len(tc.expectedIR.Gateways) { t.Errorf("Expected %d Gateways, got %d: %+v", len(tc.expectedIR.Gateways), len(ir.Gateways), ir.Gateways) diff --git a/pkg/i2gw/providers/common/resource_reader.go b/pkg/i2gw/providers/common/resource_reader.go index f1622eb78..dde35e21d 100644 --- a/pkg/i2gw/providers/common/resource_reader.go +++ b/pkg/i2gw/providers/common/resource_reader.go @@ -17,12 +17,10 @@ limitations under the License. package common import ( - "bytes" "context" "errors" "fmt" "io" - "os" apiv1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" @@ -32,6 +30,7 @@ import ( "k8s.io/apimachinery/pkg/util/sets" kubeyaml "k8s.io/apimachinery/pkg/util/yaml" "sigs.k8s.io/controller-runtime/pkg/client" + "k8s.io/apimachinery/pkg/runtime/schema" ) // ReadIngressesFromCluster reads Ingress resources from the cluster, filtering by the specified ingress classes. @@ -53,14 +52,8 @@ func ReadIngressesFromCluster(ctx context.Context, client client.Client, ingress return ingresses, nil } -// ReadIngressesFromFile reads Ingress resources from a file, filtering by the specified ingress classes. -func ReadIngressesFromFile(filename, namespace string, ingressClasses sets.Set[string]) (map[types.NamespacedName]*networkingv1.Ingress, error) { - stream, err := os.ReadFile(filename) - if err != nil { - return nil, fmt.Errorf("failed to read file %v: %w", filename, err) - } - - unstructuredObjects, err := ExtractObjectsFromReader(bytes.NewReader(stream), namespace) +func ReadIngressesFromFile(reader io.Reader, namespace string, ingressClasses sets.Set[string]) (map[types.NamespacedName]*networkingv1.Ingress, error) { + unstructuredObjects, err := ExtractObjectsFromReader(reader, namespace) if err != nil { return nil, fmt.Errorf("failed to extract objects: %w", err) } @@ -100,14 +93,8 @@ func ReadServicesFromCluster(ctx context.Context, client client.Client) (map[typ return services, nil } -// ReadServicesFromFile reads Service resources from a file. -func ReadServicesFromFile(filename, namespace string) (map[types.NamespacedName]*apiv1.Service, error) { - stream, err := os.ReadFile(filename) - if err != nil { - return nil, fmt.Errorf("failed to read file %v: %w", filename, err) - } - - unstructuredObjects, err := ExtractObjectsFromReader(bytes.NewReader(stream), namespace) +func ReadServicesFromFile(reader io.Reader, namespace string) (map[types.NamespacedName]*apiv1.Service, error) { + unstructuredObjects, err := ExtractObjectsFromReader(reader, namespace) if err != nil { return nil, fmt.Errorf("failed to extract objects: %w", err) } @@ -175,3 +162,46 @@ func ExtractObjectsFromReader(reader io.Reader, namespace string) ([]*unstructur return finalObjs, nil } +// ReadVirtualServicesFromFile reads VirtualService objects from a file reader +func ReadVirtualServicesFromFile(reader io.Reader, namespace string) (map[types.NamespacedName]*unstructured.Unstructured, error) { + unstructuredObjects, err := ExtractObjectsFromReader(reader, namespace) + if err != nil { + return nil, fmt.Errorf("failed to extract objects: %w", err) + } + + virtualServices := map[types.NamespacedName]*unstructured.Unstructured{} + for _, f := range unstructuredObjects { + if !f.GroupVersionKind().Empty() && f.GroupVersionKind().Kind == "VirtualService" { + ns := f.GetNamespace() + if ns == "" { + ns = namespace + } + virtualServices[types.NamespacedName{Namespace: ns, Name: f.GetName()}] = f + } + } + + return virtualServices, nil +} + +// ReadVirtualServicesFromCluster reads VirtualService objects from the cluster +func ReadVirtualServicesFromCluster(ctx context.Context, client client.Client) (map[types.NamespacedName]*unstructured.Unstructured, error) { + var virtualServiceList unstructured.UnstructuredList + virtualServiceList.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "gateway.solo.io", + Version: "v1", + Kind: "VirtualService", + }) + + err := client.List(ctx, &virtualServiceList) + if err != nil { + return nil, fmt.Errorf("failed to list VirtualServices from cluster: %w", err) + } + + virtualServices := map[types.NamespacedName]*unstructured.Unstructured{} + for i := range virtualServiceList.Items { + vs := &virtualServiceList.Items[i] + virtualServices[types.NamespacedName{Namespace: vs.GetNamespace(), Name: vs.GetName()}] = vs + } + + return virtualServices, nil +} diff --git a/pkg/i2gw/providers/common/utils.go b/pkg/i2gw/providers/common/utils.go index 8fa0ea701..62f7e67cd 100644 --- a/pkg/i2gw/providers/common/utils.go +++ b/pkg/i2gw/providers/common/utils.go @@ -162,6 +162,9 @@ func groupIngressPathsByMatchKey(rules []ingressRule) orderedIngressPathsByMatch } for i, ir := range rules { + if ir.rule.HTTP == nil { + continue + } for j, path := range ir.rule.HTTP.Paths { ip := ingressPath{ ruleIdx: i, diff --git a/pkg/i2gw/providers/common/utils_test.go b/pkg/i2gw/providers/common/utils_test.go index ab5646437..81426400e 100644 --- a/pkg/i2gw/providers/common/utils_test.go +++ b/pkg/i2gw/providers/common/utils_test.go @@ -43,6 +43,62 @@ func TestGroupIngressPathsByMatchKey(t *testing.T) { data: map[pathMatchKey][]ingressPath{}, }, }, + { + name: "rule with nil http", + rules: []ingressRule{ + { + rule: networkingv1.IngressRule{}, + }, + { + rule: networkingv1.IngressRule{ + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/test", + PathType: PtrTo(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: orderedIngressPathsByMatchKey{ + keys: []pathMatchKey{ + "Prefix//test", + }, + data: map[pathMatchKey][]ingressPath{ + "Prefix//test": { + { + ruleIdx: 1, + pathIdx: 0, + ruleType: "http", + path: networkingv1.HTTPIngressPath{ + Path: "/test", + PathType: &iPrefix, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, { name: "1 rule with 1 match", rules: []ingressRule{ @@ -915,7 +971,7 @@ func TestCreateBackendTLSPolicy(t *testing.T) { require.Len(t, policy.Spec.TargetRefs, 1) require.Equal(t, gatewayv1.ObjectName(tc.serviceName), policy.Spec.TargetRefs[0].Name) - require.Equal(t, "", string(policy.Spec.TargetRefs[0].Group)) // Core group + require.Empty(t, string(policy.Spec.TargetRefs[0].Group)) // Core group require.Equal(t, "Service", string(policy.Spec.TargetRefs[0].Kind)) }) } diff --git a/pkg/i2gw/providers/glooedge/README .md b/pkg/i2gw/providers/glooedge/README .md new file mode 100644 index 000000000..4d8997677 --- /dev/null +++ b/pkg/i2gw/providers/glooedge/README .md @@ -0,0 +1,37 @@ +# Gloo Edge Provider + +This provider enables conversion of Gloo Edge VirtualService resources to Kubernetes Gateway API HTTPRoute manifests. + +## Features (MVP) + +- Read VirtualService CRDs from cluster or file +- Map hosts to HTTPRoute hostnames +- Convert prefix-based routing rules +- Reference backend services as Kubernetes Services + +## Usage + +```bash +ingress2gateway \ + --provider gloo-edge \ + --input-file virtualservice.yaml \ + --output-file gateway-resources.yaml +``` + +## Supported Gloo Edge Features + +### Routing +- ✅ `spec.hosts[]` → HTTPRoute `hostnames` +- ✅ `spec.virtualHost.routes[].matchers[].prefix` → HTTPRoute `path` matches +- ✅ `spec.virtualHost.routes[].routeAction.single.upstream` → HTTPRoute backend refs + +### Not Yet Supported +- Advanced matchers (header, method, regex) +- Traffic policies (timeout, retry) +- Authentication (OAuth, JWT) +- Canary deployments +- Plugins and filters + +## Example + +See `test_virtualservice.yaml` for a complete example. \ No newline at end of file diff --git a/pkg/i2gw/providers/glooedge/converter.go b/pkg/i2gw/providers/glooedge/converter.go new file mode 100644 index 000000000..ef113efa5 --- /dev/null +++ b/pkg/i2gw/providers/glooedge/converter.go @@ -0,0 +1,187 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 glooedge + +import ( + "fmt" + "regexp" + + + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +type resourcesToIRConverter struct { + featureParsers []func(*storage, *providerir.ProviderIR) field.ErrorList +} + +func newResourcesToIRConverter() *resourcesToIRConverter { + return &resourcesToIRConverter{ + featureParsers: []func(*storage, *providerir.ProviderIR) field.ErrorList{ + basicRoutingFeature, + }, + } +} + +func (c *resourcesToIRConverter) convert(storage *storage) (providerir.ProviderIR, field.ErrorList) { + ir := providerir.ProviderIR{ + Gateways: make(map[types.NamespacedName]providerir.GatewayContext), + HTTPRoutes: make(map[types.NamespacedName]providerir.HTTPRouteContext), + } + + var errs field.ErrorList + + for _, parseFunc := range c.featureParsers { + parseErrs := parseFunc(storage, &ir) + errs = append(errs, parseErrs...) + } + + return ir, errs +} + +func basicRoutingFeature(storage *storage, ir *providerir.ProviderIR) field.ErrorList { + var errs field.ErrorList + + // Track listeners by host + listenersByHost := make(map[string]*gatewayv1.Listener) + + for _, vs := range storage.VirtualServices { + // Create one HTTPRoute per host in the VirtualService + for _, host := range vs.Spec.Hosts { + routeName := fmt.Sprintf("%s-%s", vs.Name, sanitizeHostname(host)) + routeKey := types.NamespacedName{ + Namespace: vs.Namespace, + Name: routeName, + } + + // Create listener for this host if not exists + if _, exists := listenersByHost[host]; !exists { + listenerName := fmt.Sprintf("%s-http", sanitizeHostname(host)) + listenersByHost[host] = &gatewayv1.Listener{ + Name: gatewayv1.SectionName(listenerName), + Hostname: ptrTo(gatewayv1.Hostname(host)), + Port: 80, + Protocol: "HTTP", + } + } + + // Create HTTPRoute context + httpRouteContext := providerir.HTTPRouteContext{ + HTTPRoute: gatewayv1.HTTPRoute{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "gateway.networking.k8s.io/v1", + Kind: "HTTPRoute", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: vs.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Name: "gloo-edge", + }, + }, + }, + Hostnames: []gatewayv1.Hostname{gatewayv1.Hostname(host)}, + }, + }, + RuleBackendSources: [][]providerir.BackendSource{}, + } + + // Convert routes to HTTPRoute rules + for _, route := range vs.Spec.VirtualHost.Routes { + rule := gatewayv1.HTTPRouteRule{} + + // Add path matches from Gloo Edge matchers + if len(route.Matchers) > 0 { + rule.Matches = []gatewayv1.HTTPRouteMatch{} + for _, matcher := range route.Matchers { + if matcher.Prefix != "" { + rule.Matches = append(rule.Matches, gatewayv1.HTTPRouteMatch{ + Path: &gatewayv1.HTTPPathMatch{ + Type: ptrTo(gatewayv1.PathMatchPathPrefix), + Value: ptrTo(matcher.Prefix), + }, + }) + } + } + } + + // Add backend ref from upstream + if route.RouteAction.Single.Upstream.Name != "" { + backendRef := gatewayv1.HTTPBackendRef{ + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName(route.RouteAction.Single.Upstream.Name), + }, + }, + } + rule.BackendRefs = []gatewayv1.HTTPBackendRef{backendRef} + } + + httpRouteContext.HTTPRoute.Spec.Rules = append(httpRouteContext.HTTPRoute.Spec.Rules, rule) + httpRouteContext.RuleBackendSources = append(httpRouteContext.RuleBackendSources, []providerir.BackendSource{}) + } + + ir.HTTPRoutes[routeKey] = httpRouteContext + } + } + + // Create Gateway with collected listeners + gatewayKey := types.NamespacedName{ + Namespace: "default", + Name: "gloo-edge", + } + listeners := make([]gatewayv1.Listener, 0) + for _, listener := range listenersByHost { + listeners = append(listeners, *listener) + } + + ir.Gateways[gatewayKey] = providerir.GatewayContext{ + Gateway: gatewayv1.Gateway{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "gateway.networking.k8s.io/v1", + Kind: "Gateway", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "gloo-edge", + Namespace: "default", + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "gloo-edge", + Listeners: listeners, + }, + }, + } + + return errs +} + +func sanitizeHostname(host string) string { + // Replace dots and special chars with hyphens for valid k8s name + reg := regexp.MustCompile("[^a-zA-Z0-9]+") + return reg.ReplaceAllString(host, "-") +} + +func ptrTo[T any](v T) *T { + return &v +} diff --git a/pkg/i2gw/providers/glooedge/converter_test.go b/pkg/i2gw/providers/glooedge/converter_test.go new file mode 100644 index 000000000..c85623f86 --- /dev/null +++ b/pkg/i2gw/providers/glooedge/converter_test.go @@ -0,0 +1,176 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 glooedge + +import ( + "errors" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" + apiequality "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func Test_ToIR(t *testing.T) { + gPathPrefix := gatewayv1.PathMatchPathPrefix + + testCases := []struct { + name string + virtualService *VirtualService + expectedIR providerir.ProviderIR + expectedErrors field.ErrorList + }{ + { + name: "basic single upstream conversion", + virtualService: &VirtualService{ + Name: "example-vs", + Namespace: "default", + Spec: VirtualServiceSpec{ + Hosts: []string{"example.com"}, + VirtualHost: VirtualHost{ + Routes: []Route{ + { + Matchers: []Matcher{ + {Prefix: "/api"}, + }, + RouteAction: RouteAction{ + Single: SingleUpstream{ + Upstream: Upstream{ + Name: "my-service", + Namespace: "default", + }, + }, + }, + }, + }, + }, + }, + }, + expectedIR: providerir.ProviderIR{ + Gateways: map[types.NamespacedName]providerir.GatewayContext{ + {Namespace: "default", Name: "gloo-edge"}: { + Gateway: gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gloo-edge", Namespace: "default"}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "gloo-edge", + Listeners: []gatewayv1.Listener{{ + Name: "example-com-http", + Port: 80, + Protocol: gatewayv1.HTTPProtocolType, + Hostname: ptr.To(gatewayv1.Hostname("example.com")), + }}, + }, + }, + }, + }, + HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{ + {Namespace: "default", Name: "example-vs-example-com"}: { + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "example-vs-example-com", Namespace: "default"}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{ + Name: "gloo-edge", + }}, + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{{ + Matches: []gatewayv1.HTTPRouteMatch{{ + Path: &gatewayv1.HTTPPathMatch{ + Type: &gPathPrefix, + Value: ptr.To("/api"), + }, + }}, + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "my-service", + }, + }, + }, + }, + }}, + }, + }, + }, + }, + }, + expectedErrors: field.ErrorList{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + provider := NewProvider(&i2gw.ProviderConf{}) + + geProvider := provider.(*Provider) + // Create storage and add the VirtualService + geProvider.storage.addVirtualService(tc.virtualService) + + ir, errs := provider.ToIR() + + // Validate error count + if len(errs) != len(tc.expectedErrors) { + t.Errorf("Expected %d errors, got %d: %+v", len(tc.expectedErrors), len(errs), errs) + } else { + for i, e := range errs { + if errors.Is(e, tc.expectedErrors[i]) { + t.Errorf("Unexpected error message at %d index. Got %s, want: %s", i, e, tc.expectedErrors[i]) + } + } + } + + // Validate HTTPRoutes + if len(ir.HTTPRoutes) != len(tc.expectedIR.HTTPRoutes) { + t.Errorf("Expected %d HTTPRoutes, got %d: %+v", + len(tc.expectedIR.HTTPRoutes), len(ir.HTTPRoutes), ir.HTTPRoutes) + } else { + for _, gotHTTPRouteContext := range ir.HTTPRoutes { + key := types.NamespacedName{Namespace: gotHTTPRouteContext.HTTPRoute.Namespace, Name: gotHTTPRouteContext.HTTPRoute.Name} + wantHTTPRouteContext := tc.expectedIR.HTTPRoutes[key] + wantHTTPRouteContext.HTTPRoute.SetGroupVersionKind(common.HTTPRouteGVK) + if !apiequality.Semantic.DeepEqual(gotHTTPRouteContext.HTTPRoute, wantHTTPRouteContext.HTTPRoute) { + t.Errorf("Expected HTTPRoute %s to be %+v\n Got: %+v\n Diff: %s", key.Name, wantHTTPRouteContext.HTTPRoute, gotHTTPRouteContext.HTTPRoute, cmp.Diff(wantHTTPRouteContext.HTTPRoute, gotHTTPRouteContext.HTTPRoute)) + } + } + } + + // Validate Gateways + if len(ir.Gateways) != len(tc.expectedIR.Gateways) { + t.Errorf("Expected %d Gateways, got %d: %+v", + len(tc.expectedIR.Gateways), len(ir.Gateways), ir.Gateways) + } else { + for _, gotGatewayContext := range ir.Gateways { + key := types.NamespacedName{Namespace: gotGatewayContext.Gateway.Namespace, Name: gotGatewayContext.Gateway.Name} + wantGatewayContext := tc.expectedIR.Gateways[key] + wantGatewayContext.Gateway.SetGroupVersionKind(common.GatewayGVK) + if !apiequality.Semantic.DeepEqual(gotGatewayContext.Gateway, wantGatewayContext.Gateway) { + t.Errorf("Expected Gateway %s to be %+v\n Got: %+v\n Diff: %s", key.Name, wantGatewayContext.Gateway, gotGatewayContext.Gateway, cmp.Diff(wantGatewayContext.Gateway, gotGatewayContext.Gateway)) + } + } + } + }) + } +} diff --git a/pkg/i2gw/providers/glooedge/glooedge.go b/pkg/i2gw/providers/glooedge/glooedge.go new file mode 100644 index 000000000..8f600dffb --- /dev/null +++ b/pkg/i2gw/providers/glooedge/glooedge.go @@ -0,0 +1,71 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 glooedge + +import ( + "context" + "fmt" + "io" + + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +const Name = "gloo-edge" + +func init() { + i2gw.ProviderConstructorByName[Name] = NewProvider +} + +type Provider struct { + storage *storage + resourceReader *resourceReader + resourcesToIRConverter *resourcesToIRConverter +} + +func NewProvider(conf *i2gw.ProviderConf) i2gw.Provider { + return &Provider{ + storage: newResourcesStorage(), + resourceReader: newResourceReader(conf), + resourcesToIRConverter: newResourcesToIRConverter(), + } +} + +func (p *Provider) ToIR() (emitterir.EmitterIR, field.ErrorList) { + pIR, errs := p.resourcesToIRConverter.convert(p.storage) + return providerir.ToEmitterIR(pIR), errs +} + +func (p *Provider) ReadResourcesFromCluster(ctx context.Context) error { + storage, err := p.resourceReader.readResourcesFromCluster(ctx) + if err != nil { + return fmt.Errorf("failed to read gloo edge resources from cluster: %w", err) + } + p.storage = storage + return nil +} + +func (p *Provider) ReadResourcesFromFile(ctx context.Context, reader io.Reader) error { + storage, err := p.resourceReader.readResourcesFromFile(ctx, reader) + if err != nil { + return fmt.Errorf("failed to read gloo edge resources from file: %w", err) + } + p.storage = storage + return nil +} diff --git a/pkg/i2gw/providers/glooedge/resource_reader.go b/pkg/i2gw/providers/glooedge/resource_reader.go new file mode 100644 index 000000000..8f1318120 --- /dev/null +++ b/pkg/i2gw/providers/glooedge/resource_reader.go @@ -0,0 +1,169 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 glooedge + +import ( + "context" + "fmt" + "io" + + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" +) + +type resourceReader struct { + conf *i2gw.ProviderConf +} + +func newResourceReader(conf *i2gw.ProviderConf) *resourceReader { + return &resourceReader{ + conf: conf, + } +} + +func (r *resourceReader) readResourcesFromCluster(ctx context.Context) (*storage, error) { + storage := newResourcesStorage() + + virtualServices, err := common.ReadVirtualServicesFromCluster(ctx, r.conf.Client) + if err != nil { + return nil, err + } + + for _, u := range virtualServices { + vs, err := unstructuredToVirtualService(u, r.conf.Namespace) + if err != nil { + return nil, err + } + storage.addVirtualService(vs) + } + + return storage, nil +} + +func (r *resourceReader) readResourcesFromFile(ctx context.Context, reader io.Reader) (*storage, error) { + storage := newResourcesStorage() + + virtualServices, err := common.ReadVirtualServicesFromFile(reader, r.conf.Namespace) + if err != nil { + return nil, err + } + + for _, u := range virtualServices { + vs, err := unstructuredToVirtualService(u, r.conf.Namespace) + if err != nil { + return nil, err + } + storage.addVirtualService(vs) + } + + return storage, nil +} + +func unstructuredToVirtualService(u *unstructured.Unstructured, defaultNamespace string) (*VirtualService, error) { + namespace := u.GetNamespace() + if namespace == "" { + namespace = defaultNamespace + } + + // Extract spec + spec, ok := u.Object["spec"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid VirtualService spec for %s/%s", namespace, u.GetName()) + } + + // Extract hosts + hostsRaw, ok := spec["hosts"].([]interface{}) + if !ok { + return nil, fmt.Errorf("invalid hosts in VirtualService %s/%s", namespace, u.GetName()) + } + var hosts []string + for _, h := range hostsRaw { + hosts = append(hosts, h.(string)) + } + + // Extract virtualHost + vhRaw, ok := spec["virtualHost"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid virtualHost in VirtualService %s/%s", namespace, u.GetName()) + } + + // Extract routes + routesRaw, ok := vhRaw["routes"].([]interface{}) + if !ok { + return nil, fmt.Errorf("invalid routes in VirtualService %s/%s", namespace, u.GetName()) + } + + var routes []Route + for _, routeRaw := range routesRaw { + routeMap := routeRaw.(map[string]interface{}) + + // Extract matchers + var matchers []Matcher + matchersRaw, ok := routeMap["matchers"].([]interface{}) + if ok { + for _, matcherRaw := range matchersRaw { + matcherMap := matcherRaw.(map[string]interface{}) + if prefix, ok := matcherMap["prefix"].(string); ok { + matchers = append(matchers, Matcher{Prefix: prefix}) + } + } + } + + // Extract routeAction + routeActionRaw, ok := routeMap["routeAction"].(map[string]interface{}) + if !ok { + continue + } + + singleRaw, ok := routeActionRaw["single"].(map[string]interface{}) + if !ok { + continue + } + + upstreamRaw, ok := singleRaw["upstream"].(map[string]interface{}) + if !ok { + continue + } + + upstreamName, _ := upstreamRaw["name"].(string) + upstreamNamespace, _ := upstreamRaw["namespace"].(string) + + routes = append(routes, Route{ + Matchers: matchers, + RouteAction: RouteAction{ + Single: SingleUpstream{ + Upstream: Upstream{ + Name: upstreamName, + Namespace: upstreamNamespace, + }, + }, + }, + }) + } + + return &VirtualService{ + Name: u.GetName(), + Namespace: namespace, + Spec: VirtualServiceSpec{ + Hosts: hosts, + VirtualHost: VirtualHost{ + Routes: routes, + }, + }, + }, nil +} diff --git a/pkg/i2gw/emitters/gce/notification.go b/pkg/i2gw/providers/glooedge/storage.go similarity index 53% rename from pkg/i2gw/emitters/gce/notification.go rename to pkg/i2gw/providers/glooedge/storage.go index 184d88e6b..c86b10c78 100644 --- a/pkg/i2gw/emitters/gce/notification.go +++ b/pkg/i2gw/providers/glooedge/storage.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2026 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,16 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -package gce_emitter +package glooedge import ( - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" - "sigs.k8s.io/controller-runtime/pkg/client" + "k8s.io/apimachinery/pkg/types" ) -const emitterName = "gce" +type storage struct { + VirtualServices map[types.NamespacedName]*VirtualService +} + +func newResourcesStorage() *storage { + return &storage{ + VirtualServices: make(map[types.NamespacedName]*VirtualService), + } +} -func notify(mType notifications.MessageType, message string, callingObject ...client.Object) { - newNotification := notifications.Notification{Type: mType, Message: message, CallingObjects: callingObject} - notifications.NotificationAggr.DispatchNotification(newNotification, emitterName) +func (s *storage) addVirtualService(vs *VirtualService) { + key := types.NamespacedName{ + Namespace: vs.Namespace, + Name: vs.Name, + } + s.VirtualServices[key] = vs } diff --git a/pkg/i2gw/providers/glooedge/types.go b/pkg/i2gw/providers/glooedge/types.go new file mode 100644 index 000000000..395789ffe --- /dev/null +++ b/pkg/i2gw/providers/glooedge/types.go @@ -0,0 +1,55 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 glooedge + +// VirtualService represents a Gloo Edge VirtualService CRD +type VirtualService struct { + Name string + Namespace string + Spec VirtualServiceSpec +} + +type VirtualServiceSpec struct { + Hosts []string + VirtualHost VirtualHost +} + +type VirtualHost struct { + Routes []Route +} + +type Route struct { + Matchers []Matcher + RouteAction RouteAction +} + +type Matcher struct { + Prefix string +} + +type RouteAction struct { + Single SingleUpstream +} + +type SingleUpstream struct { + Upstream Upstream +} + +type Upstream struct { + Name string + Namespace string +} diff --git a/pkg/i2gw/providers/glooedge/util.go b/pkg/i2gw/providers/glooedge/util.go new file mode 100644 index 000000000..0371c86c4 --- /dev/null +++ b/pkg/i2gw/providers/glooedge/util.go @@ -0,0 +1,66 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 glooedge + +import ( + "bytes" + "fmt" + "os" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + kubeyaml "k8s.io/apimachinery/pkg/util/yaml" +) + +var versionKind = schema.GroupVersionKind{ + Group: "gateway.solo.io", + Version: "v1", + Kind: "VirtualService", +} + +func readObjectsFromFile(filename, defaultNamespace string) ([]*unstructured.Unstructured, error) { + stream, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", filename, err) + } + + decoder := kubeyaml.NewYAMLOrJSONDecoder(bytes.NewReader(stream), 4096) + var objects []*unstructured.Unstructured + + for { + u := &unstructured.Unstructured{} + err := decoder.Decode(u) + if err != nil { + if err.Error() == "EOF" { + break + } + return nil, fmt.Errorf("failed to decode object: %w", err) + } + + if u.GetName() == "" { + continue + } + + if u.GetNamespace() == "" { + u.SetNamespace(defaultNamespace) + } + + objects = append(objects, u) + } + + return objects, nil +} diff --git a/pkg/i2gw/providers/ingressnginx/README.md b/pkg/i2gw/providers/ingressnginx/README.md index 895c41a18..8b2f1c532 100644 --- a/pkg/i2gw/providers/ingressnginx/README.md +++ b/pkg/i2gw/providers/ingressnginx/README.md @@ -5,7 +5,7 @@ implementation-specific resources or user-facing notifications depending on the ## Ingress Class Name -To specify the name of the Ingress class to select, use `--ingress-nginx-ingress-class=ingress-nginx` (default to 'nginx'). +To specify the name of the Ingress class to select, use `--ingress-nginx-ingress-class=ingress-nginx` (defaults to `nginx`). ## IR Model diff --git a/pkg/i2gw/providers/ingressnginx/annotations.go b/pkg/i2gw/providers/ingressnginx/annotations.go index 46a151500..01a939949 100644 --- a/pkg/i2gw/providers/ingressnginx/annotations.go +++ b/pkg/i2gw/providers/ingressnginx/annotations.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,13 +21,157 @@ const ( CanaryAnnotation = "nginx.ingress.kubernetes.io/canary" CanaryWeightAnnotation = "nginx.ingress.kubernetes.io/canary-weight" CanaryWeightTotalAnnotation = "nginx.ingress.kubernetes.io/canary-weight-total" + CanaryByHeader = "nginx.ingress.kubernetes.io/canary-by-header" + CanaryByHeaderValue = "nginx.ingress.kubernetes.io/canary-by-header-value" + CanaryByHeaderPattern = "nginx.ingress.kubernetes.io/canary-by-header-pattern" + CanaryByCookie = "nginx.ingress.kubernetes.io/canary-by-cookie" // Rewrite annotations RewriteTargetAnnotation = "nginx.ingress.kubernetes.io/rewrite-target" + // Redirect annotations + PermanentRedirectAnnotation = "nginx.ingress.kubernetes.io/permanent-redirect" + PermanentRedirectCodeAnnotation = "nginx.ingress.kubernetes.io/permanent-redirect-code" + TemporalRedirectAnnotation = "nginx.ingress.kubernetes.io/temporal-redirect" + TemporalRedirectCodeAnnotation = "nginx.ingress.kubernetes.io/temporal-redirect-code" + FromToWWWRedirectAnnotation = "nginx.ingress.kubernetes.io/from-to-www-redirect" + ProxyRedirectFromAnnotation = "nginx.ingress.kubernetes.io/proxy-redirect-from" + ProxyRedirectToAnnotation = "nginx.ingress.kubernetes.io/proxy-redirect-to" + // Header annotations XForwardedPrefixAnnotation = "nginx.ingress.kubernetes.io/x-forwarded-prefix" UpstreamVhostAnnotation = "nginx.ingress.kubernetes.io/upstream-vhost" ConnectionProxyHeaderAnnotation = "nginx.ingress.kubernetes.io/connection-proxy-header" CustomHeadersAnnotation = "nginx.ingress.kubernetes.io/custom-headers" + + // Timeout annotations + ProxyConnectTimeoutAnnotation = "nginx.ingress.kubernetes.io/proxy-connect-timeout" + ProxySendTimeoutAnnotation = "nginx.ingress.kubernetes.io/proxy-send-timeout" + ProxyReadTimeoutAnnotation = "nginx.ingress.kubernetes.io/proxy-read-timeout" + + // Body Size annotations + ProxyBodySizeAnnotation = "nginx.ingress.kubernetes.io/proxy-body-size" + ClientBodyBufferSizeAnnotation = "nginx.ingress.kubernetes.io/client-body-buffer-size" + + // Rate limit annotations + LimitRPSAnnotation = "nginx.ingress.kubernetes.io/limit-rps" + LimitRPMAnnotation = "nginx.ingress.kubernetes.io/limit-rpm" + LimitBurstMultiplierAnnotation = "nginx.ingress.kubernetes.io/limit-burst-multiplier" + + // Load balancing annotations + LoadBalanceAnnotation = "nginx.ingress.kubernetes.io/load-balance" + + // Access log annotations + EnableAccessLogAnnotation = "nginx.ingress.kubernetes.io/enable-access-log" + + // Backend protocol annotation + BackendProtocolAnnotation = "nginx.ingress.kubernetes.io/backend-protocol" + + // Service upstream annotation + ServiceUpstreamAnnotation = "nginx.ingress.kubernetes.io/service-upstream" + + // Regex + UseRegexAnnotation = "nginx.ingress.kubernetes.io/use-regex" + + // SSL Redirect annotation + SSLRedirectAnnotation = "nginx.ingress.kubernetes.io/ssl-redirect" + + // SSL Passthrough annotation + SSLPassthroughAnnotation = "nginx.ingress.kubernetes.io/ssl-passthrough" //nolint:gosec // This is an annotation key, not a secret + + // CORS annotations + EnableCorsAnnotation = "nginx.ingress.kubernetes.io/enable-cors" + CorsAllowOriginAnnotation = "nginx.ingress.kubernetes.io/cors-allow-origin" + CorsAllowHeadersAnnotation = "nginx.ingress.kubernetes.io/cors-allow-headers" + CorsAllowMethodsAnnotation = "nginx.ingress.kubernetes.io/cors-allow-methods" + //nolint:gosec // false positive, this is an annotation key + CorsAllowCredentialsAnnotation = "nginx.ingress.kubernetes.io/cors-allow-credentials" + CorsExposeHeadersAnnotation = "nginx.ingress.kubernetes.io/cors-expose-headers" + CorsMaxAgeAnnotation = "nginx.ingress.kubernetes.io/cors-max-age" + + // IP Range Control annotations + WhiteListSourceRangeAnnotation = "nginx.ingress.kubernetes.io/whitelist-source-range" + DenyListSourceRangeAnnotation = "nginx.ingress.kubernetes.io/denylist-source-range" + + // Backend TLS annotations + ProxySSLVerifyAnnotation = "nginx.ingress.kubernetes.io/proxy-ssl-verify" + ProxySSLSecretAnnotation = "nginx.ingress.kubernetes.io/proxy-ssl-secret" //nolint:gosec // This is an annotation key, not a secret + ProxySSLNameAnnotation = "nginx.ingress.kubernetes.io/proxy-ssl-name" + ProxySSLServerNameAnnotation = "nginx.ingress.kubernetes.io/proxy-ssl-server-name" + ProxySSLVerifyDepthAnnotation = "nginx.ingress.kubernetes.io/proxy-ssl-verify-depth" + ProxySSLProtocolsAnnotation = "nginx.ingress.kubernetes.io/proxy-ssl-protocols" + + // Affinity annotations + AffinityAnnotation = "nginx.ingress.kubernetes.io/affinity" + SessionCookieNameAnnotation = "nginx.ingress.kubernetes.io/session-cookie-name" + SessionCookiePathAnnotation = "nginx.ingress.kubernetes.io/session-cookie-path" + SessionCookieDomainAnnotation = "nginx.ingress.kubernetes.io/session-cookie-domain" + SessionCookieSameSiteAnnotation = "nginx.ingress.kubernetes.io/session-cookie-samesite" + SessionCookieExpiresAnnotation = "nginx.ingress.kubernetes.io/session-cookie-expires" + SessionCookieMaxAgeAnnotation = "nginx.ingress.kubernetes.io/session-cookie-max-age" + SessionCookieSecureAnnotation = "nginx.ingress.kubernetes.io/session-cookie-secure" ) + +const ingressNGINXAnnotationsPrefix = "nginx.ingress.kubernetes.io/" + +// An annotation being in this field doesn't necessary mean that +// it will be converted. Rather, if it isn't converted, the +// error will be logged elsewhere. +var parsedAnnotations = map[string]struct{}{ + CanaryAnnotation: {}, + CanaryWeightAnnotation: {}, + CanaryWeightTotalAnnotation: {}, + CanaryByHeader: {}, + CanaryByHeaderValue: {}, + CanaryByHeaderPattern: {}, + CanaryByCookie: {}, + RewriteTargetAnnotation: {}, + PermanentRedirectAnnotation: {}, + PermanentRedirectCodeAnnotation: {}, + TemporalRedirectAnnotation: {}, + TemporalRedirectCodeAnnotation: {}, + ProxyRedirectFromAnnotation: {}, + ProxyRedirectToAnnotation: {}, + XForwardedPrefixAnnotation: {}, + UpstreamVhostAnnotation: {}, + ConnectionProxyHeaderAnnotation: {}, + CustomHeadersAnnotation: {}, + ProxyConnectTimeoutAnnotation: {}, + ProxySendTimeoutAnnotation: {}, + ProxyReadTimeoutAnnotation: {}, + ProxyBodySizeAnnotation: {}, + ClientBodyBufferSizeAnnotation: {}, + LimitRPSAnnotation: {}, + LimitRPMAnnotation: {}, + LimitBurstMultiplierAnnotation: {}, + LoadBalanceAnnotation: {}, + EnableAccessLogAnnotation: {}, + BackendProtocolAnnotation: {}, + ServiceUpstreamAnnotation: {}, + UseRegexAnnotation: {}, + SSLRedirectAnnotation: {}, + SSLPassthroughAnnotation: {}, + EnableCorsAnnotation: {}, + CorsAllowOriginAnnotation: {}, + CorsAllowHeadersAnnotation: {}, + CorsAllowMethodsAnnotation: {}, + CorsAllowCredentialsAnnotation: {}, + CorsExposeHeadersAnnotation: {}, + CorsMaxAgeAnnotation: {}, + WhiteListSourceRangeAnnotation: {}, + DenyListSourceRangeAnnotation: {}, + ProxySSLVerifyAnnotation: {}, + ProxySSLSecretAnnotation: {}, + ProxySSLNameAnnotation: {}, + ProxySSLServerNameAnnotation: {}, + ProxySSLVerifyDepthAnnotation: {}, + ProxySSLProtocolsAnnotation: {}, + AffinityAnnotation: {}, + SessionCookieNameAnnotation: {}, + SessionCookiePathAnnotation: {}, + SessionCookieDomainAnnotation: {}, + SessionCookieSameSiteAnnotation: {}, + SessionCookieExpiresAnnotation: {}, + SessionCookieMaxAgeAnnotation: {}, + SessionCookieSecureAnnotation: {}, +} diff --git a/pkg/i2gw/providers/ingressnginx/backend_protocol.go b/pkg/i2gw/providers/ingressnginx/backend_protocol.go index f026068d1..79206ffa7 100644 --- a/pkg/i2gw/providers/ingressnginx/backend_protocol.go +++ b/pkg/i2gw/providers/ingressnginx/backend_protocol.go @@ -1,5 +1,5 @@ /* -Copyright 2023 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,132 +17,150 @@ limitations under the License. package ingressnginx import ( + "fmt" "strings" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" ) -const backendProtocolAnnotation = "nginx.ingress.kubernetes.io/backend-protocol" - -// backendProtocolFeature is a FeatureParser that projects the -// backend-protocol annotation into the ingress-nginx ProviderSpecificIR. -// -// Semantics: -// - Only GRPC/GRPCS are currently supported and are mapped to Policy.BackendProtocol = grpc. -// - HTTP/HTTPS/AUTO_HTTP are treated as the default HTTP/1 behavior and do not set BackendProtocol. -// - FCGI (and any other unknown values) are reported as invalid. -// - Coverage is recorded via Policy.RuleBackendSources so the emitter can apply protocol selection -// only to the specific backends contributed by the annotated Ingress. -func backendProtocolFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - - // Per-Ingress backend protocol derived from backend-protocol. - ingressProtocols := make(map[types.NamespacedName]providerir.BackendProtocol, len(ingresses)) - - for i := range ingresses { - ing := &ingresses[i] - if ing.Annotations == nil { - continue - } - - raw, ok := ing.Annotations[backendProtocolAnnotation] - if !ok { - continue - } - - value := strings.TrimSpace(strings.ToUpper(raw)) - if value == "" { - continue - } - - ingKey := types.NamespacedName{Namespace: ing.Namespace, Name: ing.Name} - - switch value { - case "GRPC", "GRPCS": - ingressProtocols[ingKey] = providerir.BackendProtocolGRPC - case "HTTP", "HTTPS", "AUTO_HTTP": - // Default HTTP/1.x behavior; nothing to emit into IR here. - continue - default: - // Values like FCGI/AJP are not supported in Kgateway/Envoy today. - errs = append(errs, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(backendProtocolAnnotation), - raw, - `unsupported backend-protocol; only "GRPC" and "GRPCS" are mapped by the Kgateway emitter`, - )) - continue +// createBackendTLSPolicies inspects ingresses for backend-protocol annotations +// and creates BackendTLSPolicies if HTTPS or GRPCS is specified. +func createBackendTLSPolicies(_ notifications.NotifyFunc, ingresses []networkingv1.Ingress, servicePorts map[types.NamespacedName]map[string]int32, _ *providerir.ProviderIR) field.ErrorList { + ruleGroups := common.GetRuleGroups(ingresses) + var errList field.ErrorList + + for _, rg := range ruleGroups { + // Determine protocol for this rule group (host). + var protocolType string + + for _, rule := range rg.Rules { + if val, ok := rule.Ingress.Annotations[BackendProtocolAnnotation]; ok { + if val != "" { + protocolType = strings.ToUpper(val) + break + } + } } - } - - if len(ingressProtocols) == 0 { - return errs - } - // Map per-Ingress protocol onto HTTPRoute IR using RuleBackendSources. - for httpKey, httpCtx := range ir.HTTPRoutes { - // Group backend indices by source Ingress (namespace/name). - srcByIngress := map[types.NamespacedName][]providerir.PolicyIndex{} - - for ruleIdx, perRule := range httpCtx.RuleBackendSources { - for backendIdx, src := range perRule { - if src.Ingress == nil { - continue + // Handle HTTPS and GRPCS (TLS Policy) + if protocolType == "HTTPS" || protocolType == "GRPCS" { + // We iterate the Rules in the Group to find backends + for _, rule := range rg.Rules { + for _, path := range rule.IngressRule.HTTP.Paths { + backendRef, err := common.ToBackendRef(rg.Namespace, path.Backend, servicePorts, field.NewPath("backend")) + if err != nil { + errList = append(errList, err) + continue + } + serviceName := string(backendRef.Name) + if serviceName == "" { + continue + } } - ingressKey := types.NamespacedName{ - Namespace: src.Ingress.Namespace, - Name: src.Ingress.Name, - } - srcByIngress[ingressKey] = append( - srcByIngress[ingressKey], - providerir.PolicyIndex{Rule: ruleIdx, Backend: backendIdx}, - ) } } + } + return errList +} - if len(srcByIngress) == 0 { +// applyBackendProtocolToEmitterIR projects ingress-nginx backend-protocol intent into +// the emitter-neutral policy map used by custom emitters like kgateway and agentgateway. +// +// We intentionally keep GRPC upstreams as HTTPRoutes and let emitters decide how to +// project the upstream protocol. When service-upstream is also enabled, we populate +// per-backend host/port metadata so emitters can generate implementation-specific +// Backend resources and rewrite backendRefs. +func (p *Provider) applyBackendProtocolToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + for key, pRouteCtx := range pIR.HTTPRoutes { + eRouteCtx, ok := eIR.HTTPRoutes[key] + if !ok { continue } - // Ensure provider-specific IR is initialized. - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, + for ruleIdx := range eRouteCtx.Spec.Rules { + if ruleIdx >= len(pRouteCtx.RuleBackendSources) { + continue } - } else if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - - for ingressKey, idxs := range srcByIngress { - proto, ok := ingressProtocols[ingressKey] - if !ok { + if ruleIdx >= len(eRouteCtx.Spec.Rules) { continue } - // NOTE: Provider policies are keyed by Ingress name. - ingressName := ingressKey.Name + rule := eRouteCtx.Spec.Rules[ruleIdx] + sources := pRouteCtx.RuleBackendSources[ruleIdx] - existing := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingressName] + for backendIdx := range rule.BackendRefs { + if backendIdx >= len(sources) { + continue + } + source := sources[backendIdx] + if source.Ingress == nil { + continue + } - pCopy := proto - existing.BackendProtocol = &pCopy + rawProtocol, ok := source.Ingress.Annotations[BackendProtocolAnnotation] + if !ok { + continue + } + + protocol, supported := parseBackendProtocol(strings.TrimSpace(rawProtocol)) + if !supported { + continue + } - // Record coverage so the emitter can apply protocol selection to the right backends. - existing = existing.AddRuleBackendSources(idxs) + if eRouteCtx.PoliciesBySourceIngressName == nil { + eRouteCtx.PoliciesBySourceIngressName = make(map[string]emitterir.Policy) + } - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingressName] = existing + ingressName := source.Ingress.Name + policy := eRouteCtx.PoliciesBySourceIngressName[ingressName] + policy.BackendProtocol = protocol + policy = policy.AddRuleBackendSources([]emitterir.PolicyIndex{{ + Rule: ruleIdx, + Backend: backendIdx, + }}) + + if serviceUpstreamEnabled(source.Ingress) { + backendRef := rule.BackendRefs[backendIdx].BackendRef + if backendRef.Name != "" && backendRef.Port != nil { + if policy.Backends == nil { + policy.Backends = make(map[types.NamespacedName]emitterir.Backend) + } + + svcName := string(backendRef.Name) + backendKey := types.NamespacedName{ + Namespace: key.Namespace, + Name: svcName + "-service-upstream", + } + policy.Backends[backendKey] = emitterir.Backend{ + Namespace: key.Namespace, + Name: backendKey.Name, + Host: fmt.Sprintf("%s.%s.svc.cluster.local", svcName, key.Namespace), + Port: int32(*backendRef.Port), + Protocol: protocol, + } + } + } + + eRouteCtx.PoliciesBySourceIngressName[ingressName] = policy + } } - // Write back mutated HTTPRouteContext into IR. - ir.HTTPRoutes[httpKey] = httpCtx + eIR.HTTPRoutes[key] = eRouteCtx } +} - return errs +func parseBackendProtocol(raw string) (*emitterir.BackendProtocol, bool) { + switch strings.ToUpper(raw) { + case string(emitterir.BackendProtocolGRPC): + protocol := emitterir.BackendProtocolGRPC + return &protocol, true + default: + return nil, false + } } diff --git a/pkg/i2gw/providers/ingressnginx/backend_protocol_test.go b/pkg/i2gw/providers/ingressnginx/backend_protocol_test.go new file mode 100644 index 000000000..556027e7d --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/backend_protocol_test.go @@ -0,0 +1,567 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 ingressnginx + +import ( + "strings" + "testing" + + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestBackendProtocolFeature(t *testing.T) { + common.GRPCRouteGVK.Group = "gateway.networking.k8s.io" + common.GRPCRouteGVK.Version = "v1" + common.GRPCRouteGVK.Kind = "GRPCRoute" + + testCases := []struct { + name string + ingresses []networkingv1.Ingress + expectedHTTP map[types.NamespacedName]int // Count of HTTP routes expected + expectedGRPC map[types.NamespacedName]int // Count of GRPC routes expected + expectedGVK bool // Verify GVK is set correctly + expectedProtocol string // Verify backend protocol was respected + expectedTLSPolicies map[types.NamespacedName]int // Count of BackendTLSPolicies expected + }{ + { + name: "No backend protocol annotation - should result in HTTPRoute", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptrTo(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedHTTP: map[types.NamespacedName]int{ + {Namespace: "default", Name: "test-ingress-example-com"}: 1, + }, + expectedGRPC: map[types.NamespacedName]int{}, + }, + { + name: "backend protocol GRPC - should result in GRPCRoute", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-grpc", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "GRPC", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "grpc.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptrTo(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "grpc-service", + Port: networkingv1.ServiceBackendPort{ + Number: 50051, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedHTTP: map[types.NamespacedName]int{}, + expectedGRPC: map[types.NamespacedName]int{ + {Namespace: "default", Name: "test-ingress-grpc-grpc-example-com"}: 1, + }, + expectedGVK: true, + }, + { + name: "backend protocol grpc (lowercase) - should result in GRPCRoute", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-grpc-lower", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "grpc", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "grpc-lower.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptrTo(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "grpc-service", + Port: networkingv1.ServiceBackendPort{ + Number: 50051, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedHTTP: map[types.NamespacedName]int{}, + expectedGRPC: map[types.NamespacedName]int{ + {Namespace: "default", Name: "test-ingress-grpc-lower-grpc-lower-example-com"}: 1, + }, + expectedGVK: true, + }, + + /* + { + name: "backend protocol GRPCS - should result in GRPCRoute + BackendTLSPolicy", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-grpcs", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "GRPCS", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "grpcs.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptrTo(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "grpcs-service", + Port: networkingv1.ServiceBackendPort{ + Number: 443, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedHTTP: map[types.NamespacedName]int{}, + expectedGRPC: map[types.NamespacedName]int{ + {Namespace: "default", Name: "test-ingress-grpcs-grpcs-example-com"}: 1, + }, + expectedGVK: true, + expectedTLSPolicies: map[types.NamespacedName]int{ + {Namespace: "default", Name: "grpcs-service-tls-policy"}: 1, + }, + }, + */ + /* + { + name: "backend protocol HTTPS - should result in HTTPRoute + BackendTLSPolicy", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-https", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "https.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptrTo(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "https-service", + Port: networkingv1.ServiceBackendPort{ + Number: 443, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedHTTP: map[types.NamespacedName]int{ + {Namespace: "default", Name: "test-ingress-https-https-example-com"}: 1, + }, + expectedGRPC: map[types.NamespacedName]int{}, + expectedGVK: false, // HTTPRoute + expectedTLSPolicies: map[types.NamespacedName]int{ + {Namespace: "default", Name: "https-service-tls-policy"}: 1, + }, + }, + */ + { + name: "backend protocol FCGI - should result in HTTPRoute (and warning logged)", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-fcgi", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "FCGI", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "fcgi.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptrTo(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "fcgi-service", + Port: networkingv1.ServiceBackendPort{ + Number: 9000, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedHTTP: map[types.NamespacedName]int{ + {Namespace: "default", Name: "test-ingress-fcgi-fcgi-example-com"}: 1, + }, + expectedGRPC: map[types.NamespacedName]int{}, + }, + { + name: "backend protocol HTTP - should result in HTTPRoute", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-http", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTP", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "http.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptrTo(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "http-service", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedHTTP: map[types.NamespacedName]int{ + {Namespace: "default", Name: "test-ingress-http-http-example-com"}: 1, + }, + expectedGRPC: map[types.NamespacedName]int{}, + }, + { + name: "backend protocol AUTO_HTTP - should result in HTTPRoute", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-auto-http", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "AUTO_HTTP", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "auto.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptrTo(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "auto-service", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedHTTP: map[types.NamespacedName]int{ + {Namespace: "default", Name: "test-ingress-auto-http-auto-example-com"}: 1, + }, + expectedGRPC: map[types.NamespacedName]int{}, + }, + { + name: "mixed protocol (HTTP and GRPC on same host) - should result in split routes", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-mixed-http", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTP", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "mixed.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/api", + PathType: ptrTo(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "http-service", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-mixed-grpc", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "GRPC", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "mixed.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptrTo(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "grpc-service", + Port: networkingv1.ServiceBackendPort{ + Number: 9000, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + // Both ingresses will effectively map to the same RouteNamebase (test-ingress-mixed-http-mixed-example-com) + // because common.ToIR uses the first ingress name matching the host key. + // Wait, common.ToIR uses "first ingress" to determine name. + // If we pass a list, order matters. But they share the key. + // Let's rely on checking existence of Routes for that host key. + + expectedHTTP: map[types.NamespacedName]int{ + {Namespace: "default", Name: "test-ingress-mixed-http-mixed-example-com"}: 1, + }, + expectedGRPC: map[types.NamespacedName]int{ + {Namespace: "default", Name: "test-ingress-mixed-grpc-mixed-example-com"}: 1, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + servicePorts := map[types.NamespacedName]map[string]int32{} + + // Simulate converter.go logic: + // Filter Ingresses + var httpIngresses []networkingv1.Ingress + var grpcIngresses []networkingv1.Ingress + + for _, ing := range tc.ingresses { + if val, ok := ing.Annotations["nginx.ingress.kubernetes.io/backend-protocol"]; ok { + protocol := strings.ToUpper(val) + if protocol == "GRPC" || protocol == "GRPCS" { + grpcIngresses = append(grpcIngresses, ing) + } else { + httpIngresses = append(httpIngresses, ing) + } + } else { + httpIngresses = append(httpIngresses, ing) + } + } + + // Use common.ToIR to convert ingresses + ir, errs := common.ToIR(httpIngresses, grpcIngresses, servicePorts, i2gw.ProviderImplementationSpecificOptions{}) + if len(errs) > 0 { + t.Fatalf("common.ToIR returned errors: %v", errs) + } + + // createBackendTLSPolicies for ALL + tlsErrs := createBackendTLSPolicies(notifications.NoopNotify, tc.ingresses, servicePorts, &ir) + if len(tlsErrs) > 0 { + t.Fatalf("createBackendTLSPolicies returned errors: %v", tlsErrs) + } + + // Verify HTTPRoutes + if len(ir.HTTPRoutes) != len(tc.expectedHTTP) { + t.Errorf("Expected %d HTTPRoutes, got %d", len(tc.expectedHTTP), len(ir.HTTPRoutes)) + } + for key := range tc.expectedHTTP { + if _, ok := ir.HTTPRoutes[key]; !ok { + t.Errorf("Expected HTTPRoute %v not found", key) + } + } + + // Verify GRPCRoutes + if len(ir.GRPCRoutes) != len(tc.expectedGRPC) { + t.Errorf("Expected %d GRPCRoutes, got %d", len(tc.expectedGRPC), len(ir.GRPCRoutes)) + } + for key := range tc.expectedGRPC { + route, ok := ir.GRPCRoutes[key] + if !ok { + t.Errorf("Expected GRPCRoute %v not found", key) + } + if tc.expectedGVK { + if route.GroupVersionKind() != common.GRPCRouteGVK { + t.Errorf("Expected GVK %v, got %v", common.GRPCRouteGVK, route.GroupVersionKind()) + } + } + if len(route.Spec.Rules) == 0 { + t.Errorf("Expected rules in GRPCRoute, got 0") + } else { + // Check that there are no matches (catch-all) as per implementation + if len(route.Spec.Rules[0].Matches) != 0 { + t.Errorf("Expected 0 matches (catch-all) in GRPCRoute rule, got %d", len(route.Spec.Rules[0].Matches)) + } + } + } + + // Verify BackendTLSPolicies + if len(ir.BackendTLSPolicies) != len(tc.expectedTLSPolicies) { + t.Errorf("Expected %d BackendTLSPolicies, got %d", len(tc.expectedTLSPolicies), len(ir.BackendTLSPolicies)) + } + for key := range tc.expectedTLSPolicies { + if _, ok := ir.BackendTLSPolicies[key]; !ok { + t.Errorf("Expected BackendTLSPolicy %v not found", key) + } + } + }) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/backend_tls.go b/pkg/i2gw/providers/ingressnginx/backend_tls.go index f893b313f..197af711e 100644 --- a/pkg/i2gw/providers/ingressnginx/backend_tls.go +++ b/pkg/i2gw/providers/ingressnginx/backend_tls.go @@ -1,5 +1,5 @@ /* -Copyright 2023 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,165 +17,264 @@ limitations under the License. package ingressnginx import ( + "fmt" + "reflect" "strings" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" - networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" -) + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" -const ( - nginxProxySSLSecret = "nginx.ingress.kubernetes.io/proxy-ssl-secret" - nginxProxySSLVerify = "nginx.ingress.kubernetes.io/proxy-ssl-verify" - nginxProxySSLName = "nginx.ingress.kubernetes.io/proxy-ssl-name" - // nginxProxySSLServerName = "nginx.ingress.kubernetes.io/proxy-ssl-server-name" // Not relevant to Gateway API + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" ) -// backendTLSFeature parses backend TLS annotations and stores them in the IR Policy. -// The TLS configuration is then applied to kgateway BackendConfigPolicy resources -// in the kgateway emitter. -// -// Semantics: -// - proxy-ssl-secret: Specifies a Secret with tls.crt, tls.key, and ca.crt in PEM format. -// Format: "namespace/secretName" -// - proxy-ssl-verify: Enables or disables verification of the proxied HTTPS server certificate. -// Values: "on" or "off" (default: "off") -// - proxy-ssl-name: Overrides the server name used to verify the certificate and passed via SNI. -// In kgateway BackendConfigPolicy, this maps to TLS configuration fields. -// - proxy-ssl-server-name: Not handled separately. SNI is enabled when hostname is set. -func backendTLSFeature( - ingresses []networkingv1.Ingress, - servicePorts map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - - // Per-Ingress parsed backend TLS policy. - perIngress := map[types.NamespacedName]*providerir.BackendTLSPolicy{} - - for i := range ingresses { - ing := &ingresses[i] - anns := ing.Annotations - if anns == nil { - continue - } +func backendTLSFeature(notify notifications.NotifyFunc, ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *providerir.ProviderIR) field.ErrorList { + var errList field.ErrorList - // Check if proxy-ssl-secret is specified (required for backend TLS) - secretName := strings.TrimSpace(anns[nginxProxySSLSecret]) - if secretName == "" { - continue - } + if ir.BackendTLSPolicies == nil { + ir.BackendTLSPolicies = make(map[types.NamespacedName]gatewayv1.BackendTLSPolicy) + } - // Validate secret name format (should be "namespace/secretName" or just "secretName") - secretParts := strings.SplitN(secretName, "/", 2) - if len(secretParts) > 2 { - errs = append(errs, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(nginxProxySSLSecret), - secretName, - "proxy-ssl-secret must be in format 'secretName' or 'namespace/secretName'", - )) - continue + // Sort route keys for deterministic output during conflict resolution + var routeKeys []types.NamespacedName + for k := range ir.HTTPRoutes { + routeKeys = append(routeKeys, k) + } + // Sort by Namespace, then Name + for i := 0; i < len(routeKeys)-1; i++ { + for j := i + 1; j < len(routeKeys); j++ { + if routeKeys[i].Namespace > routeKeys[j].Namespace || (routeKeys[i].Namespace == routeKeys[j].Namespace && routeKeys[i].Name > routeKeys[j].Name) { + routeKeys[i], routeKeys[j] = routeKeys[j], routeKeys[i] + } } + } - key := types.NamespacedName{ - Namespace: ing.Namespace, - Name: ing.Name, - } + for _, key := range routeKeys { + httpRouteContext := ir.HTTPRoutes[key] + for ruleIdx, backendSources := range httpRouteContext.RuleBackendSources { + if ruleIdx >= len(httpRouteContext.HTTPRoute.Spec.Rules) { + continue + } + rule := httpRouteContext.HTTPRoute.Spec.Rules[ruleIdx] - policy := &providerir.BackendTLSPolicy{ - SecretName: secretName, - Verify: false, // default: off - } + for backendIdx := range backendSources { - // Parse proxy-ssl-verify (values: "on" or "off") - if verifyRaw := strings.TrimSpace(anns[nginxProxySSLVerify]); verifyRaw != "" { - if strings.ToLower(verifyRaw) == "on" { - policy.Verify = true - } - // "off" is the default, so no action needed - } + primaryIngress := getNonCanaryIngress(backendSources) + if primaryIngress == nil { + continue + } - // Parse proxy-ssl-name - // This maps to both SNI and hostname validation in Gateway API. - // In Gateway API, setting Hostname enables SNI automatically. - if hostname := strings.TrimSpace(anns[nginxProxySSLName]); hostname != "" { - policy.Hostname = hostname - } + if backendIdx >= len(rule.BackendRefs) { + continue + } + backendRef := rule.BackendRefs[backendIdx] + + if backendRef.Kind != nil && *backendRef.Kind != "Service" { + continue + } + if backendRef.Group != nil && *backendRef.Group != "" && *backendRef.Group != "core" { + continue + } - // Note: proxy-ssl-server-name is not handled separately. - // In Gateway API, SNI is enabled by setting the Hostname field. - // If proxy-ssl-name is set, SNI is automatically enabled. + backendProtocol := primaryIngress.Annotations[BackendProtocolAnnotation] + proxySSLVerify := primaryIngress.Annotations[ProxySSLVerifyAnnotation] + proxySSLSecret := primaryIngress.Annotations[ProxySSLSecretAnnotation] + proxySSLName := primaryIngress.Annotations[ProxySSLNameAnnotation] + proxySSLServerName := primaryIngress.Annotations[ProxySSLServerNameAnnotation] + proxySSLVerifyDepth := primaryIngress.Annotations[ProxySSLVerifyDepthAnnotation] + proxySSLProtocols := primaryIngress.Annotations[ProxySSLProtocolsAnnotation] - perIngress[key] = policy - } + if backendProtocol != "HTTPS" && backendProtocol != "GRPCS" { + continue + } - if len(perIngress) == 0 { - return errs - } + if proxySSLVerifyDepth != "" { + notify(notifications.WarningNotification, + fmt.Sprintf("Ingress %s/%s specifies %s. Gateway API v1 BackendTLSPolicy does not support configuring verification depth.", + primaryIngress.Namespace, primaryIngress.Name, ProxySSLVerifyDepthAnnotation), + primaryIngress, + ) + } + if proxySSLProtocols != "" { + notify(notifications.WarningNotification, + fmt.Sprintf("Ingress %s/%s specifies %s. Gateway API v1 BackendTLSPolicy does not support configuring specific TLS protocols.", + primaryIngress.Namespace, primaryIngress.Name, ProxySSLProtocolsAnnotation), + primaryIngress, + ) + } - // Map per-Ingress backend TLS policy onto HTTPRoute policies using RuleBackendSources. - ruleGroups := common.GetRuleGroups(ingresses) + // Strict Validation Rules to emit a policy + var validationErrors []string - for _, rg := range ruleGroups { - routeKey := types.NamespacedName{ - Namespace: rg.Namespace, - Name: common.RouteName(rg.Name, rg.Host), + if proxySSLVerify != "on" { + validationErrors = append(validationErrors, fmt.Sprintf("%s must be strictly 'on'", ProxySSLVerifyAnnotation)) + } + if proxySSLSecret == "" { + validationErrors = append(validationErrors, fmt.Sprintf("%s must be provided with a trusted CA certificate", ProxySSLSecretAnnotation)) + } + if proxySSLServerName != "on" { // Default is off in nginx, so must be explicitly turned on for Gateway API compatibility + validationErrors = append(validationErrors, fmt.Sprintf("%s must be strictly 'on' (SNI is required)", ProxySSLServerNameAnnotation)) + } + if proxySSLName == "" { + validationErrors = append(validationErrors, fmt.Sprintf("%s must be explicitly provided (defaulting to upstream_balancer is invalid)", ProxySSLNameAnnotation)) + } + + if len(validationErrors) > 0 { + notify(notifications.ErrorNotification, + fmt.Sprintf("Ingress %s/%s requested backend TLS but failed strict validation requirements to emit a BackendTLSPolicy: %s", + primaryIngress.Namespace, primaryIngress.Name, strings.Join(validationErrors, ", ")), + primaryIngress, + ) + continue + } + + serviceName := string(backendRef.Name) + namespace := httpRouteContext.HTTPRoute.Namespace + if backendRef.Namespace != nil { + namespace = string(*backendRef.Namespace) + } + + policyName := fmt.Sprintf("%s-backend-tls", serviceName) + policyKey := types.NamespacedName{Namespace: namespace, Name: policyName} + + // Check if we already created a policy for this service + existingPolicy, exists := ir.BackendTLSPolicies[policyKey] + var policy gatewayv1.BackendTLSPolicy + if exists { + policy = *existingPolicy.DeepCopy() + } else { + policy = common.CreateBackendTLSPolicy(namespace, policyName, serviceName) + } + + // We know proxySSLName is not empty due to strict validation above + policy.Spec.Validation.Hostname = gatewayv1.PreciseHostname(proxySSLName) + + // Handle CA Certificates. + caRefName := proxySSLSecret + if strings.Contains(caRefName, "/") { + parts := strings.SplitN(caRefName, "/", 2) + if len(parts) == 2 { + secretNamespace := parts[0] + caRefName = parts[1] + + if secretNamespace != namespace { + notify(notifications.ErrorNotification, + fmt.Sprintf("Ingress %s/%s specifies backend TLS secret %s in a different namespace. BackendTLSPolicy only supports local references. Policy will not be generated.", + primaryIngress.Namespace, primaryIngress.Name, proxySSLSecret), + primaryIngress, + ) + continue + } + } + } + + // We know proxySSLVerify is "on" and proxySSLSecret is not empty due to strict validation above. + notify(notifications.WarningNotification, + fmt.Sprintf("Ingress %s/%s: mTLS will not be configured. The original Secret %q contains client certificates (tls.crt/tls.key) "+ + "for mutual TLS authentication, but Gateway API BackendTLSPolicy does not support client certificate authentication. "+ + "Only server CA verification will be configured.", + primaryIngress.Namespace, primaryIngress.Name, proxySSLSecret), + primaryIngress, + ) + notify(notifications.InfoNotification, + fmt.Sprintf("Ingress %s/%s: The generated BackendTLSPolicy references a ConfigMap %q for CA certificate validation. "+ + "You must create a ConfigMap named %q in namespace %q with the CA certificate from your Secret under the key \"ca.crt\".", + primaryIngress.Namespace, primaryIngress.Name, caRefName, caRefName, namespace), + primaryIngress, + ) + policy.Spec.Validation.CACertificateRefs = []gatewayv1.LocalObjectReference{{ + Group: "", + Kind: "ConfigMap", + Name: gatewayv1.ObjectName(caRefName), + }} + policy.Spec.Validation.WellKnownCACertificates = nil + + if exists { + // Check for conflict using DeepEqual + if !reflect.DeepEqual(policy.Spec.Validation, existingPolicy.Spec.Validation) { + notify(notifications.WarningNotification, + fmt.Sprintf("Conflict detected for BackendTLSPolicy %s. Ingress %s/%s defines different TLS settings than a previously processed Ingress. Keeping the first one.", + policyName, primaryIngress.Namespace, primaryIngress.Name), + primaryIngress, + ) + } + // If exists, we keep the existing one (first wins strategy) + continue + } + + ir.BackendTLSPolicies[policyKey] = policy + } } + } - httpCtx, ok := ir.HTTPRoutes[routeKey] + return errList +} + +// applyBackendTLSToEmitterIR projects ingress-nginx backend TLS annotations into +// emitter-neutral policy intent so custom emitters can translate them into +// implementation-specific backend TLS configuration. +func (p *Provider) applyBackendTLSToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + for key, pRouteCtx := range pIR.HTTPRoutes { + eRouteCtx, ok := eIR.HTTPRoutes[key] if !ok { continue } - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, + for ruleIdx := range eRouteCtx.Spec.Rules { + if ruleIdx >= len(pRouteCtx.RuleBackendSources) { + continue + } + if ruleIdx >= len(eRouteCtx.Spec.Rules) { + continue } - } - if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - for ruleIdx, backendSources := range httpCtx.RuleBackendSources { - for backendIdx, src := range backendSources { - if src.Ingress == nil { + sources := pRouteCtx.RuleBackendSources[ruleIdx] + rule := eRouteCtx.Spec.Rules[ruleIdx] + for backendIdx := range rule.BackendRefs { + if backendIdx >= len(sources) { continue } - - ingKey := types.NamespacedName{ - Namespace: src.Ingress.Namespace, - Name: src.Ingress.Name, + source := sources[backendIdx] + if source.Ingress == nil { + continue } - backendTLS := perIngress[ingKey] - if backendTLS == nil { + backendProtocol := strings.ToUpper(strings.TrimSpace(source.Ingress.Annotations[BackendProtocolAnnotation])) + if backendProtocol != "HTTPS" && backendProtocol != "GRPCS" { continue } - p := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] - if p.BackendTLS == nil { - // Deep copy the policy to avoid sharing references - backendTLSCopy := *backendTLS - p.BackendTLS = &backendTLSCopy + secretName := strings.TrimSpace(source.Ingress.Annotations[ProxySSLSecretAnnotation]) + hostname := strings.TrimSpace(source.Ingress.Annotations[ProxySSLNameAnnotation]) + verify := strings.EqualFold(strings.TrimSpace(source.Ingress.Annotations[ProxySSLVerifyAnnotation]), "on") + + if secretName == "" && hostname == "" && !verify { + continue } - // Dedupe (rule, backend) pairs. - p = p.AddRuleBackendSources([]providerir.PolicyIndex{ - { - Rule: ruleIdx, - Backend: backendIdx, - }, - }) + if eRouteCtx.PoliciesBySourceIngressName == nil { + eRouteCtx.PoliciesBySourceIngressName = make(map[string]emitterir.Policy) + } - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] = p + ingressName := source.Ingress.Name + policy := eRouteCtx.PoliciesBySourceIngressName[ingressName] + policy.BackendTLS = &emitterir.BackendTLSPolicy{ + SecretName: secretName, + Verify: verify, + Hostname: hostname, + } + policy = policy.AddRuleBackendSources([]emitterir.PolicyIndex{{ + Rule: ruleIdx, + Backend: backendIdx, + }}) + eRouteCtx.PoliciesBySourceIngressName[ingressName] = policy } } - ir.HTTPRoutes[routeKey] = httpCtx + eIR.HTTPRoutes[key] = eRouteCtx } - - return errs } diff --git a/pkg/i2gw/providers/ingressnginx/backend_tls_test.go b/pkg/i2gw/providers/ingressnginx/backend_tls_test.go new file mode 100644 index 000000000..5963b008c --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/backend_tls_test.go @@ -0,0 +1,656 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" + networkingv1 "k8s.io/api/networking/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestBackendTLSFeature(t *testing.T) { + testCases := []struct { + name string + ingress networkingv1.Ingress + expectedPolicies map[types.NamespacedName]gatewayv1.BackendTLSPolicy + expectedPolicyTargeted bool // if false, expectedPolicies should be empty + }{ + { + name: "ssl-verify on", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ssl-verify", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + "nginx.ingress.kubernetes.io/proxy-ssl-verify": "on", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + expectedPolicies: nil, + expectedPolicyTargeted: false, + }, + { + name: "ssl-secret provided", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ssl-secret", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + "nginx.ingress.kubernetes.io/proxy-ssl-secret": "default/secret-valid", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + expectedPolicies: nil, + expectedPolicyTargeted: false, + }, + { + name: "ssl-secret and verify on", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ssl-secret-verify", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + "nginx.ingress.kubernetes.io/proxy-ssl-secret": "secret-valid", + "nginx.ingress.kubernetes.io/proxy-ssl-verify": "on", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + expectedPolicies: nil, + expectedPolicyTargeted: false, + }, + { + name: "backend-protocol HTTPS only", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "https-protocol", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + expectedPolicies: nil, + expectedPolicyTargeted: false, + }, + { + name: "backend-protocol HTTPS and proxy-ssl-name", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "https-protocol-ssl-name", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + "nginx.ingress.kubernetes.io/proxy-ssl-name": "custom.internal.com", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + expectedPolicies: nil, + expectedPolicyTargeted: false, + }, + { + name: "fully compliant strict TLS validation", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "strict-tls", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + "nginx.ingress.kubernetes.io/proxy-ssl-verify": "on", + "nginx.ingress.kubernetes.io/proxy-ssl-secret": "my-ca-secret", + "nginx.ingress.kubernetes.io/proxy-ssl-server-name": "on", + "nginx.ingress.kubernetes.io/proxy-ssl-name": "strict.internal.com", + // proxy-ssl-server-name defaults to "on" implicitly, but could be added here + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + expectedPolicies: map[types.NamespacedName]gatewayv1.BackendTLSPolicy{ + {Namespace: "default", Name: "test-service-backend-tls"}: { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service-backend-tls", + Namespace: "default", + }, + Spec: gatewayv1.BackendTLSPolicySpec{ + TargetRefs: []gatewayv1.LocalPolicyTargetReferenceWithSectionName{{ + LocalPolicyTargetReference: gatewayv1.LocalPolicyTargetReference{ + Group: "", + Kind: "Service", + Name: "test-service", + }, + }}, + Validation: gatewayv1.BackendTLSPolicyValidation{ + Hostname: gatewayv1.PreciseHostname("strict.internal.com"), + CACertificateRefs: []gatewayv1.LocalObjectReference{{ + Group: "", + Kind: "ConfigMap", + Name: "my-ca-secret", + }}, + }, + }, + }, + }, + expectedPolicyTargeted: true, + }, + { + name: "no relevant annotations", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "normal-ingress", + Namespace: "default", + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + expectedPolicies: nil, + expectedPolicyTargeted: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ir := providerir.ProviderIR{ + HTTPRoutes: make(map[types.NamespacedName]providerir.HTTPRouteContext), + BackendTLSPolicies: make(map[types.NamespacedName]gatewayv1.BackendTLSPolicy), + } + + // Replicate IR setup + key := types.NamespacedName{Namespace: tc.ingress.Namespace, Name: common.RouteName(tc.ingress.Name, "example.com")} + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: tc.ingress.Namespace, + Name: key.Name, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ptr.To(gatewayv1.PathMatchPathPrefix), + Value: ptr.To("/"), + }, + }, + }, + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("test-service"), + Kind: ptr.To(gatewayv1.Kind("Service")), + }, + }, + }, + }, + }, + }, + }, + } + ir.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + { + {Ingress: &tc.ingress}, + }, + }, + } + + errs := backendTLSFeature(notifications.NoopNotify, []networkingv1.Ingress{tc.ingress}, nil, &ir) + if len(errs) > 0 { + t.Fatalf("Expected no errors, got %v", errs) + } + + if !tc.expectedPolicyTargeted { + if len(ir.BackendTLSPolicies) > 0 { + t.Errorf("Expected no BackendTLSPolicies, got %d", len(ir.BackendTLSPolicies)) + } + return + } + + if len(ir.BackendTLSPolicies) != len(tc.expectedPolicies) { + t.Errorf("Expected %d BackendTLSPolicies, got %d", len(tc.expectedPolicies), len(ir.BackendTLSPolicies)) + } + + for key, wantPolicy := range tc.expectedPolicies { + gotPolicy, ok := ir.BackendTLSPolicies[key] + if !ok { + t.Errorf("Expected BackendTLSPolicy %s not found", key) + continue + } + + // Manually set GVK for comparison if needed, or rely on deep equal of fields + // common.CreateBackendTLSPolicy sets GVK roughly, but let's check deep equal of Spec + if !apiequality.Semantic.DeepEqual(gotPolicy.Spec, wantPolicy.Spec) { + t.Errorf("BackendTLSPolicy Spec mismatch (-want +got):\n%s", cmp.Diff(wantPolicy.Spec, gotPolicy.Spec)) + } + } + }) + } +} + +func TestBackendTLSFeatureExtended(t *testing.T) { + testCases := []struct { + name string + ingresses []networkingv1.Ingress + expectedPolicies map[types.NamespacedName]gatewayv1.BackendTLSPolicy + expectedPolicyTargeted bool + }{ + { + name: "canary ingress skipped", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "canary-ingress", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + "nginx.ingress.kubernetes.io/proxy-ssl-verify": "on", + "nginx.ingress.kubernetes.io/proxy-ssl-secret": "my-ca-secret", + "nginx.ingress.kubernetes.io/proxy-ssl-server-name": "on", + "nginx.ingress.kubernetes.io/proxy-ssl-name": "strict.internal.com", + "nginx.ingress.kubernetes.io/canary": "true", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + }, + expectedPolicies: map[types.NamespacedName]gatewayv1.BackendTLSPolicy{ + {Namespace: "default", Name: "test-service-backend-tls"}: { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service-backend-tls", + Namespace: "default", + }, + Spec: gatewayv1.BackendTLSPolicySpec{ + TargetRefs: []gatewayv1.LocalPolicyTargetReferenceWithSectionName{{ + LocalPolicyTargetReference: gatewayv1.LocalPolicyTargetReference{ + Group: "", + Kind: "Service", + Name: "test-service", + }, + }}, + Validation: gatewayv1.BackendTLSPolicyValidation{ + Hostname: gatewayv1.PreciseHostname("strict.internal.com"), + CACertificateRefs: []gatewayv1.LocalObjectReference{{ + Group: "", + Kind: "ConfigMap", + Name: "my-ca-secret", + }}, + }, + }, + }, + }, + expectedPolicyTargeted: true, + }, + { + name: "cross-namespace secret skipped", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "cross-ns-secret", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + "nginx.ingress.kubernetes.io/proxy-ssl-secret": "other-ns/secret", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + }, + expectedPolicies: nil, + expectedPolicyTargeted: false, + }, + { + name: "conflict - first wins", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-1", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + "nginx.ingress.kubernetes.io/proxy-ssl-verify": "on", + "nginx.ingress.kubernetes.io/proxy-ssl-secret": "my-ca-secret", + "nginx.ingress.kubernetes.io/proxy-ssl-server-name": "on", + "nginx.ingress.kubernetes.io/proxy-ssl-name": "first.com", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-2", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + "nginx.ingress.kubernetes.io/proxy-ssl-verify": "on", + "nginx.ingress.kubernetes.io/proxy-ssl-secret": "my-ca-secret", + "nginx.ingress.kubernetes.io/proxy-ssl-server-name": "on", + "nginx.ingress.kubernetes.io/proxy-ssl-name": "second.com", // Conflict + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/bar", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", // Same service + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + }, + expectedPolicies: map[types.NamespacedName]gatewayv1.BackendTLSPolicy{ + {Namespace: "default", Name: "test-service-backend-tls"}: { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service-backend-tls", + Namespace: "default", + }, + Spec: gatewayv1.BackendTLSPolicySpec{ + TargetRefs: []gatewayv1.LocalPolicyTargetReferenceWithSectionName{{ + LocalPolicyTargetReference: gatewayv1.LocalPolicyTargetReference{ + Group: "", + Kind: "Service", + Name: "test-service", + }, + }}, + Validation: gatewayv1.BackendTLSPolicyValidation{ + Hostname: gatewayv1.PreciseHostname("first.com"), // Expect first one + CACertificateRefs: []gatewayv1.LocalObjectReference{{ + Group: "", + Kind: "ConfigMap", + Name: "my-ca-secret", + }}, + }, + }, + }, + }, + expectedPolicyTargeted: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ir := providerir.ProviderIR{ + HTTPRoutes: make(map[types.NamespacedName]providerir.HTTPRouteContext), + BackendTLSPolicies: make(map[types.NamespacedName]gatewayv1.BackendTLSPolicy), + } + + // Replicate IR setup for multiple ingresses + for i := range tc.ingresses { + ing := tc.ingresses[i] + key := types.NamespacedName{Namespace: ing.Namespace, Name: common.RouteName(ing.Name, "example.com")} + + // Simplified route setup + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: ing.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{{ + BackendRefs: []gatewayv1.HTTPBackendRef{{ + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "test-service", + Kind: ptr.To(gatewayv1.Kind("Service")), + }, + }, + }}, + }}, + }, + } + + ir.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + {{Ingress: &tc.ingresses[i]}}, + }, + } + } + + errs := backendTLSFeature(notifications.NoopNotify, tc.ingresses, nil, &ir) + if len(errs) > 0 { + t.Fatalf("Expected no errors, got %v", errs) + } + + if !tc.expectedPolicyTargeted { + if len(ir.BackendTLSPolicies) > 0 { + t.Errorf("Expected no BackendTLSPolicies, got %d", len(ir.BackendTLSPolicies)) + } + return + } + + if len(ir.BackendTLSPolicies) != len(tc.expectedPolicies) { + t.Errorf("Expected %d BackendTLSPolicies, got %d", len(tc.expectedPolicies), len(ir.BackendTLSPolicies)) + } + + for key, wantPolicy := range tc.expectedPolicies { + gotPolicy, ok := ir.BackendTLSPolicies[key] + if !ok { + t.Errorf("Expected BackendTLSPolicy %s not found", key) + continue + } + + if !apiequality.Semantic.DeepEqual(gotPolicy.Spec, wantPolicy.Spec) { + t.Errorf("BackendTLSPolicy Spec mismatch (-want +got):\n%s", cmp.Diff(wantPolicy.Spec, gotPolicy.Spec)) + } + } + }) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/bodybuffer.go b/pkg/i2gw/providers/ingressnginx/bodybuffer.go deleted file mode 100644 index df2e6937a..000000000 --- a/pkg/i2gw/providers/ingressnginx/bodybuffer.go +++ /dev/null @@ -1,149 +0,0 @@ -/* -Copyright 2024 The Kubernetes Authors. - -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 ingressnginx - -import ( - "fmt" - - providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" - networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/api/resource" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -const clientBodyBufferSizeAnnotation = "nginx.ingress.kubernetes.io/client-body-buffer-size" - -// bufferPolicyFeature parses the "nginx.ingress.kubernetes.io/client-body-buffer-size" annotation -// from Ingresses and records them as generic Policies in the ingress-nginx provider-specific IR. -func bufferPolicyFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errList field.ErrorList - - // Build per-Ingress policies based on the annotation. - ingressPolicies := map[types.NamespacedName]*providerir.Policy{} - - for i := range ingresses { - ing := &ingresses[i] - val := ing.Annotations[clientBodyBufferSizeAnnotation] - if val == "" { - continue - } - - q, err := resource.ParseQuantity(val) - if err != nil { - errList = append(errList, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(clientBodyBufferSizeAnnotation), - val, - "failed to parse client-body-buffer-size", - )) - continue - } - - qCopy := q.DeepCopy() - key := types.NamespacedName{ - Namespace: ing.Namespace, - Name: ing.Name, - } - ingressPolicies[key] = &providerir.Policy{ - ClientBodyBufferSize: &qCopy, - } - } - - if len(ingressPolicies) == 0 { - // No relevant annotations, nothing to do. - return errList - } - - // Use RuleBackendSources to map each ingress policy to specific - // rule/backend indices on HTTPRoutes, populating provider-specific policies. - ruleGroups := common.GetRuleGroups(ingresses) - - for _, rg := range ruleGroups { - routeKey := types.NamespacedName{ - Namespace: rg.Namespace, - Name: common.RouteName(rg.Name, rg.Host), - } - - httpRouteContext, ok := ir.HTTPRoutes[routeKey] - if !ok { - continue - } - - for ruleIdx, backendSources := range httpRouteContext.RuleBackendSources { - if ruleIdx >= len(httpRouteContext.HTTPRoute.Spec.Rules) { - errList = append(errList, field.InternalError( - field.NewPath("httproute", httpRouteContext.HTTPRoute.Name, "spec", "rules").Index(ruleIdx), - fmt.Errorf("rule index %d exceeds available rules", ruleIdx), - )) - continue - } - - for backendIdx, source := range backendSources { - if source.Ingress == nil { - continue - } - - ingKey := types.NamespacedName{ - Namespace: source.Ingress.Namespace, - Name: source.Ingress.Name, - } - pol, ok := ingressPolicies[ingKey] - if !ok { - // This ingress has no buffer policy. - continue - } - - // Ensure provider-specific IR for ingress-nginx exists. - if httpRouteContext.ProviderSpecificIR.IngressNginx == nil { - httpRouteContext.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, - } - } - if httpRouteContext.ProviderSpecificIR.IngressNginx.Policies == nil { - httpRouteContext.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - - // Get or initialize the Policy for this ingress name. - p := httpRouteContext.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] - - if p.ClientBodyBufferSize == nil && pol.ClientBodyBufferSize != nil { - p.ClientBodyBufferSize = pol.ClientBodyBufferSize - } - - // Dedupe (rule, backend) pairs. - p = p.AddRuleBackendSources([]providerir.PolicyIndex{ - { - Rule: ruleIdx, - Backend: backendIdx, - }, - }) - - httpRouteContext.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] = p - } - } - - // Write back updated context into the IR. - ir.HTTPRoutes[routeKey] = httpRouteContext - } - - return errList -} diff --git a/pkg/i2gw/providers/ingressnginx/bodysize.go b/pkg/i2gw/providers/ingressnginx/bodysize.go new file mode 100644 index 000000000..8c8af4462 --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/bodysize.go @@ -0,0 +1,165 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "fmt" + "regexp" + "strings" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// Ref: https://github.com/kubernetes/ingress-nginx/blob/main/internal/ingress/annotations/parser/validators.go#L57 +var nginxSizeRegex = regexp.MustCompile(`^(?i)(\d+)([bkmg]?)$`) + +// convertNginxSizeToK8sQuantity converts nginx size format to Kubernetes resource.Quantity format. +// +// NGINX uses binary units (e.g. k = 2^10) while in Kubernetes, k = 1000 and Ki = 2^10. +func convertNginxSizeToK8sQuantity(nginxSize string) (string, error) { + nginxSize = strings.TrimSpace(nginxSize) + + matches := nginxSizeRegex.FindStringSubmatch(nginxSize) + if matches == nil { + return "", fmt.Errorf("invalid nginx size format: %q", nginxSize) + } + + number := matches[1] + unit := matches[2] + + // Convert nginx unit to K8s Quantity unit + switch strings.ToLower(unit) { + case "b", "": + return number, nil + case "k": + return number + "Ki", nil + case "m": + return number + "Mi", nil + case "g": + return number + "Gi", nil + default: + return "", fmt.Errorf("unsupported nginx size unit: %q", unit) + } +} + +// applyBodySizeToEmitterIR reads ingress-nginx body size annotations from ProviderIR sources and stores +// provider-neutral body size intent into EmitterIR, which will later be converted by each custom emitter. +// +// Currently supported annotations are: +// - nginx.ingress.kubernetes.io/proxy-body-size +// - nginx.ingress.kubernetes.io/client-body-buffer-size +func (p *Provider) applyBodySizeToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + + for key, pRouteCtx := range pIR.HTTPRoutes { + eRouteCtx, ok := eIR.HTTPRoutes[key] + if !ok { + continue + } + + for ruleIdx := range eRouteCtx.Spec.Rules { + if ruleIdx >= len(pRouteCtx.RuleBackendSources) { + continue + } + ing := getNonCanaryIngress(pRouteCtx.RuleBackendSources[ruleIdx]) + if ing == nil { + continue + } + + var ( + maxSize *resource.Quantity + bufferSize *resource.Quantity + ) + parsedAnnotations := make([]string, 0, 2) + + // handle proxy-body-size + if val, ok := ing.Annotations[ProxyBodySizeAnnotation]; ok && val != "" { + parsedAnnotations = append(parsedAnnotations, ProxyBodySizeAnnotation) + k8sSize, err := convertNginxSizeToK8sQuantity(val) + if err != nil { + p.notify(notifications.ErrorNotification, fmt.Sprintf("Invalid proxy-body-size annotation %q: %v, skipping body size", + val, err), ing) + continue + } + + quantity, err := resource.ParseQuantity(k8sSize) + if err != nil { + p.notify(notifications.ErrorNotification, fmt.Sprintf("Invalid proxy-body-size annotation %q: %v, skipping body size", + val, err), ing) + continue + } + maxSize = &quantity + } + + // handle client-body-buffer-size + if val, ok := ing.Annotations[ClientBodyBufferSizeAnnotation]; ok && val != "" { + parsedAnnotations = append(parsedAnnotations, ClientBodyBufferSizeAnnotation) + k8sSize, err := convertNginxSizeToK8sQuantity(val) + if err != nil { + p.notify(notifications.WarningNotification, fmt.Sprintf("Invalid client-body-buffer-size annotation %q: %v, skipping buffer size", + val, err), ing) + continue + } + + quantity, err := resource.ParseQuantity(k8sSize) + if err != nil { + p.notify(notifications.WarningNotification, fmt.Sprintf("Invalid client-body-buffer-size annotation %q: %v, skipping buffer size", + val, err), ing) + continue + } + bufferSize = &quantity + } + + if maxSize == nil && bufferSize == nil { + continue + } + + if eRouteCtx.BodySizeByRuleIdx == nil { + eRouteCtx.BodySizeByRuleIdx = make(map[int]*emitterir.BodySize) + } + + bodySizeIR := emitterir.BodySize{} + { + source := fmt.Sprintf("%s/%s", ing.Namespace, ing.Name) + message := "Most Gateway API implementations have reasonable body size and buffering defaults" + paths := make([]*field.Path, len(parsedAnnotations)) + for i, ann := range parsedAnnotations { + paths[i] = field.NewPath(ing.Namespace, ing.Name, "metadata", "annotations", fmt.Sprintf("%q", ann)) + } + bodySizeIR.Metadata = emitterir.NewExtensionFeatureMetadata( + source, + paths, + message, + ) + } + + if maxSize != nil { + bodySizeIR.MaxSize = maxSize + } + if bufferSize != nil { + bodySizeIR.BufferSize = bufferSize + } + + eRouteCtx.BodySizeByRuleIdx[ruleIdx] = &bodySizeIR + } + + eIR.HTTPRoutes[key] = eRouteCtx + } +} diff --git a/pkg/i2gw/providers/ingressnginx/bodysize_test.go b/pkg/i2gw/providers/ingressnginx/bodysize_test.go new file mode 100644 index 000000000..274cfcace --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/bodysize_test.go @@ -0,0 +1,217 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestConvertNginxSizeToK8sQuantity(t *testing.T) { + tests := []struct { + name string + nginxSize string + want string + wantErr bool + }{ + { + name: "lowercase b (bytes)", + nginxSize: "1024b", + want: "1024", + wantErr: false, + }, + { + name: "k becomes Ki", + nginxSize: "100k", + want: "100Ki", + wantErr: false, + }, + { + name: "K becomes as Ki", + nginxSize: "100K", + want: "100Ki", + wantErr: false, + }, + { + name: "m to K8s Mega", + nginxSize: "10m", + want: "10Mi", + wantErr: false, + }, + { + name: "M to K8s Mega", + nginxSize: "10M", + want: "10Mi", + wantErr: false, + }, + { + name: "g to K8s Giga", + nginxSize: "5g", + want: "5Gi", + wantErr: false, + }, + { + name: "G to K8s Giga", + nginxSize: "5G", + want: "5Gi", + wantErr: false, + }, + { + name: "no unit (bytes)", + nginxSize: "512", + want: "512", + wantErr: false, + }, + { + name: "whitespace trimmed", + nginxSize: " 10m ", + want: "10Mi", + wantErr: false, + }, + { + name: "invalid format - letters only", + nginxSize: "abc", + want: "", + wantErr: true, + }, + { + name: "invalid unit - x", + nginxSize: "10x", + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := convertNginxSizeToK8sQuantity(tt.nginxSize) + if (err != nil) != tt.wantErr { + t.Errorf("convertNginxSizeToK8sQuantity() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("convertNginxSizeToK8sQuantity() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestApplyBodySizeToEmitterIR_SetMaxSize(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + annotations := map[string]string{ + ProxyBodySizeAnnotation: "10m", + } + pIR, eIR := setupBodySizeTest(key, annotations) + + p := &Provider{notify: notifications.NoopNotify} + p.applyBodySizeToEmitterIR(pIR, &eIR) + + bodySizeIR := eIR.HTTPRoutes[key].BodySizeByRuleIdx[0] + if bodySizeIR == nil { + t.Fatalf("expected body size IR to be set for rule index 0") + } + if bodySizeIR.MaxSize.String() != "10Mi" { + t.Fatalf("expected max size 10Mi, got %s", bodySizeIR.MaxSize.String()) + } +} + +func TestApplyBodySizeToEmitterIR_SetBufferSize(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + annotations := map[string]string{ + ClientBodyBufferSizeAnnotation: "10m", + } + pIR, eIR := setupBodySizeTest(key, annotations) + + p := &Provider{notify: notifications.NoopNotify} + p.applyBodySizeToEmitterIR(pIR, &eIR) + + bodySizeIR := eIR.HTTPRoutes[key].BodySizeByRuleIdx[0] + if bodySizeIR == nil { + t.Fatalf("expected body size IR to be set for rule index 0") + } + if bodySizeIR.BufferSize.String() != "10Mi" { + t.Fatalf("expected buffer size 10Mi, got %s", bodySizeIR.BufferSize.String()) + } +} + +func TestApplyBodySizeToEmitterIR_SetMaxAndBufferSize(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + annotations := map[string]string{ + ProxyBodySizeAnnotation: "100m", + ClientBodyBufferSizeAnnotation: "50m", + } + pIR, eIR := setupBodySizeTest(key, annotations) + + p := &Provider{notify: notifications.NoopNotify} + p.applyBodySizeToEmitterIR(pIR, &eIR) + + bodySizeIR := eIR.HTTPRoutes[key].BodySizeByRuleIdx[0] + if bodySizeIR == nil { + t.Fatalf("expected body size IR to be set for rule index 0") + } + if bodySizeIR.MaxSize.String() != "100Mi" { + t.Fatalf("expected max size 100Mi, got %s", bodySizeIR.MaxSize.String()) + } + if bodySizeIR.BufferSize.String() != "50Mi" { + t.Fatalf("expected buffer size 50Mi, got %s", bodySizeIR.BufferSize.String()) + } +} + +func setupBodySizeTest(httpRouteKey types.NamespacedName, ingAnnotations map[string]string) (providerir.ProviderIR, emitterir.EmitterIR) { + parentRefs := []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}} + + ing := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: httpRouteKey.Namespace, + Name: "ing", + Annotations: ingAnnotations, + }, + Spec: networkingv1.IngressSpec{}, + } + + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: httpRouteKey.Namespace, Name: httpRouteKey.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: append([]gatewayv1.ParentReference(nil), parentRefs...), + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + {}, + }, + }, + } + + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[httpRouteKey] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{{ + {Ingress: &ing}, + }}, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[httpRouteKey] = emitterir.HTTPRouteContext{HTTPRoute: route} + return pIR, eIR +} diff --git a/pkg/i2gw/providers/ingressnginx/canary.go b/pkg/i2gw/providers/ingressnginx/canary.go index ba2d7555c..b0f3633d7 100644 --- a/pkg/i2gw/providers/ingressnginx/canary.go +++ b/pkg/i2gw/providers/ingressnginx/canary.go @@ -31,142 +31,240 @@ import ( // canaryConfig holds the parsed canary configuration from a single Ingress type canaryConfig struct { + isHeader bool + header string + headerValue string + isWeight bool weight int32 weightTotal int32 } -// parseCanaryConfig extracts canary weight configuration from an Ingress -func parseCanaryConfig(ingress *networkingv1.Ingress) (canaryConfig, error) { +// parseCanaryConfig extracts canary weight configuration from an Ingress. +// Invalid annotation values are reported as notifications and fall back to defaults +// rather than returning errors. +func parseCanaryConfig(notify notifications.NotifyFunc, ingress *networkingv1.Ingress) canaryConfig { config := canaryConfig{ weight: 0, weightTotal: 100, // default } - if weight := ingress.Annotations[CanaryWeightAnnotation]; weight != "" { + if ingress.Annotations[CanaryByHeaderPattern] != "" { + notify(notifications.WarningNotification, fmt.Sprintf("ingress %s/%s uses unsupported annotation %s", + ingress.Namespace, ingress.Name, CanaryByHeaderPattern), ingress) + } + + if ingress.Annotations[CanaryByCookie] != "" { + notify(notifications.WarningNotification, fmt.Sprintf("ingress %s/%s uses unsupported annotation %s", + ingress.Namespace, ingress.Name, CanaryByCookie), ingress) + } + + if ingress.Annotations[CanaryByHeader] != "" { + config.isHeader = true + } + config.header = ingress.Annotations[CanaryByHeader] + config.headerValue = ingress.Annotations[CanaryByHeaderValue] + + weight := ingress.Annotations[CanaryWeightAnnotation] + + if weight != "" { + config.isWeight = true w, err := strconv.ParseInt(weight, 10, 32) if err != nil { - return config, fmt.Errorf("invalid canary-weight annotation %q: %w", weight, err) - } - if w < 0 { - return config, fmt.Errorf("canary-weight must be non-negative, got %d", w) + notify(notifications.ErrorNotification, fmt.Sprintf("Invalid canary-weight annotation %q, defaulting to 0: %v", + weight, err), ingress) + config.isWeight = false + } else if w < 0 { + notify(notifications.ErrorNotification, fmt.Sprintf("Negative canary-weight %d, defaulting to 0", w), ingress) + config.isWeight = false + } else { + config.weight = int32(w) } - config.weight = int32(w) } if total := ingress.Annotations[CanaryWeightTotalAnnotation]; total != "" { wt, err := strconv.ParseInt(total, 10, 32) if err != nil { - return config, fmt.Errorf("invalid canary-weight-total annotation %q: %w", total, err) + notify(notifications.ErrorNotification, fmt.Sprintf("Invalid canary-weight-total annotation %q, defaulting to 100: %v", + total, err), ingress) } if wt <= 0 { - return config, fmt.Errorf("canary-weight-total must be positive, got %d", wt) + notify(notifications.ErrorNotification, fmt.Sprintf("Non-positive canary-weight-total %d, defaulting to 100", + wt), ingress) + } else { + config.weightTotal = int32(wt) } - config.weightTotal = int32(wt) } if config.weight > config.weightTotal { - return config, fmt.Errorf("canary-weight (%d) exceeds canary-weight-total (%d)", config.weight, config.weightTotal) + notify(notifications.ErrorNotification, fmt.Sprintf("Canary-weight (%d) exceeding canary-weight-total (%d), capping weight to %d", + config.weight, config.weightTotal, config.weightTotal), ingress) + config.weight = config.weightTotal } - return config, nil + return config +} + +func createHeaderMatchRule(header string, value string, existingMatches []gatewayv1.HTTPRouteMatch, backend gatewayv1.HTTPBackendRef) gatewayv1.HTTPRouteRule { + headerMatch := gatewayv1.HTTPRouteMatch{ + Headers: []gatewayv1.HTTPHeaderMatch{ + { + Name: gatewayv1.HTTPHeaderName(header), + Value: value, + }, + }, + } + if len(existingMatches) > 0 && existingMatches[0].Path != nil { + headerMatch.Path = existingMatches[0].Path + } + return gatewayv1.HTTPRouteRule{ + Matches: []gatewayv1.HTTPRouteMatch{headerMatch}, + BackendRefs: []gatewayv1.HTTPBackendRef{backend}, + } } -func canaryFeature(ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *providerir.ProviderIR) field.ErrorList { +func canaryFeature(notify notifications.NotifyFunc, ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *providerir.ProviderIR) field.ErrorList { ruleGroups := common.GetRuleGroups(ingresses) var errList field.ErrorList for _, rg := range ruleGroups { key := types.NamespacedName{Namespace: rg.Namespace, Name: common.RouteName(rg.Name, rg.Host)} - httpRouteContext, ok := ir.HTTPRoutes[key] - if !ok { - continue - } - for ruleIdx, backendSources := range httpRouteContext.RuleBackendSources { - if ruleIdx >= len(httpRouteContext.HTTPRoute.Spec.Rules) { - errList = append(errList, field.InternalError( - field.NewPath("httproute", httpRouteContext.HTTPRoute.Name, "spec", "rules").Index(ruleIdx), - fmt.Errorf("rule index %d exceeds available rules", ruleIdx), - )) - continue - } + if httpRouteContext, ok := ir.HTTPRoutes[key]; ok { + var rulesToAdd []gatewayv1.HTTPRouteRule + var sourcesToAdd [][]providerir.BackendSource - // There must be a non canary backend and at most one canary backend - // This is done in place. - var canaryBackend *gatewayv1.HTTPBackendRef - var nonCanaryBackend *gatewayv1.HTTPBackendRef - var canaryConfig canaryConfig - var canarySourceIngress *networkingv1.Ingress + for ruleIdx := 0; ruleIdx < len(httpRouteContext.HTTPRoute.Spec.Rules); ruleIdx++ { + backendSources := httpRouteContext.RuleBackendSources[ruleIdx] + existingMatches := httpRouteContext.HTTPRoute.Spec.Rules[ruleIdx].Matches + canaryWeight, nonCanaryWeight, config, canaryBackendIdx, nonCanaryBackendIdx, parseErrs := getCanaryInfo(notify, backendSources, "httproute", httpRouteContext.HTTPRoute.Name, ruleIdx) + errList = append(errList, parseErrs...) + if canaryBackendIdx != -1 && nonCanaryBackendIdx != -1 { + // Set weights if isWeight is true or both header and weight are not set (all traffic should go to non-canary) + if config.isWeight || !config.isHeader { + httpRouteContext.HTTPRoute.Spec.Rules[ruleIdx].BackendRefs[canaryBackendIdx].Weight = &canaryWeight + httpRouteContext.HTTPRoute.Spec.Rules[ruleIdx].BackendRefs[nonCanaryBackendIdx].Weight = &nonCanaryWeight + } - // Find the canary and non-canary backends - for backendIdx, source := range backendSources { - if source.Ingress == nil { - continue - } + if config.isHeader { + canaryBackend := httpRouteContext.HTTPRoute.Spec.Rules[ruleIdx].BackendRefs[canaryBackendIdx] + nonCanaryBackend := httpRouteContext.HTTPRoute.Spec.Rules[ruleIdx].BackendRefs[nonCanaryBackendIdx] + canaryBackendSource := backendSources[canaryBackendIdx] + nonCanaryBackendSource := backendSources[nonCanaryBackendIdx] - backendRef := &httpRouteContext.HTTPRoute.Spec.Rules[ruleIdx].BackendRefs[backendIdx] + var canaryHeaderValue = "always" + if config.headerValue != "" { + canaryHeaderValue = config.headerValue + } + canaryBackendCopy := canaryBackend + canaryBackendCopy.Weight = nil - if source.Ingress.Annotations[CanaryAnnotation] == "true" { - if canaryBackend != nil { - errList = append(errList, field.Invalid( - field.NewPath("httproute", httpRouteContext.HTTPRoute.Name, "spec", "rules").Index(ruleIdx).Child("backendRefs"), - fmt.Sprintf("ingresses %s/%s and %s/%s", canarySourceIngress.Namespace, canarySourceIngress.Name, source.Ingress.Namespace, source.Ingress.Name), - "at most one canary backend is allowed per rule", - )) - continue - } + var canaryMatchRule = createHeaderMatchRule(config.header, canaryHeaderValue, existingMatches, canaryBackendCopy) + rulesToAdd = append(rulesToAdd, canaryMatchRule) + sourcesToAdd = append(sourcesToAdd, []providerir.BackendSource{canaryBackendSource, nonCanaryBackendSource}) - config, err := parseCanaryConfig(source.Ingress) - if err != nil { - errList = append(errList, field.Invalid( - field.NewPath("ingress", source.Ingress.Namespace, source.Ingress.Name, "metadata", "annotations"), - source.Ingress.Annotations, - fmt.Sprintf("failed to parse canary configuration: %v", err), - )) - continue - } + if config.headerValue == "" { + nonCanaryBackendCopy := nonCanaryBackend + nonCanaryBackendCopy.Weight = nil + var nonCanaryMatchRule = createHeaderMatchRule(config.header, "never", existingMatches, nonCanaryBackendCopy) + rulesToAdd = append(rulesToAdd, nonCanaryMatchRule) + sourcesToAdd = append(sourcesToAdd, []providerir.BackendSource{nonCanaryBackendSource}) + } - canaryBackend = backendRef - canaryConfig = config - canarySourceIngress = source.Ingress - } else { - if nonCanaryBackend != nil { - errList = append(errList, field.Invalid( - field.NewPath("httproute", httpRouteContext.HTTPRoute.Name, "spec", "rules").Index(ruleIdx).Child("backendRefs"), - "multiple non-canary backends", - "at most one non-canary backend is allowed per rule when using canary", - )) - continue + if !config.isWeight { + // Find and remove the canary backend from the original rule's BackendRefs + var filteredBackendRefs []gatewayv1.HTTPBackendRef + var filteredBackendSources []providerir.BackendSource + for i := range httpRouteContext.HTTPRoute.Spec.Rules[ruleIdx].BackendRefs { + if i != canaryBackendIdx { + filteredBackendRefs = append(filteredBackendRefs, httpRouteContext.HTTPRoute.Spec.Rules[ruleIdx].BackendRefs[i]) + filteredBackendSources = append(filteredBackendSources, backendSources[i]) + } + } + httpRouteContext.HTTPRoute.Spec.Rules[ruleIdx].BackendRefs = filteredBackendRefs + httpRouteContext.RuleBackendSources[ruleIdx] = filteredBackendSources + } } - nonCanaryBackend = backendRef } } + httpRouteContext.HTTPRoute.Spec.Rules = append(httpRouteContext.HTTPRoute.Spec.Rules, rulesToAdd...) + httpRouteContext.RuleBackendSources = append(httpRouteContext.RuleBackendSources, sourcesToAdd...) + ir.HTTPRoutes[key] = httpRouteContext + } - // If there is a canary backend, validate and set weights - if canaryBackend != nil { - if nonCanaryBackend == nil { - errList = append(errList, field.Invalid( - field.NewPath("httproute", httpRouteContext.HTTPRoute.Name, "spec", "rules").Index(ruleIdx).Child("backendRefs"), - "canary backend without non-canary backend", - "a non-canary backend is required when using canary", + if grpcRouteContext, ok := ir.GRPCRoutes[key]; ok { + for ruleIdx, backendSources := range grpcRouteContext.RuleBackendSources { + if ruleIdx >= len(grpcRouteContext.GRPCRoute.Spec.Rules) { + errList = append(errList, field.InternalError( + field.NewPath("grpcroute", grpcRouteContext.GRPCRoute.Name, "spec", "rules").Index(ruleIdx), + fmt.Errorf("rule index %d exceeds available rules", ruleIdx), )) continue } - canaryWeight := canaryConfig.weight + canaryWeight, nonCanaryWeight, _, canaryBackendIdx, nonCanaryBackendIdx, parseErrs := getCanaryInfo(notify, backendSources, "grpcroute", grpcRouteContext.GRPCRoute.Name, ruleIdx) + errList = append(errList, parseErrs...) + if canaryBackendIdx != -1 && nonCanaryBackendIdx != -1 { + grpcRouteContext.GRPCRoute.Spec.Rules[ruleIdx].BackendRefs[canaryBackendIdx].Weight = &canaryWeight + grpcRouteContext.GRPCRoute.Spec.Rules[ruleIdx].BackendRefs[nonCanaryBackendIdx].Weight = &nonCanaryWeight + } + } + } + } - canaryBackend.Weight = &canaryWeight - nonCanaryWeight := canaryConfig.weightTotal - canaryWeight - nonCanaryBackend.Weight = &nonCanaryWeight + return errList +} + +func getCanaryInfo(notify notifications.NotifyFunc, backendSources []providerir.BackendSource, routeType, routeName string, ruleIdx int) (int32, int32, canaryConfig, int, int, field.ErrorList) { + var errList field.ErrorList + canaryBackendIdx := -1 + nonCanaryBackendIdx := -1 + var config canaryConfig - notify(notifications.InfoNotification, fmt.Sprintf("parsed canary annotations of ingress %s/%s and set weights (canary: %d, non-canary: %d, total: %d)", - canarySourceIngress.Namespace, canarySourceIngress.Name, canaryWeight, nonCanaryWeight, canaryConfig.weightTotal), &httpRouteContext.HTTPRoute) + for backendIdx, source := range backendSources { + if source.Ingress == nil { + continue + } + + if source.Ingress.Annotations[CanaryAnnotation] == "true" { + if canaryBackendIdx != -1 { + errList = append(errList, field.Invalid( + field.NewPath(routeType, routeName, "spec", "rules").Index(ruleIdx).Child("backendRefs"), + "multiple canary backends", + "at most one canary backend is allowed per rule", + )) + continue + } + + parsedConfig := parseCanaryConfig(notify, source.Ingress) + + canaryBackendIdx = backendIdx + config = parsedConfig + } else { + if nonCanaryBackendIdx != -1 { + errList = append(errList, field.Invalid( + field.NewPath(routeType, routeName, "spec", "rules").Index(ruleIdx).Child("backendRefs"), + "multiple non-canary backends", + "at most one non-canary backend is allowed per rule when using canary", + )) + continue } + nonCanaryBackendIdx = backendIdx } } - if len(errList) > 0 { - return errList + if canaryBackendIdx != -1 { + if nonCanaryBackendIdx == -1 { + errList = append(errList, field.Invalid( + field.NewPath(routeType, routeName, "spec", "rules").Index(ruleIdx).Child("backendRefs"), + "canary backend without non-canary backend", + "a non-canary backend is required when using canary", + )) + return 0, 0, config, -1, -1, errList + } + canaryWeight := config.weight + nonCanaryWeight := config.weightTotal - canaryWeight + return canaryWeight, nonCanaryWeight, config, canaryBackendIdx, nonCanaryBackendIdx, errList } - return nil + + return 0, 0, config, -1, -1, errList } diff --git a/pkg/i2gw/providers/ingressnginx/canary_test.go b/pkg/i2gw/providers/ingressnginx/canary_test.go index d59dbba05..0fcad23ae 100644 --- a/pkg/i2gw/providers/ingressnginx/canary_test.go +++ b/pkg/i2gw/providers/ingressnginx/canary_test.go @@ -17,12 +17,15 @@ limitations under the License. package ingressnginx import ( - "strings" "testing" "github.com/google/go-cmp/cmp" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) func Test_parseCanaryConfig(t *testing.T) { @@ -30,8 +33,6 @@ func Test_parseCanaryConfig(t *testing.T) { name string ingress networkingv1.Ingress expectedConfig canaryConfig - expectError bool - errorContains string }{ { name: "actually get weights", @@ -45,10 +46,30 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, expectedConfig: canaryConfig{ + isHeader: false, + header: "", + headerValue: "", + isWeight: true, + weight: 50, + weightTotal: 100, + }, + }, + { + name: "actually get weights with canary-weight", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/canary": "true", + "nginx.ingress.kubernetes.io/canary-weight": "50", + "nginx.ingress.kubernetes.io/canary-weight-total": "100", + }, + }, + }, + expectedConfig: canaryConfig{ + isWeight: true, weight: 50, weightTotal: 100, }, - expectError: false, }, { name: "assigns default weight total", @@ -61,10 +82,13 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, expectedConfig: canaryConfig{ + isHeader: false, + header: "", + headerValue: "", + isWeight: true, weight: 50, weightTotal: 100, }, - expectError: false, }, { name: "weight set to 0", @@ -77,10 +101,13 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, expectedConfig: canaryConfig{ + isHeader: false, + header: "", + headerValue: "", + isWeight: true, weight: 0, weightTotal: 100, }, - expectError: false, }, { name: "weight set to 100", @@ -93,10 +120,13 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, expectedConfig: canaryConfig{ + isHeader: false, + header: "", + headerValue: "", + isWeight: true, weight: 100, weightTotal: 100, }, - expectError: false, }, { name: "custom weight total", @@ -110,10 +140,13 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, expectedConfig: canaryConfig{ + isHeader: false, + header: "", + headerValue: "", + isWeight: true, weight: 50, weightTotal: 200, }, - expectError: false, }, { name: "no weight annotation defaults to 0", @@ -125,13 +158,16 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, expectedConfig: canaryConfig{ + isHeader: false, + header: "", + headerValue: "", + isWeight: false, weight: 0, weightTotal: 100, }, - expectError: false, }, { - name: "errors on non integer weight", + name: "invalid non-integer weight defaults to 0", ingress: networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ @@ -140,11 +176,14 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, }, - expectError: true, - errorContains: "invalid canary-weight annotation", + expectedConfig: canaryConfig{ + isWeight: false, + weight: 0, + weightTotal: 100, + }, }, { - name: "errors on non integer weight total", + name: "invalid non-integer weight total defaults to 100", ingress: networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ @@ -153,11 +192,14 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, }, - expectError: true, - errorContains: "invalid canary-weight-total annotation", + expectedConfig: canaryConfig{ + isWeight: false, + weight: 0, + weightTotal: 100, + }, }, { - name: "errors on invalid weight string", + name: "invalid weight string defaults to 0", ingress: networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ @@ -166,11 +208,14 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, }, - expectError: true, - errorContains: "invalid canary-weight annotation", + expectedConfig: canaryConfig{ + isWeight: false, + weight: 0, + weightTotal: 100, + }, }, { - name: "errors on invalid weight total string", + name: "invalid weight total string defaults to 100", ingress: networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ @@ -179,11 +224,14 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, }, - expectError: true, - errorContains: "invalid canary-weight-total annotation", + expectedConfig: canaryConfig{ + isWeight: false, + weight: 0, + weightTotal: 100, + }, }, { - name: "errors on negative weight", + name: "negative weight defaults to 0", ingress: networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ @@ -192,11 +240,14 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, }, - expectError: true, - errorContains: "canary-weight must be non-negative", + expectedConfig: canaryConfig{ + isWeight: false, + weight: 0, + weightTotal: 100, + }, }, { - name: "errors on zero weight total", + name: "zero weight total defaults to 100", ingress: networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ @@ -205,11 +256,14 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, }, - expectError: true, - errorContains: "canary-weight-total must be positive", + expectedConfig: canaryConfig{ + isWeight: false, + weight: 0, + weightTotal: 100, + }, }, { - name: "errors on negative weight total", + name: "negative weight total defaults to 100", ingress: networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ @@ -218,11 +272,14 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, }, - expectError: true, - errorContains: "canary-weight-total must be positive", + expectedConfig: canaryConfig{ + isWeight: false, + weight: 0, + weightTotal: 100, + }, }, { - name: "errors when weight exceeds total", + name: "weight exceeding total is capped", ingress: networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ @@ -232,8 +289,11 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, }, - expectError: true, - errorContains: "canary-weight (150) exceeds canary-weight-total (100)", + expectedConfig: canaryConfig{ + isWeight: true, + weight: 100, + weightTotal: 100, + }, }, { name: "weight equal to total is valid", @@ -247,30 +307,98 @@ func Test_parseCanaryConfig(t *testing.T) { }, }, expectedConfig: canaryConfig{ + isHeader: false, + header: "", + headerValue: "", + isWeight: true, weight: 200, weightTotal: 200, }, - expectError: false, + }, + { + name: "parses canary-by-header", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/canary": "true", + "nginx.ingress.kubernetes.io/canary-by-header": "X-Canary", + }, + }, + }, + expectedConfig: canaryConfig{ + isHeader: true, + header: "X-Canary", + headerValue: "", + isWeight: false, + weight: 0, + weightTotal: 100, + }, + }, + { + name: "parses canary-by-header with header value", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/canary": "true", + "nginx.ingress.kubernetes.io/canary-by-header": "X-Canary", + "nginx.ingress.kubernetes.io/canary-by-header-value": "canary-deploy", + }, + }, + }, + expectedConfig: canaryConfig{ + isHeader: true, + header: "X-Canary", + headerValue: "canary-deploy", + isWeight: false, + weight: 0, + weightTotal: 100, + }, + }, + { + name: "parses both weight and header", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/canary": "true", + "nginx.ingress.kubernetes.io/canary-by-header": "X-Canary", + "nginx.ingress.kubernetes.io/canary-by-header-value": "always", + "nginx.ingress.kubernetes.io/canary-weight": "30", + }, + }, + }, + expectedConfig: canaryConfig{ + isHeader: true, + header: "X-Canary", + headerValue: "always", + isWeight: true, + weight: 30, + weightTotal: 100, + }, + }, + { + name: "header value without header name is still parsed", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/canary": "true", + "nginx.ingress.kubernetes.io/canary-by-header-value": "test-value", + }, + }, + }, + expectedConfig: canaryConfig{ + isHeader: false, + header: "", + headerValue: "test-value", + isWeight: false, + weight: 0, + weightTotal: 100, + }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - config, err := parseCanaryConfig(&tc.ingress) - - if tc.expectError { - if err == nil { - t.Fatalf("expected error but got none") - } - if tc.errorContains != "" && !strings.Contains(err.Error(), tc.errorContains) { - t.Fatalf("expected error containing %q, got %q", tc.errorContains, err.Error()) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + config := parseCanaryConfig(notifications.NoopNotify, &tc.ingress) if diff := cmp.Diff(config, tc.expectedConfig, cmp.AllowUnexported(canaryConfig{})); diff != "" { t.Fatalf("parseCanaryConfig() mismatch (-want +got):\n%s", diff) @@ -278,3 +406,251 @@ func Test_parseCanaryConfig(t *testing.T) { }) } } + +func Test_canaryFeature_GRPC(t *testing.T) { + ingress1 := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grpcbin", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "GRPC", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "grpcbin.local", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/hello.HelloService/abc", + }, + }, + }, + }, + }, + }, + }, + } + ingress2 := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grpcbin2", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "GRPC", + "nginx.ingress.kubernetes.io/canary": "true", + "nginx.ingress.kubernetes.io/canary-weight": "10", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "grpcbin.local", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/hello.HelloService/abc", + }, + }, + }, + }, + }, + }, + }, + } + + ir := &providerir.ProviderIR{ + GRPCRoutes: map[types.NamespacedName]providerir.GRPCRouteContext{ + {Namespace: "default", Name: "grpcbin-grpcbin-local"}: { + GRPCRoute: gatewayv1.GRPCRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grpcbin-grpcbin-local", + Namespace: "default", + }, + Spec: gatewayv1.GRPCRouteSpec{ + Rules: []gatewayv1.GRPCRouteRule{ + { + BackendRefs: []gatewayv1.GRPCBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "grpcbin", + }, + }, + }, + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "grpcbin2", + }, + }, + }, + }, + }, + }, + }, + }, + RuleBackendSources: [][]providerir.BackendSource{ + { + {Ingress: ingress1}, + {Ingress: ingress2}, + }, + }, + }, + }, + } + + errs := canaryFeature(notifications.NoopNotify, []networkingv1.Ingress{*ingress1, *ingress2}, nil, ir) + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + + route := ir.GRPCRoutes[types.NamespacedName{Namespace: "default", Name: "grpcbin-grpcbin-local"}] + backendRefs := route.GRPCRoute.Spec.Rules[0].BackendRefs + + if len(backendRefs) != 2 { + t.Fatalf("expected 2 backend refs, got %d", len(backendRefs)) + } + + if backendRefs[0].Weight == nil { + t.Fatalf("expected weight for non-canary backend to be set, got nil") + } + // Non-canary weight should be 90 (100-10) + if *backendRefs[0].Weight != 90 { + t.Errorf("expected weight 90 for non-canary backend, got %d", *backendRefs[0].Weight) + } + + if backendRefs[1].Weight == nil { + t.Fatalf("expected weight for canary backend to be set, got nil") + } + // Canary weight should be 10 + if *backendRefs[1].Weight != 10 { + t.Errorf("expected weight 10 for canary backend, got %d", *backendRefs[1].Weight) + } +} + +func Test_canaryFeature_GRPC_ByWeight(t *testing.T) { + ingress1 := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grpcbin", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "GRPC", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "grpcbin.local", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/hello.HelloService/abc", + }, + }, + }, + }, + }, + }, + }, + } + ingress2 := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grpcbin2", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "GRPC", + "nginx.ingress.kubernetes.io/canary": "true", + "nginx.ingress.kubernetes.io/canary-weight": "25", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "grpcbin.local", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/hello.HelloService/abc", + }, + }, + }, + }, + }, + }, + }, + } + + ir := &providerir.ProviderIR{ + GRPCRoutes: map[types.NamespacedName]providerir.GRPCRouteContext{ + {Namespace: "default", Name: "grpcbin-grpcbin-local"}: { + GRPCRoute: gatewayv1.GRPCRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grpcbin-grpcbin-local", + Namespace: "default", + }, + Spec: gatewayv1.GRPCRouteSpec{ + Rules: []gatewayv1.GRPCRouteRule{ + { + BackendRefs: []gatewayv1.GRPCBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "grpcbin", + }, + }, + }, + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "grpcbin2", + }, + }, + }, + }, + }, + }, + }, + }, + RuleBackendSources: [][]providerir.BackendSource{ + { + {Ingress: ingress1}, + {Ingress: ingress2}, + }, + }, + }, + }, + } + + errs := canaryFeature(notifications.NoopNotify, []networkingv1.Ingress{*ingress1, *ingress2}, nil, ir) + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + + route := ir.GRPCRoutes[types.NamespacedName{Namespace: "default", Name: "grpcbin-grpcbin-local"}] + backendRefs := route.GRPCRoute.Spec.Rules[0].BackendRefs + + if len(backendRefs) != 2 { + t.Fatalf("expected 2 backend refs, got %d", len(backendRefs)) + } + + if backendRefs[0].Weight == nil { + t.Fatalf("expected weight for non-canary backend to be set, got nil") + } + // Non-canary weight should be 75 (100-25) + if *backendRefs[0].Weight != 75 { + t.Errorf("expected weight 75 for non-canary backend, got %d", *backendRefs[0].Weight) + } + + if backendRefs[1].Weight == nil { + t.Fatalf("expected weight for canary backend to be set, got nil") + } + // Canary weight should be 25 + if *backendRefs[1].Weight != 25 { + t.Errorf("expected weight 25 for canary backend, got %d", *backendRefs[1].Weight) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/converter.go b/pkg/i2gw/providers/ingressnginx/converter.go index bf7d06920..b0321905a 100644 --- a/pkg/i2gw/providers/ingressnginx/converter.go +++ b/pkg/i2gw/providers/ingressnginx/converter.go @@ -17,67 +17,127 @@ limitations under the License. package ingressnginx import ( + "fmt" + "strings" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" + networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/util/validation/field" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) // resourcesToIRConverter implements the ToIR function of i2gw.ResourcesToIRConverter interface. type resourcesToIRConverter struct { featureParsers []i2gw.FeatureParser + notify notifications.NotifyFunc } // newResourcesToIRConverter returns an ingress-nginx resourcesToIRConverter instance. -func newResourcesToIRConverter() *resourcesToIRConverter { +func newResourcesToIRConverter(notify notifications.NotifyFunc) *resourcesToIRConverter { return &resourcesToIRConverter{ featureParsers: []i2gw.FeatureParser{ canaryFeature, - bufferPolicyFeature, - corsPolicyFeature, - rateLimitPolicyFeature, - proxyBodySizeFeature, - proxySendTimeoutFeature, - proxyReadTimeoutFeature, - proxyConnectTimeoutFeature, - enableAccessLogFeature, - extAuthFeature, - basicAuthFeature, - sessionAffinityFeature, - loadBalancingFeature, + createBackendTLSPolicies, + redirectFeature, + headerModifierFeature, + regexFeature, backendTLSFeature, - serviceUpstreamFeature, - backendProtocolFeature, // Must come after serviceUpstreamFeature. - sslRedirectFeature, + sessionAffinityFeature, sslPassthroughFeature, - rewriteTargetFeature, - useRegexFeature, - headerModifierFeature, }, + notify: notify, } } -func (c *resourcesToIRConverter) convert(storage *storage) (providerir.ProviderIR, field.ErrorList) { +func (c *resourcesToIRConverter) convert(notify notifications.NotifyFunc, storage *storage) (providerir.ProviderIR, field.ErrorList) { // TODO(liorliberman) temporary until we decide to change ToIR and featureParsers to get a map of [types.NamespacedName]*networkingv1.Ingress instead of a list ingressList := storage.Ingresses.List() + // Filter Ingresses for common conversion. + // + // backend-protocol annotations are now projected by emitter-specific logic, + // so even GRPC/GRPCS upstreams still convert to HTTPRoutes here. Emitters can + // then project backend protocol behavior without forcing GRPCRoute output. + var httpIngresses []networkingv1.Ingress + + for _, ing := range ingressList { + if val, ok := ing.Annotations[BackendProtocolAnnotation]; ok { + switch strings.ToUpper(val) { + case "GRPC", "GRPCS", "HTTP", "HTTPS", "AUTO_HTTP": + httpIngresses = append(httpIngresses, ing) + default: + // Should cover FCGI and unknown + notify(notifications.WarningNotification, fmt.Sprintf("%s backend-protocol is not supported in Gateway API conversion for ingress %s/%s", val, ing.Namespace, ing.Name), nil) + httpIngresses = append(httpIngresses, ing) + } + } else { + httpIngresses = append(httpIngresses, ing) + } + } + // Convert plain ingress resources to gateway resources, ignoring all // provider-specific features. - ir, errs := common.ToIR(ingressList, storage.ServicePorts, i2gw.ProviderImplementationSpecificOptions{}) + pIR, errs := common.ToIR(httpIngresses, nil, storage.ServicePorts, i2gw.ProviderImplementationSpecificOptions{ + ToImplementationSpecificHTTPPathTypeMatch: implementationSpecificPathMatch, + }) + + // Warn about hosts that lack TLS certificates. Ingress NGINX serves TLS + // for all hosts using a self-signed certificate when no explicit cert is + // configured. We do not translate this behavior. + for _, gwCtx := range pIR.Gateways { + httpsHosts := map[string]struct{}{} + var httpHosts []string + for _, listener := range gwCtx.Gateway.Spec.Listeners { + if listener.Hostname == nil { + continue + } + host := string(*listener.Hostname) + switch listener.Port { + case 443: + httpsHosts[host] = struct{}{} + case 80: + httpHosts = append(httpHosts, host) + } + } + for _, host := range httpHosts { + if _, ok := httpsHosts[host]; !ok { + c.notify(notifications.WarningNotification, fmt.Sprintf( + "Ingress NGINX serves TLS traffic for host %q with a self-signed certificate. This behavior will not be translated and the host will not be accessible via HTTPS.", + host)) + } + } + } + + for _, ingress := range ingressList { + for annotation := range ingress.Annotations { + if _, ok := parsedAnnotations[annotation]; !ok && strings.HasPrefix(annotation, ingressNGINXAnnotationsPrefix) { + c.notify(notifications.WarningNotification, fmt.Sprintf("Unsupported annotation %v", annotation), &ingress) + } + } + } + if len(errs) > 0 { return providerir.ProviderIR{}, errs } for _, parseFeatureFunc := range c.featureParsers { // Apply the feature parsing function to the gateway resources, one by one. - parseErrs := parseFeatureFunc(ingressList, storage.ServicePorts, &ir) + parseErrs := parseFeatureFunc(c.notify, ingressList, storage.ServicePorts, &pIR) // Append the parsing errors to the error list. errs = append(errs, parseErrs...) } - // Cross-feature validation that depends on derived host-wide regex mode. - errs = append(errs, validateRegexCookiePath(&ir)...) + return pIR, errs +} - return ir, errs +func implementationSpecificPathMatch(path *gatewayv1.HTTPPathMatch) { + // Nginx Ingress Controller treats ImplementationSpecific as Prefix by default, + // unless regex characters are present (handled by regexFeature). + // We safely default to Prefix here to pass the common.ToIR check. + t := gatewayv1.PathMatchPathPrefix + path.Type = &t } diff --git a/pkg/i2gw/providers/ingressnginx/converter_test.go b/pkg/i2gw/providers/ingressnginx/converter_test.go index a7b6e435a..f06caf268 100644 --- a/pkg/i2gw/providers/ingressnginx/converter_test.go +++ b/pkg/i2gw/providers/ingressnginx/converter_test.go @@ -18,19 +18,21 @@ package ingressnginx import ( "errors" + "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" apiv1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" apiequality "k8s.io/apimachinery/pkg/api/equality" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" - "k8s.io/utils/ptr" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) @@ -138,6 +140,7 @@ func Test_ToIR(t *testing.T) { }, Hostnames: []gatewayv1.Hostname{"echo.prod.mydomain.com"}, Rules: []gatewayv1.HTTPRouteRule{{ + Name: ptrTo(gatewayv1.SectionName("rule-0")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -266,6 +269,7 @@ func Test_ToIR(t *testing.T) { }, Hostnames: []gatewayv1.Hostname{"echo.prod.mydomain.com"}, Rules: []gatewayv1.HTTPRouteRule{{ + Name: ptrTo(gatewayv1.SectionName("rule-0")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -337,15 +341,59 @@ func Test_ToIR(t *testing.T) { }, }, }, - expectedIR: providerir.ProviderIR{}, - expectedErrors: field.ErrorList{ - { - Type: field.ErrorTypeInvalid, - Field: "spec.rules[0].http.paths[0].pathType", - BadValue: ptr.To("ImplementationSpecific"), - Detail: "implementationSpecific path type is not supported in generic translation, and your provider does not provide custom support to translate it", + expectedIR: providerir.ProviderIR{ + Gateways: map[types.NamespacedName]providerir.GatewayContext{ + {Namespace: "default", Name: "ingress-nginx"}: { + Gateway: gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "ingress-nginx", Namespace: "default"}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "ingress-nginx", + Listeners: []gatewayv1.Listener{{ + Name: "test-mydomain-com-http", + Port: 80, + Protocol: gatewayv1.HTTPProtocolType, + Hostname: ptrTo(gatewayv1.Hostname("test.mydomain.com")), + }}, + }, + }, + }, + }, + HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{ + {Namespace: "default", Name: "implementation-specific-regex-test-mydomain-com"}: { + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "implementation-specific-regex-test-mydomain-com", Namespace: "default"}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{ + Name: "ingress-nginx", + }}, + }, + Hostnames: []gatewayv1.Hostname{"test.mydomain.com"}, + Rules: []gatewayv1.HTTPRouteRule{{ + Name: ptrTo(gatewayv1.SectionName("rule-0")), + Matches: []gatewayv1.HTTPRouteMatch{{ + Path: &gatewayv1.HTTPPathMatch{ + Type: &gPathPrefix, + Value: ptrTo("/~/echo/**/test"), + }, + }}, + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "test", + Port: ptrTo(gatewayv1.PortNumber(80)), + }, + }, + }, + }, + }}, + }, + }, + }, }, }, + expectedErrors: field.ErrorList{}, }, { name: "multiple rules with TLS", @@ -353,7 +401,12 @@ func Test_ToIR(t *testing.T) { ingressNames: []types.NamespacedName{{Namespace: "default", Name: "example-ingress"}}, ingressObjects: map[types.NamespacedName]*networkingv1.Ingress{ {Namespace: "default", Name: "example-ingress"}: { - ObjectMeta: metav1.ObjectMeta{Name: "example-ingress", Namespace: "default"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "example-ingress", Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/ssl-redirect": "false", + }, + }, Spec: networkingv1.IngressSpec{ IngressClassName: ptrTo("nginx"), TLS: []networkingv1.IngressTLS{{ @@ -438,7 +491,11 @@ func Test_ToIR(t *testing.T) { Hostname: ptrTo(gatewayv1.Hostname("bar.example.com")), TLS: &gatewayv1.ListenerTLSConfig{ CertificateRefs: []gatewayv1.SecretObjectReference{ - {Name: "example-com"}, + { + Group: ptrTo(gatewayv1.Group("")), + Kind: ptrTo(gatewayv1.Kind("Secret")), + Name: "example-com", + }, }, }, }, @@ -455,7 +512,11 @@ func Test_ToIR(t *testing.T) { Hostname: ptrTo(gatewayv1.Hostname("foo.example.com")), TLS: &gatewayv1.ListenerTLSConfig{ CertificateRefs: []gatewayv1.SecretObjectReference{ - {Name: "example-com"}, + { + Group: ptrTo(gatewayv1.Group("")), + Kind: ptrTo(gatewayv1.Kind("Secret")), + Name: "example-com", + }, }, }, }, @@ -476,6 +537,7 @@ func Test_ToIR(t *testing.T) { }, Hostnames: []gatewayv1.Hostname{"bar.example.com"}, Rules: []gatewayv1.HTTPRouteRule{{ + Name: ptrTo(gatewayv1.SectionName("rule-0")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -508,6 +570,7 @@ func Test_ToIR(t *testing.T) { Hostnames: []gatewayv1.Hostname{"foo.example.com"}, Rules: []gatewayv1.HTTPRouteRule{ { + Name: ptrTo(gatewayv1.SectionName("rule-0")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -526,6 +589,7 @@ func Test_ToIR(t *testing.T) { }, }, { + Name: ptrTo(gatewayv1.SectionName("rule-1")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -688,6 +752,7 @@ func Test_ToIR(t *testing.T) { }, Hostnames: []gatewayv1.Hostname{"bar.example.com"}, Rules: []gatewayv1.HTTPRouteRule{{ + Name: ptrTo(gatewayv1.SectionName("rule-0")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -730,6 +795,7 @@ func Test_ToIR(t *testing.T) { Hostnames: []gatewayv1.Hostname{"foo.example.com"}, Rules: []gatewayv1.HTTPRouteRule{ { + Name: ptrTo(gatewayv1.SectionName("rule-0")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -748,6 +814,7 @@ func Test_ToIR(t *testing.T) { }, }, { + Name: ptrTo(gatewayv1.SectionName("rule-1")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -887,6 +954,7 @@ func Test_ToIR(t *testing.T) { Hostnames: []gatewayv1.Hostname{"api.example.com"}, Rules: []gatewayv1.HTTPRouteRule{ { + Name: ptrTo(gatewayv1.SectionName("rule-0")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -916,6 +984,7 @@ func Test_ToIR(t *testing.T) { }, }, { + Name: ptrTo(gatewayv1.SectionName("rule-1")), Matches: []gatewayv1.HTTPRouteMatch{{ Path: &gatewayv1.HTTPPathMatch{ Type: &gPathPrefix, @@ -951,7 +1020,6 @@ func Test_ToIR(t *testing.T) { }, }, }, - expectedErrors: field.ErrorList{}, }, } @@ -1002,6 +1070,25 @@ func Test_ToIR(t *testing.T) { } } } + + if len(ir.BackendTLSPolicies) != len(tc.expectedIR.BackendTLSPolicies) { + t.Errorf("Expected %d BackendTLSPolicies, got %d: %+v", + len(tc.expectedIR.BackendTLSPolicies), len(ir.BackendTLSPolicies), ir.BackendTLSPolicies) + } else { + for i, gotPolicy := range ir.BackendTLSPolicies { + wantPolicy := tc.expectedIR.BackendTLSPolicies[i] + wantPolicy.SetGroupVersionKind( + schema.GroupVersionKind{ + Kind: "BackendTLSPolicy", + Group: gatewayv1.GroupVersion.Group, + Version: gatewayv1.GroupVersion.Version, + }) + // gotPolicy is emitterir.BackendTLSPolicyContext, wantPolicy is gatewayv1.BackendTLSPolicy + if !apiequality.Semantic.DeepEqual(gotPolicy.BackendTLSPolicy, wantPolicy) { + t.Errorf("Expected BackendTLSPolicy %s to be %+v\n Got: %+v\n Diff: %s", i, wantPolicy, gotPolicy.BackendTLSPolicy, cmp.Diff(wantPolicy, gotPolicy.BackendTLSPolicy)) + } + } + } }) } } @@ -1009,3 +1096,170 @@ func Test_ToIR(t *testing.T) { func ptrTo[T any](a T) *T { return &a } + +func Test_TLSWarningForHostsWithoutCert(t *testing.T) { + iPrefix := networkingv1.PathTypePrefix + + testCases := []struct { + name string + ingresses OrderedIngressMap + wantWarningHost string // host expected in warning, empty if no warning expected + }{ + { + name: "host without TLS cert triggers warning", + ingresses: OrderedIngressMap{ + ingressNames: []types.NamespacedName{{Namespace: "default", Name: "no-tls"}}, + ingressObjects: map[types.NamespacedName]*networkingv1.Ingress{ + {Namespace: "default", Name: "no-tls"}: { + ObjectMeta: metav1.ObjectMeta{Name: "no-tls", Namespace: "default"}, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptrTo("nginx"), + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: &iPrefix, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + }, + }, + wantWarningHost: "example.com", + }, + { + name: "host with TLS cert does not trigger warning", + ingresses: OrderedIngressMap{ + ingressNames: []types.NamespacedName{{Namespace: "default", Name: "with-tls"}}, + ingressObjects: map[types.NamespacedName]*networkingv1.Ingress{ + {Namespace: "default", Name: "with-tls"}: { + ObjectMeta: metav1.ObjectMeta{Name: "with-tls", Namespace: "default"}, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptrTo("nginx"), + TLS: []networkingv1.IngressTLS{{ + Hosts: []string{"example.com"}, + SecretName: "example-cert", + }}, + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: &iPrefix, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + }, + }, + wantWarningHost: "", + }, + { + name: "host covered by TLS in another ingress does not trigger warning", + ingresses: OrderedIngressMap{ + ingressNames: []types.NamespacedName{ + {Namespace: "default", Name: "ing-no-tls"}, + {Namespace: "default", Name: "ing-with-tls"}, + }, + ingressObjects: map[types.NamespacedName]*networkingv1.Ingress{ + {Namespace: "default", Name: "ing-with-tls"}: { + ObjectMeta: metav1.ObjectMeta{Name: "ing-with-tls", Namespace: "default"}, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptrTo("nginx"), + TLS: []networkingv1.IngressTLS{{ + Hosts: []string{"example.com"}, + SecretName: "example-cert", + }}, + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/foo", + PathType: &iPrefix, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "svc-foo", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + {Namespace: "default", Name: "ing-no-tls"}: { + ObjectMeta: metav1.ObjectMeta{Name: "ing-no-tls", Namespace: "default"}, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptrTo("nginx"), + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/bar", + PathType: &iPrefix, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "svc-bar", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }}, + }, + }, + }}, + }, + }, + }, + }, + wantWarningHost: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + report := notifications.NewReport(true) + provider := NewProvider(&i2gw.ProviderConf{Report: report}) + + nginxProvider := provider.(*Provider) + nginxProvider.storage.Ingresses = tc.ingresses + + _, errs := provider.ToIR() + if len(errs) > 0 { + t.Fatalf("Unexpected errors: %v", errs) + } + + rendered := report.Render() + if tc.wantWarningHost != "" { + if !strings.Contains(rendered, tc.wantWarningHost) || !strings.Contains(rendered, "self-signed certificate") { + t.Errorf("Expected TLS warning for host %q, got report:\n%s", tc.wantWarningHost, rendered) + } + } else { + if strings.Contains(rendered, "self-signed certificate") { + t.Errorf("Expected no TLS warning, but got report:\n%s", rendered) + } + } + }) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/cors.go b/pkg/i2gw/providers/ingressnginx/cors.go index 409a99f4e..fe3c5c4a1 100644 --- a/pkg/i2gw/providers/ingressnginx/cors.go +++ b/pkg/i2gw/providers/ingressnginx/cors.go @@ -1,5 +1,5 @@ /* -Copyright 2023 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,228 +17,180 @@ limitations under the License. package ingressnginx import ( + "fmt" "strconv" "strings" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) -const ( - corsEnabledAnnotation = "nginx.ingress.kubernetes.io/enable-cors" - corsAllowOriginAnnotation = "nginx.ingress.kubernetes.io/cors-allow-origin" - corsAllowCredentialsAnnotation = "nginx.ingress.kubernetes.io/cors-allow-credentials" - corsAllowHeadersAnnotation = "nginx.ingress.kubernetes.io/cors-allow-headers" - corsExposeHeadersAnnotation = "nginx.ingress.kubernetes.io/cors-expose-headers" - corsAllowMethodsAnnotation = "nginx.ingress.kubernetes.io/cors-allow-methods" - corsMaxAgeAnnotation = "nginx.ingress.kubernetes.io/cors-max-age" -) - -// corsPolicyFeature is a FeatureParser that projects CORS-related annotations into -// the Ingress NGINX ProviderSpecificIR. -func corsPolicyFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - - // Build per-Ingress policy from the CORS annotations. - ing2pol := make(map[string]providerir.Policy, len(ingresses)) - - for _, ing := range ingresses { - if ing.Annotations == nil { +// applyCorsToEmitterIR parses CORS annotations and populates the EmitterIR. +// It matches the pattern of applyRewriteTargetToEmitterIR by applying changes directly to EmitterIR +// after the initial ProviderIR -> EmitterIR conversion. +func (p *Provider) applyCorsToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + for key, pRouteCtx := range pIR.HTTPRoutes { + eRouteCtx, ok := eIR.HTTPRoutes[key] + if !ok { continue } - enableRaw := strings.TrimSpace(ing.Annotations[corsEnabledAnnotation]) - if enableRaw == "" || enableRaw != "true" { - continue - } - - // Handle allow-origin annotation. - allowRaw := strings.TrimSpace(ing.Annotations[corsAllowOriginAnnotation]) - if allowRaw == "" { - // Common nginx behavior is to default to "*". - allowRaw = "*" - } + for ruleIdx := range pRouteCtx.HTTPRoute.Spec.Rules { + if ruleIdx >= len(pRouteCtx.RuleBackendSources) { + continue + } + sources := pRouteCtx.RuleBackendSources[ruleIdx] - var origins []string - for _, part := range strings.Split(allowRaw, ",") { - v := strings.TrimSpace(part) - if v != "" { - origins = append(origins, v) + ing := getNonCanaryIngress(sources) + if ing == nil { + continue } - } - if len(origins) == 0 { - // No valid origins (nothing to do). - continue - } - // Handle allow-credentials annotation. - var allowCreds *bool - if raw := strings.TrimSpace(ing.Annotations[corsAllowCredentialsAnnotation]); raw != "" { - switch { - case strings.EqualFold(raw, "true"): - v := true - allowCreds = &v - case strings.EqualFold(raw, "false"): - v := false - allowCreds = &v - default: - // Ignore invalid values. + // Check if CORS is enabled + enableCors, ok := ing.Annotations[EnableCorsAnnotation] + if !ok { + continue + } + if enabled, err := strconv.ParseBool(enableCors); err != nil || !enabled { + continue } - } - // Handle allow-headers annotation. - var allowHeaders []string - if raw := strings.TrimSpace(ing.Annotations[corsAllowHeadersAnnotation]); raw != "" { - for _, part := range strings.Split(raw, ",") { - v := strings.TrimSpace(part) - if v != "" { - allowHeaders = append(allowHeaders, v) - } + if eRouteCtx.CorsPolicyByRuleIdx == nil { + eRouteCtx.CorsPolicyByRuleIdx = make(map[int]*emitterir.CORSConfig) } - } - // Handle expose-headers annotation. - var exposeHeaders []string - if raw := strings.TrimSpace(ing.Annotations[corsExposeHeadersAnnotation]); raw != "" { - for _, part := range strings.Split(raw, ",") { - v := strings.TrimSpace(part) - if v != "" { - exposeHeaders = append(exposeHeaders, v) + corsFilter := &emitterir.CORSConfig{} + + // Allow Origin + if origin, ok := ing.Annotations[CorsAllowOriginAnnotation]; ok && origin != "" { + origins := strings.Split(origin, ",") + for _, o := range origins { + o = strings.TrimSpace(o) + if o == "" { + continue + } + corsFilter.AllowOrigins = append(corsFilter.AllowOrigins, gatewayv1.CORSOrigin(o)) } + } else { + // Default to * + corsFilter.AllowOrigins = []gatewayv1.CORSOrigin{"*"} } - } - // Handle allow-methods annotation. - var allowMethods []string - if raw := strings.TrimSpace(ing.Annotations[corsAllowMethodsAnnotation]); raw != "" { - for _, part := range strings.Split(raw, ",") { - v := strings.TrimSpace(part) - if v != "" { - allowMethods = append(allowMethods, v) + // Allow Methods + if methods, ok := ing.Annotations[CorsAllowMethodsAnnotation]; ok && methods != "" { + methodList := strings.Split(methods, ",") + for _, m := range methodList { + m = strings.TrimSpace(m) + if m == "" { + continue + } + corsFilter.AllowMethods = append(corsFilter.AllowMethods, gatewayv1.HTTPMethodWithWildcard(m)) } + } else { + // Default methods: GET, PUT, POST, DELETE, PATCH, OPTIONS + corsFilter.AllowMethods = []gatewayv1.HTTPMethodWithWildcard{"GET", "PUT", "POST", "DELETE", "PATCH", "OPTIONS"} } - } - // Handle max-age annotation. - var maxAge *int32 - if raw := strings.TrimSpace(ing.Annotations[corsMaxAgeAnnotation]); raw != "" { - if secs, err := strconv.ParseInt(raw, 10, 32); err == nil && secs > 0 { - v := int32(secs) - maxAge = &v + // Allow Headers + if headers, ok := ing.Annotations[CorsAllowHeadersAnnotation]; ok && headers != "" { + headerList := strings.Split(headers, ",") + for _, h := range headerList { + h = strings.TrimSpace(h) + if h == "" { + continue + } + corsFilter.AllowHeaders = append(corsFilter.AllowHeaders, gatewayv1.HTTPHeaderName(h)) + } + } else { + // Default headers from Nginx documentation + defaultHeaders := []string{"DNT", "Keep-Alive", "User-Agent", "X-Requested-With", "If-Modified-Since", "Cache-Control", "Content-Type", "Range", "Authorization"} + for _, h := range defaultHeaders { + corsFilter.AllowHeaders = append(corsFilter.AllowHeaders, gatewayv1.HTTPHeaderName(h)) + } } - } - - pol := ing2pol[ing.Name] - if pol.Cors == nil { - pol.Cors = &providerir.CorsPolicy{} - } - - pol.Cors.Enable = true - pol.Cors.AllowOrigin = append(pol.Cors.AllowOrigin, origins...) - - if allowCreds != nil { - pol.Cors.AllowCredentials = allowCreds - } - if len(allowHeaders) > 0 { - pol.Cors.AllowHeaders = append(pol.Cors.AllowHeaders, allowHeaders...) - } - if len(exposeHeaders) > 0 { - pol.Cors.ExposeHeaders = append(pol.Cors.ExposeHeaders, exposeHeaders...) - } - if len(allowMethods) > 0 { - pol.Cors.AllowMethods = append(pol.Cors.AllowMethods, allowMethods...) - } - if maxAge != nil { - pol.Cors.MaxAge = maxAge - } - - ing2pol[ing.Name] = pol - } - if len(ing2pol) == 0 { - return errs - } - - // Map policies onto HTTPRoute rules/backends using BackendSource. - for key, httpCtx := range ir.HTTPRoutes { - // Group BackendSources by source Ingress name. - srcByIngress := map[string][]providerir.PolicyIndex{} - - for ruleIdx, perRule := range httpCtx.RuleBackendSources { - for backendIdx, src := range perRule { - if src.Ingress == nil { - continue + // Expose Headers + if exposeHeaders, ok := ing.Annotations[CorsExposeHeadersAnnotation]; ok && exposeHeaders != "" { + headerList := strings.Split(exposeHeaders, ",") + for _, h := range headerList { + h = strings.TrimSpace(h) + if h == "" { + continue + } + corsFilter.ExposeHeaders = append(corsFilter.ExposeHeaders, gatewayv1.HTTPHeaderName(h)) } - ingressName := src.Ingress.Name - srcByIngress[ingressName] = append( - srcByIngress[ingressName], - providerir.PolicyIndex{Rule: ruleIdx, Backend: backendIdx}, - ) } - } - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, + // Allow Credentials + // Nginx default is true. Only false if explicitly set to "false" (or other falsy values). + corsFilter.AllowCredentials = ptr.To(true) + if creds, ok := ing.Annotations[CorsAllowCredentialsAnnotation]; ok { + if allowed, err := strconv.ParseBool(creds); err == nil && !allowed { + corsFilter.AllowCredentials = ptr.To(false) + } } - } else if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - for ingressName, idxs := range srcByIngress { - pol, ok := ing2pol[ingressName] - if !ok || pol.Cors == nil { - continue + // Max Age + // Nginx default is 1728000. + var maxAgeVal int32 = 1728000 // Default from Nginx + if maxAgeStr, ok := ing.Annotations[CorsMaxAgeAnnotation]; ok && maxAgeStr != "" { + // Try parsing as integer (seconds) using ParseInt with bitSize 32 + if val, err := strconv.ParseInt(maxAgeStr, 10, 32); err == nil { + maxAgeVal = int32(val) + } else { + p.notify(notifications.ErrorNotification, fmt.Sprintf("Invalid cors-max-age annotation %q, using default %d", maxAgeStr, maxAgeVal), ing) + } } - existing := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingressName] - - // Merge CORS into existing policy for this Ingress (if any). - if existing.Cors == nil { - existing.Cors = pol.Cors - } else { - existing.Cors.Enable = existing.Cors.Enable || pol.Cors.Enable - - // Origins: append and dedupe later in the emitter. - existing.Cors.AllowOrigin = append(existing.Cors.AllowOrigin, pol.Cors.AllowOrigin...) - - // Latest non-nil AllowCredentials wins. - if pol.Cors.AllowCredentials != nil { - existing.Cors.AllowCredentials = pol.Cors.AllowCredentials - } + corsFilter.MaxAge = maxAgeVal - // Headers and methods: append and dedupe later in the emitter. - if len(pol.Cors.AllowHeaders) > 0 { - existing.Cors.AllowHeaders = append(existing.Cors.AllowHeaders, pol.Cors.AllowHeaders...) - } - if len(pol.Cors.ExposeHeaders) > 0 { - existing.Cors.ExposeHeaders = append(existing.Cors.ExposeHeaders, pol.Cors.ExposeHeaders...) - } - if len(pol.Cors.AllowMethods) > 0 { - existing.Cors.AllowMethods = append(existing.Cors.AllowMethods, pol.Cors.AllowMethods...) - } + eRouteCtx.CorsPolicyByRuleIdx[ruleIdx] = corsFilter - // Latest non-nil MaxAge wins. - if pol.Cors.MaxAge != nil { - existing.Cors.MaxAge = pol.Cors.MaxAge - } + if eRouteCtx.PoliciesBySourceIngressName == nil { + eRouteCtx.PoliciesBySourceIngressName = make(map[string]emitterir.Policy) } - - // Dedupe (rule, backend) pairs. - existing = existing.AddRuleBackendSources(idxs) - - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingressName] = existing + policy := eRouteCtx.PoliciesBySourceIngressName[ing.Name] + policy.Cors = corsConfigToPolicy(corsFilter) + for backendIdx := range eRouteCtx.Spec.Rules[ruleIdx].BackendRefs { + policy = policy.AddRuleBackendSources([]emitterir.PolicyIndex{{ + Rule: ruleIdx, + Backend: backendIdx, + }}) + } + eRouteCtx.PoliciesBySourceIngressName[ing.Name] = policy } + eIR.HTTPRoutes[key] = eRouteCtx + } +} - // Write back mutated HTTPRouteContext into IR. - ir.HTTPRoutes[key] = httpCtx +func corsConfigToPolicy(cfg *emitterir.CORSConfig) *emitterir.CorsPolicy { + if cfg == nil { + return nil } - return errs + policy := &emitterir.CorsPolicy{ + Enable: true, + } + for _, origin := range cfg.AllowOrigins { + policy.AllowOrigin = append(policy.AllowOrigin, string(origin)) + } + if cfg.AllowCredentials != nil { + policy.AllowCredentials = ptr.To(*cfg.AllowCredentials) + } + for _, header := range cfg.AllowHeaders { + policy.AllowHeaders = append(policy.AllowHeaders, string(header)) + } + for _, header := range cfg.ExposeHeaders { + policy.ExposeHeaders = append(policy.ExposeHeaders, string(header)) + } + for _, method := range cfg.AllowMethods { + policy.AllowMethods = append(policy.AllowMethods, string(method)) + } + if cfg.MaxAge > 0 { + policy.MaxAge = ptr.To(cfg.MaxAge) + } + return policy } diff --git a/pkg/i2gw/providers/ingressnginx/cors_test.go b/pkg/i2gw/providers/ingressnginx/cors_test.go new file mode 100644 index 000000000..7b633a1fc --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/cors_test.go @@ -0,0 +1,317 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestApplyCorsToEmitterIR(t *testing.T) { + testCases := []struct { + name string + ingress networkingv1.Ingress + expectedCors *gatewayv1.HTTPCORSFilter + }{ + { + name: "enable-cors defaults", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cors-defaults", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/enable-cors": "true", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + }, + expectedCors: &gatewayv1.HTTPCORSFilter{ + AllowOrigins: []gatewayv1.CORSOrigin{"*"}, + AllowMethods: []gatewayv1.HTTPMethodWithWildcard{"GET", "PUT", "POST", "DELETE", "PATCH", "OPTIONS"}, + AllowHeaders: []gatewayv1.HTTPHeaderName{"DNT", "Keep-Alive", "User-Agent", "X-Requested-With", "If-Modified-Since", "Cache-Control", "Content-Type", "Range", "Authorization"}, + AllowCredentials: ptr.To(true), + MaxAge: 1728000, + }, + }, + { + name: "specific origin and expose headers", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cors-origin", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/enable-cors": "true", + "nginx.ingress.kubernetes.io/cors-allow-origin": "https://foo.com, https://bar.com", + "nginx.ingress.kubernetes.io/cors-expose-headers": "X-Exposed-1, X-Exposed-2", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + }, + expectedCors: &gatewayv1.HTTPCORSFilter{ + AllowOrigins: []gatewayv1.CORSOrigin{"https://foo.com", "https://bar.com"}, + AllowMethods: []gatewayv1.HTTPMethodWithWildcard{"GET", "PUT", "POST", "DELETE", "PATCH", "OPTIONS"}, + AllowHeaders: []gatewayv1.HTTPHeaderName{"DNT", "Keep-Alive", "User-Agent", "X-Requested-With", "If-Modified-Since", "Cache-Control", "Content-Type", "Range", "Authorization"}, + ExposeHeaders: []gatewayv1.HTTPHeaderName{"X-Exposed-1", "X-Exposed-2"}, + AllowCredentials: ptr.To(true), + MaxAge: 1728000, + }, + }, + { + name: "explicit max age and false credentials", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cors-max-age", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/enable-cors": "true", + "nginx.ingress.kubernetes.io/cors-max-age": "600", + "nginx.ingress.kubernetes.io/cors-allow-credentials": "false", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + }, + expectedCors: &gatewayv1.HTTPCORSFilter{ + AllowOrigins: []gatewayv1.CORSOrigin{"*"}, + AllowMethods: []gatewayv1.HTTPMethodWithWildcard{"GET", "PUT", "POST", "DELETE", "PATCH", "OPTIONS"}, + AllowHeaders: []gatewayv1.HTTPHeaderName{"DNT", "Keep-Alive", "User-Agent", "X-Requested-With", "If-Modified-Since", "Cache-Control", "Content-Type", "Range", "Authorization"}, + AllowCredentials: ptr.To(false), + MaxAge: 600, + }, + }, + { + name: "cors disabled", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cors-disabled", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/enable-cors": "false", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + }, + expectedCors: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pIR := providerir.ProviderIR{ + HTTPRoutes: make(map[types.NamespacedName]providerir.HTTPRouteContext), + } + eIR := emitterir.EmitterIR{ + HTTPRoutes: make(map[types.NamespacedName]emitterir.HTTPRouteContext), + } + + key := types.NamespacedName{Namespace: tc.ingress.Namespace, Name: common.RouteName(tc.ingress.Name, "example.com")} + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: tc.ingress.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Type: ptr.To(gatewayv1.PathMatchPathPrefix), Value: ptr.To("/")}}}, + }, + }, + }, + } + + // Provider IR setup (for sources) + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{{ + {Ingress: &tc.ingress}, + }}, + } + + // Emitter IR setup (target) + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{ + HTTPRoute: route, + } + + (&Provider{notify: notifications.NoopNotify}).applyCorsToEmitterIR(pIR, &eIR) + + result := eIR.HTTPRoutes[key] + var cors *gatewayv1.HTTPCORSFilter + if result.CorsPolicyByRuleIdx != nil { + if cfg := result.CorsPolicyByRuleIdx[0]; cfg != nil { + cors = &cfg.HTTPCORSFilter + } + } + + if tc.expectedCors == nil { + if cors != nil { + t.Fatalf("Expected nil CORS, got %v", cors) + } + return + } + if cors == nil { + t.Fatalf("Expected CORS policy, got nil") + } + + // Validate Origins + if len(cors.AllowOrigins) != len(tc.expectedCors.AllowOrigins) { + t.Errorf("Expected %d origins, got %d", len(tc.expectedCors.AllowOrigins), len(cors.AllowOrigins)) + } else { + for i, o := range tc.expectedCors.AllowOrigins { + if o != cors.AllowOrigins[i] { + t.Errorf("Origin mismatch at %d: expected %v, got %v", i, o, cors.AllowOrigins[i]) + } + } + } + + // Validate Methods + if len(cors.AllowMethods) != len(tc.expectedCors.AllowMethods) { + t.Errorf("Expected %d methods, got %d", len(tc.expectedCors.AllowMethods), len(cors.AllowMethods)) + } else { + for i, m := range tc.expectedCors.AllowMethods { + if m != cors.AllowMethods[i] { + t.Errorf("Method mismatch at %d: expected %v, got %v", i, m, cors.AllowMethods[i]) + } + } + } + + // Validate Headers + if len(cors.AllowHeaders) != len(tc.expectedCors.AllowHeaders) { + t.Errorf("Expected %d headers, got %d", len(tc.expectedCors.AllowHeaders), len(cors.AllowHeaders)) + } else { + for i, h := range tc.expectedCors.AllowHeaders { + if h != cors.AllowHeaders[i] { + t.Errorf("Header mismatch at %d: expected %v, got %v", i, h, cors.AllowHeaders[i]) + } + } + } + + // Validate Expose Headers + if len(cors.ExposeHeaders) != len(tc.expectedCors.ExposeHeaders) { + t.Errorf("Expected %d expose headers, got %d", len(tc.expectedCors.ExposeHeaders), len(cors.ExposeHeaders)) + } else { + for i, h := range tc.expectedCors.ExposeHeaders { + if h != cors.ExposeHeaders[i] { + t.Errorf("Expose Header mismatch at %d: expected %v, got %v", i, h, cors.ExposeHeaders[i]) + } + } + } + + // Validate Credentials + if tc.expectedCors.AllowCredentials != nil { + if cors.AllowCredentials == nil || *cors.AllowCredentials != *tc.expectedCors.AllowCredentials { + t.Errorf("Expected allowCredentials %v, got %v", *tc.expectedCors.AllowCredentials, cors.AllowCredentials) + } + } else if cors.AllowCredentials != nil { + t.Errorf("Expected nil allowCredentials, got %v", *cors.AllowCredentials) + } + + // Validate MaxAge + if cors.MaxAge != tc.expectedCors.MaxAge { + t.Errorf("Expected MaxAge %v, got %v", tc.expectedCors.MaxAge, cors.MaxAge) + } + }) + } +} + +func TestCorsMaxAgeParsing(t *testing.T) { + testCases := []struct { + name string + annotationVal string + expectedVal int32 + }{ + { + name: "valid integer", + annotationVal: "100", + expectedVal: 100, + }, + { + name: "invalid value defaults to nginx default", + annotationVal: "invalid", + expectedVal: 1728000, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pIR := providerir.ProviderIR{ + HTTPRoutes: make(map[types.NamespacedName]providerir.HTTPRouteContext), + } + eIR := emitterir.EmitterIR{ + HTTPRoutes: make(map[types.NamespacedName]emitterir.HTTPRouteContext), + } + + ing := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cors-test", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/enable-cors": "true", + "nginx.ingress.kubernetes.io/cors-max-age": tc.annotationVal, + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + key := types.NamespacedName{Namespace: ing.Namespace, Name: common.RouteName(ing.Name, "example.com")} + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: ing.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Type: ptr.To(gatewayv1.PathMatchPathPrefix), Value: ptr.To("/")}}}, + }, + }, + }, + } + + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{{ + {Ingress: &ing}, + }}, + } + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{ + HTTPRoute: route, + } + + (&Provider{notify: notifications.NoopNotify}).applyCorsToEmitterIR(pIR, &eIR) + + if eIR.HTTPRoutes[key].CorsPolicyByRuleIdx == nil || eIR.HTTPRoutes[key].CorsPolicyByRuleIdx[0] == nil { + t.Fatalf("Expected CORS policy, got nil") + } + cors := eIR.HTTPRoutes[key].CorsPolicyByRuleIdx[0] + if cors.MaxAge != tc.expectedVal { + t.Errorf("Expected MaxAge %d, got %d", tc.expectedVal, cors.MaxAge) + } + }) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/enable_access_log.go b/pkg/i2gw/providers/ingressnginx/enable_access_log.go index 5f2405472..d8f22d371 100644 --- a/pkg/i2gw/providers/ingressnginx/enable_access_log.go +++ b/pkg/i2gw/providers/ingressnginx/enable_access_log.go @@ -17,109 +17,73 @@ limitations under the License. package ingressnginx import ( + "fmt" "strings" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" - networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" ) -// this is actually enabled by default -const enableAccessLogAnnotation = "nginx.ingress.kubernetes.io/enable-access-log" - -// enableAccessLogFeature extracts the "enable-access-log" annotation and -// projects it into the provider-specific IR similarly to other annotation features. -func enableAccessLogFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - - var errs field.ErrorList - ingressPolicies := map[types.NamespacedName]*providerir.Policy{} - - for i := range ingresses { - ing := &ingresses[i] - raw := strings.TrimSpace(ing.Annotations[enableAccessLogAnnotation]) - if raw == "" { - continue - } - - // Parse boolean value - only "true" (case-sensitive) enables access log - enableAccessLog := raw == "true" - - key := types.NamespacedName{Namespace: ing.Namespace, Name: ing.Name} - pol := ingressPolicies[key] - if pol == nil { - pol = &providerir.Policy{} - ingressPolicies[key] = pol - } - - pol.EnableAccessLog = &enableAccessLog - } - - if len(ingressPolicies) == 0 { - return errs - } - - // Map policies to HTTPRoutes (same pattern as other features) - ruleGroups := common.GetRuleGroups(ingresses) - - for _, rg := range ruleGroups { - routeKey := types.NamespacedName{ - Namespace: rg.Namespace, - Name: common.RouteName(rg.Name, rg.Host), - } - - httpCtx, ok := ir.HTTPRoutes[routeKey] +// applyAccessLogToEmitterIR reads ingress-nginx access log annotations from ProviderIR sources and stores +// provider-neutral access log intent into EmitterIR, which will later be converted by each custom emitter. +// +// Currently supported annotations are: +// - nginx.ingress.kubernetes.io/enable-access-log +func (p *Provider) applyAccessLogToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + for key, pRouteCtx := range pIR.HTTPRoutes { + eRouteCtx, ok := eIR.HTTPRoutes[key] if !ok { continue } - for ruleIdx, backendSources := range httpCtx.RuleBackendSources { - for backendIdx, src := range backendSources { - if src.Ingress == nil { - continue - } - - ingKey := types.NamespacedName{ - Namespace: src.Ingress.Namespace, - Name: src.Ingress.Name, - } - - pol := ingressPolicies[ingKey] - if pol == nil || pol.EnableAccessLog == nil { - continue - } - - // Ensure provider-specific IR exists - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, - } - } else if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } + for ruleIdx := range eRouteCtx.Spec.Rules { + if ruleIdx >= len(pRouteCtx.RuleBackendSources) { + continue + } + ing := getNonCanaryIngress(pRouteCtx.RuleBackendSources[ruleIdx]) + if ing == nil { + continue + } - existing := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] - if existing.EnableAccessLog == nil { - existing.EnableAccessLog = pol.EnableAccessLog - } + accessLog, parsedAnnotations := p.parseIngressNginxAccessLog(ing) + if accessLog == nil { + continue + } - // Dedupe (rule, backend) pairs. - existing = existing.AddRuleBackendSources([]providerir.PolicyIndex{ - {Rule: ruleIdx, Backend: backendIdx}, - }) + if eRouteCtx.EnableAccessLogByRuleIdx == nil { + eRouteCtx.EnableAccessLogByRuleIdx = make(map[int]*emitterir.AccessLog) + } - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] = existing + source := fmt.Sprintf("%s/%s", ing.Namespace, ing.Name) + message := "Access log configuration is implementation-specific and may not map exactly across Gateway API implementations" + paths := make([]*field.Path, len(parsedAnnotations)) + for i, ann := range parsedAnnotations { + paths[i] = field.NewPath(ing.Namespace, ing.Name, "metadata", "annotations", fmt.Sprintf("%q", ann)) } + accessLog.Metadata = emitterir.NewExtensionFeatureMetadata( + source, + paths, + message, + ) + + eRouteCtx.EnableAccessLogByRuleIdx[ruleIdx] = accessLog } - ir.HTTPRoutes[routeKey] = httpCtx + eIR.HTTPRoutes[key] = eRouteCtx + } +} + +func (p *Provider) parseIngressNginxAccessLog(ing *networkingv1.Ingress) (*emitterir.AccessLog, []string) { + raw := strings.TrimSpace(ing.Annotations[EnableAccessLogAnnotation]) + if raw == "" { + return nil, nil } - return errs + // ingress-nginx defaults access logs on; only an explicit "true" should enable + // and any other non-empty value is treated as false to preserve prior behavior. + return &emitterir.AccessLog{ + Enabled: raw == "true", + }, []string{EnableAccessLogAnnotation} } diff --git a/pkg/i2gw/providers/ingressnginx/enable_access_log_test.go b/pkg/i2gw/providers/ingressnginx/enable_access_log_test.go new file mode 100644 index 000000000..8199071c7 --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/enable_access_log_test.go @@ -0,0 +1,62 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "testing" + + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + "k8s.io/apimachinery/pkg/types" +) + +func TestApplyAccessLogToEmitterIR_Enabled(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + annotations := map[string]string{ + EnableAccessLogAnnotation: "true", + } + pIR, eIR := setupBodySizeTest(key, annotations) + + p := &Provider{notify: notifications.NoopNotify} + p.applyAccessLogToEmitterIR(pIR, &eIR) + + accessLogIR := eIR.HTTPRoutes[key].EnableAccessLogByRuleIdx[0] + if accessLogIR == nil { + t.Fatalf("expected access log IR to be set for rule index 0") + } + if !accessLogIR.Enabled { + t.Fatalf("expected access log to be enabled") + } +} + +func TestApplyAccessLogToEmitterIR_Disabled(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + annotations := map[string]string{ + EnableAccessLogAnnotation: "false", + } + pIR, eIR := setupBodySizeTest(key, annotations) + + p := &Provider{notify: notifications.NoopNotify} + p.applyAccessLogToEmitterIR(pIR, &eIR) + + accessLogIR := eIR.HTTPRoutes[key].EnableAccessLogByRuleIdx[0] + if accessLogIR == nil { + t.Fatalf("expected access log IR to be set for rule index 0") + } + if accessLogIR.Enabled { + t.Fatalf("expected access log to be disabled") + } +} diff --git a/pkg/i2gw/providers/ingressnginx/external_auth.go b/pkg/i2gw/providers/ingressnginx/external_auth.go index 8d5a7569e..a0083d591 100644 --- a/pkg/i2gw/providers/ingressnginx/external_auth.go +++ b/pkg/i2gw/providers/ingressnginx/external_auth.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,253 +19,143 @@ package ingressnginx import ( "strings" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" - networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/validation/field" ) const ( authURLAnnotation = "nginx.ingress.kubernetes.io/auth-url" authResponseHeadersAnnotation = "nginx.ingress.kubernetes.io/auth-response-headers" authTypeAnnotation = "nginx.ingress.kubernetes.io/auth-type" - authSecretAnnotation = "nginx.ingress.kubernetes.io/auth-secret" - authSecretTypeAnnotation = "nginx.ingress.kubernetes.io/auth-secret-type" + authSecretAnnotation = "nginx.ingress.kubernetes.io/auth-secret" //nolint:gosec // G101: annotation key, not a credential + authSecretTypeAnnotation = "nginx.ingress.kubernetes.io/auth-secret-type" //nolint:gosec // G101: annotation key, not a credential ) -// extAuthFeature extracts the "auth-url" and "auth-response-headers" annotations and -// projects them into the provider-specific IR similarly to other annotation features. -func extAuthFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - - var errs field.ErrorList - ingressPolicies := map[types.NamespacedName]*providerir.Policy{} - - for i := range ingresses { - ing := &ingresses[i] - authURLRaw := strings.TrimSpace(ing.Annotations[authURLAnnotation]) - authResponseHeadersRaw := strings.TrimSpace(ing.Annotations[authResponseHeadersAnnotation]) - - // Skip if neither annotation is present - if authURLRaw == "" && authResponseHeadersRaw == "" { - continue - } - - key := types.NamespacedName{Namespace: ing.Namespace, Name: ing.Name} - pol := ingressPolicies[key] - if pol == nil { - pol = &providerir.Policy{} - ingressPolicies[key] = pol - } - - if pol.ExtAuth == nil { - pol.ExtAuth = &providerir.ExtAuthPolicy{} - } +type extAuthConfig struct { + authURL string + responseHeaders []string +} - if authURLRaw != "" { - pol.ExtAuth.AuthURL = authURLRaw - } +type basicAuthConfig struct { + secretName string + authType string +} - if authResponseHeadersRaw != "" { - var headers []string - for _, part := range strings.Split(authResponseHeadersRaw, ",") { - v := strings.TrimSpace(part) - if v != "" { - headers = append(headers, v) - } - } - pol.ExtAuth.ResponseHeaders = headers - } +func parseExtAuthConfig(ing *networkingv1.Ingress) *extAuthConfig { + authURL := strings.TrimSpace(ing.Annotations[authURLAnnotation]) + responseHeaders := splitAndTrimCSV(ing.Annotations[authResponseHeadersAnnotation]) + if authURL == "" && len(responseHeaders) == 0 { + return nil } - - if len(ingressPolicies) == 0 { - return errs + return &extAuthConfig{ + authURL: authURL, + responseHeaders: responseHeaders, } +} - // Map policies to HTTPRoutes (same pattern as other features) - ruleGroups := common.GetRuleGroups(ingresses) - - for _, rg := range ruleGroups { - routeKey := types.NamespacedName{ - Namespace: rg.Namespace, - Name: common.RouteName(rg.Name, rg.Host), - } - - httpCtx, ok := ir.HTTPRoutes[routeKey] - if !ok { - continue - } - - for ruleIdx, backendSources := range httpCtx.RuleBackendSources { - for backendIdx, src := range backendSources { - if src.Ingress == nil { - continue - } - - ingKey := types.NamespacedName{ - Namespace: src.Ingress.Namespace, - Name: src.Ingress.Name, - } - - pol := ingressPolicies[ingKey] - if pol == nil || pol.ExtAuth == nil { - continue - } - - // Ensure provider-specific IR exists - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, - } - } else if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - - existing := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] - if existing.ExtAuth == nil { - existing.ExtAuth = pol.ExtAuth - } else { - // Merge ExtAuth policy: preserve existing values if new ones are empty - if pol.ExtAuth.AuthURL != "" { - existing.ExtAuth.AuthURL = pol.ExtAuth.AuthURL - } - if len(pol.ExtAuth.ResponseHeaders) > 0 { - existing.ExtAuth.ResponseHeaders = pol.ExtAuth.ResponseHeaders - } - } - - // Dedupe (rule, backend) pairs. - existing = existing.AddRuleBackendSources([]providerir.PolicyIndex{ - {Rule: ruleIdx, Backend: backendIdx}, - }) +func parseBasicAuthConfig(ing *networkingv1.Ingress) *basicAuthConfig { + authType := strings.TrimSpace(ing.Annotations[authTypeAnnotation]) + authSecret := strings.TrimSpace(ing.Annotations[authSecretAnnotation]) + if authType != "basic" || authSecret == "" { + return nil + } - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] = existing - } + secretName := authSecret + if strings.Contains(authSecret, "/") { + parts := strings.SplitN(authSecret, "/", 2) + if len(parts) == 2 { + secretName = parts[1] } + } - ir.HTTPRoutes[routeKey] = httpCtx + secretType := strings.TrimSpace(ing.Annotations[authSecretTypeAnnotation]) + if secretType == "" { + secretType = "auth-file" } - return errs + return &basicAuthConfig{ + secretName: secretName, + authType: secretType, + } } -// basicAuthFeature extracts the "auth-type" and "auth-secret" annotations and -// projects them into the provider-specific IR similarly to other annotation features. -func basicAuthFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - ingressPolicies := map[types.NamespacedName]*providerir.Policy{} - - for i := range ingresses { - ing := &ingresses[i] - authTypeRaw := strings.TrimSpace(ing.Annotations[authTypeAnnotation]) - authSecretRaw := strings.TrimSpace(ing.Annotations[authSecretAnnotation]) - authSecretTypeRaw := strings.TrimSpace(ing.Annotations[authSecretTypeAnnotation]) +func splitAndTrimCSV(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } - // Only process if auth-type is "basic" and auth-secret is present - if authTypeRaw != "basic" || authSecretRaw == "" { + rawValues := strings.Split(value, ",") + values := make([]string, 0, len(rawValues)) + for _, rawValue := range rawValues { + value := strings.TrimSpace(rawValue) + if value == "" { continue } - - key := types.NamespacedName{Namespace: ing.Namespace, Name: ing.Name} - pol := ingressPolicies[key] - if pol == nil { - pol = &providerir.Policy{} - ingressPolicies[key] = pol - } - - // Parse secret reference (format: namespace/name or just name) - secretName := authSecretRaw - if strings.Contains(authSecretRaw, "/") { - parts := strings.SplitN(authSecretRaw, "/", 2) - if len(parts) == 2 { - // If secret is in different namespace, use just the name - // (kgateway expects secret in same namespace as TrafficPolicy) - secretName = parts[1] - } - } - - // Determine auth type based on auth-secret-type annotation - // auth-file (default): htpasswd file in key "auth" - // auth-map: keys are usernames, values are hashed passwords - authType := authSecretTypeRaw - if authType == "" { - authType = "auth-file" // default - } - - pol.BasicAuth = &providerir.BasicAuthPolicy{ - SecretName: secretName, - AuthType: authType, - } + values = append(values, value) } - - if len(ingressPolicies) == 0 { - return errs + if len(values) == 0 { + return nil } + return values +} - // Map policies to HTTPRoutes (same pattern as other features) - ruleGroups := common.GetRuleGroups(ingresses) - - for _, rg := range ruleGroups { - routeKey := types.NamespacedName{ - Namespace: rg.Namespace, - Name: common.RouteName(rg.Name, rg.Host), - } - - httpCtx, ok := ir.HTTPRoutes[routeKey] +// applyAuthToEmitterIR projects ingress-nginx auth annotations into the +// emitter-neutral per-ingress policy map used by custom emitters. +func (p *Provider) applyAuthToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + for key, pRouteCtx := range pIR.HTTPRoutes { + eRouteCtx, ok := eIR.HTTPRoutes[key] if !ok { continue } - for ruleIdx, backendSources := range httpCtx.RuleBackendSources { - for backendIdx, src := range backendSources { - if src.Ingress == nil { - continue - } + for ruleIdx := range pRouteCtx.HTTPRoute.Spec.Rules { + if ruleIdx >= len(pRouteCtx.RuleBackendSources) { + continue + } + if ruleIdx >= len(eRouteCtx.Spec.Rules) { + continue + } - ingKey := types.NamespacedName{ - Namespace: src.Ingress.Namespace, - Name: src.Ingress.Name, - } + ing := getNonCanaryIngress(pRouteCtx.RuleBackendSources[ruleIdx]) + if ing == nil { + continue + } - pol := ingressPolicies[ingKey] - if pol == nil || pol.BasicAuth == nil { - continue - } + extAuth := parseExtAuthConfig(ing) + basicAuth := parseBasicAuthConfig(ing) + if extAuth == nil && basicAuth == nil { + continue + } - // Ensure provider-specific IR exists - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, - } - } else if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } + if eRouteCtx.PoliciesBySourceIngressName == nil { + eRouteCtx.PoliciesBySourceIngressName = make(map[string]emitterir.Policy) + } - existing := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] - if existing.BasicAuth == nil { - existing.BasicAuth = pol.BasicAuth + policy := eRouteCtx.PoliciesBySourceIngressName[ing.Name] + if extAuth != nil { + policy.ExtAuth = &emitterir.ExtAuthPolicy{ + AuthURL: extAuth.authURL, + ResponseHeaders: append([]string(nil), extAuth.responseHeaders...), } + } + if basicAuth != nil { + policy.BasicAuth = &emitterir.BasicAuthPolicy{ + SecretName: basicAuth.secretName, + AuthType: basicAuth.authType, + } + } - // Dedupe (rule, backend) pairs. - existing = existing.AddRuleBackendSources([]providerir.PolicyIndex{ - {Rule: ruleIdx, Backend: backendIdx}, - }) - - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] = existing + for backendIdx := range eRouteCtx.Spec.Rules[ruleIdx].BackendRefs { + policy = policy.AddRuleBackendSources([]emitterir.PolicyIndex{{ + Rule: ruleIdx, + Backend: backendIdx, + }}) } + + eRouteCtx.PoliciesBySourceIngressName[ing.Name] = policy } - ir.HTTPRoutes[routeKey] = httpCtx + eIR.HTTPRoutes[key] = eRouteCtx } - - return errs } diff --git a/pkg/i2gw/providers/ingressnginx/external_auth_test.go b/pkg/i2gw/providers/ingressnginx/external_auth_test.go new file mode 100644 index 000000000..afff75a4c --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/external_auth_test.go @@ -0,0 +1,135 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestApplyAuthToEmitterIR_ProjectsExtAuth(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + annotations := map[string]string{ + authURLAnnotation: "http://auth.default.svc.cluster.local", + authResponseHeadersAnnotation: "X-Auth-Token, X-User-ID", + } + pIR, eIR := setupAuthTest(key, "ing-auth", annotations) + + p := &Provider{notify: notifications.NoopNotify} + p.applyAuthToEmitterIR(pIR, &eIR) + + policy := eIR.HTTPRoutes[key].PoliciesBySourceIngressName["ing-auth"] + if policy.ExtAuth == nil { + t.Fatalf("expected ext auth policy to be projected") + } + if got, want := policy.ExtAuth.AuthURL, annotations[authURLAnnotation]; got != want { + t.Fatalf("expected auth URL %q, got %q", want, got) + } + if len(policy.ExtAuth.ResponseHeaders) != 2 { + t.Fatalf("expected 2 auth response headers, got %d", len(policy.ExtAuth.ResponseHeaders)) + } + if len(policy.RuleBackendSources) != 1 || policy.RuleBackendSources[0] != (emitterir.PolicyIndex{Rule: 0, Backend: 0}) { + t.Fatalf("expected full backend coverage for ext auth, got %#v", policy.RuleBackendSources) + } +} + +func TestApplyAuthToEmitterIR_ProjectsBasicAuth(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + annotations := map[string]string{ + authTypeAnnotation: "basic", + authSecretAnnotation: "default/basic-auth-secret", + } + pIR, eIR := setupAuthTest(key, "ing-basic", annotations) + + p := &Provider{notify: notifications.NoopNotify} + p.applyAuthToEmitterIR(pIR, &eIR) + + policy := eIR.HTTPRoutes[key].PoliciesBySourceIngressName["ing-basic"] + if policy.BasicAuth == nil { + t.Fatalf("expected basic auth policy to be projected") + } + if got, want := policy.BasicAuth.SecretName, "basic-auth-secret"; got != want { + t.Fatalf("expected secret name %q, got %q", want, got) + } + if got, want := policy.BasicAuth.AuthType, "auth-file"; got != want { + t.Fatalf("expected auth type %q, got %q", want, got) + } + if len(policy.RuleBackendSources) != 1 || policy.RuleBackendSources[0] != (emitterir.PolicyIndex{Rule: 0, Backend: 0}) { + t.Fatalf("expected full backend coverage for basic auth, got %#v", policy.RuleBackendSources) + } +} + +func setupAuthTest( + httpRouteKey types.NamespacedName, + ingressName string, + annotations map[string]string, +) (providerir.ProviderIR, emitterir.EmitterIR) { + ing := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: httpRouteKey.Namespace, + Name: ingressName, + Annotations: annotations, + }, + } + + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: httpRouteKey.Namespace, + Name: httpRouteKey.Name, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{{ + BackendRefs: []gatewayv1.HTTPBackendRef{{ + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "app", + Port: func() *gatewayv1.PortNumber { + port := gatewayv1.PortNumber(80) + return &port + }(), + }, + }, + }}, + }}, + }, + } + + pIR := providerir.ProviderIR{ + HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{ + httpRouteKey: { + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{{ + {Ingress: &ing}, + }}, + }, + }, + } + eIR := emitterir.EmitterIR{ + HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{ + httpRouteKey: {HTTPRoute: route}, + }, + } + + return pIR, eIR +} diff --git a/pkg/i2gw/providers/ingressnginx/headers.go b/pkg/i2gw/providers/ingressnginx/headers.go index 61baa0bf9..307effcb3 100644 --- a/pkg/i2gw/providers/ingressnginx/headers.go +++ b/pkg/i2gw/providers/ingressnginx/headers.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ import ( gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) -func headerModifierFeature(_ []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *providerir.ProviderIR) field.ErrorList { +func headerModifierFeature(notify notifications.NotifyFunc, _ []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *providerir.ProviderIR) field.ErrorList { for _, httpRouteContext := range ir.HTTPRoutes { for i := range httpRouteContext.HTTPRoute.Spec.Rules { if i >= len(httpRouteContext.RuleBackendSources) { @@ -37,47 +37,36 @@ func headerModifierFeature(_ []networkingv1.Ingress, _ map[types.NamespacedName] ingress := getNonCanaryIngress(sources) if ingress == nil { - notify(notifications.InfoNotification, "Found canary ingress rule without non-canary ingress rule", &httpRouteContext.HTTPRoute) continue } headersToSet := make(map[string]string) - _, hasRewriteTarget := ingress.Annotations[RewriteTargetAnnotation] - - // 1. x-forwarded-prefix - // This annotation only works if rewrite-target is also present. - // TODO: X-Forwarded-Prefix is complex because it depends on rewrite-target. - // Deferring this to a future PR. - if val, ok := ingress.Annotations[XForwardedPrefixAnnotation]; ok && val != "" && hasRewriteTarget { - headersToSet["X-Forwarded-Prefix"] = val - } - - // 2. upstream-vhost -> Host header + // 1. upstream-vhost -> Host header if val, ok := ingress.Annotations[UpstreamVhostAnnotation]; ok && val != "" { headersToSet["Host"] = val } - // 3. connection-proxy-header -> Connection header + // 2. connection-proxy-header -> Connection header if val, ok := ingress.Annotations[ConnectionProxyHeaderAnnotation]; ok && val != "" { headersToSet["Connection"] = val } - // 4. custom-headers -> Warn unsupported + // 3. custom-headers -> Warn unsupported // TODO: implement custom-headers annotation. if _, ok := ingress.Annotations[CustomHeadersAnnotation]; ok { notify(notifications.WarningNotification, fmt.Sprintf("Ingress %s/%s uses '%s' which is not supported.", ingress.Namespace, ingress.Name, CustomHeadersAnnotation), &httpRouteContext.HTTPRoute) } if len(headersToSet) > 0 { - applyHeaderModifiers(&httpRouteContext.HTTPRoute, i, headersToSet) + applyHeaderModifiers(notify, &httpRouteContext.HTTPRoute, i, headersToSet) } } } return nil } -func applyHeaderModifiers(httpRoute *gatewayv1.HTTPRoute, ruleIndex int, headersToSet map[string]string) { +func applyHeaderModifiers(_ notifications.NotifyFunc, httpRoute *gatewayv1.HTTPRoute, ruleIndex int, headersToSet map[string]string) { // Find existing RequestHeaderModifier filter or create new one var filter *gatewayv1.HTTPRouteFilter for j, f := range httpRoute.Spec.Rules[ruleIndex].Filters { @@ -104,6 +93,5 @@ func applyHeaderModifiers(httpRoute *gatewayv1.HTTPRoute, ruleIndex int, headers Name: gatewayv1.HTTPHeaderName(name), Value: value, }) - notify(notifications.InfoNotification, fmt.Sprintf("Applied header modifier %s: %s to rule %d of route %s/%s", name, value, ruleIndex, httpRoute.Namespace, httpRoute.Name), httpRoute) } } diff --git a/pkg/i2gw/providers/ingressnginx/headers_test.go b/pkg/i2gw/providers/ingressnginx/headers_test.go index b6d9c8248..e06a47faa 100644 --- a/pkg/i2gw/providers/ingressnginx/headers_test.go +++ b/pkg/i2gw/providers/ingressnginx/headers_test.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package ingressnginx import ( "testing" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" networkingv1 "k8s.io/api/networking/v1" @@ -34,85 +35,6 @@ func TestHeaderModifierFeature(t *testing.T) { ingress networkingv1.Ingress expectedHeaders map[string]string }{ - { - name: "x-forwarded-prefix header with rewrite-target", - ingress: networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-x-forwarded-valid", - Namespace: "default", - Annotations: map[string]string{ - "nginx.ingress.kubernetes.io/x-forwarded-prefix": "/custom-prefix", - "nginx.ingress.kubernetes.io/rewrite-target": "/foo", - }, - }, - Spec: networkingv1.IngressSpec{ - Rules: []networkingv1.IngressRule{ - { - Host: "example.com", - IngressRuleValue: networkingv1.IngressRuleValue{ - HTTP: &networkingv1.HTTPIngressRuleValue{ - Paths: []networkingv1.HTTPIngressPath{ - { - Path: "/", - PathType: ptr.To(networkingv1.PathTypePrefix), - Backend: networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: "test-service", - Port: networkingv1.ServiceBackendPort{ - Number: 80, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - expectedHeaders: map[string]string{ - "X-Forwarded-Prefix": "/custom-prefix", - }, - }, - { - name: "x-forwarded-prefix ignored without rewrite-target", - ingress: networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-x-forwarded-ignored", - Namespace: "default", - Annotations: map[string]string{ - "nginx.ingress.kubernetes.io/x-forwarded-prefix": "/custom-prefix", - }, - }, - Spec: networkingv1.IngressSpec{ - Rules: []networkingv1.IngressRule{ - { - Host: "example.com", - IngressRuleValue: networkingv1.IngressRuleValue{ - HTTP: &networkingv1.HTTPIngressRuleValue{ - Paths: []networkingv1.HTTPIngressPath{ - { - Path: "/", - PathType: ptr.To(networkingv1.PathTypePrefix), - Backend: networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: "test-service", - Port: networkingv1.ServiceBackendPort{ - Number: 80, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - expectedHeaders: map[string]string{}, // Should be empty - }, { name: "upstream-vhost header", ingress: networkingv1.Ingress{ @@ -200,8 +122,6 @@ func TestHeaderModifierFeature(t *testing.T) { Name: "test-multiple", Namespace: "default", Annotations: map[string]string{ - "nginx.ingress.kubernetes.io/x-forwarded-prefix": "/prefix", - "nginx.ingress.kubernetes.io/rewrite-target": "/foo", "nginx.ingress.kubernetes.io/upstream-vhost": "backend.local", "nginx.ingress.kubernetes.io/connection-proxy-header": "keep-alive", }, @@ -233,9 +153,8 @@ func TestHeaderModifierFeature(t *testing.T) { }, }, expectedHeaders: map[string]string{ - "X-Forwarded-Prefix": "/prefix", - "Host": "backend.local", - "Connection": "keep-alive", + "Host": "backend.local", + "Connection": "keep-alive", }, }, } @@ -277,7 +196,7 @@ func TestHeaderModifierFeature(t *testing.T) { }, } - errs := headerModifierFeature([]networkingv1.Ingress{tc.ingress}, nil, &ir) + errs := headerModifierFeature(notifications.NoopNotify, []networkingv1.Ingress{tc.ingress}, nil, &ir) if len(errs) > 0 { t.Fatalf("Expected no errors, got %v", errs) } diff --git a/pkg/i2gw/providers/ingressnginx/ingressnginx.go b/pkg/i2gw/providers/ingressnginx/ingressnginx.go index 0ab935837..b76ea5ee0 100644 --- a/pkg/i2gw/providers/ingressnginx/ingressnginx.go +++ b/pkg/i2gw/providers/ingressnginx/ingressnginx.go @@ -19,9 +19,11 @@ package ingressnginx import ( "context" "fmt" + "io" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" "k8s.io/apimachinery/pkg/util/validation/field" ) @@ -45,22 +47,41 @@ type Provider struct { storage *storage resourceReader *resourceReader resourcesToIRConverter *resourcesToIRConverter + notify notifications.NotifyFunc } // NewProvider constructs and returns the ingress-nginx implementation of i2gw.Provider. func NewProvider(conf *i2gw.ProviderConf) i2gw.Provider { + notify := conf.Report.Notifier(Name) + return &Provider{ storage: newResourcesStorage(), resourceReader: newResourceReader(conf), - resourcesToIRConverter: newResourcesToIRConverter(), + resourcesToIRConverter: newResourcesToIRConverter(notify), + notify: notify, } } // ToIR converts stored Ingress-Nginx API entities to emitterir.IR // including the ingress-nginx specific features. func (p *Provider) ToIR() (emitterir.EmitterIR, field.ErrorList) { - pIR, errs := p.resourcesToIRConverter.convert(p.storage) - return providerir.ToEmitterIR(pIR), errs + pIR, errs := p.resourcesToIRConverter.convert(p.notify, p.storage) + eIR := providerir.ToEmitterIR(pIR) + applyRewriteTargetToEmitterIR(p.storage.Ingresses.List(), pIR, &eIR) + p.applyIPRangeControlToEmitterIR(pIR, &eIR) + p.applyTimeoutsToEmitterIR(pIR, &eIR) + p.applyCorsToEmitterIR(pIR, &eIR) + p.applyBodySizeToEmitterIR(pIR, &eIR) + p.applyRateLimitToEmitterIR(pIR, &eIR) + p.applySessionAffinityToEmitterIR(pIR, &eIR) + p.applyLoadBalancingToEmitterIR(pIR, &eIR) + p.applyAccessLogToEmitterIR(pIR, &eIR) + p.applyAuthToEmitterIR(pIR, &eIR) + p.applyBackendTLSToEmitterIR(pIR, &eIR) + p.applyServiceUpstreamToEmitterIR(pIR, &eIR) + p.applyBackendProtocolToEmitterIR(pIR, &eIR) + p.addSSLAndTrailingSlashRedirects(p.storage.Ingresses.List(), &pIR, &eIR) + return eIR, errs } func (p *Provider) ReadResourcesFromCluster(ctx context.Context) error { @@ -73,8 +94,8 @@ func (p *Provider) ReadResourcesFromCluster(ctx context.Context) error { return nil } -func (p *Provider) ReadResourcesFromFile(_ context.Context, filename string) error { - storage, err := p.resourceReader.readResourcesFromFile(filename) +func (p *Provider) ReadResourcesFromFile(_ context.Context, reader io.Reader) error { + storage, err := p.resourceReader.readResourcesFromFile(reader) if err != nil { return fmt.Errorf("failed to read resources from file: %w", err) } diff --git a/pkg/i2gw/providers/ingressnginx/iprange.go b/pkg/i2gw/providers/ingressnginx/iprange.go new file mode 100644 index 000000000..550f6828a --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/iprange.go @@ -0,0 +1,95 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "fmt" + "strings" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func parseIPSourceRangeAnnotation(annotations map[string]string, key string) []string { + value, ok := annotations[key] + if !ok { + return nil + } + items := strings.Split(value, ",") + for idx, item := range items { + items[idx] = strings.TrimSpace(item) + } + return items +} + +func (p *Provider) applyIPRangeControlToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + for key, pRouteCtx := range pIR.HTTPRoutes { + eRouteCtx, ok := eIR.HTTPRoutes[key] + if !ok { + continue + } + + for ruleIdx := range pRouteCtx.HTTPRoute.Spec.Rules { + if ruleIdx >= len(pRouteCtx.RuleBackendSources) { + continue + } + sources := pRouteCtx.RuleBackendSources[ruleIdx] + + ing := getNonCanaryIngress(sources) + if ing == nil { + continue + } + + var allowList, denyList []string + + allowList = parseIPSourceRangeAnnotation(ing.Annotations, WhiteListSourceRangeAnnotation) + denyList = parseIPSourceRangeAnnotation(ing.Annotations, DenyListSourceRangeAnnotation) + + if len(allowList) == 0 && len(denyList) == 0 { + continue + } + + if eRouteCtx.IPRangeControlByRuleIdx == nil { + eRouteCtx.IPRangeControlByRuleIdx = make(map[int]*emitterir.IPRangeControl) + } + + ipRangeControl := &emitterir.IPRangeControl{ + AllowList: allowList, + DenyList: denyList, + } + { + source := fmt.Sprintf("%s/%s", ing.Namespace, ing.Name) + message := "IP-based authorization is not supported" + paths := make([]*field.Path, 0, 2) + if len(allowList) > 0 { + paths = append(paths, field.NewPath(ing.Namespace, ing.Name, "metadata", "annotations", fmt.Sprintf("%q", WhiteListSourceRangeAnnotation))) + } + if len(denyList) > 0 { + paths = append(paths, field.NewPath(ing.Namespace, ing.Name, "metadata", "annotations", fmt.Sprintf("%q", DenyListSourceRangeAnnotation))) + } + ipRangeControl.Metadata = emitterir.NewExtensionFeatureMetadata( + source, + paths, + message, + ) + } + eRouteCtx.IPRangeControlByRuleIdx[ruleIdx] = ipRangeControl + } + eIR.HTTPRoutes[key] = eRouteCtx + } +} diff --git a/pkg/i2gw/providers/ingressnginx/iprange_test.go b/pkg/i2gw/providers/ingressnginx/iprange_test.go new file mode 100644 index 000000000..666efb1a3 --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/iprange_test.go @@ -0,0 +1,220 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "slices" + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestApplyIPRangeControlToEmitterIR(t *testing.T) { + testCases := []struct { + name string + ingress networkingv1.Ingress + expectedAllowList []string + expectedDenyList []string + }{ + { + name: "whitelist only - single CIDR", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ip-range-defaults", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/whitelist-source-range": "192.168.1.0/24", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + }, + expectedAllowList: []string{"192.168.1.0/24"}, + expectedDenyList: nil, + }, + { + name: "whitelist only - multiple CIDRs", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ip-range-defaults", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/whitelist-source-range": "192.168.1.0/24,10.0.0.0/8,172.16.0.0/12", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + }, + expectedAllowList: []string{"192.168.1.0/24", "10.0.0.0/8", "172.16.0.0/12"}, + expectedDenyList: nil, + }, + { + name: "whitelist with whitespace trimming", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ip-range-defaults", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/whitelist-source-range": " 192.168.1.0/24 , 10.0.0.0/8 ", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + }, + expectedAllowList: []string{"192.168.1.0/24", "10.0.0.0/8"}, + expectedDenyList: nil, + }, + { + name: "denylist only - single CIDR", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ip-range-defaults", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/denylist-source-range": "203.0.113.0/24", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + }, + expectedAllowList: nil, + expectedDenyList: []string{"203.0.113.0/24"}, + }, + { + name: "denylist only - multiple CIDRs", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ip-range-defaults", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/denylist-source-range": "203.0.113.0/24,198.51.100.0/24", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + }, + expectedAllowList: nil, + expectedDenyList: []string{"203.0.113.0/24", "198.51.100.0/24"}, + }, + { + name: "both whitelist and denylist", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ip-range-defaults", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/whitelist-source-range": "192.168.1.0/24,10.0.0.0/8", + "nginx.ingress.kubernetes.io/denylist-source-range": "203.0.113.0/24", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + }, + expectedAllowList: []string{"192.168.1.0/24", "10.0.0.0/8"}, + expectedDenyList: []string{"203.0.113.0/24"}, + }, + { + name: "no IP range annotations", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ip-range-defaults", + Namespace: "default", + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + }, + expectedAllowList: nil, + expectedDenyList: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pIR := providerir.ProviderIR{ + HTTPRoutes: make(map[types.NamespacedName]providerir.HTTPRouteContext), + } + eIR := emitterir.EmitterIR{ + HTTPRoutes: make(map[types.NamespacedName]emitterir.HTTPRouteContext), + } + + key := types.NamespacedName{Namespace: tc.ingress.Namespace, Name: common.RouteName(tc.ingress.Name, "example.com")} + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: tc.ingress.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Type: ptr.To(gatewayv1.PathMatchPathPrefix), Value: ptr.To("/")}}}, + }, + }, + }, + } + + // Provider IR setup (for sources) + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{{ + {Ingress: &tc.ingress}, + }}, + } + + // Emitter IR setup (target) + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{ + HTTPRoute: route, + } + + (&Provider{notify: notifications.NoopNotify}).applyIPRangeControlToEmitterIR(pIR, &eIR) + + result := eIR.HTTPRoutes[key] + var ipRangeControl *emitterir.IPRangeControl + if result.IPRangeControlByRuleIdx != nil { + ipRangeControl = result.IPRangeControlByRuleIdx[0] + } + + if tc.expectedAllowList == nil && tc.expectedDenyList == nil { + if ipRangeControl != nil { + t.Fatalf("Expected nil IPRangeControl, got %v", ipRangeControl) + } + return + } + if ipRangeControl == nil { + t.Fatalf("Expected IPRangeControl to be set, got nil") + } + + if !slices.Equal(ipRangeControl.AllowList, tc.expectedAllowList) { + t.Errorf("AllowList mismatch: expected %v, got %v", tc.expectedAllowList, ipRangeControl.AllowList) + } + if !slices.Equal(ipRangeControl.DenyList, tc.expectedDenyList) { + t.Errorf("DenyList mismatch: expected %v, got %v", tc.expectedDenyList, ipRangeControl.DenyList) + } + }) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/load_balance.go b/pkg/i2gw/providers/ingressnginx/load_balance.go index ebeab5391..15f69ac46 100644 --- a/pkg/i2gw/providers/ingressnginx/load_balance.go +++ b/pkg/i2gw/providers/ingressnginx/load_balance.go @@ -17,118 +17,92 @@ limitations under the License. package ingressnginx import ( + "fmt" "strings" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" ) -const loadBalanceAnnotation = "nginx.ingress.kubernetes.io/load-balance" - -// loadBalancingFeature is a FeatureParser that projects load-balancing–related -// annotations into the Ingress NGINX ProviderSpecificIR. -func loadBalancingFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - - // Build per-Ingress policy from the load-balance annotation. - ing2pol := make(map[string]providerir.Policy, len(ingresses)) - - for _, ing := range ingresses { - if ing.Annotations == nil { - continue - } - - raw, ok := ing.Annotations[loadBalanceAnnotation] +// applyLoadBalancingToEmitterIR reads ingress-nginx load balancing annotations from ProviderIR sources and stores +// provider-neutral load balancing intent into EmitterIR, which will later be converted by each custom emitter. +// +// Currently supported annotations are: +// - nginx.ingress.kubernetes.io/load-balance +func (p *Provider) applyLoadBalancingToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + for key, pRouteCtx := range pIR.HTTPRoutes { + eRouteCtx, ok := eIR.HTTPRoutes[key] if !ok { continue } - value := strings.TrimSpace(strings.ToLower(raw)) - if value == "" { - continue - } - - // Only support round_robin; everything else is reported as an error. - switch value { - case "round_robin": - pol := ing2pol[ing.Name] - if pol.LoadBalancing == nil { - pol.LoadBalancing = &providerir.BackendLoadBalancingPolicy{} + for ruleIdx := range eRouteCtx.Spec.Rules { + if ruleIdx >= len(pRouteCtx.RuleBackendSources) { + continue + } + ing := getNonCanaryIngress(pRouteCtx.RuleBackendSources[ruleIdx]) + if ing == nil { + continue } - pol.LoadBalancing.Strategy = providerir.LoadBalancingStrategyRoundRobin - ing2pol[ing.Name] = pol - default: - // Unsupported modes (ewma, ip_hash, etc.). - errs = append(errs, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(loadBalanceAnnotation), - raw, - `unsupported load balancing strategy; only "round_robin" is supported`, - )) - } - } - - if len(ing2pol) == 0 { - return errs - } - // Map policies onto HTTPRoute rules/backends using BackendSource. - for key, httpCtx := range ir.HTTPRoutes { - // Group BackendSources by source Ingress name. - srcByIngress := map[string][]providerir.PolicyIndex{} - - for ruleIdx, perRule := range httpCtx.RuleBackendSources { - for backendIdx, src := range perRule { - if src.Ingress == nil { - continue - } - ingressName := src.Ingress.Name - srcByIngress[ingressName] = append( - srcByIngress[ingressName], - providerir.PolicyIndex{Rule: ruleIdx, Backend: backendIdx}, - ) + loadBalancing, parsedAnnotations := p.parseIngressNginxLoadBalancing(ing) + if loadBalancing == nil { + continue } - } - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, + if eRouteCtx.LoadBalancingByRuleIdx == nil { + eRouteCtx.LoadBalancingByRuleIdx = make(map[int]*emitterir.BackendLoadBalancingPolicy) } - } else if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - for ingressName, idxs := range srcByIngress { - pol, ok := ing2pol[ingressName] - if !ok || pol.LoadBalancing == nil { - continue + source := fmt.Sprintf("%s/%s", ing.Namespace, ing.Name) + message := "Load balancing behavior is implementation-specific and may require provider-specific configuration" + paths := make([]*field.Path, len(parsedAnnotations)) + for i, ann := range parsedAnnotations { + paths[i] = field.NewPath(ing.Namespace, ing.Name, "metadata", "annotations", fmt.Sprintf("%q", ann)) } + loadBalancing.Metadata = emitterir.NewExtensionFeatureMetadata( + source, + paths, + message, + ) - existing := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingressName] + eRouteCtx.LoadBalancingByRuleIdx[ruleIdx] = loadBalancing + } - // Merge load-balancing strategy into existing policy for this Ingress (if any). - if existing.LoadBalancing == nil { - existing.LoadBalancing = pol.LoadBalancing - } else { - // Latest strategy wins for now. - existing.LoadBalancing.Strategy = pol.LoadBalancing.Strategy - } + eIR.HTTPRoutes[key] = eRouteCtx + } +} - // Dedupe (rule, backend) pairs. - existing = existing.AddRuleBackendSources(idxs) +func (p *Provider) parseIngressNginxLoadBalancing(ing *networkingv1.Ingress) (*emitterir.BackendLoadBalancingPolicy, []string) { + if ing.Annotations == nil { + return nil, nil + } - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingressName] = existing - } + raw, ok := ing.Annotations[LoadBalanceAnnotation] + if !ok { + return nil, nil + } - // Write back mutated HTTPRouteContext into IR. - ir.HTTPRoutes[key] = httpCtx + value := strings.TrimSpace(strings.ToLower(raw)) + if value == "" { + return nil, nil } - return errs + switch value { + case string(emitterir.LoadBalancingStrategyRoundRobin): + return &emitterir.BackendLoadBalancingPolicy{ + Strategy: emitterir.LoadBalancingStrategyRoundRobin, + }, []string{LoadBalanceAnnotation} + default: + p.notify( + notifications.WarningNotification, + fmt.Sprintf(`Unsupported load-balance annotation %q: only "round_robin" is supported, skipping load balancing`, raw), + ing, + ) + return nil, nil + } } diff --git a/pkg/i2gw/providers/ingressnginx/load_balance_test.go b/pkg/i2gw/providers/ingressnginx/load_balance_test.go new file mode 100644 index 000000000..ca37c49fa --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/load_balance_test.go @@ -0,0 +1,59 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + "k8s.io/apimachinery/pkg/types" +) + +func TestApplyLoadBalancingToEmitterIR_SetRoundRobin(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + annotations := map[string]string{ + LoadBalanceAnnotation: "round_robin", + } + pIR, eIR := setupBodySizeTest(key, annotations) + + p := &Provider{notify: notifications.NoopNotify} + p.applyLoadBalancingToEmitterIR(pIR, &eIR) + + loadBalancingIR := eIR.HTTPRoutes[key].LoadBalancingByRuleIdx[0] + if loadBalancingIR == nil { + t.Fatalf("expected load balancing IR to be set for rule index 0") + } + if loadBalancingIR.Strategy != emitterir.LoadBalancingStrategyRoundRobin { + t.Fatalf("expected strategy %q, got %q", emitterir.LoadBalancingStrategyRoundRobin, loadBalancingIR.Strategy) + } +} + +func TestApplyLoadBalancingToEmitterIR_SkipsUnsupportedStrategy(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + annotations := map[string]string{ + LoadBalanceAnnotation: "ewma", + } + pIR, eIR := setupBodySizeTest(key, annotations) + + p := &Provider{notify: notifications.NoopNotify} + p.applyLoadBalancingToEmitterIR(pIR, &eIR) + + if got := eIR.HTTPRoutes[key].LoadBalancingByRuleIdx; got != nil { + t.Fatalf("expected no load balancing IR for unsupported annotation, got %#v", got) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/proxy_connect_timeout.go b/pkg/i2gw/providers/ingressnginx/proxy_connect_timeout.go deleted file mode 100644 index adc47b70b..000000000 --- a/pkg/i2gw/providers/ingressnginx/proxy_connect_timeout.go +++ /dev/null @@ -1,143 +0,0 @@ -/* -Copyright 2024 The Kubernetes Authors. - -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 ingressnginx - -import ( - "time" - - providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" - - networkingv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -const nginxProxyConnectTimeout = "nginx.ingress.kubernetes.io/proxy-connect-timeout" - -// proxyConnectTimeoutFeature parses proxy-connect-timeout and stores it in the IR Policy. -// -// Semantics: -// - Annotation value is treated like nginx: either a bare number of seconds ("30") -// or a Go-style duration ("30s", "2m", ...). -// - We normalize it to metav1.Duration and attach it per-Ingress, then map to -// specific (rule, backend) pairs via RuleBackendSources. -func proxyConnectTimeoutFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - - // Per-Ingress parsed timeout. - perIngress := map[types.NamespacedName]*metav1.Duration{} - - for i := range ingresses { - ing := &ingresses[i] - raw := ing.Annotations[nginxProxyConnectTimeout] - if raw == "" { - continue - } - - // Try parsing as a Go duration first ("30s", "2m", etc.). - d, err := time.ParseDuration(raw) - if err != nil { - // Fallback: assume bare seconds ("30"). - d, err = time.ParseDuration(raw + "s") - } - if err != nil { - errs = append(errs, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(nginxProxyConnectTimeout), - raw, - "failed to parse proxy-connect-timeout", - )) - continue - } - - key := types.NamespacedName{ - Namespace: ing.Namespace, - Name: ing.Name, - } - perIngress[key] = &metav1.Duration{Duration: d} - } - - if len(perIngress) == 0 { - return errs - } - - // Map per-Ingress timeout onto HTTPRoute policies using RuleBackendSources. - ruleGroups := common.GetRuleGroups(ingresses) - - for _, rg := range ruleGroups { - routeKey := types.NamespacedName{ - Namespace: rg.Namespace, - Name: common.RouteName(rg.Name, rg.Host), - } - - httpCtx, ok := ir.HTTPRoutes[routeKey] - if !ok { - continue - } - - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, - } - } - if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - - for ruleIdx, backendSources := range httpCtx.RuleBackendSources { - for backendIdx, src := range backendSources { - if src.Ingress == nil { - continue - } - - ingKey := types.NamespacedName{ - Namespace: src.Ingress.Namespace, - Name: src.Ingress.Name, - } - - timeout := perIngress[ingKey] - if timeout == nil { - continue - } - - p := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] - if p.ProxyConnectTimeout == nil { - p.ProxyConnectTimeout = timeout - } - - // Dedupe (rule, backend) pairs. - p.AddRuleBackendSources([]providerir.PolicyIndex{ - { - Rule: ruleIdx, - Backend: backendIdx, - }, - }) - - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] = p - } - } - - ir.HTTPRoutes[routeKey] = httpCtx - } - - return errs -} diff --git a/pkg/i2gw/providers/ingressnginx/proxy_read_timeout.go b/pkg/i2gw/providers/ingressnginx/proxy_read_timeout.go deleted file mode 100644 index 2bce4e894..000000000 --- a/pkg/i2gw/providers/ingressnginx/proxy_read_timeout.go +++ /dev/null @@ -1,145 +0,0 @@ -/* -Copyright 2024 The Kubernetes Authors. - -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 ingressnginx - -import ( - "fmt" - "strconv" - "strings" - "time" - - providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - - networkingv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -const nginxProxyReadTimeoutAnnotation = "nginx.ingress.kubernetes.io/proxy-read-timeout" - -// proxyReadTimeoutFeature parses the "nginx.ingress.kubernetes.io/proxy-read-timeout" -// annotation from Ingresses and projects it into the ingress-nginx provider-specific IR. -// -// Semantics: -// - Accepts values like "30s", "1m", etc. (time.ParseDuration). -// - Also accepts bare numbers like "60", interpreted as seconds. -// - Stored on providerir.Policy.ProxyReadTimeout. -func proxyReadTimeoutFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - - // Per-ingress parsed durations. - perIngress := map[string]*metav1.Duration{} - - for _, ing := range ingresses { - anns := ing.GetAnnotations() - if anns == nil { - continue - } - - raw := strings.TrimSpace(anns[nginxProxyReadTimeoutAnnotation]) - if raw == "" { - continue - } - - d, err := parseProxyReadTimeoutDuration(raw) - if err != nil { - errs = append(errs, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(nginxProxyReadTimeoutAnnotation), - raw, - fmt.Sprintf("failed to parse proxy-read-timeout: %v", err), - )) - continue - } - - perIngress[ing.Name] = d - } - - if len(perIngress) == 0 { - return errs - } - - // Map to HTTPRoutes using RuleBackendSources. - for routeKey, httpCtx := range ir.HTTPRoutes { - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, - } - } - if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - - // Group PolicyIndex by ingress name. - srcByIngress := map[string][]providerir.PolicyIndex{} - for ruleIdx, perRule := range httpCtx.RuleBackendSources { - for backendIdx, src := range perRule { - if src.Ingress == nil { - continue - } - name := src.Ingress.Name - srcByIngress[name] = append(srcByIngress[name], - providerir.PolicyIndex{Rule: ruleIdx, Backend: backendIdx}, - ) - } - } - - for ingressName, idxs := range srcByIngress { - d, ok := perIngress[ingressName] - if !ok { - continue - } - - existing := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingressName] - existing.ProxyReadTimeout = d - existing.AddRuleBackendSources(idxs) - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingressName] = existing - } - - ir.HTTPRoutes[routeKey] = httpCtx - } - - return errs -} - -// parseProxyReadTimeoutDuration accepts either: -// - standard Go duration strings ("30s", "1m", "1m30s"), or -// - bare integer seconds like "60". -func parseProxyReadTimeoutDuration(raw string) (*metav1.Duration, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil, fmt.Errorf("empty duration") - } - - // First, try full duration syntax. - if d, err := time.ParseDuration(raw); err == nil { - return &metav1.Duration{Duration: d}, nil - } - - // Fallback: treat it as plain seconds. - sec, err := strconv.Atoi(raw) - if err != nil { - return nil, fmt.Errorf("invalid duration %q: %w", raw, err) - } - - d := time.Duration(sec) * time.Second - return &metav1.Duration{Duration: d}, nil -} diff --git a/pkg/i2gw/providers/ingressnginx/proxy_send_timeout.go b/pkg/i2gw/providers/ingressnginx/proxy_send_timeout.go deleted file mode 100644 index da0ecf094..000000000 --- a/pkg/i2gw/providers/ingressnginx/proxy_send_timeout.go +++ /dev/null @@ -1,128 +0,0 @@ -/* -Copyright 2023 The Kubernetes Authors. - -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 ingressnginx - -import ( - "time" - - providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" - - networkingv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -const proxySendTimeoutAnnotation = "nginx.ingress.kubernetes.io/proxy-send-timeout" - -// proxySendTimeoutFeature parses proxy-send-timeout and stores it on the IR Policy. -// TODO [danehans]: Set the `grpc_send_timeout` when gRPC support is added. -func proxySendTimeoutFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - - perIngress := map[types.NamespacedName]*metav1.Duration{} - - for i := range ingresses { - ing := &ingresses[i] - raw := ing.Annotations[proxySendTimeoutAnnotation] - if raw == "" { - continue - } - - // nginx uses seconds (e.g. "30", "60s"). time.ParseDuration handles both if we normalize. - d, err := time.ParseDuration(raw) - if err != nil { - // try seconds fallback (e.g. "30") - d, err = time.ParseDuration(raw + "s") - } - if err != nil { - errs = append(errs, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(proxySendTimeoutAnnotation), - raw, - "failed to parse proxy-send-timeout", - )) - continue - } - - perIngress[types.NamespacedName{ - Namespace: ing.Namespace, - Name: ing.Name, - }] = &metav1.Duration{Duration: d} - } - - if len(perIngress) == 0 { - return errs - } - - ruleGroups := common.GetRuleGroups(ingresses) - - for _, rg := range ruleGroups { - routeKey := types.NamespacedName{ - Namespace: rg.Namespace, - Name: common.RouteName(rg.Name, rg.Host), - } - - httpCtx, ok := ir.HTTPRoutes[routeKey] - if !ok { - continue - } - - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = - &providerir.IngressNginxHTTPRouteIR{Policies: map[string]providerir.Policy{}} - } - - for ruleIdx, backendSources := range httpCtx.RuleBackendSources { - for backendIdx, src := range backendSources { - if src.Ingress == nil { - continue - } - - key := types.NamespacedName{ - Namespace: src.Ingress.Namespace, - Name: src.Ingress.Name, - } - - dur := perIngress[key] - if dur == nil { - continue - } - - p := httpCtx.ProviderSpecificIR.IngressNginx.Policies[key.Name] - if p.ProxySendTimeout == nil { - p.ProxySendTimeout = dur - } - - p.RuleBackendSources = append( - p.RuleBackendSources, - providerir.PolicyIndex{Rule: ruleIdx, Backend: backendIdx}, - ) - - httpCtx.ProviderSpecificIR.IngressNginx.Policies[key.Name] = p - } - } - - ir.HTTPRoutes[routeKey] = httpCtx - } - - return errs -} diff --git a/pkg/i2gw/providers/ingressnginx/proxybodysize.go b/pkg/i2gw/providers/ingressnginx/proxybodysize.go deleted file mode 100644 index e693c3ee5..000000000 --- a/pkg/i2gw/providers/ingressnginx/proxybodysize.go +++ /dev/null @@ -1,131 +0,0 @@ -/* -Copyright 2024 The Kubernetes Authors. - -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 ingressnginx - -import ( - providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" - - networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/api/resource" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -const proxyBodySizeAnnotation = "nginx.ingress.kubernetes.io/proxy-body-size" - -// proxyBodySizeFeature extracts the "proxy-body-size" annotation and -// projects it into the provider-specific IR similarly to the buffer feature. -func proxyBodySizeFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - - var errs field.ErrorList - ingressPolicies := map[types.NamespacedName]*providerir.Policy{} - - for i := range ingresses { - ing := &ingresses[i] - raw := ing.Annotations[proxyBodySizeAnnotation] - if raw == "" { - continue - } - - q, err := resource.ParseQuantity(raw) - if err != nil { - errs = append(errs, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(proxyBodySizeAnnotation), - raw, - "failed to parse proxy-body-size", - )) - continue - } - - key := types.NamespacedName{Namespace: ing.Namespace, Name: ing.Name} - pol := ingressPolicies[key] - if pol == nil { - pol = &providerir.Policy{} - ingressPolicies[key] = pol - } - - qCopy := q.DeepCopy() - pol.ProxyBodySize = &qCopy - } - - if len(ingressPolicies) == 0 { - return errs - } - - // Map policies to HTTPRoutes (same pattern as bufferPolicyFeature) - ruleGroups := common.GetRuleGroups(ingresses) - - for _, rg := range ruleGroups { - routeKey := types.NamespacedName{ - Namespace: rg.Namespace, - Name: common.RouteName(rg.Name, rg.Host), - } - - httpCtx, ok := ir.HTTPRoutes[routeKey] - if !ok { - continue - } - - for ruleIdx, backendSources := range httpCtx.RuleBackendSources { - for backendIdx, src := range backendSources { - if src.Ingress == nil { - continue - } - - ingKey := types.NamespacedName{ - Namespace: src.Ingress.Namespace, - Name: src.Ingress.Name, - } - - pol := ingressPolicies[ingKey] - if pol == nil || pol.ProxyBodySize == nil { - continue - } - - // Ensure provider-specific IR exists - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, - } - } else if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - - existing := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] - if existing.ProxyBodySize == nil { - existing.ProxyBodySize = pol.ProxyBodySize - } - - // Dedupe (rule, backend) pairs. - existing = existing.AddRuleBackendSources([]providerir.PolicyIndex{ - {Rule: ruleIdx, Backend: backendIdx}, - }) - - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] = existing - } - } - - ir.HTTPRoutes[routeKey] = httpCtx - } - - return errs -} diff --git a/pkg/i2gw/providers/ingressnginx/ratelimit.go b/pkg/i2gw/providers/ingressnginx/ratelimit.go index adab8e88b..4e2f2a193 100644 --- a/pkg/i2gw/providers/ingressnginx/ratelimit.go +++ b/pkg/i2gw/providers/ingressnginx/ratelimit.go @@ -17,141 +17,131 @@ limitations under the License. package ingressnginx import ( + "fmt" "strconv" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" ) -const ( - nginxLimitRPS = "nginx.ingress.kubernetes.io/limit-rps" - nginxLimitRPM = "nginx.ingress.kubernetes.io/limit-rpm" - nginxLimitBurstMultiplier = "nginx.ingress.kubernetes.io/limit-burst-multiplier" -) - -// rateLimitPolicyFeature parses the rate limiting annotations from Ingresses -// and records them as ingress-nginx specific IR Policies. -func rateLimitPolicyFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - - // Build a map of raw per-Ingress RateLimitPolicy - perIngress := map[string]*providerir.RateLimitPolicy{} - - for _, ing := range ingresses { - anns := ing.GetAnnotations() - if anns == nil { +// applyRateLimitToEmitterIR reads ingress-nginx rate limit annotations from ProviderIR sources and stores +// provider-neutral rate limit intent into EmitterIR, which will later be converted by each custom emitter. +// +// Currently supported annotations are: +// - nginx.ingress.kubernetes.io/limit-rps +// - nginx.ingress.kubernetes.io/limit-rpm +// - nginx.ingress.kubernetes.io/limit-burst-multiplier +func (p *Provider) applyRateLimitToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + for key, pRouteCtx := range pIR.HTTPRoutes { + eRouteCtx, ok := eIR.HTTPRoutes[key] + if !ok { continue } - var ( - limit int32 - unit providerir.RateLimitUnit - hasLimit bool - burstMult int32 = 1 - ) - - // Prefer RPS over RPM - if v, ok := anns[nginxLimitRPS]; ok && v != "" { - if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { - limit = int32(parsed) - unit = providerir.RateLimitUnitRPS - hasLimit = true + for ruleIdx := range eRouteCtx.Spec.Rules { + if ruleIdx >= len(pRouteCtx.RuleBackendSources) { + continue } - } - if !hasLimit { - if v, ok := anns[nginxLimitRPM]; ok && v != "" { - if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { - limit = int32(parsed) - unit = providerir.RateLimitUnitRPM - hasLimit = true - } + ing := getNonCanaryIngress(pRouteCtx.RuleBackendSources[ruleIdx]) + if ing == nil { + continue } - } - if !hasLimit { - continue - } + limitPolicy, parsedAnnotations := p.parseIngressNginxRateLimit(ing) + if limitPolicy == nil { + continue + } - if v, ok := anns[nginxLimitBurstMultiplier]; ok && v != "" { - if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { - burstMult = int32(parsed) + if eRouteCtx.RateLimitByRuleIdx == nil { + eRouteCtx.RateLimitByRuleIdx = make(map[int]*emitterir.RateLimitPolicy) } - } - perIngress[ing.Name] = &providerir.RateLimitPolicy{ - Limit: limit, - Unit: unit, - BurstMultiplier: burstMult, + source := fmt.Sprintf("%s/%s", ing.Namespace, ing.Name) + message := "Rate limiting usually requires implementation-specific policy configuration" + paths := make([]*field.Path, len(parsedAnnotations)) + for i, ann := range parsedAnnotations { + paths[i] = field.NewPath(ing.Namespace, ing.Name, "metadata", "annotations", fmt.Sprintf("%q", ann)) + } + limitPolicy.Metadata = emitterir.NewExtensionFeatureMetadata( + source, + paths, + message, + ) + + eRouteCtx.RateLimitByRuleIdx[ruleIdx] = limitPolicy } - } - if len(perIngress) == 0 { - return errs // nothing to do + eIR.HTTPRoutes[key] = eRouteCtx } +} - // For each HTTPRoute, map sources to provider-specific IR Policies - for routeKey, httpCtx := range ir.HTTPRoutes { - // Ensure provider IR exists - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = - &providerir.IngressNginxHTTPRouteIR{Policies: map[string]providerir.Policy{}} - } - if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} +func (p *Provider) parseIngressNginxRateLimit(ing *networkingv1.Ingress) (*emitterir.RateLimitPolicy, []string) { + var ( + limit int32 + unit emitterir.RateLimitUnit + hasLimit bool + burstMultiplier int32 = 1 + parsedAnnotations = make([]string, 0, 2) + ) + + if val, ok := ing.Annotations[LimitRPSAnnotation]; ok && val != "" { + parsedAnnotations = append(parsedAnnotations, LimitRPSAnnotation) + parsed, err := strconv.ParseInt(val, 10, 32) + if err != nil || parsed <= 0 { + p.notify( + notifications.WarningNotification, + fmt.Sprintf("Invalid limit-rps annotation %q: must be a positive integer, skipping rate limit", val), + ing, + ) + return nil, nil } + limit = int32(parsed) + unit = emitterir.RateLimitUnitRPS + hasLimit = true + } - // Group PolicyIndex entries by ingress name - sourceIndexes := map[string][]providerir.PolicyIndex{} - for ruleIdx, perRule := range httpCtx.RuleBackendSources { - for backIdx, src := range perRule { - if src.Ingress == nil { - continue - } - name := src.Ingress.Name - sourceIndexes[name] = append( - sourceIndexes[name], - providerir.PolicyIndex{Rule: ruleIdx, Backend: backIdx}, + if !hasLimit { + if val, ok := ing.Annotations[LimitRPMAnnotation]; ok && val != "" { + parsedAnnotations = append(parsedAnnotations, LimitRPMAnnotation) + parsed, err := strconv.ParseInt(val, 10, 32) + if err != nil || parsed <= 0 { + p.notify( + notifications.WarningNotification, + fmt.Sprintf("Invalid limit-rpm annotation %q: must be a positive integer, skipping rate limit", val), + ing, ) + return nil, nil } + limit = int32(parsed) + unit = emitterir.RateLimitUnitRPM + hasLimit = true } + } - // For each ingress source, attach the rate limit policy - for ingressName, idxs := range sourceIndexes { - rl, exists := perIngress[ingressName] - if !exists { - continue - } - - // Fetch/Create provider policy - existing := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingressName] - - // Merge rate limit settings - if existing.RateLimit == nil { - existing.RateLimit = rl - } else { - // Merge semantics = "last writer wins" (consistent with other providers) - existing.RateLimit.Limit = rl.Limit - existing.RateLimit.Unit = rl.Unit - if rl.BurstMultiplier > 0 { - existing.RateLimit.BurstMultiplier = rl.BurstMultiplier - } - } - - // Dedupe (rule, backend) pairs. - existing = existing.AddRuleBackendSources(idxs) + if !hasLimit { + return nil, nil + } - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingressName] = existing + if val, ok := ing.Annotations[LimitBurstMultiplierAnnotation]; ok && val != "" { + parsedAnnotations = append(parsedAnnotations, LimitBurstMultiplierAnnotation) + parsed, err := strconv.ParseInt(val, 10, 32) + if err != nil || parsed <= 0 { + p.notify( + notifications.WarningNotification, + fmt.Sprintf("Invalid limit-burst-multiplier annotation %q: must be a positive integer, using default burst multiplier 1", val), + ing, + ) + } else { + burstMultiplier = int32(parsed) } - - // Write back updated route context - ir.HTTPRoutes[routeKey] = httpCtx } - return errs + return &emitterir.RateLimitPolicy{ + Limit: limit, + Unit: unit, + BurstMultiplier: burstMultiplier, + }, parsedAnnotations } diff --git a/pkg/i2gw/providers/ingressnginx/ratelimit_test.go b/pkg/i2gw/providers/ingressnginx/ratelimit_test.go new file mode 100644 index 000000000..8c1cd6221 --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/ratelimit_test.go @@ -0,0 +1,92 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + "k8s.io/apimachinery/pkg/types" +) + +func TestApplyRateLimitToEmitterIR_PrefersRPS(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + annotations := map[string]string{ + LimitRPSAnnotation: "10", + LimitRPMAnnotation: "600", + LimitBurstMultiplierAnnotation: "3", + } + pIR, eIR := setupBodySizeTest(key, annotations) + + p := &Provider{notify: notifications.NoopNotify} + p.applyRateLimitToEmitterIR(pIR, &eIR) + + rateLimitIR := eIR.HTTPRoutes[key].RateLimitByRuleIdx[0] + if rateLimitIR == nil { + t.Fatalf("expected rate limit IR to be set for rule index 0") + } + if rateLimitIR.Limit != 10 { + t.Fatalf("expected limit 10, got %d", rateLimitIR.Limit) + } + if rateLimitIR.Unit != emitterir.RateLimitUnitRPS { + t.Fatalf("expected unit %q, got %q", emitterir.RateLimitUnitRPS, rateLimitIR.Unit) + } + if rateLimitIR.BurstMultiplier != 3 { + t.Fatalf("expected burst multiplier 3, got %d", rateLimitIR.BurstMultiplier) + } +} + +func TestApplyRateLimitToEmitterIR_UsesRPMWhenRPSMissing(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + annotations := map[string]string{ + LimitRPMAnnotation: "120", + } + pIR, eIR := setupBodySizeTest(key, annotations) + + p := &Provider{notify: notifications.NoopNotify} + p.applyRateLimitToEmitterIR(pIR, &eIR) + + rateLimitIR := eIR.HTTPRoutes[key].RateLimitByRuleIdx[0] + if rateLimitIR == nil { + t.Fatalf("expected rate limit IR to be set for rule index 0") + } + if rateLimitIR.Limit != 120 { + t.Fatalf("expected limit 120, got %d", rateLimitIR.Limit) + } + if rateLimitIR.Unit != emitterir.RateLimitUnitRPM { + t.Fatalf("expected unit %q, got %q", emitterir.RateLimitUnitRPM, rateLimitIR.Unit) + } + if rateLimitIR.BurstMultiplier != 1 { + t.Fatalf("expected default burst multiplier 1, got %d", rateLimitIR.BurstMultiplier) + } +} + +func TestApplyRateLimitToEmitterIR_SkipsInvalidLimit(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + annotations := map[string]string{ + LimitRPSAnnotation: "nope", + } + pIR, eIR := setupBodySizeTest(key, annotations) + + p := &Provider{notify: notifications.NoopNotify} + p.applyRateLimitToEmitterIR(pIR, &eIR) + + if got := eIR.HTTPRoutes[key].RateLimitByRuleIdx; got != nil { + t.Fatalf("expected no rate limit IR for invalid annotation, got %#v", got) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/redirect.go b/pkg/i2gw/providers/ingressnginx/redirect.go new file mode 100644 index 000000000..1c9b3af5e --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/redirect.go @@ -0,0 +1,479 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// redirectFeature converts permanent and temporal redirect annotations to Gateway API RequestRedirect filters. +// This matches ingress-nginx's execution order: temporal redirect is checked first, then permanent. +// If the temporal-redirect annotation key is present (even with an empty value), the function +// short-circuits and permanent redirect annotations are never evaluated. +// +// Gateway API only supports status codes 301, 302, 303, 307, 308. +// Intersecting with ingress-nginx's valid ranges: +// - temporal-redirect defaults to 302, supported custom codes: 301, 302, 303, 307 +// - permanent-redirect defaults to 301, supported custom codes: 301, 302, 303, 307, 308. +func redirectFeature(notify notifications.NotifyFunc, ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *providerir.ProviderIR) field.ErrorList { + + // Iterate over all HTTPRoutes in the IR. + for key, httpRouteContext := range ir.HTTPRoutes { + // Iterate over each rule in the HTTPRoute. + for ruleIndex := range httpRouteContext.HTTPRoute.Spec.Rules { + // Check if this rule has backend sources. + if ruleIndex >= len(httpRouteContext.RuleBackendSources) { + continue + } + + // Get the non canary ingress for this rule. + ingress := getNonCanaryIngress(httpRouteContext.RuleBackendSources[ruleIndex]) + + if ingress == nil { + continue + } + + // Warn about unsupported proxy-redirect annotations. + if ingress.Annotations[ProxyRedirectFromAnnotation] != "" { + notify(notifications.WarningNotification, fmt.Sprintf("ingress %s/%s uses unsupported annotation %s", + ingress.Namespace, ingress.Name, ProxyRedirectFromAnnotation), ingress) + } + if ingress.Annotations[ProxyRedirectToAnnotation] != "" { + notify(notifications.WarningNotification, fmt.Sprintf("ingress %s/%s uses unsupported annotation %s", + ingress.Namespace, ingress.Name, ProxyRedirectToAnnotation), ingress) + } + + temporalRedirectURL, hasTemporal := ingress.Annotations[TemporalRedirectAnnotation] + permanentRedirectURL, hasPermanent := ingress.Annotations[PermanentRedirectAnnotation] + + // Skip if neither annotation is present. + if !hasPermanent && !hasTemporal { + continue + } + + // Determine redirect URL and status code. + // Matching ingress-nginx execution order: temporal is checked first. + // If the temporal-redirect annotation key is present (even with an empty value), + // the function short-circuits — permanent redirect is never evaluated. + var redirectURL string + var statusCode int + var annotationUsed string + + if hasTemporal { + redirectURL = temporalRedirectURL + statusCode = 302 + annotationUsed = TemporalRedirectAnnotation + + // Warn if both annotations are present (permanent is ignored). + if hasPermanent { + notify(notifications.WarningNotification, fmt.Sprintf("ingress %s/%s has both %s and %s annotations; temporal-redirect takes priority, permanent-redirect is ignored", + ingress.Namespace, ingress.Name, PermanentRedirectAnnotation, TemporalRedirectAnnotation), ingress) + } + + // Check custom status code annotation. + if codeStr := ingress.Annotations[TemporalRedirectCodeAnnotation]; codeStr != "" { + code, err := strconv.Atoi(codeStr) + if err != nil || !isValidTemporalRedirectCode(code) { + notify(notifications.WarningNotification, fmt.Sprintf("ingress %s/%s uses unsupported status code %q in %s annotation (Gateway API supports: 301, 302, 303, 307 for temporal redirects), using default 302", + ingress.Namespace, ingress.Name, codeStr, TemporalRedirectCodeAnnotation), ingress) + } else { + statusCode = code + } + } + } else { + // Only reached if temporal-redirect annotation is completely absent. + redirectURL = permanentRedirectURL + statusCode = 301 + annotationUsed = PermanentRedirectAnnotation + + // Check custom status code annotation. + if codeStr := ingress.Annotations[PermanentRedirectCodeAnnotation]; codeStr != "" { + code, err := strconv.Atoi(codeStr) + if err != nil || !isValidPermanentRedirectCode(code) { + notify(notifications.WarningNotification, fmt.Sprintf("ingress %s/%s uses unsupported status code %q in %s annotation (Gateway API supports: 301, 302, 303, 307, 308 for permanent redirects), using default 301", + ingress.Namespace, ingress.Name, codeStr, PermanentRedirectCodeAnnotation), ingress) + } else { + statusCode = code + } + } + } + + // Validate that the redirect URL is not empty. + if redirectURL == "" { + notify(notifications.ErrorNotification, fmt.Sprintf("Empty %s annotation, skipping redirect", + annotationUsed), ingress) + continue + } + + // Parse the redirect URL. + parsedURL, err := url.Parse(redirectURL) + if err != nil { + notify(notifications.ErrorNotification, fmt.Sprintf("Invalid redirect URL in %s annotation: %v, skipping redirect", + annotationUsed, err), ingress) + continue + } + + // Create the redirect filter. + redirectFilterConfig := &gatewayv1.HTTPRequestRedirectFilter{ + StatusCode: ptr.To(statusCode), + } + + // Set scheme if present. + if parsedURL.Scheme != "" { + redirectFilterConfig.Scheme = ptr.To(parsedURL.Scheme) + } + + // Set hostname if present. + if parsedURL.Hostname() != "" { + hostname := gatewayv1.PreciseHostname(parsedURL.Hostname()) + redirectFilterConfig.Hostname = &hostname + } + + // Set port if present. + if parsedURL.Port() != "" { + port, err := strconv.Atoi(parsedURL.Port()) + if err == nil { + portNumber := gatewayv1.PortNumber(port) + redirectFilterConfig.Port = &portNumber + } else { + notify(notifications.ErrorNotification, fmt.Sprintf("Invalid port in redirect URL %q: %v, skipping redirect", + redirectURL, err), ingress) + continue + } + } + + // Set path - default to root path if not specified in redirect URL + // This matches ingress-nginx behavior where redirects override the request path. + path := parsedURL.Path + if path == "" { + path = "/" + } + pathType := gatewayv1.FullPathHTTPPathModifier + redirectFilterConfig.Path = &gatewayv1.HTTPPathModifier{ + Type: pathType, + ReplaceFullPath: ptr.To(path), + } + + redirectFilter := gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterRequestRedirect, + RequestRedirect: redirectFilterConfig, + } + + // Add redirect filter to the current rule. + httpRouteContext.HTTPRoute.Spec.Rules[ruleIndex].Filters = append( + httpRouteContext.HTTPRoute.Spec.Rules[ruleIndex].Filters, + redirectFilter, + ) + + // Clear backend refs as redirects don't route to backends. + httpRouteContext.HTTPRoute.Spec.Rules[ruleIndex].BackendRefs = nil + } + + // Save the updated context back to the IR. + ir.HTTPRoutes[key] = httpRouteContext + } + + return nil +} + +// addSSLAndTrailingSlashRedirects adds HTTP→HTTPS redirect routes and trailing slash +// redirect rules to match ingress-nginx behavior. +// +// SSL redirect: In ingress-nginx, TLS is merged at the hostname/server level — if any +// ingress on a hostname has TLS configured, the cert applies to the entire server block +// and ssl-redirect defaults to true for all locations. A rule only skips the redirect if +// its source ingress explicitly sets "nginx.ingress.kubernetes.io/ssl-redirect" to "false". +// +// Trailing slash redirect: NGINX implicitly redirects /path to /path/ with 301 when a +// location /path/ {} block exists. On the HTTP (port 80) side, trailing slash redirects +// are combined with the SSL upgrade into a single hop (301 to https://host/path/) to +// avoid unnecessary intermediate redirects. +func (p *Provider) addSSLAndTrailingSlashRedirects(ingresses []networkingv1.Ingress, pir *providerir.ProviderIR, eir *emitterir.EmitterIR) { + // Find hosts with TLS enabled. + hostsWithTLS := make(map[string]struct{}) + for _, ing := range ingresses { + for _, tls := range ing.Spec.TLS { + if len(tls.Hosts) > 0 { + for _, host := range tls.Hosts { + hostsWithTLS[host] = struct{}{} + } + } else { + for _, rule := range ing.Spec.Rules { + if rule.Host != "" { + hostsWithTLS[rule.Host] = struct{}{} + } + } + break + } + } + } + + for key, httpRouteContext := range pir.HTTPRoutes { + eRouteCtx, ok := eir.HTTPRoutes[key] + if !ok { + continue + } + + // Compute trailing slash redirect rules for this route. + trailingSlashRules := buildTrailingSlashRedirectRules(eRouteCtx.Spec.Rules) + + // Add trailing slash rules to the main route (HTTPS side, or the only route if no TLS). + eRouteCtx.Spec.Rules = append(eRouteCtx.Spec.Rules, trailingSlashRules...) + + hostHasTLS := false + for _, hostname := range httpRouteContext.HTTPRoute.Spec.Hostnames { + if _, ok := hostsWithTLS[string(hostname)]; ok { + hostHasTLS = true + break + } + } + + if !hostHasTLS { + eir.HTTPRoutes[key] = eRouteCtx + continue + } + + // Build HTTP (port 80) rules: per-rule SSL redirect or pass-through. + var httpRules []gatewayv1.HTTPRouteRule + hasRedirect := false + for ruleIdx, sources := range httpRouteContext.RuleBackendSources { + ingress := getNonCanaryIngress(sources) + if ingress == nil { + continue + } + + if ruleIdx >= len(eRouteCtx.Spec.Rules) { + continue + } + + enableRedirect := true + if val, ok := ingress.Annotations[SSLRedirectAnnotation]; ok { + enableRedirect, _ = strconv.ParseBool(val) + } + + if enableRedirect { + hasRedirect = true + rule := gatewayv1.HTTPRouteRule{ + Matches: eRouteCtx.Spec.Rules[ruleIdx].DeepCopy().Matches, + Filters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestRedirect, + RequestRedirect: &gatewayv1.HTTPRequestRedirectFilter{ + Scheme: ptr.To("https"), + StatusCode: ptr.To(308), + }, + }, + }, + } + httpRules = append(httpRules, rule) + } else { + httpRules = append(httpRules, *eRouteCtx.Spec.Rules[ruleIdx].DeepCopy()) + } + } + + if !hasRedirect { + eir.HTTPRoutes[key] = eRouteCtx + continue + } + + // For the HTTP route, combine trailing slash + SSL upgrade into single-hop + // redirects: http://host/path -> 301 https://host/path/ + // Ingress nginx does http://host/path -> http://host/path/ -> https://host/path -> https://host/path/. + for _, tsRule := range trailingSlashRules { + combinedRule := *tsRule.DeepCopy() + combinedRule.Filters[0].RequestRedirect.Scheme = ptr.To("https") + httpRules = append(httpRules, combinedRule) + } + + redirectRoute := gatewayv1.HTTPRoute{ + TypeMeta: httpRouteContext.HTTPRoute.TypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-http", httpRouteContext.HTTPRoute.Name), + Namespace: httpRouteContext.HTTPRoute.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: httpRouteContext.HTTPRoute.Spec.DeepCopy().Hostnames, + Rules: httpRules, + }, + } + // Add parentrefs. + redirectRoute.Spec.ParentRefs = httpRouteContext.HTTPRoute.Spec.DeepCopy().ParentRefs + // Bind to port 80. + for i := range redirectRoute.Spec.ParentRefs { + redirectRoute.Spec.ParentRefs[i].Port = ptr.To[int32](80) + } + eir.HTTPRoutes[types.NamespacedName{ + Namespace: redirectRoute.Namespace, + Name: redirectRoute.Name, + }] = emitterir.HTTPRouteContext{ + HTTPRoute: redirectRoute, + } + + // Bind original route to port 443. + for i := range eRouteCtx.Spec.ParentRefs { + eRouteCtx.Spec.ParentRefs[i].Port = ptr.To[int32](443) + } + eir.HTTPRoutes[key] = eRouteCtx + } +} + +// isValidTemporalRedirectCode returns true if the code is in the intersection of +// ingress-nginx temporal-redirect codes (300-307) and Gateway API codes (301,302,303,307,308). +// Result: 301, 302, 303, 307. +func isValidTemporalRedirectCode(code int) bool { + switch code { + case 301, 302, 303, 307: + return true + default: + return false + } +} + +// isValidPermanentRedirectCode returns true if the code is in the intersection of +// ingress-nginx permanent-redirect codes (300-308) and Gateway API codes (301,302,303,307,308). +// Result: 301, 302, 303, 307, 308. +func isValidPermanentRedirectCode(code int) bool { + switch code { + case 301, 302, 303, 307, 308: + return true + default: + return false + } +} + +// buildTrailingSlashRedirectRules generates HTTPRouteRules that redirect /path +// to /path/ with a 301, matching NGINX's implicit trailing slash behavior. +// Rules are only generated when a trailing-slash path exists in the input and +// no existing rule already covers the non-slash variant. +func buildTrailingSlashRedirectRules(rules []gatewayv1.HTTPRouteRule) []gatewayv1.HTTPRouteRule { + sourcePathAlreadyMatched := make(map[string]struct{}) + var existingPrefixes []string + for _, rule := range rules { + for _, match := range rule.Matches { + if match.Path == nil || match.Path.Type == nil || match.Path.Value == nil { + continue + } + if *match.Path.Type == gatewayv1.PathMatchExact || *match.Path.Type == gatewayv1.PathMatchPathPrefix { + sourcePathAlreadyMatched[*match.Path.Value] = struct{}{} + } + if *match.Path.Type == gatewayv1.PathMatchPathPrefix { + existingPrefixes = append(existingPrefixes, *match.Path.Value) + } + } + } + + var redirectRules []gatewayv1.HTTPRouteRule + redirectAdded := make(map[string]struct{}) + for _, rule := range rules { + for _, match := range rule.Matches { + if match.Path == nil || match.Path.Type == nil || match.Path.Value == nil { + continue + } + + matchType := *match.Path.Type + if matchType != gatewayv1.PathMatchExact && matchType != gatewayv1.PathMatchPathPrefix { + continue + } + + redirectTarget := *match.Path.Value + if redirectTarget == "/" || !strings.HasSuffix(redirectTarget, "/") { + continue + } + + redirectSource := strings.TrimSuffix(redirectTarget, "/") + if redirectSource == "" { + continue + } + if _, exists := sourcePathAlreadyMatched[redirectSource]; exists { + continue + } + if pathPrefixCoveredByAny(existingPrefixes, redirectSource) { + continue + } + if _, added := redirectAdded[redirectSource]; added { + continue + } + + exact := gatewayv1.PathMatchExact + redirectRules = append(redirectRules, gatewayv1.HTTPRouteRule{ + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: &exact, + Value: ptr.To(redirectSource), + }, + }, + }, + Filters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestRedirect, + RequestRedirect: &gatewayv1.HTTPRequestRedirectFilter{ + StatusCode: ptr.To(301), + Path: &gatewayv1.HTTPPathModifier{ + Type: gatewayv1.FullPathHTTPPathModifier, + ReplaceFullPath: ptr.To(redirectTarget), + }, + }, + }, + }, + }) + + redirectAdded[redirectSource] = struct{}{} + } + } + + return redirectRules +} + +// pathPrefixCoveredByAny returns true if any existing PathPrefix would match +// the given path using Gateway API segment-based prefix matching semantics. +func pathPrefixCoveredByAny(prefixes []string, path string) bool { + for _, prefix := range prefixes { + if pathPrefixCovers(prefix, path) { + return true + } + } + return false +} + +// pathPrefixCovers returns true if a Gateway API PathPrefix match for prefix +// would match path. Gateway API PathPrefix matching is segment-based: +// PathPrefix "/a" matches "/a", "/a/", "/a/b" but NOT "/ab". +func pathPrefixCovers(prefix, path string) bool { + if !strings.HasPrefix(path, prefix) { + return false + } + if len(path) == len(prefix) { + return true + } + // Path is longer than prefix; check segment boundary. + return prefix[len(prefix)-1] == '/' || path[len(prefix)] == '/' +} diff --git a/pkg/i2gw/providers/ingressnginx/redirect_test.go b/pkg/i2gw/providers/ingressnginx/redirect_test.go new file mode 100644 index 000000000..b4ea3aa6a --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/redirect_test.go @@ -0,0 +1,1560 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func Test_redirectFeature(t *testing.T) { + tests := []struct { + name string + ingress networkingv1.Ingress + initialHTTPRoute *gatewayv1.HTTPRoute + expectedHTTPRoute *gatewayv1.HTTPRoute + expectError bool + }{ + { + name: "permanent-redirect annotation", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: map[string]string{ + PermanentRedirectAnnotation: "https://example.com/new-path", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "foo.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "foo", + Port: networkingv1.ServiceBackendPort{ + Number: 3000, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + initialHTTPRoute: &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-foo-com", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"foo.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + {BackendRef: gatewayv1.BackendRef{BackendObjectReference: gatewayv1.BackendObjectReference{Name: "foo", Port: ptr.To(gatewayv1.PortNumber(3000))}}}, + }, + }, + }, + }, + }, + expectedHTTPRoute: &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-foo-com", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"foo.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + { + Filters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestRedirect, + RequestRedirect: &gatewayv1.HTTPRequestRedirectFilter{ + Scheme: ptr.To("https"), + Hostname: ptr.To(gatewayv1.PreciseHostname("example.com")), + Path: &gatewayv1.HTTPPathModifier{Type: gatewayv1.FullPathHTTPPathModifier, ReplaceFullPath: ptr.To("/new-path")}, + StatusCode: ptr.To(301), + }, + }, + }, + BackendRefs: nil, + }, + }, + }, + }, + expectError: false, + }, + { + name: "temporal-redirect annotation", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: map[string]string{ + TemporalRedirectAnnotation: "https://example.com/temporary", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "bar.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "bar", + Port: networkingv1.ServiceBackendPort{ + Number: 8080, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + initialHTTPRoute: &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-bar-com", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"bar.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + {BackendRef: gatewayv1.BackendRef{BackendObjectReference: gatewayv1.BackendObjectReference{Name: "bar", Port: ptr.To(gatewayv1.PortNumber(8080))}}}, + }, + }, + }, + }, + }, + expectedHTTPRoute: &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-bar-com", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"bar.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + { + Filters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestRedirect, + RequestRedirect: &gatewayv1.HTTPRequestRedirectFilter{ + Scheme: ptr.To("https"), + Hostname: ptr.To(gatewayv1.PreciseHostname("example.com")), + Path: &gatewayv1.HTTPPathModifier{Type: gatewayv1.FullPathHTTPPathModifier, ReplaceFullPath: ptr.To("/temporary")}, + StatusCode: ptr.To(302), + }, + }, + }, + BackendRefs: nil, + }, + }, + }, + }, + expectError: false, + }, + { + name: "both annotations present should choose temporal", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: map[string]string{ + PermanentRedirectAnnotation: "https://example.com/permanent", + TemporalRedirectAnnotation: "https://example.com/temporal", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "conflict.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "conflict", + Port: networkingv1.ServiceBackendPort{ + Number: 8080, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + initialHTTPRoute: &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-conflict-com", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"conflict.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + {BackendRef: gatewayv1.BackendRef{BackendObjectReference: gatewayv1.BackendObjectReference{Name: "conflict", Port: ptr.To(gatewayv1.PortNumber(8080))}}}, + }, + }, + }, + }, + }, + expectedHTTPRoute: &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-conflict-com", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"conflict.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + { + Filters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestRedirect, + RequestRedirect: &gatewayv1.HTTPRequestRedirectFilter{ + Scheme: ptr.To("https"), + Hostname: ptr.To(gatewayv1.PreciseHostname("example.com")), + Path: &gatewayv1.HTTPPathModifier{Type: gatewayv1.FullPathHTTPPathModifier, ReplaceFullPath: ptr.To("/temporal")}, + StatusCode: ptr.To(302), + }, + }, + }, + BackendRefs: nil, + }, + }, + }, + }, + expectError: false, + }, + { + name: "no redirect annotations", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "normal.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "normal", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + initialHTTPRoute: &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-normal-com", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"normal.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + {BackendRef: gatewayv1.BackendRef{BackendObjectReference: gatewayv1.BackendObjectReference{Name: "normal", Port: ptr.To(gatewayv1.PortNumber(80))}}}, + }, + }, + }, + }, + }, + expectedHTTPRoute: &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-normal-com", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"normal.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + {BackendRef: gatewayv1.BackendRef{BackendObjectReference: gatewayv1.BackendObjectReference{Name: "normal", Port: ptr.To(gatewayv1.PortNumber(80))}}}, + }, + }, + }, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up the IR with the initial HTTPRoute. + ir := providerir.ProviderIR{ + HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}, + } + + if tt.initialHTTPRoute != nil { + routeKey := types.NamespacedName{ + Namespace: tt.initialHTTPRoute.Namespace, + Name: tt.initialHTTPRoute.Name, + } + // Initialize RuleBackendSources to match the number of rules. + ruleBackendSources := make([][]providerir.BackendSource, len(tt.initialHTTPRoute.Spec.Rules)) + for i := range ruleBackendSources { + ruleBackendSources[i] = []providerir.BackendSource{ + { + Ingress: &tt.ingress, + }, + } + } + ir.HTTPRoutes[routeKey] = providerir.HTTPRouteContext{ + HTTPRoute: *tt.initialHTTPRoute, + RuleBackendSources: ruleBackendSources, + } + } + + // Call the feature parser. + errs := redirectFeature(notifications.NoopNotify, []networkingv1.Ingress{tt.ingress}, nil, &ir) + + // Check error expectations. + if tt.expectError && len(errs) == 0 { + t.Errorf("Expected error but got none") + } + if !tt.expectError && len(errs) > 0 { + t.Errorf("Unexpected errors: %v", errs) + } + + // If we don't expect errors, verify the HTTPRoute was modified correctly. + if !tt.expectError && tt.expectedHTTPRoute != nil { + routeKey := types.NamespacedName{ + Namespace: tt.expectedHTTPRoute.Namespace, + Name: tt.expectedHTTPRoute.Name, + } + httpRouteContext, exists := ir.HTTPRoutes[routeKey] + if !exists { + t.Errorf("Expected HTTPRoute %s to exist", routeKey) + return + } + + actualRoute := httpRouteContext.HTTPRoute + + // Verify number of rules. + if len(actualRoute.Spec.Rules) != len(tt.expectedHTTPRoute.Spec.Rules) { + t.Errorf("Expected %d rules, got %d", len(tt.expectedHTTPRoute.Spec.Rules), len(actualRoute.Spec.Rules)) + return + } + + // Verify redirect filter in first rule if expected. + if len(tt.expectedHTTPRoute.Spec.Rules) > 0 && len(tt.expectedHTTPRoute.Spec.Rules[0].Filters) > 0 { + if len(actualRoute.Spec.Rules[0].Filters) == 0 { + t.Errorf("Expected redirect filter in first rule") + return + } + + actualFilter := actualRoute.Spec.Rules[0].Filters[0] + expectedFilter := tt.expectedHTTPRoute.Spec.Rules[0].Filters[0] + + if actualFilter.Type != expectedFilter.Type { + t.Errorf("Expected filter type %v, got %v", expectedFilter.Type, actualFilter.Type) + } + + if actualFilter.RequestRedirect == nil { + t.Errorf("Expected RequestRedirect to be set") + return + } + + expected := expectedFilter.RequestRedirect + actual := actualFilter.RequestRedirect + + if expected.StatusCode != nil { + if actual.StatusCode == nil { + t.Errorf("Expected status code to be set") + } else if *actual.StatusCode != *expected.StatusCode { + t.Errorf("Expected status code %d, got %d", *expected.StatusCode, *actual.StatusCode) + } + } + + if expected.Scheme != nil { + if actual.Scheme == nil { + t.Errorf("Expected scheme to be set") + } else if *actual.Scheme != *expected.Scheme { + t.Errorf("Expected scheme %s, got %s", *expected.Scheme, *actual.Scheme) + } + } + + if expected.Hostname != nil { + if actual.Hostname == nil { + t.Errorf("Expected hostname to be set") + } else if *actual.Hostname != *expected.Hostname { + t.Errorf("Expected hostname %s, got %s", *expected.Hostname, *actual.Hostname) + } + } + + if expected.Path != nil { + if actual.Path == nil { + t.Errorf("Expected path to be set") + } else { + if actual.Path.Type != expected.Path.Type { + t.Errorf("Expected path type %v, got %v", expected.Path.Type, actual.Path.Type) + } + if expected.Path.ReplaceFullPath != nil { + if actual.Path.ReplaceFullPath == nil { + t.Errorf("Expected ReplaceFullPath to be set") + } else if *actual.Path.ReplaceFullPath != *expected.Path.ReplaceFullPath { + t.Errorf("Expected ReplaceFullPath %s, got %s", *expected.Path.ReplaceFullPath, *actual.Path.ReplaceFullPath) + } + } + } + } + + // Verify BackendRefs are cleared when redirect is present. + if len(actualRoute.Spec.Rules[0].BackendRefs) != 0 { + t.Errorf("Expected BackendRefs to be cleared for redirect rule, got %d refs", len(actualRoute.Spec.Rules[0].BackendRefs)) + } + } + } + }) + } +} + +func Test_redirectFeature_emptyURL(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: map[string]string{ + PermanentRedirectAnnotation: "", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "empty.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "empty", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + ir := providerir.ProviderIR{ + HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{ + {Namespace: "default", Name: common.RouteName("test-ingress", "empty.com")}: { + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: common.RouteName("test-ingress", "empty.com"), + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{{}}, + }, + }, + RuleBackendSources: [][]providerir.BackendSource{ + { + {Ingress: &ingress}, + }, + }, + }, + }, + } + + errs := redirectFeature(notifications.NoopNotify, []networkingv1.Ingress{ingress}, nil, &ir) + + if len(errs) != 0 { + t.Errorf("Expected no errors for empty redirect URL (should be a notification), got %v", errs) + } +} + +func TestAddDefaultSSLRedirect_enabled(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + parentRefs := []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}} + + ing := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing", + Annotations: map[string]string{ + // No SSLRedirectAnnotation -> default enabled. + }, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: append([]gatewayv1.ParentReference(nil), parentRefs...), + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{{}}}, + }, + }, + } + + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{{ + {Ingress: &ing}, + }}, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + (&Provider{}).addSSLAndTrailingSlashRedirects([]networkingv1.Ingress{ing}, &pIR, &eIR) + + redirectKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-http"} + redirectCtx, ok := eIR.HTTPRoutes[redirectKey] + if !ok { + t.Fatalf("expected redirect route %v to be added", redirectKey) + } + + if len(redirectCtx.Spec.ParentRefs) != 1 || redirectCtx.Spec.ParentRefs[0].Port == nil || *redirectCtx.Spec.ParentRefs[0].Port != 80 { + t.Fatalf("expected redirect route parentRef port 80, got %#v", redirectCtx.Spec.ParentRefs) + } + + origCtx := eIR.HTTPRoutes[key] + if len(origCtx.Spec.ParentRefs) != 1 || origCtx.Spec.ParentRefs[0].Port == nil || *origCtx.Spec.ParentRefs[0].Port != 443 { + t.Fatalf("expected original route parentRef port 443, got %#v", origCtx.Spec.ParentRefs) + } + + if len(redirectCtx.Spec.Rules) != 1 || len(redirectCtx.Spec.Rules[0].Filters) != 1 { + t.Fatalf("expected redirect route to have 1 rule with 1 filter, got %#v", redirectCtx.Spec.Rules) + } + + f := redirectCtx.Spec.Rules[0].Filters[0] + if f.Type != gatewayv1.HTTPRouteFilterRequestRedirect || f.RequestRedirect == nil { + t.Fatalf("expected RequestRedirect filter, got %#v", f) + } + if f.RequestRedirect.Scheme == nil || *f.RequestRedirect.Scheme != "https" { + t.Fatalf("expected scheme https, got %#v", f.RequestRedirect.Scheme) + } + if f.RequestRedirect.StatusCode == nil || *f.RequestRedirect.StatusCode != 308 { + t.Fatalf("expected status code 308, got %#v", f.RequestRedirect.StatusCode) + } +} + +func TestAddDefaultSSLRedirect_disabledByAnnotation(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + + ing := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing", + Annotations: map[string]string{ + SSLRedirectAnnotation: "false", + }, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}}, + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + }, + } + + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{{ + {Ingress: &ing}, + }}, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + (&Provider{}).addSSLAndTrailingSlashRedirects([]networkingv1.Ingress{ing}, &pIR, &eIR) + + redirectKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-http"} + if _, ok := eIR.HTTPRoutes[redirectKey]; ok { + t.Fatalf("did not expect redirect route %v to be added", redirectKey) + } + + origCtx := eIR.HTTPRoutes[key] + if len(origCtx.Spec.ParentRefs) != 1 { + t.Fatalf("expected 1 parentRef, got %#v", origCtx.Spec.ParentRefs) + } + if origCtx.Spec.ParentRefs[0].Port != nil { + t.Fatalf("expected original route parentRef port to remain nil, got %#v", origCtx.Spec.ParentRefs[0].Port) + } +} + +func TestAddDefaultSSLRedirect_conflictingAnnotations(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + parentRefs := []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}} + + ingEnabled := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-enabled", + Annotations: map[string]string{ + SSLRedirectAnnotation: "true", + }, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + ingDisabled := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-disabled", + Annotations: map[string]string{ + SSLRedirectAnnotation: "false", + }, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + pathA := "/a" + pathB := "/b" + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: append([]gatewayv1.ParentReference(nil), parentRefs...), + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathA}}}}, + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathB}}}}, + }, + }, + } + + // Two rules, each from a different ingress with conflicting ssl-redirect values. + // Per-rule semantics: only the rule from ingEnabled should get a redirect. + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + {{Ingress: &ingEnabled}}, + {{Ingress: &ingDisabled}}, + }, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + (&Provider{}).addSSLAndTrailingSlashRedirects([]networkingv1.Ingress{ingEnabled, ingDisabled}, &pIR, &eIR) + + httpKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-http"} + httpCtx, ok := eIR.HTTPRoutes[httpKey] + if !ok { + t.Fatalf("expected http route %v to be created", httpKey) + } + + // Consolidated route: redirect rule for /a, passthrough rule for /b. + if len(httpCtx.Spec.Rules) != 2 { + t.Fatalf("expected 2 rules, got %d", len(httpCtx.Spec.Rules)) + } + if *httpCtx.Spec.Rules[0].Matches[0].Path.Value != "/a" { + t.Fatalf("expected first rule to match /a, got %s", *httpCtx.Spec.Rules[0].Matches[0].Path.Value) + } + if len(httpCtx.Spec.Rules[0].Filters) != 1 || httpCtx.Spec.Rules[0].Filters[0].Type != gatewayv1.HTTPRouteFilterRequestRedirect { + t.Fatalf("expected first rule to have redirect filter") + } + if *httpCtx.Spec.Rules[1].Matches[0].Path.Value != "/b" { + t.Fatalf("expected second rule to match /b, got %s", *httpCtx.Spec.Rules[1].Matches[0].Path.Value) + } + if len(httpCtx.Spec.Rules[1].Filters) != 0 { + t.Fatalf("expected second rule to have no filters (passthrough)") + } + if len(httpCtx.Spec.ParentRefs) != 1 || httpCtx.Spec.ParentRefs[0].Port == nil || *httpCtx.Spec.ParentRefs[0].Port != 80 { + t.Fatalf("expected http route parentRef port 80, got %#v", httpCtx.Spec.ParentRefs) + } + + origCtx := eIR.HTTPRoutes[key] + if len(origCtx.Spec.ParentRefs) != 1 || origCtx.Spec.ParentRefs[0].Port == nil || *origCtx.Spec.ParentRefs[0].Port != 443 { + t.Fatalf("expected original route parentRef port 443, got %#v", origCtx.Spec.ParentRefs) + } +} + +func TestAddDefaultSSLRedirect_allRulesDisabled(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + + ingDisabledA := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-disabled-a", + Annotations: map[string]string{ + SSLRedirectAnnotation: "false", + }, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + ingDisabledB := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-disabled-b", + Annotations: map[string]string{ + SSLRedirectAnnotation: "false", + }, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + pathA := "/a" + pathB := "/b" + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}}, + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathA}}}}, + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathB}}}}, + }, + }, + } + + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + {{Ingress: &ingDisabledA}}, + {{Ingress: &ingDisabledB}}, + }, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + (&Provider{}).addSSLAndTrailingSlashRedirects([]networkingv1.Ingress{ingDisabledA, ingDisabledB}, &pIR, &eIR) + + httpKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-http"} + if _, ok := eIR.HTTPRoutes[httpKey]; ok { + t.Fatalf("did not expect http route when all rules have ssl-redirect=false") + } + + origCtx := eIR.HTTPRoutes[key] + if origCtx.Spec.ParentRefs[0].Port != nil { + t.Fatalf("expected original route parentRef port to remain nil, got %v", *origCtx.Spec.ParentRefs[0].Port) + } +} + +func TestAddDefaultSSLRedirect_threeRulesMixed(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + parentRefs := []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}} + + ingEnabled := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-enabled", + Annotations: map[string]string{}, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + ingDisabled := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-disabled", + Annotations: map[string]string{ + SSLRedirectAnnotation: "false", + }, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + pathA := "/a" + pathB := "/b" + pathC := "/c" + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: append([]gatewayv1.ParentReference(nil), parentRefs...), + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathA}}}}, + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathB}}}}, + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathC}}}}, + }, + }, + } + + // Rule 0 (/a) -> enabled, Rule 1 (/b) -> disabled, Rule 2 (/c) -> enabled. + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + {{Ingress: &ingEnabled}}, + {{Ingress: &ingDisabled}}, + {{Ingress: &ingEnabled}}, + }, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + (&Provider{}).addSSLAndTrailingSlashRedirects([]networkingv1.Ingress{ingEnabled, ingDisabled}, &pIR, &eIR) + + // Consolidated: /a (redirect), /b (passthrough), /c (redirect) — in iteration order. + httpKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-http"} + httpCtx, ok := eIR.HTTPRoutes[httpKey] + if !ok { + t.Fatalf("expected http route %v to be created", httpKey) + } + if len(httpCtx.Spec.Rules) != 3 { + t.Fatalf("expected 3 rules, got %d", len(httpCtx.Spec.Rules)) + } + if *httpCtx.Spec.Rules[0].Matches[0].Path.Value != "/a" { + t.Fatalf("expected first rule to match /a, got %s", *httpCtx.Spec.Rules[0].Matches[0].Path.Value) + } + if *httpCtx.Spec.Rules[1].Matches[0].Path.Value != "/b" { + t.Fatalf("expected second rule (passthrough) to match /b, got %s", *httpCtx.Spec.Rules[1].Matches[0].Path.Value) + } + if *httpCtx.Spec.Rules[2].Matches[0].Path.Value != "/c" { + t.Fatalf("expected third rule to match /c, got %s", *httpCtx.Spec.Rules[2].Matches[0].Path.Value) + } +} + +func TestAddDefaultSSLRedirect_canarySourceIgnored(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + + // Primary ingress disables SSL redirect. + ingPrimary := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-primary", + Annotations: map[string]string{ + SSLRedirectAnnotation: "false", + }, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + // Canary ingress enables SSL redirect — should be ignored. + ingCanary := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-canary", + Annotations: map[string]string{ + CanaryAnnotation: "true", + SSLRedirectAnnotation: "true", + }, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + pathA := "/a" + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}}, + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathA}}}}, + }, + }, + } + + // Rule has both canary and primary sources; primary disables redirect. + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + { + {Ingress: &ingCanary}, + {Ingress: &ingPrimary}, + }, + }, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + (&Provider{}).addSSLAndTrailingSlashRedirects([]networkingv1.Ingress{ingPrimary, ingCanary}, &pIR, &eIR) + + redirectKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-http"} + if _, ok := eIR.HTTPRoutes[redirectKey]; ok { + t.Fatalf("did not expect redirect route — primary ingress has ssl-redirect=false, canary should be ignored") + } +} + +func TestAddDefaultSSLRedirect_multipleParentRefs(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + + ing := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing", + Annotations: map[string]string{}, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + {Name: gatewayv1.ObjectName("gw1")}, + {Name: gatewayv1.ObjectName("gw2")}, + }, + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{{Matches: []gatewayv1.HTTPRouteMatch{{}}}}, + }, + } + + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{{ + {Ingress: &ing}, + }}, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + (&Provider{}).addSSLAndTrailingSlashRedirects([]networkingv1.Ingress{ing}, &pIR, &eIR) + + redirectKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-http"} + redirectCtx, ok := eIR.HTTPRoutes[redirectKey] + if !ok { + t.Fatalf("expected redirect route to be created") + } + + // Both parent refs on the redirect route should have port 80. + if len(redirectCtx.Spec.ParentRefs) != 2 { + t.Fatalf("expected 2 parentRefs on redirect route, got %d", len(redirectCtx.Spec.ParentRefs)) + } + for i, ref := range redirectCtx.Spec.ParentRefs { + if ref.Port == nil || *ref.Port != 80 { + t.Fatalf("redirect route parentRef[%d] expected port 80, got %v", i, ref.Port) + } + } + + // Both parent refs on the original route should have port 443. + origCtx := eIR.HTTPRoutes[key] + if len(origCtx.Spec.ParentRefs) != 2 { + t.Fatalf("expected 2 parentRefs on original route, got %d", len(origCtx.Spec.ParentRefs)) + } + for i, ref := range origCtx.Spec.ParentRefs { + if ref.Port == nil || *ref.Port != 443 { + t.Fatalf("original route parentRef[%d] expected port 443, got %v", i, ref.Port) + } + } +} + +func TestAddDefaultSSLRedirect_mixedTLSAndNoTLSRules(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + + // Ingress with TLS configured (default ssl-redirect=true). + ingWithTLS := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-tls", + Annotations: map[string]string{}, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + // Ingress without TLS — but since the hostname has TLS from another ingress, + // ssl-redirect should still apply (matching real ingress-nginx behavior where + // TLS is merged at the server/hostname level). + ingNoTLS := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-no-tls", + Annotations: map[string]string{}, + }, + Spec: networkingv1.IngressSpec{}, + } + + pathA := "/a" + pathB := "/b" + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}}, + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathA}}}}, + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathB}}}}, + }, + }, + } + + // Rule 0 (/a) -> has TLS, Rule 1 (/b) -> no TLS on its own ingress. + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + {{Ingress: &ingWithTLS}}, + {{Ingress: &ingNoTLS}}, + }, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + (&Provider{}).addSSLAndTrailingSlashRedirects([]networkingv1.Ingress{ingWithTLS, ingNoTLS}, &pIR, &eIR) + + // Both /a and /b should get redirects because the hostname has TLS + // (from ingWithTLS), matching ingress-nginx's hostname-level TLS merging. + + redirectKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-http"} + redirectCtx, ok := eIR.HTTPRoutes[redirectKey] + if !ok { + t.Fatalf("expected redirect route for hostname with TLS") + } + if len(redirectCtx.Spec.Rules) != 2 { + t.Fatalf("expected 2 redirect rules (both /a and /b), got %d", len(redirectCtx.Spec.Rules)) + } + if *redirectCtx.Spec.Rules[0].Matches[0].Path.Value != "/a" { + t.Fatalf("expected first redirect rule to match /a, got %s", *redirectCtx.Spec.Rules[0].Matches[0].Path.Value) + } + if *redirectCtx.Spec.Rules[1].Matches[0].Path.Value != "/b" { + t.Fatalf("expected second redirect rule to match /b, got %s", *redirectCtx.Spec.Rules[1].Matches[0].Path.Value) + } +} + +func TestAddDefaultSSLRedirect_noTLS(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + + ing := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing", + Annotations: map[string]string{}, + }, + Spec: networkingv1.IngressSpec{}, + } + + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}}, + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + }, + } + + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{{ + {Ingress: &ing}, + }}, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + (&Provider{}).addSSLAndTrailingSlashRedirects([]networkingv1.Ingress{ing}, &pIR, &eIR) + + redirectKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-http"} + if _, ok := eIR.HTTPRoutes[redirectKey]; ok { + t.Fatalf("did not expect redirect route %v to be added", redirectKey) + } + + origCtx := eIR.HTTPRoutes[key] + if len(origCtx.Spec.ParentRefs) != 1 { + t.Fatalf("expected 1 parentRef, got %#v", origCtx.Spec.ParentRefs) + } + if origCtx.Spec.ParentRefs[0].Port != nil { + t.Fatalf("expected original route parentRef port to remain nil, got %#v", origCtx.Spec.ParentRefs[0].Port) + } +} + +func TestAddDefaultSSLRedirect_crossIngressTLSWithOptOut(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + + ingWithTLS := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-tls", + Annotations: map[string]string{}, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + // Ingress B has no TLS but explicitly opts out of ssl-redirect. + // Even though the hostname has TLS, this path should not redirect. + ingNoTLSOptOut := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-no-tls-optout", + Annotations: map[string]string{ + SSLRedirectAnnotation: "false", + }, + }, + Spec: networkingv1.IngressSpec{}, + } + + pathA := "/a" + pathB := "/b" + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}}, + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathA}}}}, + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathB}}}}, + }, + }, + } + + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + {{Ingress: &ingWithTLS}}, + {{Ingress: &ingNoTLSOptOut}}, + }, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + (&Provider{}).addSSLAndTrailingSlashRedirects([]networkingv1.Ingress{ingWithTLS, ingNoTLSOptOut}, &pIR, &eIR) + + // Consolidated: /a redirect + /b passthrough in one route. + httpKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-http"} + httpCtx, ok := eIR.HTTPRoutes[httpKey] + if !ok { + t.Fatalf("expected http route to be created") + } + if len(httpCtx.Spec.Rules) != 2 { + t.Fatalf("expected 2 rules, got %d", len(httpCtx.Spec.Rules)) + } + if *httpCtx.Spec.Rules[0].Matches[0].Path.Value != "/a" { + t.Fatalf("expected first rule to match /a, got %s", *httpCtx.Spec.Rules[0].Matches[0].Path.Value) + } + if *httpCtx.Spec.Rules[1].Matches[0].Path.Value != "/b" { + t.Fatalf("expected second rule (passthrough) to match /b, got %s", *httpCtx.Spec.Rules[1].Matches[0].Path.Value) + } +} + +func TestAddDefaultSSLRedirect_crossIngressTLSThreeWayMixed(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + parentRefs := []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}} + + ingWithTLS := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-tls", + Annotations: map[string]string{}, + }, + Spec: networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{{SecretName: "secret", Hosts: []string{"example.com"}}}, + Rules: []networkingv1.IngressRule{{Host: "example.com"}}, + }, + } + + ingNoTLSOptOut := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-no-tls-optout", + Annotations: map[string]string{ + SSLRedirectAnnotation: "false", + }, + }, + Spec: networkingv1.IngressSpec{}, + } + + // Ingress C: no TLS, default ssl-redirect (inherits from hostname). + ingNoTLSDefault := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-no-tls-default", + Annotations: map[string]string{}, + }, + Spec: networkingv1.IngressSpec{}, + } + + pathA := "/a" + pathB := "/b" + pathC := "/c" + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: append([]gatewayv1.ParentReference(nil), parentRefs...), + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathA}}}}, + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathB}}}}, + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathC}}}}, + }, + }, + } + + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + {{Ingress: &ingWithTLS}}, + {{Ingress: &ingNoTLSOptOut}}, + {{Ingress: &ingNoTLSDefault}}, + }, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + (&Provider{}).addSSLAndTrailingSlashRedirects([]networkingv1.Ingress{ingWithTLS, ingNoTLSOptOut, ingNoTLSDefault}, &pIR, &eIR) + + // Consolidated: /a (redirect), /b (passthrough), /c (redirect) — in iteration order. + httpKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-http"} + httpCtx, ok := eIR.HTTPRoutes[httpKey] + if !ok { + t.Fatalf("expected http route to be created") + } + if len(httpCtx.Spec.Rules) != 3 { + t.Fatalf("expected 3 rules, got %d", len(httpCtx.Spec.Rules)) + } + if *httpCtx.Spec.Rules[0].Matches[0].Path.Value != "/a" { + t.Fatalf("expected first rule to match /a, got %s", *httpCtx.Spec.Rules[0].Matches[0].Path.Value) + } + if *httpCtx.Spec.Rules[1].Matches[0].Path.Value != "/b" { + t.Fatalf("expected second rule (passthrough) to match /b, got %s", *httpCtx.Spec.Rules[1].Matches[0].Path.Value) + } + if *httpCtx.Spec.Rules[2].Matches[0].Path.Value != "/c" { + t.Fatalf("expected third rule to match /c, got %s", *httpCtx.Spec.Rules[2].Matches[0].Path.Value) + } +} + +func TestAddDefaultSSLRedirect_allIngressesNoTLS(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + + ingA := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-a", + Annotations: map[string]string{}, + }, + Spec: networkingv1.IngressSpec{}, + } + ingB := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing-b", + Annotations: map[string]string{}, + }, + Spec: networkingv1.IngressSpec{}, + } + + pathA := "/a" + pathB := "/b" + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}}, + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathA}}}}, + {Matches: []gatewayv1.HTTPRouteMatch{{Path: &gatewayv1.HTTPPathMatch{Value: &pathB}}}}, + }, + }, + } + + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + {{Ingress: &ingA}}, + {{Ingress: &ingB}}, + }, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + (&Provider{}).addSSLAndTrailingSlashRedirects([]networkingv1.Ingress{ingA, ingB}, &pIR, &eIR) + + // No ingress has TLS, so no http route should be created. + httpKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-http"} + if _, ok := eIR.HTTPRoutes[httpKey]; ok { + t.Fatalf("did not expect http route when no ingress has TLS") + } + + origCtx := eIR.HTTPRoutes[key] + if origCtx.Spec.ParentRefs[0].Port != nil { + t.Fatalf("expected original route parentRef port to remain nil, got %v", *origCtx.Spec.ParentRefs[0].Port) + } +} +func TestBuildTrailingSlashRedirectRules(t *testing.T) { + prefix := gatewayv1.PathMatchPathPrefix + exact := gatewayv1.PathMatchExact + + t.Run("generates redirect for trailing slash path", func(t *testing.T) { + rules := []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{{ + Path: &gatewayv1.HTTPPathMatch{ + Type: &prefix, + Value: ptr.To("/foo/"), + }, + }}, + }, + } + + got := buildTrailingSlashRedirectRules(rules) + if len(got) != 1 { + t.Fatalf("expected 1 redirect rule, got %d", len(got)) + } + + redirect := got[0] + if *redirect.Matches[0].Path.Value != "/foo" { + t.Fatalf("expected redirect source /foo, got %s", *redirect.Matches[0].Path.Value) + } + if *redirect.Matches[0].Path.Type != gatewayv1.PathMatchExact { + t.Fatalf("expected Exact match type") + } + if *redirect.Filters[0].RequestRedirect.StatusCode != 301 { + t.Fatalf("expected 301 status code") + } + if *redirect.Filters[0].RequestRedirect.Path.ReplaceFullPath != "/foo/" { + t.Fatalf("expected redirect target /foo/") + } + }) + + t.Run("no redirect when exact path exists", func(t *testing.T) { + rules := []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &exact, Value: ptr.To("/foo")}}, + {Path: &gatewayv1.HTTPPathMatch{Type: &prefix, Value: ptr.To("/foo/")}}, + }, + }, + } + got := buildTrailingSlashRedirectRules(rules) + if len(got) != 0 { + t.Fatalf("expected no redirect when exact /foo exists, got %d", len(got)) + } + }) + + t.Run("no redirect when prefix path exists", func(t *testing.T) { + rules := []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &prefix, Value: ptr.To("/foo")}}, + {Path: &gatewayv1.HTTPPathMatch{Type: &prefix, Value: ptr.To("/foo/")}}, + }, + }, + } + got := buildTrailingSlashRedirectRules(rules) + if len(got) != 0 { + t.Fatalf("expected no redirect when prefix /foo exists, got %d", len(got)) + } + }) + + t.Run("no redirect when shorter prefix covers path", func(t *testing.T) { + rules := []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &prefix, Value: ptr.To("/a")}}, + }}, + {Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &exact, Value: ptr.To("/a/b/c/")}}, + }}, + } + got := buildTrailingSlashRedirectRules(rules) + if len(got) != 0 { + t.Fatalf("expected no redirect when /a covers /a/b/c, got %d", len(got)) + } + }) + + t.Run("no redirect when root prefix covers path", func(t *testing.T) { + rules := []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &prefix, Value: ptr.To("/")}}, + }}, + {Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &prefix, Value: ptr.To("/xyz/")}}, + }}, + } + got := buildTrailingSlashRedirectRules(rules) + if len(got) != 0 { + t.Fatalf("expected no redirect when / covers /xyz, got %d", len(got)) + } + }) + + t.Run("redirect when prefix does not cover at segment boundary", func(t *testing.T) { + rules := []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &prefix, Value: ptr.To("/ab")}}, + }}, + {Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &exact, Value: ptr.To("/a/b/c/")}}, + }}, + } + got := buildTrailingSlashRedirectRules(rules) + if len(got) != 1 { + t.Fatalf("expected redirect for /a/b/c when /ab does NOT cover it, got %d", len(got)) + } + }) + + t.Run("no redirect for root path", func(t *testing.T) { + rules := []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &prefix, Value: ptr.To("/")}}, + }}, + } + got := buildTrailingSlashRedirectRules(rules) + if len(got) != 0 { + t.Fatalf("expected no redirect for root path, got %d", len(got)) + } + }) + + t.Run("no redirect for path without trailing slash", func(t *testing.T) { + rules := []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &prefix, Value: ptr.To("/foo")}}, + }}, + } + got := buildTrailingSlashRedirectRules(rules) + if len(got) != 0 { + t.Fatalf("expected no redirect for /foo (no trailing slash), got %d", len(got)) + } + }) + + t.Run("deduplicates redirect sources", func(t *testing.T) { + rules := []gatewayv1.HTTPRouteRule{ + {Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &prefix, Value: ptr.To("/foo/")}}, + }}, + {Matches: []gatewayv1.HTTPRouteMatch{ + {Path: &gatewayv1.HTTPPathMatch{Type: &exact, Value: ptr.To("/foo/")}}, + }}, + } + got := buildTrailingSlashRedirectRules(rules) + if len(got) != 1 { + t.Fatalf("expected 1 deduplicated redirect rule, got %d", len(got)) + } + }) +} diff --git a/pkg/i2gw/providers/ingressnginx/regex.go b/pkg/i2gw/providers/ingressnginx/regex.go new file mode 100644 index 000000000..c151aa95c --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/regex.go @@ -0,0 +1,88 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "strconv" + "strings" + + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func regexHosts(ingresses []networkingv1.Ingress) map[string]struct{} { + hostsWithRegex := make(map[string]struct{}) + + for _, ingress := range ingresses { + val := ingress.Annotations[CanaryAnnotation] + isCanary, _ := strconv.ParseBool(val) + if isCanary { + continue + } + useRegex, _ := strconv.ParseBool(ingress.Annotations[UseRegexAnnotation]) + rewriteTargetUsesCaptureGroups := strings.Contains(ingress.Annotations[RewriteTargetAnnotation], "$") + if !useRegex && !rewriteTargetUsesCaptureGroups { + continue + } + + for _, rule := range ingress.Spec.Rules { + if rule.Host != "" { + hostsWithRegex[rule.Host] = struct{}{} + } + } + } + return hostsWithRegex +} + +// regexFeature converts ingress-nginx regex-driving annotations +// to Gateway API HTTPRoute RegularExpression path match. +func regexFeature(notify notifications.NotifyFunc, ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *providerir.ProviderIR) field.ErrorList { + var errs field.ErrorList + + hostsWithRegex := regexHosts(ingresses) + + for _, httpRouteCtx := range ir.HTTPRoutes { + hasRegex := false + for _, host := range httpRouteCtx.Spec.Hostnames { + if _, found := hostsWithRegex[string(host)]; found { + hasRegex = true + break + } + } + if !hasRegex { + continue + } + + for i, rule := range httpRouteCtx.Spec.Rules { + for j, path := range rule.Matches { + if path.Path != nil { + // Ingress nginx regex path matches are prefix matches by default + httpRouteCtx.Spec.Rules[i].Matches[j].Path.Type = ptr.To[gatewayv1.PathMatchType](gatewayv1.PathMatchRegularExpression) + // All engines I could find support (?i) (other than javascript). + httpRouteCtx.Spec.Rules[i].Matches[j].Path.Value = ptr.To("(?i)" + *httpRouteCtx.Spec.Rules[i].Matches[j].Path.Value + ".*") + } + } + } + notify(notifications.InfoNotification, "Using case-insensitive regex path matches. You may want to change this.", &httpRouteCtx.HTTPRoute) + } + return errs +} diff --git a/pkg/i2gw/providers/ingressnginx/regex_test.go b/pkg/i2gw/providers/ingressnginx/regex_test.go new file mode 100644 index 000000000..6dc6fc3f1 --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/regex_test.go @@ -0,0 +1,284 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestRegexFeature(t *testing.T) { + regexType := gatewayv1.PathMatchRegularExpression + prefixType := gatewayv1.PathMatchPathPrefix + + testCases := []struct { + name string + ingress networkingv1.Ingress + expected []gatewayv1.HTTPRouteMatch + }{ + { + name: "Should map to RegularExpression when use-regex annotation is true", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "regex-ingress", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/use-regex": "true", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/users/.*/profile", + PathType: ptr.To(networkingv1.PathTypeImplementationSpecific), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "service1", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ®exType, + Value: ptr.To("(?i)/users/.*/profile.*"), + }, + }, + }, + }, + { + name: "Should default to PathPrefix when use-regex is missing", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prefix-ingress", + Namespace: "default", + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/api", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "service1", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: &prefixType, + Value: ptr.To("/api"), + }, + }, + }, + }, + { + name: "Should keep PathPrefix when rewrite-target is a static path", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rewrite-static-ingress", + Namespace: "default", + Annotations: map[string]string{ + RewriteTargetAnnotation: "/after/rewrite", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/before/rewrite", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "service1", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: &prefixType, + Value: ptr.To("/before/rewrite"), + }, + }, + }, + }, + { + name: "Should map to RegularExpression when rewrite-target uses capture groups", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rewrite-regex-ingress", + Namespace: "default", + Annotations: map[string]string{ + RewriteTargetAnnotation: "/$1", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/products/(.+)", + PathType: ptr.To(networkingv1.PathTypeImplementationSpecific), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "service1", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ®exType, + Value: ptr.To("(?i)/products/(.+).*"), + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ir := providerir.ProviderIR{ + HTTPRoutes: make(map[types.NamespacedName]providerir.HTTPRouteContext), + } + + // Manual IR setup simulating what common.ToIR would produce + key := types.NamespacedName{Namespace: tc.ingress.Namespace, Name: common.RouteName(tc.ingress.Name, "example.com")} + + // Determine initial match type based on input PathType (simulating generic conversion) + var initialMatchType *gatewayv1.PathMatchType + if *tc.ingress.Spec.Rules[0].HTTP.Paths[0].PathType == networkingv1.PathTypePrefix { + initialMatchType = &prefixType + } else { + // ImplementationSpecific often defaults to Prefix if not handled, or just stays nil/impl-specific + // For the sake of this test, let's assume common.ToIR set it to something or we are testing the overwrite. + // But common.ToIR throws error for ImplSpecific if no custom converter. + // However, regexFeature runs AFTER common.ToIR. + // Let's assume common.ToIR generated a Prefix match (soft default) or checks if feature handles it. + // Actually, simplified: we just want to see if regexFeature *updates* it. + // So we init with Prefix for both cases. + initialMatchType = &prefixType + } + + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: tc.ingress.Namespace, + Name: key.Name, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"example.com"}, + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: initialMatchType, + Value: ptr.To(tc.ingress.Spec.Rules[0].HTTP.Paths[0].Path), + }, + }, + }, + }, + }, + }, + } + ir.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + { + {Ingress: &tc.ingress}, + }, + }, + } + + regexFeature(notifications.NoopNotify, []networkingv1.Ingress{tc.ingress}, nil, &ir) + + // We expect only 1 route + if len(ir.HTTPRoutes) != 1 { + t.Fatalf("Expected 1 HTTPRoute, got %d", len(ir.HTTPRoutes)) + } + + for _, routeCtx := range ir.HTTPRoutes { + if len(routeCtx.Spec.Rules) != 1 { + t.Fatalf("Expected 1 Rule, got %d", len(routeCtx.Spec.Rules)) + } + if diff := cmp.Diff(tc.expected, routeCtx.Spec.Rules[0].Matches); diff != "" { + t.Errorf("Unexpected matches diff (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/resource_reader.go b/pkg/i2gw/providers/ingressnginx/resource_reader.go index b0caa7fd9..26ca28fc1 100644 --- a/pkg/i2gw/providers/ingressnginx/resource_reader.go +++ b/pkg/i2gw/providers/ingressnginx/resource_reader.go @@ -18,6 +18,7 @@ package ingressnginx import ( "context" + "io" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" @@ -61,16 +62,16 @@ func (r *resourceReader) readResourcesFromCluster(ctx context.Context) (*storage return storage, nil } -func (r *resourceReader) readResourcesFromFile(filename string) (*storage, error) { +func (r *resourceReader) readResourcesFromFile(reader io.Reader) (*storage, error) { storage := newResourcesStorage() - ingresses, err := common.ReadIngressesFromFile(filename, r.conf.Namespace, sets.New(r.ingressClass)) + ingresses, err := common.ReadIngressesFromFile(reader, r.conf.Namespace, sets.New(r.ingressClass)) if err != nil { return nil, err } storage.Ingresses.FromMap(ingresses) - services, err := common.ReadServicesFromFile(filename, r.conf.Namespace) + services, err := common.ReadServicesFromFile(reader, r.conf.Namespace) if err != nil { return nil, err } diff --git a/pkg/i2gw/providers/ingressnginx/resource_reader_test.go b/pkg/i2gw/providers/ingressnginx/resource_reader_test.go index 623d721bf..a47aeb513 100644 --- a/pkg/i2gw/providers/ingressnginx/resource_reader_test.go +++ b/pkg/i2gw/providers/ingressnginx/resource_reader_test.go @@ -17,9 +17,7 @@ limitations under the License. package ingressnginx import ( - "io" - "os" - "path/filepath" + "strings" "testing" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw" @@ -65,18 +63,6 @@ var IngressClass = "nginx" // Test that the ingress-class provider-specific flag is honored by the resource reader func TestResourceReader_FiltersByIngressClass_FromFile(t *testing.T) { - dir := t.TempDir() - filePath := filepath.Join(dir, "ingress.yaml") - - f, err := os.Create(filePath) - if err != nil { - t.Fatalf("failed to create file: %v", err) - } - defer f.Close() - - if _, err := io.WriteString(f, ingressText); err != nil { - t.Fatalf("failed to write string: %v", err) - } // Configure the ingress-nginx provider with the ingress-class flag set to "nginx". conf := &i2gw.ProviderConf{ @@ -88,7 +74,9 @@ func TestResourceReader_FiltersByIngressClass_FromFile(t *testing.T) { } rr := newResourceReader(conf) - storage, err := rr.readResourcesFromFile(filePath) + reader := strings.NewReader(ingressText) + + storage, err := rr.readResourcesFromFile(reader) if err != nil { t.Fatalf("readResourcesFromFile() error = %v", err) } diff --git a/pkg/i2gw/providers/ingressnginx/rewrite.go b/pkg/i2gw/providers/ingressnginx/rewrite.go new file mode 100644 index 000000000..ea0b559f2 --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/rewrite.go @@ -0,0 +1,106 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 ingressnginx + +import ( + "fmt" + "strings" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + networkingv1 "k8s.io/api/networking/v1" + + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// applyRewriteTargetToEmitterIR is a temporary bridge until we decide how rewrite +// should be integrated into the generic feature parsing flow. +// +// It reads ingress-nginx rewrite annotations from ProviderIR sources and stores +// provider-neutral rewrite intent into EmitterIR, which will later be converted +// to Gateway API URLRewrite filters by the common emitter. +func applyRewriteTargetToEmitterIR(ingresses []networkingv1.Ingress, + pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + hostsWithRegex := regexHosts(ingresses) + + for key, pRouteCtx := range pIR.HTTPRoutes { + hasRegex := false + for _, host := range pRouteCtx.Spec.Hostnames { + if _, val := hostsWithRegex[string(host)]; val { + hasRegex = true + break + } + } + + eRouteCtx, ok := eIR.HTTPRoutes[key] + if !ok { + continue + } + + for ruleIdx := range eRouteCtx.Spec.Rules { + if ruleIdx >= len(pRouteCtx.RuleBackendSources) { + continue + } + ing := getNonCanaryIngress(pRouteCtx.RuleBackendSources[ruleIdx]) + if ing == nil { + continue + } + + rewriteTarget := ing.Annotations[RewriteTargetAnnotation] + if rewriteTarget == "" { + continue + } + + if eRouteCtx.PathRewriteByRuleIdx == nil { + eRouteCtx.PathRewriteByRuleIdx = make(map[int]*emitterir.PathRewrite) + } + + pathRewriteIR := emitterir.PathRewrite{ReplaceFullPath: rewriteTarget, Headers: make(map[string]string)} + + if val, ok := ing.Annotations[XForwardedPrefixAnnotation]; ok && val != "" { + pathRewriteIR.Headers["X-Forwarded-Prefix"] = val + } + + if hasRegex && strings.Contains(rewriteTarget, "$") { + pathRewriteIR.RegexCaptureGroupReferences = true + } + + source := fmt.Sprintf("rewrite-target from Ingress %s/%s", ing.Namespace, ing.Name) + paths := []*field.Path{field.NewPath("metadata", "annotations", fmt.Sprintf("%q", RewriteTargetAnnotation))} + if hasRegex && strings.Contains(rewriteTarget, "$") { + // Otherwise, rewrites without capture group references work. + pathRewriteIR.RegexCaptureGroupReferences = true + + pathRewriteIR.Metadata = emitterir.NewExtensionFeatureMetadata( + source, + paths, + "Path rewrites with capture group references are not supported", + ) + } else { + pathRewriteIR.Metadata = emitterir.NewExtensionFeatureMetadata( + source, + paths, + "Could not apply rewrite-target annotation", + ) + } + + eRouteCtx.PathRewriteByRuleIdx[ruleIdx] = &pathRewriteIR + } + + eIR.HTTPRoutes[key] = eRouteCtx + } +} diff --git a/pkg/i2gw/providers/ingressnginx/rewrite_target.go b/pkg/i2gw/providers/ingressnginx/rewrite_target.go deleted file mode 100644 index 82dbf23a9..000000000 --- a/pkg/i2gw/providers/ingressnginx/rewrite_target.go +++ /dev/null @@ -1,135 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -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 ingressnginx - -import ( - "strings" - - providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" - networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/validation/field" - "k8s.io/utils/ptr" -) - -const nginxRewriteTargetAnnotation = "nginx.ingress.kubernetes.io/rewrite-target" - -// rewriteTargetFeature parses the nginx.ingress.kubernetes.io/rewrite-target annotation. -// -// Semantics: -// - Per ingress, if rewrite-target is present and non-empty, store it in Policy.RewriteTarget. -// - Coverage is tracked via RuleBackendSources in the normal way. -// - For a host-group (merged HTTPRoute), RegexForcedByRewrite is true if ANY ingress has rewrite-target present, -// and RegexLocationForHost is OR'd to true. -func rewriteTargetFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - - // Per-Ingress rewrite target value. - perIngress := map[types.NamespacedName]string{} - - for i := range ingresses { - ing := &ingresses[i] - anns := ing.Annotations - if anns == nil { - continue - } - raw, ok := anns[nginxRewriteTargetAnnotation] - if !ok { - continue - } - val := strings.TrimSpace(raw) - if val == "" { - continue - } - perIngress[types.NamespacedName{Namespace: ing.Namespace, Name: ing.Name}] = val - } - - if len(perIngress) == 0 { - return errs - } - - // Map per-Ingress rewrite target onto HTTPRoute policies using RuleBackendSources. - ruleGroups := common.GetRuleGroups(ingresses) - for _, rg := range ruleGroups { - routeKey := types.NamespacedName{ - Namespace: rg.Namespace, - Name: common.RouteName(rg.Name, rg.Host), - } - - httpCtx, ok := ir.HTTPRoutes[routeKey] - if !ok { - continue - } - - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, - } - } - if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - - // host-scoped: any rewrite-target forces regex mode for host. - anyRewrite := false - for _, r := range rg.Rules { - ing := r.Ingress - if _, ok := perIngress[types.NamespacedName{Namespace: ing.Namespace, Name: ing.Name}]; ok { - anyRewrite = true - break - } - } - if anyRewrite { - httpCtx.ProviderSpecificIR.IngressNginx.RegexForcedByRewrite = true - if httpCtx.ProviderSpecificIR.IngressNginx.RegexLocationForHost == nil { - httpCtx.ProviderSpecificIR.IngressNginx.RegexLocationForHost = ptr.To(true) - } else { - *httpCtx.ProviderSpecificIR.IngressNginx.RegexLocationForHost = - *httpCtx.ProviderSpecificIR.IngressNginx.RegexLocationForHost || true - } - } - - // policy-scoped: attach rewrite target to each ingress policy with coverage. - for ruleIdx, perRule := range httpCtx.RuleBackendSources { - for backendIdx, src := range perRule { - if src.Ingress == nil { - continue - } - - ingKey := types.NamespacedName{Namespace: src.Ingress.Namespace, Name: src.Ingress.Name} - rt, ok := perIngress[ingKey] - if !ok { - continue - } - - p := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] - p.RewriteTarget = ptr.To(rt) - p = p.AddRuleBackendSources([]providerir.PolicyIndex{{Rule: ruleIdx, Backend: backendIdx}}) - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] = p - } - } - - ir.HTTPRoutes[routeKey] = httpCtx - } - - return errs -} diff --git a/pkg/i2gw/providers/ingressnginx/rewrite_test.go b/pkg/i2gw/providers/ingressnginx/rewrite_test.go new file mode 100644 index 000000000..e0ddab235 --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/rewrite_test.go @@ -0,0 +1,146 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "reflect" + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestApplyRewriteTargetToEmitterIR_SetsRewriteHeadersAndRegex(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + + ing := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "ing", + Annotations: map[string]string{ + RewriteTargetAnnotation: "/rewritten/\\$1", + XForwardedPrefixAnnotation: "/prefix", + UseRegexAnnotation: "true", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + }}, + }, + } + + pIR := providerir.ProviderIR{ + HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{ + key: { + HTTPRoute: gatewayv1.HTTPRoute{ + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"example.com"}, + }, + }, + RuleBackendSources: [][]providerir.BackendSource{{{Ingress: &ing}}}, + }, + }, + } + + eIR := emitterir.EmitterIR{ + HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{ + key: { + HTTPRoute: gatewayv1.HTTPRoute{Spec: gatewayv1.HTTPRouteSpec{Rules: []gatewayv1.HTTPRouteRule{{}}}}, + }, + }, + } + + applyRewriteTargetToEmitterIR([]networkingv1.Ingress{ing}, pIR, &eIR) + + got := eIR.HTTPRoutes[key].PathRewriteByRuleIdx[0] + if got == nil { + t.Fatalf("expected PathRewriteByRuleIdx[0] to be set") + } + if got.ReplaceFullPath != "/rewritten/\\$1" { + t.Fatalf("expected ReplaceFullPath=/rewritten/\\$1, got %q", got.ReplaceFullPath) + } + if got.RegexCaptureGroupReferences != true { + t.Fatalf("expected Regex=true, got %v", got.RegexCaptureGroupReferences) + } + wantHeaders := map[string]string{"X-Forwarded-Prefix": "/prefix"} + if !reflect.DeepEqual(got.Headers, wantHeaders) { + t.Fatalf("expected headers %v, got %v", wantHeaders, got.Headers) + } +} + +func TestApplyRewriteTargetToEmitterIR_PrefersNonCanaryIngressSource(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + + canary := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "canary", + Annotations: map[string]string{ + CanaryAnnotation: "true", + RewriteTargetAnnotation: "/bad", + }, + }, + } + main := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "main", + Annotations: map[string]string{ + RewriteTargetAnnotation: "/good", + XForwardedPrefixAnnotation: "/p", + }, + }, + } + + pIR := providerir.ProviderIR{ + HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{ + key: { + RuleBackendSources: [][]providerir.BackendSource{{ + {Ingress: &canary}, + {Ingress: &main}, + }}, + }, + }, + } + + eIR := emitterir.EmitterIR{ + HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{ + key: { + HTTPRoute: gatewayv1.HTTPRoute{Spec: gatewayv1.HTTPRouteSpec{Rules: []gatewayv1.HTTPRouteRule{{}}}}, + }, + }, + } + + applyRewriteTargetToEmitterIR([]networkingv1.Ingress{canary, main}, pIR, &eIR) + + got := eIR.HTTPRoutes[key].PathRewriteByRuleIdx[0] + if got == nil { + t.Fatalf("expected PathRewriteByRuleIdx[0] to be set") + } + if got.ReplaceFullPath != "/good" { + t.Fatalf("expected ReplaceFullPath=/good, got %q", got.ReplaceFullPath) + } + wantHeaders := map[string]string{"X-Forwarded-Prefix": "/p"} + if !reflect.DeepEqual(got.Headers, wantHeaders) { + t.Fatalf("expected headers %v, got %v", wantHeaders, got.Headers) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/service_upstream.go b/pkg/i2gw/providers/ingressnginx/service_upstream.go index 7b55b2003..6c7630be7 100644 --- a/pkg/i2gw/providers/ingressnginx/service_upstream.go +++ b/pkg/i2gw/providers/ingressnginx/service_upstream.go @@ -17,173 +17,89 @@ limitations under the License. package ingressnginx import ( + "fmt" "strings" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/validation/field" ) -const serviceUpstreamAnnotation = "nginx.ingress.kubernetes.io/service-upstream" - -// serviceUpstreamFeature is a FeatureParser that projects the -// nginx.ingress.kubernetes.io/service-upstream annotation into the -// Ingress NGINX provider-specific IR by creating static Backends that -// point to a single upstream (Service IP/port) rather than per-Endpoint -// Pod IPs. -// -// It does NOT change the HTTPRoute here; instead it populates -// Policy.Backends and Policy.RuleBackendSources, which an emitter -// can later use to: -// 1. Emit implementation-specific backend CRs, and -// 2. Rewrite HTTPRoute backendRefs to reference those Backend CRs. -func serviceUpstreamFeature( - ingresses []networkingv1.Ingress, - servicePorts map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - - // First, determine which Ingresses have service-upstream enabled. - // - // We follow the same pattern as loadBalancingFeature and key by Ingress - // name; HTTPRoutes are already namespaced, so we disambiguate via the - // HTTPRoute namespace later. - ingSvcUpstream := make(map[string]bool, len(ingresses)) - for _, ing := range ingresses { - if ing.Annotations == nil { - continue - } - raw, ok := ing.Annotations[serviceUpstreamAnnotation] +// applyServiceUpstreamToEmitterIR projects service-upstream backend metadata into +// emitter-neutral per-route policy state so custom emitters can materialize +// backend CRs and rewrite covered HTTPRoute backendRefs. +func (p *Provider) applyServiceUpstreamToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + for key, pRouteCtx := range pIR.HTTPRoutes { + eRouteCtx, ok := eIR.HTTPRoutes[key] if !ok { continue } - value := strings.TrimSpace(strings.ToLower(raw)) - if value == "true" { - ingSvcUpstream[ing.Name] = true - } - } - - if len(ingSvcUpstream) == 0 { - return errs - } - - // Walk all HTTPRoutes in the IR and project service-upstream Ingresses - // into provider-specific policies. - for key, httpCtx := range ir.HTTPRoutes { - // Group BackendSources by source Ingress name. - srcByIng := map[string][]providerir.PolicyIndex{} - - for ruleIdx, perRule := range httpCtx.RuleBackendSources { - for backendIdx, src := range perRule { - if src.Ingress == nil { - continue - } - ingName := src.Ingress.Name - srcByIng[ingName] = append( - srcByIng[ingName], - providerir.PolicyIndex{Rule: ruleIdx, Backend: backendIdx}, - ) - } - } - - if len(srcByIng) == 0 { - continue - } - - // Ensure provider-specific IR is initialized. - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, - } - } else if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - - ingPolicies := httpCtx.ProviderSpecificIR.IngressNginx.Policies - for ingName, idxs := range srcByIng { - // Only process Ingresses that have service-upstream enabled. - if !ingSvcUpstream[ingName] { + for ruleIdx := range eRouteCtx.Spec.Rules { + if ruleIdx >= len(pRouteCtx.RuleBackendSources) || ruleIdx >= len(eRouteCtx.Spec.Rules) { continue } - pol := ingPolicies[ingName] - if pol.Backends == nil { - pol.Backends = map[types.NamespacedName]providerir.Backend{} - } - - for _, idx := range idxs { - // Bounds checks. - if idx.Rule >= len(httpCtx.Spec.Rules) { + sources := pRouteCtx.RuleBackendSources[ruleIdx] + rule := eRouteCtx.Spec.Rules[ruleIdx] + for backendIdx := range rule.BackendRefs { + if backendIdx >= len(sources) { continue } - rule := httpCtx.Spec.Rules[idx.Rule] - if idx.Backend >= len(rule.BackendRefs) { + + source := sources[backendIdx] + if !serviceUpstreamEnabled(source.Ingress) { continue } - br := rule.BackendRefs[idx.Backend] - // Only rewrite core Service backends. - if br.Group != nil && string(*br.Group) != "" { + backendRef := rule.BackendRefs[backendIdx].BackendRef + if backendRef.Group != nil && *backendRef.Group != "" { continue } - if br.Kind != nil && string(*br.Kind) != "" && string(*br.Kind) != "Service" { + if backendRef.Kind != nil && *backendRef.Kind != "Service" { continue } - if br.Name == "" { + if backendRef.Name == "" || backendRef.Port == nil { continue } - svcKey := types.NamespacedName{ - Namespace: key.Namespace, - Name: string(br.Name), - } - - // Resolve port. - var port int32 - if br.Port != nil { - port = int32(*br.Port) + if eRouteCtx.PoliciesBySourceIngressName == nil { + eRouteCtx.PoliciesBySourceIngressName = make(map[string]emitterir.Policy) } - if port == 0 { - // Cannot determine port; skip this backendRef. - // TODO [danehans]: Emit a notification/warning. - continue + ingressName := source.Ingress.Name + policy := eRouteCtx.PoliciesBySourceIngressName[ingressName] + if policy.Backends == nil { + policy.Backends = make(map[types.NamespacedName]emitterir.Backend) } - // Derive a stable Backend name; the emitter will create a Backend with this name. - backendName := svcKey.Name + "-service-upstream" backendKey := types.NamespacedName{ - Namespace: svcKey.Namespace, - Name: backendName, - } - - // Host: if you later add ClusterIP into the IR, set Backend.IP - // to that value instead. For now we use in-cluster DNS. - host := svcKey.Name + "." + svcKey.Namespace + ".svc.cluster.local" - - pol.Backends[backendKey] = providerir.Backend{ - Namespace: backendKey.Namespace, - Name: backendKey.Name, - Port: port, - Host: host, + Namespace: key.Namespace, + Name: string(backendRef.Name) + "-service-upstream", } - } - - if len(pol.Backends) > 0 { - // Track which (rule, backend) indices this Policy applies to; - // the emitter will use this to rewrite backendRefs to Backend. - pol = pol.AddRuleBackendSources(idxs) - ingPolicies[ingName] = pol + backend := policy.Backends[backendKey] + backend.Namespace = backendKey.Namespace + backend.Name = backendKey.Name + backend.Host = fmt.Sprintf("%s.%s.svc.cluster.local", backendRef.Name, key.Namespace) + backend.Port = int32(*backendRef.Port) + policy.Backends[backendKey] = backend + + policy = policy.AddRuleBackendSources([]emitterir.PolicyIndex{{ + Rule: ruleIdx, + Backend: backendIdx, + }}) + eRouteCtx.PoliciesBySourceIngressName[ingressName] = policy } } - httpCtx.ProviderSpecificIR.IngressNginx.Policies = ingPolicies - // Write back mutated HTTPRouteContext into IR. - ir.HTTPRoutes[key] = httpCtx + eIR.HTTPRoutes[key] = eRouteCtx } +} - return errs +func serviceUpstreamEnabled(ing *networkingv1.Ingress) bool { + if ing == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(ing.Annotations[ServiceUpstreamAnnotation]), "true") } diff --git a/pkg/i2gw/providers/ingressnginx/service_upstream_test.go b/pkg/i2gw/providers/ingressnginx/service_upstream_test.go new file mode 100644 index 000000000..b6c5e9f05 --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/service_upstream_test.go @@ -0,0 +1,136 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestApplyServiceUpstreamToEmitterIR(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + + serviceUpstreamIngress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ing-service-upstream", + Namespace: key.Namespace, + Annotations: map[string]string{ + ServiceUpstreamAnnotation: "true", + }, + }, + } + plainIngress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ing-plain", + Namespace: key.Namespace, + }, + } + + serviceKind := gatewayv1.Kind("Service") + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("myservice"), + Kind: &serviceKind, + Port: portPtr(80), + }, + }, + }, + }, + }, + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("myservice"), + Kind: &serviceKind, + Port: portPtr(80), + }, + }, + }, + }, + }, + }, + }, + } + + pIR := providerir.ProviderIR{ + HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{ + key: { + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + {{Ingress: &serviceUpstreamIngress}}, + {{Ingress: &plainIngress}}, + }, + }, + }, + } + eIR := emitterir.EmitterIR{ + HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{ + key: {HTTPRoute: route}, + }, + } + + (&Provider{}).applyServiceUpstreamToEmitterIR(pIR, &eIR) + + policy, ok := eIR.HTTPRoutes[key].PoliciesBySourceIngressName[serviceUpstreamIngress.Name] + if !ok { + t.Fatalf("expected service-upstream policy for ingress %q", serviceUpstreamIngress.Name) + } + if len(policy.RuleBackendSources) != 1 || policy.RuleBackendSources[0] != (emitterir.PolicyIndex{Rule: 0, Backend: 0}) { + t.Fatalf("expected only rule 0/backend 0 coverage, got %#v", policy.RuleBackendSources) + } + + backendKey := types.NamespacedName{ + Namespace: key.Namespace, + Name: "myservice-service-upstream", + } + backend, ok := policy.Backends[backendKey] + if !ok { + t.Fatalf("expected backend %v to be projected", backendKey) + } + if backend.Host != "myservice.default.svc.cluster.local" { + t.Fatalf("expected host %q, got %q", "myservice.default.svc.cluster.local", backend.Host) + } + if backend.Port != 80 { + t.Fatalf("expected port 80, got %d", backend.Port) + } + + if _, ok := eIR.HTTPRoutes[key].PoliciesBySourceIngressName[plainIngress.Name]; ok { + t.Fatalf("did not expect policy for ingress without service-upstream annotation") + } +} + +func portPtr(p gatewayv1.PortNumber) *gatewayv1.PortNumber { + return &p +} diff --git a/pkg/i2gw/providers/ingressnginx/session_affinity.go b/pkg/i2gw/providers/ingressnginx/session_affinity.go index ff55373e1..7bf42944e 100644 --- a/pkg/i2gw/providers/ingressnginx/session_affinity.go +++ b/pkg/i2gw/providers/ingressnginx/session_affinity.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,217 +17,216 @@ limitations under the License. package ingressnginx import ( + "fmt" "strconv" "strings" - "time" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" - networkingv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" ) -const ( - nginxAffinityAnnotation = "nginx.ingress.kubernetes.io/affinity" - nginxSessionCookiePathAnnotation = "nginx.ingress.kubernetes.io/session-cookie-path" - nginxSessionCookieDomainAnnotation = "nginx.ingress.kubernetes.io/session-cookie-domain" - nginxSessionCookieSameSiteAnnotation = "nginx.ingress.kubernetes.io/session-cookie-samesite" - nginxSessionCookieExpiresAnnotation = "nginx.ingress.kubernetes.io/session-cookie-expires" - nginxSessionCookieMaxAgeAnnotation = "nginx.ingress.kubernetes.io/session-cookie-max-age" - nginxSessionCookieSecureAnnotation = "nginx.ingress.kubernetes.io/session-cookie-secure" - nginxSessionCookieNameAnnotation = "nginx.ingress.kubernetes.io/session-cookie-name" -) - -// sessionAffinityFeature parses session affinity annotations and stores them in the IR Policy. -// -// Semantics: -// - The affinity annotation enables session affinity (only "cookie" type is supported). -// - Session cookie annotations configure the cookie properties. -// - We normalize cookie expires to metav1.Duration and attach it per-Ingress, then map to -// specific (rule, backend) pairs via RuleBackendSources. -func sessionAffinityFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - - // Per-Ingress parsed session affinity policy. - perIngress := map[types.NamespacedName]*providerir.SessionAffinityPolicy{} - - for i := range ingresses { - ing := &ingresses[i] - anns := ing.Annotations - if anns == nil { - continue - } - - // Check if affinity is enabled - affinityType := strings.TrimSpace(anns[nginxAffinityAnnotation]) - if affinityType == "" || affinityType != "cookie" { - // Only cookie affinity is supported - continue - } - - key := types.NamespacedName{ - Namespace: ing.Namespace, - Name: ing.Name, - } - - policy := &providerir.SessionAffinityPolicy{ - CookieName: "INGRESSCOOKIE", // Default cookie name used by NGINX - } - - // Parse cookie name (if specified) - if cookieName := strings.TrimSpace(anns[nginxSessionCookieNameAnnotation]); cookieName != "" { - policy.CookieName = cookieName - } - - // Parse cookie path - if cookiePath := strings.TrimSpace(anns[nginxSessionCookiePathAnnotation]); cookiePath != "" { - policy.CookiePath = cookiePath - } - - // Parse cookie domain - if cookieDomain := strings.TrimSpace(anns[nginxSessionCookieDomainAnnotation]); cookieDomain != "" { - policy.CookieDomain = cookieDomain - } - - // Parse cookie SameSite - if cookieSameSite := strings.TrimSpace(anns[nginxSessionCookieSameSiteAnnotation]); cookieSameSite != "" { - // Validate SameSite values - if cookieSameSite == "None" || cookieSameSite == "Lax" || cookieSameSite == "Strict" { - policy.CookieSameSite = cookieSameSite - } else { - errs = append(errs, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(nginxSessionCookieSameSiteAnnotation), - cookieSameSite, - "session-cookie-samesite must be one of: None, Lax, Strict", - )) +func sessionAffinityFeature(notify notifications.NotifyFunc, _ []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *providerir.ProviderIR) field.ErrorList { + // Iterate over all HTTPRoutes to find backend services and apply generic SessionAffinity + for _, httpRouteCtx := range ir.HTTPRoutes { + for ruleIdx := range httpRouteCtx.Spec.Rules { + if ruleIdx >= len(httpRouteCtx.RuleBackendSources) { continue } - } - - // Parse cookie expires (TTL) - max-age takes precedence over expires - parseDuration := func(annotationValue string) (*metav1.Duration, error) { - // value is in seconds, e.g. "3600" - d, err := time.ParseDuration(annotationValue + "s") - if err != nil { - return nil, err + sources := httpRouteCtx.RuleBackendSources[ruleIdx] + if len(sources) == 0 { + continue } - return &metav1.Duration{Duration: d}, nil - } - // Parse session-cookie-max-age first (takes precedence) - if cookieMaxAgeRaw := strings.TrimSpace(anns[nginxSessionCookieMaxAgeAnnotation]); cookieMaxAgeRaw != "" { - duration, err := parseDuration(cookieMaxAgeRaw) - if err != nil { - errs = append(errs, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(nginxSessionCookieMaxAgeAnnotation), - cookieMaxAgeRaw, - "failed to parse session-cookie-max-age", - )) - continue + // We need to find the backend service for this rule to attach the policy. + // Currently, we just look at the BackendRefs. + // Note: This logic assumes we can map back to the service. + // Ingress-Nginx usually maps path -> backend service. + // We check the Ingress sources for the annotation. + + var affinityType string + var cookieTTL *int64 + var sourceIngress *networkingv1.Ingress + + for _, source := range sources { + if val, ok := source.Ingress.Annotations[AffinityAnnotation]; ok && val == "cookie" { + affinityType = "Cookie" + sourceIngress = source.Ingress + + // Check for Max Age (Expires) + if ttlVal, ok := source.Ingress.Annotations[SessionCookieExpiresAnnotation]; ok { + if ttl, err := strconv.ParseInt(ttlVal, 10, 64); err == nil { + cookieTTL = &ttl + } + } + + break + } } - policy.CookieExpires = duration - } else if cookieExpiresRaw := strings.TrimSpace(anns[nginxSessionCookieExpiresAnnotation]); cookieExpiresRaw != "" { - // Only parse expires if max-age is not set - duration, err := parseDuration(cookieExpiresRaw) - if err != nil { - errs = append(errs, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(nginxSessionCookieExpiresAnnotation), - cookieExpiresRaw, - "failed to parse session-cookie-expires", - )) + + if affinityType == "" { continue } - policy.CookieExpires = duration - } - // Parse cookie secure - if cookieSecureRaw := strings.TrimSpace(anns[nginxSessionCookieSecureAnnotation]); cookieSecureRaw != "" { - secure, err := strconv.ParseBool(cookieSecureRaw) - if err != nil { - errs = append(errs, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(nginxSessionCookieSecureAnnotation), - cookieSecureRaw, - "failed to parse session-cookie-secure (must be true or false)", - )) - continue + // Build metadata following the same pattern as IPRangeControl: + // source is namespace/name, paths list all parsed annotations. + source := fmt.Sprintf("%s/%s", sourceIngress.Namespace, sourceIngress.Name) + message := "Session affinity is not supported" + paths := []*field.Path{ + field.NewPath(sourceIngress.Namespace, sourceIngress.Name, "metadata", "annotations", fmt.Sprintf("%q", AffinityAnnotation)), } - policy.CookieSecure = &secure - } + if cookieTTL != nil { + paths = append(paths, field.NewPath(sourceIngress.Namespace, sourceIngress.Name, "metadata", "annotations", fmt.Sprintf("%q", SessionCookieExpiresAnnotation))) + } + metadata := emitterir.NewExtensionFeatureMetadata(source, paths, message) - perIngress[key] = policy - } + // Apply to all backend refs in this rule? + // Session Affinity is per Backend Service. + // We need to update the ServiceIR for the referenced services. - if len(perIngress) == 0 { - return errs - } + for _, backendRef := range httpRouteCtx.Spec.Rules[ruleIdx].BackendRefs { + refName := string(backendRef.Name) - // Map per-Ingress session affinity policy onto HTTPRoute policies using RuleBackendSources. - ruleGroups := common.GetRuleGroups(ingresses) + svcKey := types.NamespacedName{ + Namespace: httpRouteCtx.HTTPRoute.Namespace, // assumption: same namespace + Name: refName, + } - for _, rg := range ruleGroups { - routeKey := types.NamespacedName{ - Namespace: rg.Namespace, - Name: common.RouteName(rg.Name, rg.Host), + if svc, ok := ir.Services[svcKey]; ok { + if svc.SessionAffinity == nil { + svc.SessionAffinity = &emitterir.SessionAffinity{} + } + + svc.SessionAffinity.Type = affinityType + svc.SessionAffinity.CookieTTLSec = cookieTTL + svc.SessionAffinity.Metadata = metadata + + // Update the map + ir.Services[svcKey] = svc + } else { + // Service doesn't exist yet, create it + svc = providerir.ProviderSpecificServiceIR{ + SessionAffinity: &emitterir.SessionAffinity{ + Metadata: metadata, + Type: affinityType, + CookieTTLSec: cookieTTL, + }, + } + ir.Services[svcKey] = svc + } + } } + } + return nil +} - httpCtx, ok := ir.HTTPRoutes[routeKey] +// applySessionAffinityToEmitterIR projects ingress-nginx cookie affinity annotations into +// emitter-neutral per-route policy intent for emitters like kgateway. +func (p *Provider) applySessionAffinityToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + for key, pRouteCtx := range pIR.HTTPRoutes { + eRouteCtx, ok := eIR.HTTPRoutes[key] if !ok { continue } - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, + for ruleIdx := range eRouteCtx.Spec.Rules { + if ruleIdx >= len(pRouteCtx.RuleBackendSources) { + continue } - } - if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } + if ruleIdx >= len(eRouteCtx.Spec.Rules) { + continue + } + + sources := pRouteCtx.RuleBackendSources[ruleIdx] + rule := eRouteCtx.Spec.Rules[ruleIdx] - for ruleIdx, backendSources := range httpCtx.RuleBackendSources { - for backendIdx, src := range backendSources { - if src.Ingress == nil { + for backendIdx := range rule.BackendRefs { + if backendIdx >= len(sources) { continue } - ingKey := types.NamespacedName{ - Namespace: src.Ingress.Namespace, - Name: src.Ingress.Name, + source := sources[backendIdx] + if source.Ingress == nil { + continue } - sessionAffinity := perIngress[ingKey] + sessionAffinity, parsedAnnotations := parseIngressNginxSessionAffinity(source.Ingress) if sessionAffinity == nil { continue } - p := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] - if p.SessionAffinity == nil { - // Deep copy the policy to avoid sharing references - sessionAffinityCopy := *sessionAffinity - p.SessionAffinity = &sessionAffinityCopy + if eRouteCtx.PoliciesBySourceIngressName == nil { + eRouteCtx.PoliciesBySourceIngressName = make(map[string]emitterir.Policy) } - // Dedupe (rule, backend) pairs. - p = p.AddRuleBackendSources([]providerir.PolicyIndex{ - { - Rule: ruleIdx, - Backend: backendIdx, - }, - }) + ingressName := source.Ingress.Name + policy := eRouteCtx.PoliciesBySourceIngressName[ingressName] + policy.SessionAffinity = sessionAffinity + policy = policy.AddRuleBackendSources([]emitterir.PolicyIndex{{ + Rule: ruleIdx, + Backend: backendIdx, + }}) + eRouteCtx.PoliciesBySourceIngressName[ingressName] = policy - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] = p + _ = parsedAnnotations } } - ir.HTTPRoutes[routeKey] = httpCtx + eIR.HTTPRoutes[key] = eRouteCtx + } +} + +func parseIngressNginxSessionAffinity(ing *networkingv1.Ingress) (*emitterir.SessionAffinityPolicy, []string) { + if ing == nil { + return nil, nil + } + if !strings.EqualFold(strings.TrimSpace(ing.Annotations[AffinityAnnotation]), "cookie") { + return nil, nil + } + + parsedAnnotations := []string{AffinityAnnotation} + policy := &emitterir.SessionAffinityPolicy{ + CookieName: "INGRESSCOOKIE", + } + + if v := strings.TrimSpace(ing.Annotations[SessionCookieNameAnnotation]); v != "" { + policy.CookieName = v + parsedAnnotations = append(parsedAnnotations, SessionCookieNameAnnotation) + } + if v := strings.TrimSpace(ing.Annotations[SessionCookiePathAnnotation]); v != "" { + policy.CookiePath = v + parsedAnnotations = append(parsedAnnotations, SessionCookiePathAnnotation) + } + if v := strings.TrimSpace(ing.Annotations[SessionCookieDomainAnnotation]); v != "" { + policy.CookieDomain = v + parsedAnnotations = append(parsedAnnotations, SessionCookieDomainAnnotation) + } + if v := strings.TrimSpace(ing.Annotations[SessionCookieSameSiteAnnotation]); v != "" { + policy.CookieSameSite = v + parsedAnnotations = append(parsedAnnotations, SessionCookieSameSiteAnnotation) + } + if v := strings.TrimSpace(ing.Annotations[SessionCookieSecureAnnotation]); v != "" { + if secure, err := strconv.ParseBool(v); err == nil { + policy.CookieSecure = &secure + parsedAnnotations = append(parsedAnnotations, SessionCookieSecureAnnotation) + } + } + + ttlRaw := strings.TrimSpace(ing.Annotations[SessionCookieMaxAgeAnnotation]) + ttlAnnotation := SessionCookieMaxAgeAnnotation + if ttlRaw == "" { + ttlRaw = strings.TrimSpace(ing.Annotations[SessionCookieExpiresAnnotation]) + ttlAnnotation = SessionCookieExpiresAnnotation + } + if ttlRaw != "" { + if ttl, err := strconv.ParseInt(ttlRaw, 10, 64); err == nil { + policy.CookieExpires = &ttl + parsedAnnotations = append(parsedAnnotations, ttlAnnotation) + } } - return errs + return policy, parsedAnnotations } diff --git a/pkg/i2gw/providers/ingressnginx/session_affinity_test.go b/pkg/i2gw/providers/ingressnginx/session_affinity_test.go new file mode 100644 index 000000000..35a5bf0ba --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/session_affinity_test.go @@ -0,0 +1,178 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestGCEFeature(t *testing.T) { + testCases := []struct { + name string + ingress networkingv1.Ingress + expectedSessionAffinity *emitterir.SessionAffinity + }{ + { + name: "No Affinity", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-affinity", + Namespace: "default", + }, + }, + expectedSessionAffinity: nil, // Should not modify service + }, + { + name: "Cookie Affinity", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cookie-affinity", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/affinity": "cookie", + }, + }, + }, + expectedSessionAffinity: &emitterir.SessionAffinity{ + Metadata: emitterir.NewExtensionFeatureMetadata( + "default/cookie-affinity", + []*field.Path{field.NewPath("default", "cookie-affinity", "metadata", "annotations", fmt.Sprintf("%q", "nginx.ingress.kubernetes.io/affinity"))}, + "Session affinity is not supported", + ), + Type: "Cookie", + }, + }, + { + name: "Cookie Affinity with Expires", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cookie-affinity-expires", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/affinity": "cookie", + "nginx.ingress.kubernetes.io/session-cookie-expires": "3600", + }, + }, + }, + expectedSessionAffinity: &emitterir.SessionAffinity{ + Metadata: emitterir.NewExtensionFeatureMetadata( + "default/cookie-affinity-expires", + []*field.Path{ + field.NewPath("default", "cookie-affinity-expires", "metadata", "annotations", fmt.Sprintf("%q", "nginx.ingress.kubernetes.io/affinity")), + field.NewPath("default", "cookie-affinity-expires", "metadata", "annotations", fmt.Sprintf("%q", "nginx.ingress.kubernetes.io/session-cookie-expires")), + }, + "Session affinity is not supported", + ), + Type: "Cookie", + CookieTTLSec: ptr.To(int64(3600)), + }, + }, + { + name: "Cookie Affinity with Name (unparsed - no emitter consumes it)", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cookie-affinity-name", + Namespace: "default", + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/affinity": "cookie", + "nginx.ingress.kubernetes.io/session-cookie-name": "MY_COOKIE", + }, + }, + }, + expectedSessionAffinity: &emitterir.SessionAffinity{ + Metadata: emitterir.NewExtensionFeatureMetadata( + "default/cookie-affinity-name", + []*field.Path{field.NewPath("default", "cookie-affinity-name", "metadata", "annotations", fmt.Sprintf("%q", "nginx.ingress.kubernetes.io/affinity"))}, + "Session affinity is not supported", + ), + Type: "Cookie", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ir := providerir.ProviderIR{ + HTTPRoutes: make(map[types.NamespacedName]providerir.HTTPRouteContext), + Services: make(map[types.NamespacedName]providerir.ProviderSpecificServiceIR), + } + + // Mock Service + svcKey := types.NamespacedName{Namespace: "default", Name: "my-service"} + ir.Services[svcKey] = providerir.ProviderSpecificServiceIR{} + + // Mock Route Logic (Simplified to match sessionAffinityFeature expectations) + key := types.NamespacedName{Namespace: "default", Name: "test"} + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test"}, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "my-service", + }, + }, + }, + }, + }, + }, + }, + } + ir.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + { + {Ingress: &tc.ingress}, + }, + }, + } + + sessionAffinityFeature(func(_ notifications.MessageType, _ string, _ ...client.Object) {}, []networkingv1.Ingress{tc.ingress}, nil, &ir) + + actual := ir.Services[svcKey].SessionAffinity + + // If expected is nil, we expect nil OR empty SessionAffinity struct (depends on implementation) + // Our implementation initializes SessionAffinity if it finds affinity. + + if tc.expectedSessionAffinity == nil { + if actual != nil { + t.Errorf("Expected nil SessionAffinity, got %v", actual) + } + } else { + if diff := cmp.Diff(tc.expectedSessionAffinity, actual, cmp.AllowUnexported(emitterir.ExtensionFeatureMetadata{}, field.Path{})); diff != "" { + t.Errorf("SessionAffinity IR mismatch (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/ssl_passthrough.go b/pkg/i2gw/providers/ingressnginx/ssl_passthrough.go index 75ba7286e..0b929ce38 100644 --- a/pkg/i2gw/providers/ingressnginx/ssl_passthrough.go +++ b/pkg/i2gw/providers/ingressnginx/ssl_passthrough.go @@ -19,6 +19,7 @@ package ingressnginx import ( "strings" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" @@ -30,22 +31,16 @@ import ( gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" ) -const ( - sslPassthroughAnnotation = "nginx.ingress.kubernetes.io/ssl-passthrough" -) - // sslPassthroughFeature extracts the "ssl-passthrough" annotation and converts // HTTPRoutes to TLSRoutes with TLS passthrough Gateway listeners. // When ssl-passthrough is enabled, TLS termination happens at the backend service // rather than at the ingress controller, requiring TLSRoute instead of HTTPRoute. func sslPassthroughFeature( + _ notifications.NotifyFunc, ingresses []networkingv1.Ingress, - servicePorts map[types.NamespacedName]map[string]int32, + _ map[types.NamespacedName]map[string]int32, ir *providerir.ProviderIR, ) field.ErrorList { - - var errs field.ErrorList - // Track ingresses with ssl-passthrough enabled passthroughIngresses := make(map[types.NamespacedName]bool) @@ -56,7 +51,7 @@ func sslPassthroughFeature( continue } - sslPassthroughRaw := strings.TrimSpace(ing.Annotations[sslPassthroughAnnotation]) + sslPassthroughRaw := strings.TrimSpace(ing.Annotations[SSLPassthroughAnnotation]) if strings.EqualFold(sslPassthroughRaw, "true") { key := types.NamespacedName{Namespace: ing.Namespace, Name: ing.Name} passthroughIngresses[key] = true @@ -64,7 +59,7 @@ func sslPassthroughFeature( } if len(passthroughIngresses) == 0 { - return errs + return nil } // Get rule groups to map ingresses to HTTPRoutes @@ -110,8 +105,10 @@ func sslPassthroughFeature( // Create TLSRoute tlsRoute := gatewayv1alpha2.TLSRoute{ ObjectMeta: metav1.ObjectMeta{ - Name: routeKey.Name, - Namespace: routeKey.Namespace, + Name: routeKey.Name, + Namespace: routeKey.Namespace, + Labels: httpRouteCtx.Labels, + Annotations: httpRouteCtx.Annotations, }, Spec: gatewayv1alpha2.TLSRouteSpec{ CommonRouteSpec: gatewayv1.CommonRouteSpec{ @@ -138,16 +135,7 @@ func sslPassthroughFeature( // Convert backendRefs from HTTPBackendRef to BackendRef for _, httpBackendRef := range httpRule.BackendRefs { - backendRef := gatewayv1.BackendRef{ - BackendObjectReference: gatewayv1.BackendObjectReference{ - Name: httpBackendRef.Name, - }, - } - - // Copy namespace if specified - if httpBackendRef.Namespace != nil { - backendRef.Namespace = httpBackendRef.Namespace - } + backendRef := httpBackendRef.BackendRef // Copy port (default to 443 for TLS if not specified) if httpBackendRef.Port != nil { @@ -221,19 +209,15 @@ func sslPassthroughFeature( listenerPort = 443 // Use standard HTTPS port when hostname is specified } - // Check if TLS passthrough listener already exists + // Remove HTTP listeners that were created for this passthrough ingress + // The common converter creates HTTP listeners, but for TLS passthrough we only want TLS listeners + var filteredListeners []gatewayv1.Listener listenerExists := false for _, existingListener := range gatewayCtx.Spec.Listeners { if existingListener.Name == gatewayv1.SectionName(listenerName) { listenerExists = true - break } - } - // Remove HTTP listeners that were created for this passthrough ingress - // The common converter creates HTTP listeners, but for TLS passthrough we only want TLS listeners - var filteredListeners []gatewayv1.Listener - for _, existingListener := range gatewayCtx.Spec.Listeners { // Remove HTTP listeners that match the hostname of this TLSRoute if existingListener.Protocol == gatewayv1.HTTPProtocolType { if len(tlsRoute.Spec.Hostnames) > 0 && tlsRoute.Spec.Hostnames[0] != "" { @@ -267,16 +251,16 @@ func sslPassthroughFeature( } gatewayCtx.Spec.Listeners = append(gatewayCtx.Spec.Listeners, listener) - ir.Gateways[gatewayKey] = gatewayCtx + } - // Update TLSRoute parentRef to reference the specific listener - for i := range ir.TLSRoutes[routeKey].Spec.ParentRefs { - sectionName := gatewayv1.SectionName(listenerName) - ir.TLSRoutes[routeKey].Spec.ParentRefs[i].SectionName = §ionName - } + sectionName := gatewayv1.SectionName(listenerName) + for i := range ir.TLSRoutes[routeKey].Spec.ParentRefs { + ir.TLSRoutes[routeKey].Spec.ParentRefs[i].SectionName = §ionName } + + ir.Gateways[gatewayKey] = gatewayCtx } } - return errs + return nil } diff --git a/pkg/i2gw/providers/ingressnginx/ssl_passthrough_test.go b/pkg/i2gw/providers/ingressnginx/ssl_passthrough_test.go new file mode 100644 index 000000000..16c4a95d6 --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/ssl_passthrough_test.go @@ -0,0 +1,123 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "testing" + + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestConvert_WiresSSLPassthroughFeature(t *testing.T) { + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tls-passthrough", + Namespace: "default", + Annotations: map[string]string{ + SSLPassthroughAnnotation: "true", + }, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptrTo("nginx"), + Rules: []networkingv1.IngressRule{{ + Host: "nginx.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: ptrTo(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "my-nginx", + Port: networkingv1.ServiceBackendPort{Number: 443}, + }, + }, + }}, + }, + }, + }}, + }, + } + + storage := newResourcesStorage() + key := types.NamespacedName{Namespace: ingress.Namespace, Name: ingress.Name} + storage.Ingresses.FromMap(map[types.NamespacedName]*networkingv1.Ingress{key: ingress}) + + noopNotify := notifications.NotifyFunc(func(notifications.MessageType, string, ...client.Object) {}) + ir, errs := newResourcesToIRConverter(noopNotify).convert(noopNotify, storage) + if len(errs) > 0 { + t.Fatalf("convert returned errors: %v", errs) + } + + routeKey := types.NamespacedName{Namespace: "default", Name: "tls-passthrough-nginx-example-com"} + if _, ok := ir.HTTPRoutes[routeKey]; ok { + t.Fatalf("expected HTTPRoute %s to be replaced by TLSRoute", routeKey) + } + + tlsRoute, ok := ir.TLSRoutes[routeKey] + if !ok { + t.Fatalf("expected TLSRoute %s to be created", routeKey) + } + + if got, want := len(tlsRoute.Spec.Rules), 1; got != want { + t.Fatalf("expected %d TLSRoute rule, got %d", want, got) + } + if got, want := len(tlsRoute.Spec.Rules[0].BackendRefs), 1; got != want { + t.Fatalf("expected %d backend ref, got %d", want, got) + } + if tlsRoute.Spec.Rules[0].BackendRefs[0].Port == nil || *tlsRoute.Spec.Rules[0].BackendRefs[0].Port != 443 { + t.Fatalf("expected backend port 443, got %#v", tlsRoute.Spec.Rules[0].BackendRefs[0].Port) + } + + gatewayKey := types.NamespacedName{Namespace: "default", Name: "nginx"} + gatewayCtx, ok := ir.Gateways[gatewayKey] + if !ok { + t.Fatalf("expected Gateway %s to be present", gatewayKey) + } + if got, want := len(gatewayCtx.Spec.Listeners), 1; got != want { + t.Fatalf("expected %d listener, got %d", want, got) + } + + listener := gatewayCtx.Spec.Listeners[0] + if got, want := listener.Name, gatewayv1.SectionName("nginx-example-com-tls-passthrough"); got != want { + t.Fatalf("expected listener name %q, got %q", want, got) + } + if got, want := listener.Protocol, gatewayv1.TLSProtocolType; got != want { + t.Fatalf("expected listener protocol %q, got %q", want, got) + } + if got, want := listener.Port, gatewayv1.PortNumber(443); got != want { + t.Fatalf("expected listener port %d, got %d", want, got) + } + if listener.TLS == nil || listener.TLS.Mode == nil || *listener.TLS.Mode != gatewayv1.TLSModePassthrough { + t.Fatalf("expected listener TLS mode Passthrough, got %#v", listener.TLS) + } + if listener.Hostname == nil || *listener.Hostname != gatewayv1.Hostname("nginx.example.com") { + t.Fatalf("expected listener hostname nginx.example.com, got %#v", listener.Hostname) + } + + if got, want := len(tlsRoute.Spec.ParentRefs), 1; got != want { + t.Fatalf("expected %d parent ref, got %d", want, got) + } + if tlsRoute.Spec.ParentRefs[0].SectionName == nil || *tlsRoute.Spec.ParentRefs[0].SectionName != listener.Name { + t.Fatalf("expected TLSRoute parent ref sectionName %q, got %#v", listener.Name, tlsRoute.Spec.ParentRefs[0].SectionName) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/ssl_redirect.go b/pkg/i2gw/providers/ingressnginx/ssl_redirect.go deleted file mode 100644 index 5f0799284..000000000 --- a/pkg/i2gw/providers/ingressnginx/ssl_redirect.go +++ /dev/null @@ -1,137 +0,0 @@ -/* -Copyright 2024 The Kubernetes Authors. - -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 ingressnginx - -import ( - "strings" - - providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" - - networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -const ( - sslRedirectAnnotation = "nginx.ingress.kubernetes.io/ssl-redirect" - forceSSLRedirectAnnotation = "nginx.ingress.kubernetes.io/force-ssl-redirect" -) - -// sslRedirectFeature extracts the "ssl-redirect" and "force-ssl-redirect" annotations -// and projects them into the provider-specific IR similarly to other annotation features. -// Both annotations are treated the same way - if either is "true", SSL redirect is enabled. -func sslRedirectFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - - var errs field.ErrorList - ingressPolicies := map[types.NamespacedName]*providerir.Policy{} - - for i := range ingresses { - ing := &ingresses[i] - if ing.Annotations == nil { - continue - } - - // Check both annotations - either one can enable SSL redirect - sslRedirectRaw := strings.TrimSpace(ing.Annotations[sslRedirectAnnotation]) - forceSSLRedirectRaw := strings.TrimSpace(ing.Annotations[forceSSLRedirectAnnotation]) - - // If neither annotation is present, skip this ingress - if sslRedirectRaw == "" && forceSSLRedirectRaw == "" { - continue - } - - // Parse boolean values - "true" (case-insensitive) enables SSL redirect - // If either annotation is "true", enable SSL redirect - sslRedirect := strings.EqualFold(sslRedirectRaw, "true") || strings.EqualFold(forceSSLRedirectRaw, "true") - - key := types.NamespacedName{Namespace: ing.Namespace, Name: ing.Name} - pol := ingressPolicies[key] - if pol == nil { - pol = &providerir.Policy{} - ingressPolicies[key] = pol - } - - pol.SSLRedirect = &sslRedirect - } - - if len(ingressPolicies) == 0 { - return errs - } - - // Map policies to HTTPRoutes - ruleGroups := common.GetRuleGroups(ingresses) - - for _, rg := range ruleGroups { - routeKey := types.NamespacedName{ - Namespace: rg.Namespace, - Name: common.RouteName(rg.Name, rg.Host), - } - - httpCtx, ok := ir.HTTPRoutes[routeKey] - if !ok { - continue - } - - for ruleIdx, backendSources := range httpCtx.RuleBackendSources { - for backendIdx, src := range backendSources { - if src.Ingress == nil { - continue - } - - ingKey := types.NamespacedName{ - Namespace: src.Ingress.Namespace, - Name: src.Ingress.Name, - } - - pol := ingressPolicies[ingKey] - if pol == nil || pol.SSLRedirect == nil { - continue - } - - // Ensure provider-specific IR exists - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, - } - } else if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - - existing := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] - if existing.SSLRedirect == nil { - existing.SSLRedirect = pol.SSLRedirect - } - - // Dedupe (rule, backend) pairs. - existing = existing.AddRuleBackendSources([]providerir.PolicyIndex{ - {Rule: ruleIdx, Backend: backendIdx}, - }) - - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] = existing - } - } - - ir.HTTPRoutes[routeKey] = httpCtx - } - - return errs -} diff --git a/pkg/i2gw/providers/ingressnginx/timeouts.go b/pkg/i2gw/providers/ingressnginx/timeouts.go new file mode 100644 index 000000000..780455fae --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/timeouts.go @@ -0,0 +1,109 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "fmt" + "strconv" + "time" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + networkingv1 "k8s.io/api/networking/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func parseIngressNginxTimeout(val string) (time.Duration, error) { + if val == "" { + return 0, fmt.Errorf("empty timeout") + } + + // These annotations are specified as unitless seconds. + // https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#custom-timeouts + secs, err := strconv.ParseInt(val, 10, 64) + if err != nil { + return 0, fmt.Errorf("must be an integer number of seconds") + } + if secs <= 0 { + return 0, fmt.Errorf("must be > 0") + } + return time.Duration(secs) * time.Second, nil +} + +// applyTimeoutsToEmitterIR is a temporary bridge until timeout parsing is integrated +// into the generic feature parsing flow. +func (p *Provider) applyTimeoutsToEmitterIR(pIR providerir.ProviderIR, eIR *emitterir.EmitterIR) { + + for key, httpRouteContext := range pIR.HTTPRoutes { + eHTTPContext, ok := eIR.HTTPRoutes[key] + if !ok { + continue + } + if eHTTPContext.TCPTimeoutsByRuleIdx == nil { + eHTTPContext.TCPTimeoutsByRuleIdx = make(map[int]*emitterir.TCPTimeouts, len(eHTTPContext.Spec.Rules)) + } + + for ruleIdx := range httpRouteContext.HTTPRoute.Spec.Rules { + if ruleIdx >= len(httpRouteContext.RuleBackendSources) { + continue + } + sources := httpRouteContext.RuleBackendSources[ruleIdx] + ingress := getNonCanaryIngress(sources) + if ingress == nil { + continue + } + + connect := p.parseIngressNginxTimeoutAnnotation(ingress, ProxyConnectTimeoutAnnotation) + read := p.parseIngressNginxTimeoutAnnotation(ingress, ProxyReadTimeoutAnnotation) + write := p.parseIngressNginxTimeoutAnnotation(ingress, ProxySendTimeoutAnnotation) + if connect == nil && read == nil && write == nil { + continue + } + + eHTTPContext.TCPTimeoutsByRuleIdx[ruleIdx] = &emitterir.TCPTimeouts{ + Connect: connect, + Read: read, + Write: write, + } + + p.notify( + notifications.WarningNotification, + "ingress-nginx only supports TCP-level timeouts; i2gw has made a best-effort translation to Gateway API timeouts.request."+ + " Please verify that this meets your needs. See documentation: https://gateway-api.sigs.k8s.io/guides/http-timeouts/", + &httpRouteContext.HTTPRoute, + ) + } + + eIR.HTTPRoutes[key] = eHTTPContext + } +} + +func (p *Provider) parseIngressNginxTimeoutAnnotation(ingress *networkingv1.Ingress, annotation string) *gatewayv1.Duration { + val, ok := ingress.Annotations[annotation] + if !ok || val == "" { + return nil + } + d, err := parseIngressNginxTimeout(val) + if err != nil { + p.notify(notifications.WarningNotification, fmt.Sprintf("Invalid timeout annotation %s=%q: %v, skipping timeout", + annotation, val, err), ingress) + return nil + } + gwDur := gatewayv1.Duration(d.String()) + return &gwDur +} diff --git a/pkg/i2gw/providers/ingressnginx/timeouts_test.go b/pkg/i2gw/providers/ingressnginx/timeouts_test.go new file mode 100644 index 000000000..f50a319a3 --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/timeouts_test.go @@ -0,0 +1,129 @@ +/* +Copyright The Kubernetes Authors. + +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 ingressnginx + +import ( + "reflect" + "testing" + + emitterir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" + "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestTimeoutFeature(t *testing.T) { + testCases := []struct { + name string + annotations map[string]string + wantTimeouts *emitterir.TCPTimeouts + }{ + { + name: "no timeouts", + annotations: map[string]string{}, + wantTimeouts: nil, + }, + { + name: "seconds parse + multiplier", + annotations: map[string]string{ + ProxyReadTimeoutAnnotation: "2", + }, + wantTimeouts: &emitterir.TCPTimeouts{Read: common.PtrTo[gatewayv1.Duration]("2s")}, + }, + { + name: "max + multiplier", + annotations: map[string]string{ + ProxyConnectTimeoutAnnotation: "1", + ProxySendTimeoutAnnotation: "2", + ProxyReadTimeoutAnnotation: "3", + }, + wantTimeouts: &emitterir.TCPTimeouts{ + Connect: common.PtrTo[gatewayv1.Duration]("1s"), + Read: common.PtrTo[gatewayv1.Duration]("3s"), + Write: common.PtrTo[gatewayv1.Duration]("2s"), + }, + }, + { + name: "skips invalid duration strings", + annotations: map[string]string{ + ProxyReadTimeoutAnnotation: "1s", + }, + wantTimeouts: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ing := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Annotations: tc.annotations, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{Service: &networkingv1.IngressServiceBackend{Name: "svc", Port: networkingv1.ServiceBackendPort{Number: 80}}}, + }}}, + }, + }}, + }, + } + + ir := providerir.ProviderIR{HTTPRoutes: make(map[types.NamespacedName]providerir.HTTPRouteContext)} + key := types.NamespacedName{Namespace: ing.Namespace, Name: common.RouteName(ing.Name, "example.com")} + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: ing.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{Rules: []gatewayv1.HTTPRouteRule{{}}}, + } + ir.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{{ + {Ingress: &ing}, + }}, + } + + eir := providerir.ToEmitterIR(ir) + + p := &Provider{notify: notifications.NoopNotify} + p.applyTimeoutsToEmitterIR(ir, &eir) + + // Timeout feature should populate IR, not mutate the HTTPRoute directly. + if got := ir.HTTPRoutes[key].HTTPRoute.Spec.Rules[0].Timeouts; got != nil { + t.Fatalf("expected no direct HTTPRoute mutation, got timeouts %v", got) + } + + gotTimeouts := eir.HTTPRoutes[key].TCPTimeoutsByRuleIdx[0] + if tc.wantTimeouts == nil { + if gotTimeouts != nil { + t.Fatalf("expected no TCP timeouts, got %v", gotTimeouts) + } + } else if gotTimeouts == nil || !reflect.DeepEqual(*gotTimeouts, *tc.wantTimeouts) { + t.Fatalf("expected TCP timeouts %v, got %v", tc.wantTimeouts, gotTimeouts) + } + }) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/use_regex.go b/pkg/i2gw/providers/ingressnginx/use_regex.go deleted file mode 100644 index a2fd65b73..000000000 --- a/pkg/i2gw/providers/ingressnginx/use_regex.go +++ /dev/null @@ -1,205 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -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 ingressnginx - -import ( - "fmt" - "strconv" - "strings" - - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/notifications" - providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/providers/common" - - networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/validation/field" - "k8s.io/utils/ptr" -) - -const nginxUseRegexAnnotation = "nginx.ingress.kubernetes.io/use-regex" - -// useRegexFeature parses the nginx.ingress.kubernetes.io/use-regex annotation and sets -// HTTPRoute.ProviderSpecificIR.IngressNginx.RegexLocationForHost using host-group semantics. -// -// Semantics: -// - Per ingress, parse boolean. -// - For a given host-group (merged HTTPRoute), RegexForcedByUseRegex is true if ANY ingress -// contributing a rule to that host-group has use-regex=true. -func useRegexFeature( - ingresses []networkingv1.Ingress, - _ map[types.NamespacedName]map[string]int32, - ir *providerir.ProviderIR, -) field.ErrorList { - var errs field.ErrorList - - // Track which ingress keys have use-regex=true - useRegexTrue := map[types.NamespacedName]bool{} - - for i := range ingresses { - ing := &ingresses[i] - anns := ing.Annotations - if anns == nil { - continue - } - - raw, ok := anns[nginxUseRegexAnnotation] - if !ok { - continue - } - - s := strings.TrimSpace(raw) - if s == "" { - continue - } - - b, err := strconv.ParseBool(s) - if err != nil { - errs = append(errs, field.Invalid( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(nginxUseRegexAnnotation), - raw, - "use-regex must be a boolean (true/false)", - )) - continue - } - - if !b { - continue - } - - ingKey := types.NamespacedName{Namespace: ing.Namespace, Name: ing.Name} - useRegexTrue[ingKey] = true - - // Validate: use-regex=true affinity=cookie requires session-cookie-path. - if strings.TrimSpace(anns[nginxAffinityAnnotation]) == "cookie" { - if strings.TrimSpace(anns[nginxSessionCookiePathAnnotation]) == "" { - errs = append(errs, field.Required( - field.NewPath("ingress", ing.Namespace, ing.Name, "metadata", "annotations").Key(nginxSessionCookiePathAnnotation), - "session-cookie-path must be set when use-regex=true and affinity=cookie; session cookie paths do not support regex", - )) - } - } - } - - if len(useRegexTrue) == 0 { - return errs - } - - // Apply host-scoped derived flag per route group. - ruleGroups := common.GetRuleGroups(ingresses) - for _, rg := range ruleGroups { - routeKey := types.NamespacedName{ - Namespace: rg.Namespace, - Name: common.RouteName(rg.Name, rg.Host), - } - - httpCtx, ok := ir.HTTPRoutes[routeKey] - if !ok { - continue - } - - // Determine if any ingress contributing to this host-group has use-regex=true. - anyTrue := false - for _, r := range rg.Rules { - ing := r.Ingress - if useRegexTrue[types.NamespacedName{Namespace: ing.Namespace, Name: ing.Name}] { - anyTrue = true - break - } - } - if !anyTrue { - continue - } - - // Initialize ProviderSpecificIR.IngressNginx (if needed). - if httpCtx.ProviderSpecificIR.IngressNginx == nil { - httpCtx.ProviderSpecificIR.IngressNginx = &providerir.IngressNginxHTTPRouteIR{ - Policies: map[string]providerir.Policy{}, - } - } - if httpCtx.ProviderSpecificIR.IngressNginx.Policies == nil { - httpCtx.ProviderSpecificIR.IngressNginx.Policies = map[string]providerir.Policy{} - } - - // Host-wide: mark RegexForcedByUseRegex = true - httpCtx.ProviderSpecificIR.IngressNginx.RegexForcedByUseRegex = true - - // If RegexLocationForHost already set, keep it OR'd. - if httpCtx.ProviderSpecificIR.IngressNginx.RegexLocationForHost == nil { - httpCtx.ProviderSpecificIR.IngressNginx.RegexLocationForHost = ptr.To(true) - } else { - *httpCtx.ProviderSpecificIR.IngressNginx.RegexLocationForHost = - *httpCtx.ProviderSpecificIR.IngressNginx.RegexLocationForHost || true - } - - // Notification: use-regex + cookie affinity + session-cookie-path => warn/info. - // Emit once per ingress contributing to this host-group. - notified := map[types.NamespacedName]bool{} - for _, r := range rg.Rules { - ing := r.Ingress - ingKey := types.NamespacedName{Namespace: ing.Namespace, Name: ing.Name} - if notified[ingKey] || !useRegexTrue[ingKey] { - continue - } - anns := ing.Annotations - if anns == nil { - continue - } - if strings.TrimSpace(anns[nginxAffinityAnnotation]) != "cookie" { - continue - } - if strings.TrimSpace(anns[nginxSessionCookiePathAnnotation]) == "" { - // Missing path is already reported as an error above. - continue - } - - notify( - notifications.InfoNotification, - fmt.Sprintf("Session cookie paths do not support regex (ingress %s/%s): %s is used for affinity=cookie while %s=true; ensure the session cookie path is a literal path", - ing.Namespace, ing.Name, - nginxSessionCookiePathAnnotation, - nginxUseRegexAnnotation, - ), - &httpCtx.HTTPRoute, - ) - notified[ingKey] = true - } - - // policy-scoped: attach use regex to each ingress policy with coverage. - for ruleIdx, perRule := range httpCtx.RuleBackendSources { - for backendIdx, src := range perRule { - if src.Ingress == nil { - continue - } - - ingKey := types.NamespacedName{Namespace: src.Ingress.Namespace, Name: src.Ingress.Name} - if !useRegexTrue[ingKey] { - continue - } - - p := httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] - p.UseRegexPaths = ptr.To(true) - p = p.AddRuleBackendSources([]providerir.PolicyIndex{{Rule: ruleIdx, Backend: backendIdx}}) - httpCtx.ProviderSpecificIR.IngressNginx.Policies[ingKey.Name] = p - } - } - - ir.HTTPRoutes[routeKey] = httpCtx - } - - return errs -} diff --git a/pkg/i2gw/providers/ingressnginx/utils.go b/pkg/i2gw/providers/ingressnginx/utils.go index 6e451936b..acdc13259 100644 --- a/pkg/i2gw/providers/ingressnginx/utils.go +++ b/pkg/i2gw/providers/ingressnginx/utils.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,6 +17,8 @@ limitations under the License. package ingressnginx import ( + "strconv" + providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" networkingv1 "k8s.io/api/networking/v1" ) @@ -26,10 +28,18 @@ import ( // This is used to prioritize the "main" Ingress for reading common annotations. func getNonCanaryIngress(sources []providerir.BackendSource) *networkingv1.Ingress { for _, source := range sources { - if _, ok := source.Ingress.Annotations[CanaryAnnotation]; !ok { + val := source.Ingress.Annotations[CanaryAnnotation] + parsedVal, _ := strconv.ParseBool(val) + if !parsedVal { return source.Ingress } } + // Fallback: If all sources are somehow canaries (invalid nginx config without a primary), + // we still return an Ingress to read annotations from to avoid panics. + if len(sources) > 0 { + return sources[0].Ingress + } + return nil } diff --git a/pkg/i2gw/providers/ingressnginx/validation.go b/pkg/i2gw/providers/ingressnginx/validation.go deleted file mode 100644 index 02e0474f4..000000000 --- a/pkg/i2gw/providers/ingressnginx/validation.go +++ /dev/null @@ -1,58 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -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 ingressnginx - -import ( - providerir "github.com/kgateway-dev/ingress2gateway/pkg/i2gw/provider_intermediate" - - "k8s.io/apimachinery/pkg/util/validation/field" -) - -// validateRegexCookiePath enforces if regex location modifier is enabled for host -// AND affinity=cookie is used by an ingress, then session-cookie-path must be set -// (cookie paths do not support regex). -// -// **Note:** This validation should be in IR package but keeping separate to avoid -// -// polluting core IR package with downstream logic. -func validateRegexCookiePath(ir *providerir.ProviderIR) field.ErrorList { - var errs field.ErrorList - - for _, httpCtx := range ir.HTTPRoutes { - ing := httpCtx.ProviderSpecificIR.IngressNginx - if ing == nil || ing.RegexLocationForHost == nil || !*ing.RegexLocationForHost { - continue - } - if ing.Policies == nil { - continue - } - for ingressName, pol := range ing.Policies { - if pol.SessionAffinity == nil { - continue - } - if pol.SessionAffinity.CookiePath == "" { - errs = append(errs, field.Invalid( - field.NewPath("ingress", ingressName, "metadata", "annotations").Key("nginx.ingress.kubernetes.io/session-cookie-path"), - "", - "session-cookie-path must be set when cookie affinity is used with regex location matching (use-regex or rewrite-target forces regex)", - )) - } - } - } - - return errs -} diff --git a/test/e2e/emitters/agentgateway/README.md b/test/e2e/emitters/agentgateway/README.md index 1a739747a..bee981cef 100644 --- a/test/e2e/emitters/agentgateway/README.md +++ b/test/e2e/emitters/agentgateway/README.md @@ -114,7 +114,7 @@ KEEP_KIND_CLUSTER=false go test ./test/e2e/emitters/agentgateway -v -run TestIng | Variable | Default | Description | |---|---:|---| | `INGRESS_NGINX_VERSION` | `v1.14.1` | used in URL `controller-${VERSION}` | -| `GATEWAY_API_VERSION` | `v1.4.0` | applies `experimental-install.yaml` | +| `GATEWAY_API_VERSION` | `v1.5.1` | applies `experimental-install.yaml` | | `METALLB_VERSION` | `v0.15.3` | applies `metallb-native.yaml` | | `AGENTGATEWAY_VERSION` | (derived) | overrides Helm chart version. If unset, version is derived from `go.mod` and normalized for agentgateway release naming | diff --git a/test/e2e/emitters/agentgateway/e2e_test.go b/test/e2e/emitters/agentgateway/e2e_test.go index e084e2c6e..300ea7ff4 100644 --- a/test/e2e/emitters/agentgateway/e2e_test.go +++ b/test/e2e/emitters/agentgateway/e2e_test.go @@ -74,7 +74,7 @@ func TestMain(m *testing.M) { // e2eTestSetup handles common setup for e2e tests and returns the context, gateway address, host, and ingress address. // The caller is responsible for cleanup and test-specific validation. -func e2eTestSetup(t *testing.T, inputFile, outputFile string) (context.Context, string, string, string, string) { +func e2eTestSetup(t *testing.T, inputFile, outputFile string) (string, string, string, string) { if !e2eSetupComplete { t.Fatalf("e2e setup did not complete") } @@ -132,8 +132,8 @@ func e2eTestSetup(t *testing.T, inputFile, outputFile string) (context.Context, } // Verify expected output file exists for comparison. - if _, err := os.Stat(outPath); err != nil { - t.Fatalf("expected output file missing: %s (%v)", outPath, err) + if _, statErr := os.Stat(outPath); statErr != nil { + t.Fatalf("expected output file missing: %s (%v)", outPath, statErr) } // Run ingress2gateway to generate output from input, compare with expected output, @@ -149,8 +149,8 @@ func e2eTestSetup(t *testing.T, inputFile, outputFile string) (context.Context, if _, delErr := testutils.Kubectl(ctx, kubeContext, "delete", "-f", generatedOutPath, "--ignore-not-found=true", "--wait=true", "--timeout=2m"); delErr != nil { t.Logf("failed to delete generated output resources: %v", delErr) } - if err := os.Remove(generatedOutPath); err != nil { - t.Logf("failed to remove generated output temp file %q: %v", generatedOutPath, err) + if rmErr := os.Remove(generatedOutPath); rmErr != nil { + t.Logf("failed to remove generated output temp file %q: %v", generatedOutPath, rmErr) } }) @@ -190,11 +190,11 @@ func e2eTestSetup(t *testing.T, inputFile, outputFile string) (context.Context, } } - return ctx, gwAddr, host, hostHeader, ingressIP + return gwAddr, host, hostHeader, ingressIP } func TestBasic(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "basic.yaml", "basic.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "basic.yaml", "basic.yaml") // Test HTTP connectivity via Ingress testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ @@ -231,7 +231,7 @@ func TestBackendProtocol(t *testing.T) { } }) - _, gwAddr, host, _, _ := e2eTestSetup(t, "backend_protocol.yaml", "backend_protocol.yaml") + gwAddr, host, _, _ := e2eTestSetup(t, "backend_protocol.yaml", "backend_protocol.yaml") // Validate end-to-end gRPC traffic via Gateway when backend-protocol is projected. testutils.MakeGRPCRequestEventually(t, testutils.GRPCRequestConfig{ @@ -245,7 +245,7 @@ func TestBackendProtocol(t *testing.T) { } func TestLoadBalance(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "load_balance.yaml", "load_balance.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "load_balance.yaml", "load_balance.yaml") // Test HTTP connectivity via Ingress testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ @@ -274,7 +274,7 @@ func TestLoadBalance(t *testing.T) { } func TestRateLimit(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "rate_limit.yaml", "rate_limit.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "rate_limit.yaml", "rate_limit.yaml") // Test HTTP connectivity via Ingress testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ @@ -300,7 +300,7 @@ func TestRateLimit(t *testing.T) { } func TestTimeouts(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "timeouts.yaml", "timeouts.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "timeouts.yaml", "timeouts.yaml") // Test HTTP connectivity via Ingress testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ @@ -326,7 +326,7 @@ func TestTimeouts(t *testing.T) { } func TestBasicAuth(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "basic_auth.yaml", "basic_auth.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "basic_auth.yaml", "basic_auth.yaml") username := "user" password := "password" @@ -394,7 +394,7 @@ func TestBasicAuth(t *testing.T) { } func TestExternalAuth(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "external_auth.yaml", "external_auth.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "external_auth.yaml", "external_auth.yaml") // Test unauthenticated request → expect 401 via Ingress testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ @@ -462,7 +462,7 @@ func TestExternalAuth(t *testing.T) { } func TestCORS(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "cors.yaml", "cors.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "cors.yaml", "cors.yaml") // Test HTTP connectivity via Ingress testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ @@ -533,7 +533,7 @@ func TestCORS(t *testing.T) { } func TestRewriteTarget(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "rewrite_target.yaml", "rewrite_target.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "rewrite_target.yaml", "rewrite_target.yaml") // Must match "test/e2e/emitters/agentgateway/testdata/output/rewrite_target.yaml". reqPath := "/before/rewrite" diff --git a/test/e2e/emitters/agentgateway/testdata/output/backend_protocol.yaml b/test/e2e/emitters/agentgateway/testdata/output/backend_protocol.yaml index a37275fbc..0bc9840af 100644 --- a/test/e2e/emitters/agentgateway/testdata/output/backend_protocol.yaml +++ b/test/e2e/emitters/agentgateway/testdata/output/backend_protocol.yaml @@ -31,6 +31,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- diff --git a/test/e2e/emitters/agentgateway/testdata/output/basic.yaml b/test/e2e/emitters/agentgateway/testdata/output/basic.yaml index e131cc105..f28b974d4 100644 --- a/test/e2e/emitters/agentgateway/testdata/output/basic.yaml +++ b/test/e2e/emitters/agentgateway/testdata/output/basic.yaml @@ -31,5 +31,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/test/e2e/emitters/agentgateway/testdata/output/basic_auth.yaml b/test/e2e/emitters/agentgateway/testdata/output/basic_auth.yaml index d9a08a9e7..54ed8ce2c 100644 --- a/test/e2e/emitters/agentgateway/testdata/output/basic_auth.yaml +++ b/test/e2e/emitters/agentgateway/testdata/output/basic_auth.yaml @@ -33,6 +33,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- diff --git a/test/e2e/emitters/agentgateway/testdata/output/cors.yaml b/test/e2e/emitters/agentgateway/testdata/output/cors.yaml index 124260822..558a322fc 100644 --- a/test/e2e/emitters/agentgateway/testdata/output/cors.yaml +++ b/test/e2e/emitters/agentgateway/testdata/output/cors.yaml @@ -28,6 +28,33 @@ spec: - name: echo-backend port: 8080 filters: + - cors: + allowCredentials: true + allowHeaders: + - DNT + - Keep-Alive + - User-Agent + - X-Requested-With + - If-Modified-Since + - Cache-Control + - Content-Type + - Range + - Authorization + allowMethods: + - GET + - PUT + - POST + - DELETE + - PATCH + - OPTIONS + allowOrigins: + - https://example.com + - https://another.com + exposeHeaders: + - '*' + - X-CustomResponseHeader + maxAge: 1728000 + type: CORS - responseHeaderModifier: remove: - Access-Control-Allow-Origin @@ -41,6 +68,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -55,11 +83,30 @@ spec: name: ingress-cors-b-cors-localdev-me traffic: cors: + allowCredentials: true + allowHeaders: + - DNT + - Keep-Alive + - User-Agent + - X-Requested-With + - If-Modified-Since + - Cache-Control + - Content-Type + - Range + - Authorization + allowMethods: + - GET + - PUT + - POST + - DELETE + - PATCH + - OPTIONS allowOrigins: - https://example.com - https://another.com exposeHeaders: - '*' - X-CustomResponseHeader + maxAge: 1728000 status: ancestors: null diff --git a/test/e2e/emitters/agentgateway/testdata/output/external_auth.yaml b/test/e2e/emitters/agentgateway/testdata/output/external_auth.yaml index 4c50e8ce0..1e48d5ff4 100644 --- a/test/e2e/emitters/agentgateway/testdata/output/external_auth.yaml +++ b/test/e2e/emitters/agentgateway/testdata/output/external_auth.yaml @@ -33,6 +33,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- diff --git a/test/e2e/emitters/agentgateway/testdata/output/load_balance.yaml b/test/e2e/emitters/agentgateway/testdata/output/load_balance.yaml index 29f3af238..0f04c19e2 100644 --- a/test/e2e/emitters/agentgateway/testdata/output/load_balance.yaml +++ b/test/e2e/emitters/agentgateway/testdata/output/load_balance.yaml @@ -33,5 +33,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/test/e2e/emitters/agentgateway/testdata/output/rate_limit.yaml b/test/e2e/emitters/agentgateway/testdata/output/rate_limit.yaml index bd6b5d8f9..88c99e8c9 100644 --- a/test/e2e/emitters/agentgateway/testdata/output/rate_limit.yaml +++ b/test/e2e/emitters/agentgateway/testdata/output/rate_limit.yaml @@ -37,6 +37,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -60,6 +61,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 - backendRefs: - name: echo-backend port: 8080 @@ -67,6 +69,7 @@ spec: - path: type: PathPrefix value: /rpm + name: rule-1 status: parents: [] --- diff --git a/test/e2e/emitters/agentgateway/testdata/output/rewrite_target.yaml b/test/e2e/emitters/agentgateway/testdata/output/rewrite_target.yaml index b8a32e4bb..800ed6f4a 100644 --- a/test/e2e/emitters/agentgateway/testdata/output/rewrite_target.yaml +++ b/test/e2e/emitters/agentgateway/testdata/output/rewrite_target.yaml @@ -29,28 +29,16 @@ spec: - backendRefs: - name: echo-backend port: 8080 + filters: + - type: URLRewrite + urlRewrite: + path: + replaceFullPath: /after/rewrite + type: ReplaceFullPath matches: - path: type: PathPrefix value: /before/rewrite + name: rule-0 status: parents: [] ---- -apiVersion: agentgateway.dev/v1alpha1 -kind: AgentgatewayPolicy -metadata: - name: rewrite-localhost - namespace: default -spec: - targetRefs: - - group: gateway.networking.k8s.io - kind: HTTPRoute - name: rewrite-localhost-rewrite-localdev-me - traffic: - transformation: - request: - set: - - name: :path - value: '"/after/rewrite"' -status: - ancestors: null diff --git a/test/e2e/emitters/agentgateway/testdata/output/ssl_redirect.yaml b/test/e2e/emitters/agentgateway/testdata/output/ssl_redirect.yaml index a5640034c..a4cd50c91 100644 --- a/test/e2e/emitters/agentgateway/testdata/output/ssl_redirect.yaml +++ b/test/e2e/emitters/agentgateway/testdata/output/ssl_redirect.yaml @@ -18,31 +18,32 @@ spec: protocol: HTTPS tls: certificateRefs: - - name: ssl-redirect-tls + - group: "" + kind: Secret + name: ssl-redirect-tls --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ssl-redirect-localhost-ssl-redirect-localdev-me-http-redirect + name: ssl-redirect-localhost-ssl-redirect-localdev-me namespace: default spec: hostnames: - ssl-redirect.localdev.me parentRefs: - name: nginx - sectionName: ssl-redirect-localdev-me-http + port: 443 rules: - - filters: - - requestRedirect: - scheme: https - statusCode: 301 - type: RequestRedirect + - backendRefs: + - name: echo-backend + port: 8080 matches: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -51,21 +52,23 @@ kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ssl-redirect-localhost-ssl-redirect-localdev-me-https + name: ssl-redirect-localhost-ssl-redirect-localdev-me-http namespace: default spec: hostnames: - ssl-redirect.localdev.me parentRefs: - name: nginx - sectionName: ssl-redirect-localdev-me-https + port: 80 rules: - - backendRefs: - - name: echo-backend - port: 8080 + - filters: + - requestRedirect: + scheme: https + statusCode: 308 + type: RequestRedirect matches: - path: type: PathPrefix value: / status: - parents: [] + parents: null diff --git a/test/e2e/emitters/agentgateway/testdata/output/timeouts.yaml b/test/e2e/emitters/agentgateway/testdata/output/timeouts.yaml index 4833bd70f..0d4350177 100644 --- a/test/e2e/emitters/agentgateway/testdata/output/timeouts.yaml +++ b/test/e2e/emitters/agentgateway/testdata/output/timeouts.yaml @@ -26,11 +26,11 @@ kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-timeout-proxy-send-timeout-proxy-send-example-org + name: ingress-timeout-proxy-read-timeout-proxy-read-example-org namespace: default spec: hostnames: - - timeout-proxy-send.example.org + - timeout-proxy-read.example.org parentRefs: - name: nginx rules: @@ -40,7 +40,8 @@ spec: matches: - path: type: PathPrefix - value: / + value: /read + name: rule-0 status: parents: [] --- @@ -49,11 +50,11 @@ kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-timeout-send-and-read-timeout-send-and-read-example-org + name: ingress-timeout-proxy-send-timeout-proxy-send-example-org namespace: default spec: hostnames: - - timeout-send-and-read.example.org + - timeout-proxy-send.example.org parentRefs: - name: nginx rules: @@ -64,6 +65,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -72,11 +74,11 @@ kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ingress-timeout-proxy-read-timeout-proxy-read-example-org + name: ingress-timeout-send-and-read-timeout-send-and-read-example-org namespace: default spec: hostnames: - - timeout-proxy-read.example.org + - timeout-send-and-read.example.org parentRefs: - name: nginx rules: @@ -86,54 +88,7 @@ spec: matches: - path: type: PathPrefix - value: /read + value: / + name: rule-0 status: parents: [] ---- -apiVersion: agentgateway.dev/v1alpha1 -kind: AgentgatewayPolicy -metadata: - name: ingress-timeout-proxy-read - namespace: default -spec: - targetRefs: - - group: gateway.networking.k8s.io - kind: HTTPRoute - name: ingress-timeout-proxy-read-timeout-proxy-read-example-org - traffic: - timeouts: - request: 45s -status: - ancestors: null ---- -apiVersion: agentgateway.dev/v1alpha1 -kind: AgentgatewayPolicy -metadata: - name: ingress-timeout-proxy-send - namespace: default -spec: - targetRefs: - - group: gateway.networking.k8s.io - kind: HTTPRoute - name: ingress-timeout-proxy-send-timeout-proxy-send-example-org - traffic: - timeouts: - request: 30s -status: - ancestors: null ---- -apiVersion: agentgateway.dev/v1alpha1 -kind: AgentgatewayPolicy -metadata: - name: ingress-timeout-send-and-read - namespace: default -spec: - targetRefs: - - group: gateway.networking.k8s.io - kind: HTTPRoute - name: ingress-timeout-send-and-read-timeout-send-and-read-example-org - traffic: - timeouts: - request: 1m0s -status: - ancestors: null diff --git a/test/e2e/emitters/common/config.go b/test/e2e/emitters/common/config.go index d60fa3b85..3c25ad8fe 100644 --- a/test/e2e/emitters/common/config.go +++ b/test/e2e/emitters/common/config.go @@ -23,7 +23,7 @@ import ( const ( DefaultClusterName = "i2g-e2e" DefaultIngressNginxVersion = "v1.14.1" - DefaultGatewayAPIVersion = "v1.4.0" + DefaultGatewayAPIVersion = "v1.5.1" DefaultMetalLBVersion = "v0.15.3" DefaultEchoImage = "gcr.io/k8s-staging-gateway-api/echo-basic:v20231214-v1.0.0-140-gf544a46e" diff --git a/test/e2e/emitters/common/file.go b/test/e2e/emitters/common/file.go index 7eed9cf0e..27f8b61fb 100644 --- a/test/e2e/emitters/common/file.go +++ b/test/e2e/emitters/common/file.go @@ -34,7 +34,7 @@ func KgatewayVersionFromGoMod(ctx context.Context) (string, error) { if goModPath == "" || goModPath == os.DevNull { return "", fmt.Errorf("GOMOD not set (are you running in module mode?)") } - b, err := os.ReadFile(goModPath) + b, err := os.ReadFile(goModPath) //nolint:gosec if err != nil { return "", err } diff --git a/test/e2e/emitters/kgateway/README.md b/test/e2e/emitters/kgateway/README.md index cedb73847..3d30c55fc 100644 --- a/test/e2e/emitters/kgateway/README.md +++ b/test/e2e/emitters/kgateway/README.md @@ -114,7 +114,7 @@ KEEP_KIND_CLUSTER=false go test ./test/e2e/emitters/kgateway -v -run TestIngress | Variable | Default | Description | |---|---:|---| | `INGRESS_NGINX_VERSION` | `v1.14.1` | used in URL `controller-${VERSION}` | -| `GATEWAY_API_VERSION` | `v1.4.0` | applies `experimental-install.yaml` | +| `GATEWAY_API_VERSION` | `v1.5.1` | applies `experimental-install.yaml` | | `METALLB_VERSION` | `v0.15.3` | applies `metallb-native.yaml` | | `KGATEWAY_VERSION` | (derived) | overrides Helm chart version. If unset, version is derived from `go.mod` and normalized for kgateway release naming | diff --git a/test/e2e/emitters/kgateway/e2e_test.go b/test/e2e/emitters/kgateway/e2e_test.go index 342dbba55..34c9050cd 100644 --- a/test/e2e/emitters/kgateway/e2e_test.go +++ b/test/e2e/emitters/kgateway/e2e_test.go @@ -78,7 +78,7 @@ func TestMain(m *testing.M) { // e2eTestSetup handles common setup for e2e tests and returns the context, gateway address, host, and ingress address. // The caller is responsible for cleanup and test-specific validation. -func e2eTestSetup(t *testing.T, inputFile, outputFile string) (context.Context, string, string, string, string) { +func e2eTestSetup(t *testing.T, inputFile, outputFile string) (string, string, string, string) { if !e2eSetupComplete { t.Fatalf("e2e setup did not complete") } @@ -136,8 +136,8 @@ func e2eTestSetup(t *testing.T, inputFile, outputFile string) (context.Context, } // Verify expected output file exists for comparison. - if _, err := os.Stat(outPath); err != nil { - t.Fatalf("expected output file missing: %s (%v)", outPath, err) + if _, statErr := os.Stat(outPath); statErr != nil { + t.Fatalf("expected output file missing: %s (%v)", outPath, statErr) } // Run ingress2gateway to generate output from input, compare with expected output, @@ -153,8 +153,8 @@ func e2eTestSetup(t *testing.T, inputFile, outputFile string) (context.Context, if _, delErr := testutils.Kubectl(ctx, kubeContext, "delete", "-f", generatedOutPath, "--ignore-not-found=true", "--wait=true", "--timeout=2m"); delErr != nil { t.Logf("failed to delete generated output resources: %v", delErr) } - if err := os.Remove(generatedOutPath); err != nil { - t.Logf("failed to remove generated output temp file %q: %v", generatedOutPath, err) + if rmErr := os.Remove(generatedOutPath); rmErr != nil { + t.Logf("failed to remove generated output temp file %q: %v", generatedOutPath, rmErr) } }) @@ -194,11 +194,11 @@ func e2eTestSetup(t *testing.T, inputFile, outputFile string) (context.Context, } } - return ctx, gwAddr, host, hostHeader, ingressIP + return gwAddr, host, hostHeader, ingressIP } func TestBasic(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "basic.yaml", "basic.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "basic.yaml", "basic.yaml") // Test HTTP connectivity via Ingress testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ @@ -224,7 +224,7 @@ func TestBasic(t *testing.T) { } func TestSSLRedirect(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "ssl_redirect.yaml", "ssl_redirect.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "ssl_redirect.yaml", "ssl_redirect.yaml") // Test HTTP redirect (308) through Ingress testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ @@ -243,13 +243,13 @@ func TestSSLRedirect(t *testing.T) { }, }) - // Test HTTP redirect (301) through Gateway + // Test HTTP redirect (308) through Gateway testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ HostHeader: host, Address: gwAddr, Port: "", Path: "/", - ExpectedStatusCodes: []int{301}, + ExpectedStatusCodes: []int{308}, Timeout: 5 * time.Second, UnfollowRedirect: true, SNI: host, @@ -274,7 +274,7 @@ func TestSSLRedirect(t *testing.T) { } func TestLoadBalance(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "load_balance.yaml", "load_balance.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "load_balance.yaml", "load_balance.yaml") // Test HTTP connectivity via Ingress testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ @@ -303,7 +303,7 @@ func TestLoadBalance(t *testing.T) { } func TestCORS(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "cors.yaml", "cors.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "cors.yaml", "cors.yaml") // Test HTTP connectivity via Ingress testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ @@ -374,7 +374,7 @@ func TestCORS(t *testing.T) { } func TestRewriteTarget(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "rewrite_target.yaml", "rewrite_target.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "rewrite_target.yaml", "rewrite_target.yaml") // Must match "test/e2e/emitters/kgateway/testdata/output/rewrite_target.yaml". reqPath := "/before/rewrite" @@ -388,7 +388,7 @@ func TestRewriteTarget(t *testing.T) { } func TestUseRegex(t *testing.T) { - _, gwAddr, _, _, ingressIP := e2eTestSetup(t, "use_regex.yaml", "use_regex.yaml") + gwAddr, _, _, ingressIP := e2eTestSetup(t, "use_regex.yaml", "use_regex.yaml") // Test HTTP connectivity via Ingress testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ @@ -450,7 +450,7 @@ func TestUseRegex(t *testing.T) { } func TestUseRegexRewriteTarget(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "rewrite_target_use_regex.yaml", "rewrite_target_use_regex.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "rewrite_target_use_regex.yaml", "rewrite_target_use_regex.yaml") // Ingress should rewrite /before/rewrite -> /after/rewrite testutils.RequireEchoedPathEventually(t, ingressHostHeader, "http", ingressIP, "", "/before/rewrite", "/after/rewrite", 1*time.Minute) @@ -460,7 +460,7 @@ func TestUseRegexRewriteTarget(t *testing.T) { } func TestSessionAffinityCookie(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "session_affinity.yaml", "session_affinity.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "session_affinity.yaml", "session_affinity.yaml") // Test HTTP connectivity via Ingress and Gateway testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ @@ -494,7 +494,7 @@ func TestSessionAffinityCookie(t *testing.T) { } func TestSSLPassthrough(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "ssl_passthrough.yaml", "ssl_passthrough.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "ssl_passthrough.yaml", "ssl_passthrough.yaml") // Load TLS certificates from secret for verification cl, err := testutils.GetKubernetesClient(kubeContext) @@ -535,7 +535,7 @@ func TestSSLPassthrough(t *testing.T) { } func TestBasicAuth(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "basic_auth.yaml", "basic_auth.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "basic_auth.yaml", "basic_auth.yaml") username := "user" password := "password" @@ -603,7 +603,7 @@ func TestBasicAuth(t *testing.T) { } func TestExternalAuth(t *testing.T) { - _, gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "external_auth.yaml", "external_auth.yaml") + gwAddr, host, ingressHostHeader, ingressIP := e2eTestSetup(t, "external_auth.yaml", "external_auth.yaml") // Test unauthenticated request → expect 401 via Ingress testutils.MakeHTTPRequestEventually(t, kubeContext, testutils.HTTPRequestConfig{ diff --git a/test/e2e/emitters/kgateway/testdata/output/basic.yaml b/test/e2e/emitters/kgateway/testdata/output/basic.yaml index 177934ba9..931f9c922 100644 --- a/test/e2e/emitters/kgateway/testdata/output/basic.yaml +++ b/test/e2e/emitters/kgateway/testdata/output/basic.yaml @@ -31,5 +31,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/test/e2e/emitters/kgateway/testdata/output/basic_auth.yaml b/test/e2e/emitters/kgateway/testdata/output/basic_auth.yaml index 8dcd7ecee..21f2f8cb8 100644 --- a/test/e2e/emitters/kgateway/testdata/output/basic_auth.yaml +++ b/test/e2e/emitters/kgateway/testdata/output/basic_auth.yaml @@ -33,6 +33,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -52,4 +53,3 @@ spec: name: basic-auth-localhost-basic-auth-localdev-me status: ancestors: null - diff --git a/test/e2e/emitters/kgateway/testdata/output/cors.yaml b/test/e2e/emitters/kgateway/testdata/output/cors.yaml index 96f3d43b2..bf71d4f57 100644 --- a/test/e2e/emitters/kgateway/testdata/output/cors.yaml +++ b/test/e2e/emitters/kgateway/testdata/output/cors.yaml @@ -41,24 +41,45 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- apiVersion: gateway.kgateway.dev/v1alpha1 kind: TrafficPolicy metadata: - name: ingress-cors-b + name: ingress-cors-b-cors-localdev-me-0 spec: cors: + allowCredentials: true + allowHeaders: + - DNT + - Keep-Alive + - User-Agent + - X-Requested-With + - If-Modified-Since + - Cache-Control + - Content-Type + - Range + - Authorization + allowMethods: + - GET + - PUT + - POST + - DELETE + - PATCH + - OPTIONS allowOrigins: - https://example.com - https://another.com exposeHeaders: - '*' - X-CustomResponseHeader + maxAge: 1728000 targetRefs: - group: gateway.networking.k8s.io kind: HTTPRoute name: ingress-cors-b-cors-localdev-me + sectionName: rule-0 status: ancestors: null diff --git a/test/e2e/emitters/kgateway/testdata/output/external_auth.yaml b/test/e2e/emitters/kgateway/testdata/output/external_auth.yaml index 1ac8c2cfd..78138ee58 100644 --- a/test/e2e/emitters/kgateway/testdata/output/external_auth.yaml +++ b/test/e2e/emitters/kgateway/testdata/output/external_auth.yaml @@ -33,6 +33,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- diff --git a/test/e2e/emitters/kgateway/testdata/output/load_balance.yaml b/test/e2e/emitters/kgateway/testdata/output/load_balance.yaml index 84f2daca5..4af35f0e3 100644 --- a/test/e2e/emitters/kgateway/testdata/output/load_balance.yaml +++ b/test/e2e/emitters/kgateway/testdata/output/load_balance.yaml @@ -33,6 +33,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- diff --git a/test/e2e/emitters/kgateway/testdata/output/rewrite_target.yaml b/test/e2e/emitters/kgateway/testdata/output/rewrite_target.yaml index 42844d76a..0cc2e707e 100644 --- a/test/e2e/emitters/kgateway/testdata/output/rewrite_target.yaml +++ b/test/e2e/emitters/kgateway/testdata/output/rewrite_target.yaml @@ -39,5 +39,6 @@ spec: - path: type: PathPrefix value: /before/rewrite + name: rule-0 status: parents: [] diff --git a/test/e2e/emitters/kgateway/testdata/output/rewrite_target_use_regex.yaml b/test/e2e/emitters/kgateway/testdata/output/rewrite_target_use_regex.yaml index ff34a250c..08bab255e 100644 --- a/test/e2e/emitters/kgateway/testdata/output/rewrite_target_use_regex.yaml +++ b/test/e2e/emitters/kgateway/testdata/output/rewrite_target_use_regex.yaml @@ -32,7 +32,8 @@ spec: matches: - path: type: RegularExpression - value: ^/path/one + value: (?i)/path/one.* + name: rule-0 - backendRefs: - name: echo-backend port: 8080 @@ -44,7 +45,8 @@ spec: type: ReplaceFullPath matches: - path: - type: PathPrefix - value: /before/rewrite + type: RegularExpression + value: (?i)/before/rewrite.* + name: rule-1 status: parents: [] diff --git a/test/e2e/emitters/kgateway/testdata/output/session_affinity.yaml b/test/e2e/emitters/kgateway/testdata/output/session_affinity.yaml index 3616db6da..9a873633f 100644 --- a/test/e2e/emitters/kgateway/testdata/output/session_affinity.yaml +++ b/test/e2e/emitters/kgateway/testdata/output/session_affinity.yaml @@ -31,6 +31,7 @@ spec: - path: type: PathPrefix value: /session/affinity + name: rule-0 status: parents: [] --- diff --git a/test/e2e/emitters/kgateway/testdata/output/ssl_passthrough.yaml b/test/e2e/emitters/kgateway/testdata/output/ssl_passthrough.yaml index eb06e2e33..89e9b0801 100644 --- a/test/e2e/emitters/kgateway/testdata/output/ssl_passthrough.yaml +++ b/test/e2e/emitters/kgateway/testdata/output/ssl_passthrough.yaml @@ -34,4 +34,3 @@ spec: port: 443 status: parents: [] - diff --git a/test/e2e/emitters/kgateway/testdata/output/ssl_redirect.yaml b/test/e2e/emitters/kgateway/testdata/output/ssl_redirect.yaml index d6aef5313..79a5a4c8b 100644 --- a/test/e2e/emitters/kgateway/testdata/output/ssl_redirect.yaml +++ b/test/e2e/emitters/kgateway/testdata/output/ssl_redirect.yaml @@ -18,21 +18,23 @@ spec: protocol: HTTPS tls: certificateRefs: - - name: ssl-redirect-tls + - group: "" + kind: Secret + name: ssl-redirect-tls --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ssl-redirect-localhost-ssl-redirect-localdev-me-https + name: ssl-redirect-localhost-ssl-redirect-localdev-me namespace: default spec: hostnames: - ssl-redirect.localdev.me parentRefs: - name: nginx - sectionName: ssl-redirect-localdev-me-https + port: 443 rules: - backendRefs: - name: echo-backend @@ -41,6 +43,7 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] --- @@ -49,23 +52,23 @@ kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-dev - name: ssl-redirect-localhost-ssl-redirect-localdev-me-http-redirect + name: ssl-redirect-localhost-ssl-redirect-localdev-me-http namespace: default spec: hostnames: - ssl-redirect.localdev.me parentRefs: - name: nginx - sectionName: ssl-redirect-localdev-me-http + port: 80 rules: - filters: - requestRedirect: scheme: https - statusCode: 301 + statusCode: 308 type: RequestRedirect matches: - path: type: PathPrefix value: / status: - parents: [] + parents: null diff --git a/test/e2e/emitters/kgateway/testdata/output/use_regex.yaml b/test/e2e/emitters/kgateway/testdata/output/use_regex.yaml index 7a3927f56..f40c59c41 100644 --- a/test/e2e/emitters/kgateway/testdata/output/use_regex.yaml +++ b/test/e2e/emitters/kgateway/testdata/output/use_regex.yaml @@ -36,14 +36,16 @@ spec: matches: - path: type: RegularExpression - value: ^/path/one + value: (?i)/path/one.* + name: rule-0 - backendRefs: - name: echo-backend port: 8080 matches: - path: - type: PathPrefix - value: /path/two + type: RegularExpression + value: (?i)/path/two.* + name: rule-1 status: parents: [] --- @@ -67,5 +69,6 @@ spec: - path: type: PathPrefix value: / + name: rule-0 status: parents: [] diff --git a/test/e2e/utils/exec.go b/test/e2e/utils/exec.go index f96f92016..56b2b34e9 100644 --- a/test/e2e/utils/exec.go +++ b/test/e2e/utils/exec.go @@ -48,6 +48,7 @@ func Run(ctx context.Context, bin string, args ...string) error { // RunIngress2Gateway runs ingress2gateway against the input file and returns the generated YAML output. func RunIngress2Gateway(ctx context.Context, emitter, root, inputFile string) ([]byte, error) { + //nolint:gosec // G204: e2e invokes the local ingress2gateway module with fixed argv shape. cmd := exec.CommandContext( ctx, "go", "run", ".", @@ -72,6 +73,7 @@ func RunIngress2Gateway(ctx context.Context, emitter, root, inputFile string) ([ func Kubectl(ctx context.Context, kubeContext string, args ...string) (string, error) { base := []string{"--context", kubeContext} base = append(base, args...) + //nolint:gosec // G204: kubectl is invoked with caller-controlled args in test tooling. cmd := exec.CommandContext(ctx, "kubectl", base...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -91,7 +93,7 @@ func Kubectl(ctx context.Context, kubeContext string, args ...string) (string, e func MustKubectl(ctx context.Context, kubeContext string, args ...string) { out, err := Kubectl(ctx, kubeContext, args...) if err != nil { - panic(fmt.Errorf("kubectl failed: %v\n%s", err, out)) + panic(fmt.Errorf("kubectl failed: %w\n%s", err, out)) } } diff --git a/test/e2e/utils/install.go b/test/e2e/utils/install.go index 1996b9ddc..54c972ba5 100644 --- a/test/e2e/utils/install.go +++ b/test/e2e/utils/install.go @@ -419,6 +419,7 @@ func CreateTLSSecret(ctx context.Context, kubeContext, secretName, hostname stri ) // Create Kubernetes secret from the certificate files + //nolint:gosec // G204: e2e builds a fixed kubectl argv from temp cert paths. cmd := exec.CommandContext(ctx, "kubectl", "--context", kubeContext, "create", "secret", "tls", secretName, @@ -447,7 +448,7 @@ func CreateBasicAuthCombinedSecret(ctx context.Context, kubeContext, secretName } // Same htpasswd line for both keys. (This is base64 for: user:{SHA}...) - htpasswdB64 := "dXNlcjp7U0hBfVc2cGg1TW01UHo4R2dpVUxiUGd6RzM3bWo5Zz0=" + htpasswdB64 := "dXNlcjp7U0hBfVc2cGg1TW01UHo4R2dpVUxiUGd6RzM3bWo5Zz0=" //nolint:gosec secretYAML := ` apiVersion: v1 diff --git a/test/e2e/utils/k8s_objects.go b/test/e2e/utils/k8s_objects.go index c694d8b44..ecf015f67 100644 --- a/test/e2e/utils/k8s_objects.go +++ b/test/e2e/utils/k8s_objects.go @@ -28,16 +28,20 @@ import ( k8syaml "k8s.io/apimachinery/pkg/util/yaml" ) -func DecodeObjects(path string) ([]unstructured.Unstructured, error) { +func DecodeObjects(path string) (objs []unstructured.Unstructured, err error) { + //nolint:gosec // G304: path comes from test fixtures / temp files under the module. f, err := os.Open(path) if err != nil { return nil, err } - defer f.Close() + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = cerr + } + }() dec := k8syaml.NewYAMLOrJSONDecoder(f, 4096) - var objs []unstructured.Unstructured for { var raw map[string]any if err := dec.Decode(&raw); err != nil { @@ -126,15 +130,15 @@ func GetIngressNginxControllerAddress(ctx context.Context, kubeContext string) ( return "", fmt.Errorf("no ip/hostname in ingress-nginx-controller service status") } -func hasTopLevelCondition(u unstructured.Unstructured, typ, status string) bool { +func hasTopLevelCondition(u unstructured.Unstructured, typ string) bool { conds, found, _ := unstructured.NestedSlice(u.Object, "status", "conditions") if !found { return false } - return anyConditionEquals(conds, typ, status) + return anyConditionEquals(conds, typ, "True") } -func hasRouteParentCondition(u unstructured.Unstructured, typ, status string) bool { +func hasRouteParentCondition(u unstructured.Unstructured, typ string) bool { parents, found, _ := unstructured.NestedSlice(u.Object, "status", "parents") if !found { return false @@ -148,7 +152,7 @@ func hasRouteParentCondition(u unstructured.Unstructured, typ, status string) bo if !found { continue } - if anyConditionEquals(conds, typ, status) { + if anyConditionEquals(conds, typ, "True") { return true } } diff --git a/test/e2e/utils/utils.go b/test/e2e/utils/utils.go index 242447c83..e3c497de4 100644 --- a/test/e2e/utils/utils.go +++ b/test/e2e/utils/utils.go @@ -136,7 +136,7 @@ func CompareAndGenerateOutput(ctx context.Context, t *testing.T, emitter, root, } // Read expected output - expectedYAML, err := os.ReadFile(expectedOutputFile) + expectedYAML, err := os.ReadFile(expectedOutputFile) //nolint:gosec if err != nil { return "", fmt.Errorf("failed to read expected output file %q: %w", expectedOutputFile, err) } diff --git a/test/e2e/utils/wait.go b/test/e2e/utils/wait.go index f36c44e1d..c4112659c 100644 --- a/test/e2e/utils/wait.go +++ b/test/e2e/utils/wait.go @@ -48,13 +48,13 @@ func WaitForOutputReadiness(t *testing.T, ctx context.Context, kubeContext strin name := gc.GetName() for time.Now().Before(deadline) { u, err := getUnstructured(ctx, kubeContext, "gatewayclass", "", name) - if err == nil && hasTopLevelCondition(u, "Accepted", "True") { + if err == nil && hasTopLevelCondition(u, "Accepted") { break } time.Sleep(2 * time.Second) } u, err := getUnstructured(ctx, kubeContext, "gatewayclass", "", name) - if err != nil || !hasTopLevelCondition(u, "Accepted", "True") { + if err != nil || !hasTopLevelCondition(u, "Accepted") { t.Fatalf("GatewayClass/%s not Accepted=True (err=%v)", name, err) } } @@ -68,7 +68,7 @@ func WaitForOutputReadiness(t *testing.T, ctx context.Context, kubeContext strin for time.Now().Before(deadline) { u, err := getUnstructured(ctx, kubeContext, "gateway", ns, name) - if err == nil && hasTopLevelCondition(u, "Accepted", "True") && hasTopLevelCondition(u, "Programmed", "True") { + if err == nil && hasTopLevelCondition(u, "Accepted") && hasTopLevelCondition(u, "Programmed") { break } time.Sleep(2 * time.Second) @@ -77,7 +77,7 @@ func WaitForOutputReadiness(t *testing.T, ctx context.Context, kubeContext strin if err != nil { t.Fatalf("Gateway/%s get: %v", name, err) } - if !hasTopLevelCondition(u, "Accepted", "True") || !hasTopLevelCondition(u, "Programmed", "True") { + if !hasTopLevelCondition(u, "Accepted") || !hasTopLevelCondition(u, "Programmed") { t.Fatalf("Gateway/%s not ready: need Accepted=True and Programmed=True", name) } } @@ -91,7 +91,7 @@ func WaitForOutputReadiness(t *testing.T, ctx context.Context, kubeContext strin for time.Now().Before(deadline) { u, err := getUnstructured(ctx, kubeContext, "httproute", ns, name) - if err == nil && hasRouteParentCondition(u, "Accepted", "True") && hasRouteParentCondition(u, "ResolvedRefs", "True") { + if err == nil && hasRouteParentCondition(u, "Accepted") && hasRouteParentCondition(u, "ResolvedRefs") { break } time.Sleep(2 * time.Second) @@ -100,7 +100,7 @@ func WaitForOutputReadiness(t *testing.T, ctx context.Context, kubeContext strin if err != nil { t.Fatalf("HTTPRoute/%s get: %v", name, err) } - if !hasRouteParentCondition(u, "Accepted", "True") || !hasRouteParentCondition(u, "ResolvedRefs", "True") { + if !hasRouteParentCondition(u, "Accepted") || !hasRouteParentCondition(u, "ResolvedRefs") { t.Fatalf("HTTPRoute/%s not ready: need parents[].conditions Accepted=True and ResolvedRefs=True", name) } } @@ -114,7 +114,7 @@ func WaitForOutputReadiness(t *testing.T, ctx context.Context, kubeContext strin for time.Now().Before(deadline) { u, err := getUnstructured(ctx, kubeContext, "tlsroute", ns, name) - if err == nil && hasRouteParentCondition(u, "Accepted", "True") && hasRouteParentCondition(u, "ResolvedRefs", "True") { + if err == nil && hasRouteParentCondition(u, "Accepted") && hasRouteParentCondition(u, "ResolvedRefs") { break } time.Sleep(2 * time.Second) @@ -123,7 +123,7 @@ func WaitForOutputReadiness(t *testing.T, ctx context.Context, kubeContext strin if err != nil { t.Fatalf("TLSRoute/%s get: %v", name, err) } - if !hasRouteParentCondition(u, "Accepted", "True") || !hasRouteParentCondition(u, "ResolvedRefs", "True") { + if !hasRouteParentCondition(u, "Accepted") || !hasRouteParentCondition(u, "ResolvedRefs") { t.Fatalf("TLSRoute/%s not ready: need parents[].conditions Accepted=True and ResolvedRefs=True", name) } } @@ -209,7 +209,7 @@ func RequireStickySessionEventually( ok := true for i := 0; i < numRequests; i++ { - pod, code, _, err := podAndCodeFromClientWithCookie(t, hostHeader, scheme, address, port, path, cookieName, cookieValue) + pod, code, err := podAndCodeFromClientWithCookie(t, hostHeader, scheme, address, port, path, cookieName, cookieValue) if err != nil || strings.TrimSpace(code) != "200" || pod == "" { ok = false break @@ -273,7 +273,7 @@ func stablePodForCookie( ) (string, bool) { var basePod string for i := 0; i < numRequests; i++ { - pod, code, _, err := podAndCodeFromClientWithCookie(t, hostHeader, scheme, address, port, path, cookieName, cookieValue) + pod, code, err := podAndCodeFromClientWithCookie(t, hostHeader, scheme, address, port, path, cookieName, cookieValue) if err != nil || strings.TrimSpace(code) != "200" || pod == "" { return "", false } @@ -292,7 +292,7 @@ func podAndCodeFromClientWithCookie( t *testing.T, hostHeader, scheme, address, port, path string, cookieName, cookieValue string, -) (pod, code, out string, err error) { +) (pod, code string, err error) { t.Helper() if port == "" { @@ -314,7 +314,7 @@ func podAndCodeFromClientWithCookie( Method: "GET", Path: path, }, - Response: gwhttp.Response{StatusCode: 200}, + Response: gwhttp.Response{StatusCodes: []int{200}}, } req := gwhttp.MakeRequest(t, &expected, gwAddr, strings.ToUpper(scheme), scheme) @@ -333,15 +333,14 @@ func podAndCodeFromClientWithCookie( rt := getRoundTripper() cReq, cRes, err := rt.CaptureRoundTrip(req) if err != nil { - return "", "000", fmt.Sprintf("request failed: %v", err), err + return "", "000", err } if cReq != nil { pod = cReq.Pod } code = fmt.Sprintf("%d", cRes.StatusCode) - out = fmt.Sprintf("Status: %d, Protocol: %s, Pod: %s", cRes.StatusCode, cRes.Protocol, pod) - return pod, code, out, nil + return pod, code, nil } // GetKubernetesClient creates a Kubernetes client using the kubeconfig context. @@ -421,10 +420,10 @@ func MakeHTTPRequestEventually(t *testing.T, kubeContext string, cfg HTTPRequest if len(cfg.ExpectedStatusCodes) > 0 { expected.Response.StatusCodes = cfg.ExpectedStatusCodes } else if cfg.ExpectedStatusCode != 0 { - expected.Response.StatusCode = cfg.ExpectedStatusCode + expected.Response.StatusCodes = []int{cfg.ExpectedStatusCode} } else { // Default to 200 if not specified - expected.Response.StatusCode = 200 + expected.Response.StatusCodes = []int{200} } rt := getRoundTripper() @@ -551,7 +550,7 @@ func podAndCodeFromClient(t *testing.T, hostHeader, scheme, address, port, path Method: "GET", Path: path, }, - Response: gwhttp.Response{StatusCode: 200}, + Response: gwhttp.Response{StatusCodes: []int{200}}, } req := gwhttp.MakeRequest(t, &expected, gwAddr, strings.ToUpper(scheme), scheme) @@ -602,7 +601,7 @@ func pathAndCodeFromClient(t *testing.T, hostHeader, scheme, address, port, path Method: "GET", Path: path, }, - Response: gwhttp.Response{StatusCode: 200}, + Response: gwhttp.Response{StatusCodes: []int{200}}, } req := gwhttp.MakeRequest(t, &expected, gwAddr, strings.ToUpper(scheme), scheme) @@ -702,11 +701,7 @@ func RequireResponseHeaderEventually( RedirectRequest: cfg.RedirectRequest, } - if len(expectedCodes) > 1 { - expected.Response.StatusCodes = expectedCodes - } else { - expected.Response.StatusCode = expectedCodes[0] - } + expected.Response.StatusCodes = expectedCodes for k, v := range cfg.Headers { expected.Request.Headers[k] = v