diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 140f102d65..5ae5365b2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -311,3 +311,32 @@ jobs: - name: Push to GitHub Container Registry run: | helm push ${{ steps.package.outputs.path }} oci://ghcr.io/nginx/charts + + cel-tests: + name: CEL Tests + runs-on: ubuntu-24.04 + needs: vars + steps: + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Golang Environment + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: stable + cache-dependency-path: | + go.sum + .github/.cache/buster-for-unit-tests + + - name: Deploy Kubernetes + id: k8s + run: | + kind create cluster --name ${{ github.run_id }} --image=kindest/node:${{ needs.vars.outputs.k8s_latest }} + + - name: Apply CustomResourceDefinition + run: | + kubectl kustomize config/crd | kubectl apply --server-side -f - + + - name: Run Tests + run: make test-cel-validation + working-directory: ./tests diff --git a/tests/Makefile b/tests/Makefile index 1e986c4984..6771828ceb 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -17,6 +17,7 @@ STANDARD_CONFORMANCE_PROFILES = GATEWAY-HTTP,GATEWAY-GRPC EXPERIMENTAL_CONFORMANCE_PROFILES = GATEWAY-TLS CONFORMANCE_PROFILES = $(STANDARD_CONFORMANCE_PROFILES) # by default we use the standard conformance profiles. If experimental is enabled we override this and add the experimental profiles. SKIP_TESTS = +CEL_TEST_TARGET = # Check if ENABLE_EXPERIMENTAL is true ifeq ($(ENABLE_EXPERIMENTAL),true) @@ -186,3 +187,14 @@ uninstall-ngf: ## Uninstall NGF on configured kind cluster -make uninstall-gateway-crds -kubectl delete namespace nginx-gateway -kubectl kustomize ../config/crd | kubectl delete -f - + +# Run CEL validation integration tests against a real cluster +.PHONY: test-cel-validation +test-cel-validation: + @if [ -z "$(CEL_TEST_TARGET)" ]; then \ + echo "Running all tests in ./cel"; \ + go test -v ./cel; \ + else \ + echo "Running test: $(CEL_TEST_TARGET) in ./cel"; \ + go test -v ./cel -run "$(CEL_TEST_TARGET)"; \ + fi diff --git a/tests/cel/clientsettingspolicy_test.go b/tests/cel/clientsettingspolicy_test.go new file mode 100644 index 0000000000..00833b39aa --- /dev/null +++ b/tests/cel/clientsettingspolicy_test.go @@ -0,0 +1,169 @@ +package cel + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" + controllerruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + ngfAPIv1alpha1 "github.com/nginx/nginx-gateway-fabric/v2/apis/v1alpha1" + "github.com/nginx/nginx-gateway-fabric/v2/tests/framework" +) + +func TestClientSettingsPoliciesTargetRefKind(t *testing.T) { + t.Parallel() + g := NewWithT(t) + k8sClient, err := getKubernetesClient(t) + g.Expect(err).ToNot(HaveOccurred()) + tests := []struct { + policySpec ngfAPIv1alpha1.ClientSettingsPolicySpec + name string + wantErrors []string + }{ + { + name: "Validate TargetRef of kind Gateway is allowed", + policySpec: ngfAPIv1alpha1.ClientSettingsPolicySpec{ + TargetRef: gatewayv1alpha2.LocalPolicyTargetReference{ + Kind: gatewayKind, + Group: gatewayGroup, + }, + }, + }, + { + name: "Validate TargetRef of kind HTTPRoute is allowed", + policySpec: ngfAPIv1alpha1.ClientSettingsPolicySpec{ + TargetRef: gatewayv1alpha2.LocalPolicyTargetReference{ + Kind: httpRouteKind, + Group: gatewayGroup, + }, + }, + }, + { + name: "Validate TargetRef of kind GRPCRoute is allowed", + policySpec: ngfAPIv1alpha1.ClientSettingsPolicySpec{ + TargetRef: gatewayv1alpha2.LocalPolicyTargetReference{ + Kind: grpcRouteKind, + Group: gatewayGroup, + }, + }, + }, + { + name: "Validate Invalid TargetRef Kind is not allowed", + wantErrors: []string{expectedTargetRefKindError}, + policySpec: ngfAPIv1alpha1.ClientSettingsPolicySpec{ + TargetRef: gatewayv1alpha2.LocalPolicyTargetReference{ + Kind: invalidKind, + Group: gatewayGroup, + }, + }, + }, + { + name: "Validate TCPRoute TargetRef Kind is not allowed", + wantErrors: []string{expectedTargetRefKindError}, + policySpec: ngfAPIv1alpha1.ClientSettingsPolicySpec{ + TargetRef: gatewayv1alpha2.LocalPolicyTargetReference{ + Kind: tcpRouteKind, + Group: gatewayGroup, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + validateClientSettingsPolicy(t, tt, g, k8sClient) + }) + } +} + +func TestClientSettingsPoliciesTargetRefGroup(t *testing.T) { + t.Parallel() + g := NewWithT(t) + k8sClient, err := getKubernetesClient(t) + g.Expect(err).ToNot(HaveOccurred()) + tests := []struct { + policySpec ngfAPIv1alpha1.ClientSettingsPolicySpec + name string + wantErrors []string + }{ + { + name: "Validate gateway.networking.k8s.io TargetRef Group is allowed", + policySpec: ngfAPIv1alpha1.ClientSettingsPolicySpec{ + TargetRef: gatewayv1alpha2.LocalPolicyTargetReference{ + Kind: gatewayKind, + Group: gatewayGroup, + }, + }, + }, + { + name: "Validate invalid.networking.k8s.io TargetRef Group is not allowed", + wantErrors: []string{expectedTargetRefGroupError}, + policySpec: ngfAPIv1alpha1.ClientSettingsPolicySpec{ + TargetRef: gatewayv1alpha2.LocalPolicyTargetReference{ + Kind: gatewayKind, + Group: invalidGroup, + }, + }, + }, + { + name: "Validate discovery.k8s.io/v1 TargetRef Group is not allowed", + wantErrors: []string{expectedTargetRefGroupError}, + policySpec: ngfAPIv1alpha1.ClientSettingsPolicySpec{ + TargetRef: gatewayv1alpha2.LocalPolicyTargetReference{ + Kind: gatewayKind, + Group: discoveryGroup, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + validateClientSettingsPolicy(t, tt, g, k8sClient) + }) + } +} + +func validateClientSettingsPolicy(t *testing.T, tt struct { + policySpec ngfAPIv1alpha1.ClientSettingsPolicySpec + name string + wantErrors []string +}, g *WithT, k8sClient client.Client, +) { + t.Helper() + + policySpec := tt.policySpec + policySpec.TargetRef.Name = gatewayv1alpha2.ObjectName(uniqueResourceName(testTargetRefName)) + policyName := uniqueResourceName(testPolicyName) + + clientSettingsPolicy := &ngfAPIv1alpha1.ClientSettingsPolicy{ + ObjectMeta: controllerruntime.ObjectMeta{ + Name: policyName, + Namespace: defaultNamespace, + }, + Spec: policySpec, + } + timeoutConfig := framework.DefaultTimeoutConfig() + ctx, cancel := context.WithTimeout(context.Background(), timeoutConfig.KubernetesClientTimeout) + err := k8sClient.Create(ctx, clientSettingsPolicy) + defer cancel() + + // Clean up after test + defer func() { + _ = k8sClient.Delete(context.Background(), clientSettingsPolicy) + }() + + if len(tt.wantErrors) == 0 { + g.Expect(err).ToNot(HaveOccurred()) + } else { + g.Expect(err).To(HaveOccurred()) + for _, wantError := range tt.wantErrors { + g.Expect(err.Error()).To(ContainSubstring(wantError), "Expected error '%s' not found in: %s", wantError, err.Error()) + } + } +} diff --git a/tests/cel/common.go b/tests/cel/common.go new file mode 100644 index 0000000000..e7b969c3a6 --- /dev/null +++ b/tests/cel/common.go @@ -0,0 +1,78 @@ +package cel + +import ( + "crypto/rand" + "fmt" + "testing" + + "k8s.io/apimachinery/pkg/runtime" + controllerruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + ngfAPIv1alpha1 "github.com/nginx/nginx-gateway-fabric/v2/apis/v1alpha1" + ngfAPIv1alpha2 "github.com/nginx/nginx-gateway-fabric/v2/apis/v1alpha2" +) + +const ( + gatewayKind = "Gateway" + httpRouteKind = "HTTPRoute" + grpcRouteKind = "GRPCRoute" + tcpRouteKind = "TCPRoute" + invalidKind = "InvalidKind" +) + +const ( + gatewayGroup = "gateway.networking.k8s.io" + invalidGroup = "invalid.networking.k8s.io" + discoveryGroup = "discovery.k8s.io/v1" +) + +const ( + expectedTargetRefKindError = `TargetRef Kind must be one of: Gateway, HTTPRoute, or GRPCRoute` + expectedTargetRefGroupError = `TargetRef Group must be gateway.networking.k8s.io.` +) + +const ( + defaultNamespace = "default" +) + +const ( + testPolicyName = "test-policy" + testTargetRefName = "test-targetRef" +) + +// getKubernetesClient returns a client connected to a real Kubernetes cluster. +func getKubernetesClient(t *testing.T) (k8sClient client.Client, err error) { + t.Helper() + // Use controller-runtime to get cluster connection + k8sConfig, err := controllerruntime.GetConfig() + if err != nil { + return nil, err + } + + // Set up scheme with NGF types + scheme := runtime.NewScheme() + if err = ngfAPIv1alpha1.AddToScheme(scheme); err != nil { + return nil, err + } + if err = ngfAPIv1alpha2.AddToScheme(scheme); err != nil { + return nil, err + } + // Create a new client with the scheme and return it + return client.New(k8sConfig, client.Options{Scheme: scheme}) +} + +// randomPrimeNumber generates a random prime number of 64 bits. +// It panics if it fails to generate a random prime number. +func randomPrimeNumber() int64 { + primeNum, err := rand.Prime(rand.Reader, 64) + if err != nil { + panic(fmt.Errorf("failed to generate random prime number: %w", err)) + } + return primeNum.Int64() +} + +// uniqueResourceName generates a unique resource name by appending a random prime number to the given name. +func uniqueResourceName(name string) string { + return fmt.Sprintf("%s-%d", name, randomPrimeNumber()) +} diff --git a/tests/cel/common_test.go b/tests/cel/common_test.go new file mode 100644 index 0000000000..5f025d314e --- /dev/null +++ b/tests/cel/common_test.go @@ -0,0 +1,26 @@ +package cel + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestMustGenerateRandomPrimeNumber(t *testing.T) { + t.Parallel() + g := NewWithT(t) + g.Expect(func() { + _ = randomPrimeNumber() + }).ToNot(Panic()) +} + +func TestMustReturnUniqueResourceName(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + name := "test-resource" + uniqueName := uniqueResourceName(name) + + g.Expect(uniqueName).To(HavePrefix(name)) + g.Expect(len(uniqueName)).To(BeNumerically(">", len(name))) +} diff --git a/tests/framework/timeout.go b/tests/framework/timeout.go index 8d8557622f..87abe87189 100644 --- a/tests/framework/timeout.go +++ b/tests/framework/timeout.go @@ -35,6 +35,9 @@ type TimeoutConfig struct { // TestForTrafficTimeout represents the maximum time for NGF to test for passing or failing traffic. TestForTrafficTimeout time.Duration + + // KubernetesClientTimeout represents the maximum time for Kubernetes client operations. + KubernetesClientTimeout time.Duration } // DefaultTimeoutConfig populates a TimeoutConfig with the default values. @@ -51,5 +54,6 @@ func DefaultTimeoutConfig() TimeoutConfig { GetLeaderLeaseTimeout: 60 * time.Second, GetStatusTimeout: 60 * time.Second, TestForTrafficTimeout: 60 * time.Second, + KubernetesClientTimeout: 10 * time.Second, } }