diff --git a/api/v1/installation_types.go b/api/v1/installation_types.go index 69139054e9..96aedf0180 100644 --- a/api/v1/installation_types.go +++ b/api/v1/installation_types.go @@ -693,6 +693,8 @@ type CalicoNetworkSpec struct { // NodeAddressAutodetection provides configuration options for auto-detecting node addresses. At most one option // can be used. If no detection option is specified, then IP auto detection will be disabled for this address family and IPs // must be specified directly on the Node resource. +// +// +kubebuilder:validation:XValidation:rule="[has(self.firstFound) && self.firstFound == true, has(self.kubernetes), has(self.interface) && size(self.interface) > 0, has(self.skipInterface) && size(self.skipInterface) > 0, has(self.canReach) && size(self.canReach) > 0, has(self.cidrs) && size(self.cidrs) > 0].filter(x, x).size() <= 1",message="no more than one autodetection method can be specified" type NodeAddressAutodetection struct { // FirstFound uses default interface matching parameters to select an interface, performing best-effort // filtering based on well-known interface names. diff --git a/go.mod b/go.mod index 58d97553ec..c175efa04c 100644 --- a/go.mod +++ b/go.mod @@ -57,8 +57,11 @@ require ( sigs.k8s.io/yaml v1.6.0 ) +require github.com/google/cel-go v0.26.0 + require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect + cel.dev/expr v0.24.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/BurntSushi/toml v1.5.0 // indirect @@ -67,6 +70,7 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect @@ -168,6 +172,7 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/vbatts/tar-split v0.12.1 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect @@ -179,6 +184,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.19.0 // indirect @@ -189,6 +195,7 @@ require ( golang.org/x/tools v0.41.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/go.sum b/go.sum index e906167e4e..f36f9fed62 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= 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= @@ -26,6 +28,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= @@ -207,6 +211,8 @@ 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/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -411,7 +417,11 @@ github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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= @@ -420,6 +430,9 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= diff --git a/pkg/controller/installation/cel_validation_test.go b/pkg/controller/installation/cel_validation_test.go new file mode 100644 index 0000000000..a1b8c98c12 --- /dev/null +++ b/pkg/controller/installation/cel_validation_test.go @@ -0,0 +1,145 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package installation + +import ( + "context" + "encoding/json" + "path/filepath" + "runtime" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + operator "github.com/tigera/operator/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +var _ = Describe("Installation CRD CEL validation", Serial, func() { + var ( + testEnv *envtest.Environment + c client.Client + ctx context.Context + ) + + BeforeEach(func() { + ctx = context.Background() + + _, thisFile, _, ok := runtime.Caller(0) + Expect(ok).To(BeTrue()) + crdDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "config", "crd", "bases") + + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{crdDir}, + ErrorIfCRDPathMissing: true, + } + cfg, err := testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = testEnv.Stop() }) + + Expect(operator.AddToScheme(scheme.Scheme)).To(Succeed()) + c, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + }) + + newInstallation := func(v4 *operator.NodeAddressAutodetection) *operator.Installation { + return &operator.Installation{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Spec: operator.InstallationSpec{ + CalicoNetwork: &operator.CalicoNetworkSpec{ + NodeAddressAutodetectionV4: v4, + }, + }, + } + } + + Describe("NodeAddressAutodetection", func() { + AfterEach(func() { + inst := &operator.Installation{ObjectMeta: metav1.ObjectMeta{Name: "default"}} + err := c.Delete(ctx, inst) + if err != nil { + GinkgoLogr.Error(err, "Failed to delete Installation in AfterEach") + } + }) + + DescribeTable("should allow single or no methods", + func(v4 *operator.NodeAddressAutodetection) { + Expect(c.Create(ctx, newInstallation(v4))).To(Succeed()) + }, + Entry("empty", &operator.NodeAddressAutodetection{}), + Entry("nil", nil), + Entry("firstFound", &operator.NodeAddressAutodetection{FirstFound: ptr.To(true)}), + Entry("interface", &operator.NodeAddressAutodetection{Interface: "eth0"}), + Entry("skipInterface", &operator.NodeAddressAutodetection{SkipInterface: "docker.*"}), + Entry("canReach", &operator.NodeAddressAutodetection{CanReach: "8.8.8.8"}), + Entry("cidrs", &operator.NodeAddressAutodetection{CIDRS: []string{"10.0.0.0/8"}}), + Entry("kubernetes", &operator.NodeAddressAutodetection{Kubernetes: ptr.To(operator.NodeInternalIP)}), + ) + + DescribeTable("should reject multiple methods", + func(v4 *operator.NodeAddressAutodetection) { + err := c.Create(ctx, newInstallation(v4)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no more than one autodetection method")) + }, + Entry("firstFound + interface", &operator.NodeAddressAutodetection{ + FirstFound: ptr.To(true), + Interface: "eth0", + }), + Entry("interface + canReach", &operator.NodeAddressAutodetection{ + Interface: "eth0", + CanReach: "8.8.8.8", + }), + Entry("kubernetes + cidrs", &operator.NodeAddressAutodetection{ + Kubernetes: ptr.To(operator.NodeInternalIP), + CIDRS: []string{"10.0.0.0/8"}, + }), + Entry("three methods", &operator.NodeAddressAutodetection{ + FirstFound: ptr.To(true), + Interface: "eth0", + CanReach: "8.8.8.8", + }), + Entry("skipInterface + canReach", &operator.NodeAddressAutodetection{ + SkipInterface: "docker.*", + CanReach: "8.8.8.8", + }), + ) + + It("should reject a merge patch that adds a second method", func() { + inst := newInstallation(&operator.NodeAddressAutodetection{FirstFound: ptr.To(true)}) + Expect(c.Create(ctx, inst)).To(Succeed()) + + // Merge patch adds interface alongside firstFound — the exact + // scenario that motivated this CEL rule. + patch, err := json.Marshal(map[string]any{ + "spec": map[string]any{ + "calicoNetwork": map[string]any{ + "nodeAddressAutodetectionV4": map[string]any{ + "interface": "eth0", + }, + }, + }, + }) + Expect(err).NotTo(HaveOccurred()) + err = c.Patch(ctx, inst, client.RawPatch(types.MergePatchType, patch)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no more than one autodetection method")) + }) + }) +}) diff --git a/pkg/imports/crds/operator/operator.tigera.io_installations.yaml b/pkg/imports/crds/operator/operator.tigera.io_installations.yaml index 0ab5ce8142..5aaafba5eb 100644 --- a/pkg/imports/crds/operator/operator.tigera.io_installations.yaml +++ b/pkg/imports/crds/operator/operator.tigera.io_installations.yaml @@ -1475,6 +1475,14 @@ spec: the given regex. type: string type: object + x-kubernetes-validations: + - message: no more than one autodetection method can be specified + rule: + "[has(self.firstFound) && self.firstFound == true, has(self.kubernetes), + has(self.interface) && size(self.interface) > 0, has(self.skipInterface) + && size(self.skipInterface) > 0, has(self.canReach) && size(self.canReach) + > 0, has(self.cidrs) && size(self.cidrs) > 0].filter(x, x).size() + <= 1" nodeAddressAutodetectionV6: description: |- NodeAddressAutodetectionV6 specifies an approach to automatically detect node IPv6 addresses. If not specified, @@ -1515,6 +1523,14 @@ spec: the given regex. type: string type: object + x-kubernetes-validations: + - message: no more than one autodetection method can be specified + rule: + "[has(self.firstFound) && self.firstFound == true, has(self.kubernetes), + has(self.interface) && size(self.interface) > 0, has(self.skipInterface) + && size(self.skipInterface) > 0, has(self.canReach) && size(self.canReach) + > 0, has(self.cidrs) && size(self.cidrs) > 0].filter(x, x).size() + <= 1" sysctl: description: Sysctl configures sysctl parameters for tuning plugin items: @@ -10298,6 +10314,14 @@ spec: the given regex. type: string type: object + x-kubernetes-validations: + - message: no more than one autodetection method can be specified + rule: + "[has(self.firstFound) && self.firstFound == true, + has(self.kubernetes), has(self.interface) && size(self.interface) + > 0, has(self.skipInterface) && size(self.skipInterface) + > 0, has(self.canReach) && size(self.canReach) > 0, has(self.cidrs) + && size(self.cidrs) > 0].filter(x, x).size() <= 1" nodeAddressAutodetectionV6: description: |- NodeAddressAutodetectionV6 specifies an approach to automatically detect node IPv6 addresses. If not specified, @@ -10338,6 +10362,14 @@ spec: the given regex. type: string type: object + x-kubernetes-validations: + - message: no more than one autodetection method can be specified + rule: + "[has(self.firstFound) && self.firstFound == true, + has(self.kubernetes), has(self.interface) && size(self.interface) + > 0, has(self.skipInterface) && size(self.skipInterface) + > 0, has(self.canReach) && size(self.canReach) > 0, has(self.cidrs) + && size(self.cidrs) > 0].filter(x, x).size() <= 1" sysctl: description: Sysctl configures sysctl parameters for tuning