-
Notifications
You must be signed in to change notification settings - Fork 61
fix: add validation for rejecting operator owned keys #399
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -266,6 +266,35 @@ type ZonePinning struct { | |
| Zones []string `json:"zones"` | ||
| } | ||
|
|
||
| // ReservedConfigKeys are the valkey.conf directives the operator sets itself. | ||
| // A user value for one of these in spec.config is refused rather than silently dropped. | ||
| // | ||
| // The list has to match the keys the operator's base config emits, with TLS both on and off. | ||
| // TestReservedConfigKeysMatchBaseConfig enforces that. | ||
| // It also has to match the CEL rule on ValkeyClusterSpec.Config, which cannot reference a Go value. | ||
| // Adding a key therefore means editing both, and the tests say so when you miss one. | ||
| // | ||
| // Lowercase entries only, because the CEL rule lowercases before comparing. | ||
| var ReservedConfigKeys = []string{ | ||
| "aclfile", | ||
| "cluster-allow-replica-migration", | ||
| "cluster-config-file", | ||
| "cluster-enabled", | ||
| "cluster-node-timeout", | ||
| "cluster-replica-validity-factor", | ||
| "dir", | ||
| "port", | ||
| "protected-mode", | ||
| "shutdown-on-sigterm", | ||
| "tls-auth-clients", | ||
| "tls-ca-cert-file", | ||
| "tls-cert-file", | ||
| "tls-cluster", | ||
| "tls-key-file", | ||
| "tls-port", | ||
| "tls-replication", | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you also add these, which are managed but applied directly on the pod. |
||
|
|
||
| // ValkeyClusterSpec defines the desired state of ValkeyCluster. | ||
| // +kubebuilder:validation:XValidation:rule="!(has(self.persistence) && self.workloadType == 'Deployment')",message="persistence requires workloadType StatefulSet" | ||
| // +kubebuilder:validation:XValidation:rule="!has(oldSelf.persistence) || has(self.persistence)",message="persistence cannot be removed once set" | ||
|
|
@@ -359,7 +388,23 @@ type ValkeyClusterSpec struct { | |
| // +optional | ||
| Containers []corev1.Container `json:"containers,omitempty"` | ||
|
|
||
| // Additional Valkey configuration parameters | ||
| // Additional Valkey configuration parameters. | ||
| // | ||
| // Keys the operator owns are rejected. | ||
| // Appended operator directives silently overrode user settings due to Valkey's last-value precedence, causing silent config drift. | ||
| // The operator only emits those when TLS is configured, so with TLS off a user value took effect and could move or close the port the operator connects to. | ||
| // | ||
| // The rejected set is ReservedConfigKeys. | ||
| // Cluster directives the operator does not set, such as cluster-require-full-coverage or cluster-migration-barrier, stay available. | ||
| // | ||
| // Keys are lowercased before comparison, because Valkey treats configuration keys case-insensitively. | ||
| // | ||
| // MaxProperties exists because the rule below cannot be admitted without it. | ||
| // The API server costs a CEL rule against the largest map the schema allows. | ||
| // It is set high deliberately, because raising a bound later is backwards compatible while lowering one locks out anyone already above it. | ||
| // Valkey has roughly 200 directives in total, so 1000 cannot realistically be reached. | ||
| // +kubebuilder:validation:MaxProperties=1000 | ||
| // +kubebuilder:validation:XValidation:rule="self.all(key, !(key.lowerAscii() in ['aclfile','cluster-allow-replica-migration','cluster-config-file','cluster-enabled','cluster-node-timeout','cluster-replica-validity-factor','dir','port','protected-mode','shutdown-on-sigterm','tls-auth-clients','tls-ca-cert-file','tls-cert-file','tls-cluster','tls-key-file','tls-port','tls-replication']))",message="spec.config must not set operator-owned keys (aclfile, cluster-allow-replica-migration, cluster-config-file, cluster-enabled, cluster-node-timeout, cluster-replica-validity-factor, dir, port, protected-mode, shutdown-on-sigterm, tls-auth-clients, tls-ca-cert-file, tls-cert-file, tls-cluster, tls-key-file, tls-port, tls-replication): the operator sets these itself and a user value would be ignored or would break its connection to the nodes" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of listing these out twice, could we link to documentation where folks can go to find the blocked configs? You would also need to add the documentation, which was going to be another comment I had :) Perhaps in docs/valkeycluster.md under config. |
||
| // +optional | ||
| Config map[string]string `json:"config,omitempty"` | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| /* | ||
| Copyright 2026 Valkey Contributors. | ||
|
|
||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||
| you may not use this file except in compliance with the License. | ||
| You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, software | ||
| distributed under the License is distributed on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| See the License for the specific language governing permissions and | ||
| limitations under the License. | ||
| */ | ||
|
|
||
| package controller | ||
|
|
||
| import ( | ||
| "os" | ||
| "slices" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| valkeyiov1alpha1 "github.com/valkey-io/valkey-operator/api/v1alpha1" | ||
| ) | ||
|
|
||
| // operatorOwnedKeys returns every directive the operator's base config emits, with TLS off and on. | ||
| // The TLS branch matters on its own. | ||
| // Those keys are only emitted when TLS is configured, so with TLS off a user value takes effect instead of being discarded. | ||
| // That is how a user could move or close the port the operator connects to. | ||
| func operatorOwnedKeys() []string { | ||
| seen := map[string]struct{}{} | ||
| for _, tls := range []*valkeyiov1alpha1.NodeTLSSpec{ | ||
| nil, | ||
| {Certificates: valkeyiov1alpha1.NodeTLSCertificates{ | ||
| Server: valkeyiov1alpha1.NodeCertificateRef{SecretName: "certs"}, | ||
| }}, | ||
| } { | ||
| for key := range getBaseConfig(tls) { | ||
| seen[key] = struct{}{} | ||
| } | ||
| } | ||
| keys := make([]string, 0, len(seen)) | ||
| for key := range seen { | ||
| keys = append(keys, key) | ||
| } | ||
| slices.Sort(keys) | ||
| return keys | ||
| } | ||
|
|
||
| // TestReservedConfigKeysMatchBaseConfig pins ReservedConfigKeys to what the operator actually writes. | ||
| // Without this, adding a directive to getBaseConfig would silently reintroduce the bug. | ||
| // The new key would be accepted in spec.config and then overwritten, with nothing telling the user. | ||
| func TestReservedConfigKeysMatchBaseConfig(t *testing.T) { | ||
| owned := operatorOwnedKeys() | ||
| reserved := slices.Clone(valkeyiov1alpha1.ReservedConfigKeys) | ||
| slices.Sort(reserved) | ||
|
|
||
| assert.Equal(t, owned, reserved, | ||
| "ReservedConfigKeys is out of sync with the operator's base config. "+ | ||
| "Update ReservedConfigKeys in api/v1alpha1/valkeycluster_types.go and the XValidation rule on ValkeyClusterSpec.Config, which cannot reference it.") | ||
| } | ||
|
|
||
| // TestReservedConfigKeysMatchCELRule checks the hand-maintained CEL literal against ReservedConfigKeys. | ||
| // The rule lives in a marker string, so the compiler cannot catch a mismatch and the two would drift silently. | ||
| func TestReservedConfigKeysMatchCELRule(t *testing.T) { | ||
| source, err := os.ReadFile("../../api/v1alpha1/valkeycluster_types.go") | ||
| require.NoError(t, err) | ||
|
|
||
| const marker = "self.all(key, !(key.lowerAscii() in [" | ||
| start := strings.Index(string(source), marker) | ||
| require.NotEqual(t, -1, start, "CEL rule for spec.config not found") | ||
|
|
||
| rest := string(source)[start+len(marker):] | ||
| end := strings.Index(rest, "]") | ||
| require.NotEqual(t, -1, end, "CEL rule key list is not terminated") | ||
|
|
||
| list := rest[:end] | ||
| inRule := make([]string, 0, strings.Count(list, ",")+1) | ||
| for entry := range strings.SplitSeq(list, ",") { | ||
| inRule = append(inRule, strings.Trim(strings.TrimSpace(entry), "'")) | ||
| } | ||
| slices.Sort(inRule) | ||
|
|
||
| reserved := slices.Clone(valkeyiov1alpha1.ReservedConfigKeys) | ||
| slices.Sort(reserved) | ||
|
|
||
| assert.Equal(t, reserved, inRule, | ||
| "the CEL rule on ValkeyClusterSpec.Config does not list the same keys as ReservedConfigKeys") | ||
| } | ||
|
|
||
| // TestReservedConfigKeysAreLowercase guards the CEL comparison, which lowercases the user's key before testing membership. | ||
| // An uppercase entry in the list could therefore never match. | ||
| func TestReservedConfigKeysAreLowercase(t *testing.T) { | ||
| for _, key := range valkeyiov1alpha1.ReservedConfigKeys { | ||
| assert.Equal(t, strings.ToLower(key), key, "ReservedConfigKeys entries must be lowercase") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| /* | ||
| Copyright 2026 Valkey Contributors. | ||
|
|
||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||
| you may not use this file except in compliance with the License. | ||
| You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, software | ||
| distributed under the License is distributed on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| See the License for the specific language governing permissions and | ||
| limitations under the License. | ||
| */ | ||
|
|
||
| package controller | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| . "github.com/onsi/ginkgo/v2" | ||
| . "github.com/onsi/gomega" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
|
|
||
| valkeyiov1alpha1 "github.com/valkey-io/valkey-operator/api/v1alpha1" | ||
| ) | ||
|
|
||
| // configCluster builds a minimal admissible ValkeyCluster carrying the given spec.config. | ||
| func configCluster(name string, config map[string]string) *valkeyiov1alpha1.ValkeyCluster { | ||
| return &valkeyiov1alpha1.ValkeyCluster{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: name, | ||
| Namespace: "default", | ||
| }, | ||
| Spec: valkeyiov1alpha1.ValkeyClusterSpec{ | ||
| Shards: 1, | ||
| Replicas: 0, | ||
| Config: config, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| const reservedKeyMessage = "must not set operator-owned keys" | ||
|
|
||
| var _ = Describe("ValkeyCluster spec.config validation", func() { | ||
| ctx := context.Background() | ||
|
|
||
| It("admits tunables the operator does not own", func() { | ||
| cluster := configCluster("cfg-tunables", map[string]string{ | ||
| "maxmemory": "50mb", | ||
| "maxmemory-policy": "allkeys-lfu", | ||
| "maxclients": "1000", | ||
| "appendonly": "yes", | ||
| "timeout": "0", | ||
| }) | ||
| Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) | ||
| Expect(k8sClient.Delete(ctx, cluster)).To(Succeed()) | ||
| }) | ||
|
|
||
| It("admits cluster directives the operator leaves alone", func() { | ||
| // The operator sets some cluster- keys and not others. | ||
| // Only the ones it sets are reserved, so these have to keep working. | ||
| cluster := configCluster("cfg-cluster-ok", map[string]string{ | ||
| "cluster-require-full-coverage": "no", | ||
| "cluster-migration-barrier": "1", | ||
| "cluster-allow-reads-when-down": "yes", | ||
| }) | ||
| Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) | ||
| Expect(k8sClient.Delete(ctx, cluster)).To(Succeed()) | ||
| }) | ||
|
|
||
| It("admits an absent config", func() { | ||
| cluster := configCluster("cfg-absent", nil) | ||
| Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) | ||
| Expect(k8sClient.Delete(ctx, cluster)).To(Succeed()) | ||
| }) | ||
|
|
||
| // Every reserved key is covered, so a key added to ReservedConfigKeys without a | ||
| // matching CEL update fails here as well as in the drift guard tests. | ||
| for i, key := range valkeyiov1alpha1.ReservedConfigKeys { | ||
| reserved := key | ||
| name := fmt.Sprintf("cfg-reserved-%d", i) | ||
|
|
||
| It(fmt.Sprintf("rejects the operator-owned key %s", reserved), func() { | ||
| err := k8sClient.Create(ctx, configCluster(name, map[string]string{reserved: "somevalue"})) | ||
| Expect(err).To(HaveOccurred()) | ||
| Expect(err.Error()).To(ContainSubstring(reservedKeyMessage)) | ||
| }) | ||
| } | ||
|
|
||
| DescribeTable("rejects operator-owned keys regardless of case", | ||
| func(key string) { | ||
| err := k8sClient.Create(ctx, configCluster("cfg-case", map[string]string{key: "yes"})) | ||
| Expect(err).To(HaveOccurred()) | ||
| Expect(err.Error()).To(ContainSubstring(reservedKeyMessage)) | ||
| }, | ||
| Entry("mixed case", "Cluster-Enabled"), | ||
| Entry("upper case", "PROTECTED-MODE"), | ||
| Entry("mixed case tls", "TLS-Port"), | ||
| ) | ||
|
|
||
| It("rejects a reserved key mixed in with valid ones", func() { | ||
| err := k8sClient.Create(ctx, configCluster("cfg-mixed", map[string]string{ | ||
| "maxmemory": "50mb", | ||
| "dir": "/somewhere-else", | ||
| })) | ||
| Expect(err).To(HaveOccurred()) | ||
| Expect(err.Error()).To(ContainSubstring(reservedKeyMessage)) | ||
| }) | ||
|
|
||
| It("rejects a reserved key added by update", func() { | ||
| cluster := configCluster("cfg-update", map[string]string{"maxmemory": "50mb"}) | ||
| Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) | ||
| DeferCleanup(func() { | ||
| Expect(k8sClient.Delete(ctx, cluster)).To(Succeed()) | ||
| }) | ||
|
|
||
| cluster.Spec.Config["aclfile"] = "/tmp/users.acl" | ||
| err := k8sClient.Update(ctx, cluster) | ||
| Expect(err).To(HaveOccurred()) | ||
| Expect(err.Error()).To(ContainSubstring(reservedKeyMessage)) | ||
| }) | ||
|
|
||
| It("names the offending keys in the message", func() { | ||
| // The message has to be actionable on its own, since there is no webhook to elaborate. | ||
| err := k8sClient.Create(ctx, configCluster("cfg-message", map[string]string{"cluster-enabled": "no"})) | ||
| Expect(err).To(HaveOccurred()) | ||
| for _, key := range []string{"aclfile", "cluster-enabled", "dir", "tls-port"} { | ||
| Expect(err.Error()).To(ContainSubstring(key), "message should list the reserved keys") | ||
| } | ||
| Expect(strings.ToLower(err.Error())).To(ContainSubstring("operator sets these itself")) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could allow users to override this default. But I would say let's wait for the issue to come in first requesting the override so that we can understand the use case.