Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration
##@ Build

.PHONY: build
build: manifests generate fmt vet ## Build manager binary.
build: manifests generate fmt vet lint ## Build manager binary.
go build -o bin/manager cmd/main.go

.PHONY: run
Expand Down
21 changes: 21 additions & 0 deletions api/v1alpha1/valkeycluster_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ const (
ClusterStateFailed ClusterState = "Failed"
)

// This list defines specific Valkey server configuration parameters that cannot
// be overridden by a user-supplied configuration within the CR. Doing so would
// potentially break the operator's behavior, which could result in data loss, or
// a non-functioning cluster
var NonUserOverrideConfigParameters = []string{
"cluster-enabled",
}

// ValkeyClusterSpec defines the desired state of ValkeyCluster.
type ValkeyClusterSpec struct {

Expand Down Expand Up @@ -73,6 +81,19 @@ type ValkeyClusterSpec struct {
// +kubebuilder:default:={enabled:true}
// +optional
Exporter ExporterSpec `json:"exporter,omitempty"`

// Options, or config which are specific to Valkey server
// +optional
ValkeySpec ValkeySpec `json:"valkey,omitempty"`
}

// ValkeySpec defines any options, or configuration that is specific to valkey-server
type ValkeySpec struct {

// Additional Valkey configuration parameters
// TODO Updating the config of an existing CR currently does not trigger cluster restart
// https://github.com/valkey-io/valkey-operator/issues/50
Configuration map[string]string `json:"config,omitempty"`
}

type ExporterSpec struct {
Expand Down
23 changes: 23 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions config/crd/bases/valkey.io_valkeyclusters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,17 @@ spec:
type: string
type: object
type: array
valkey:
description: Options, or config which are specific to Valkey server
properties:
config:
additionalProperties:
type: string
description: |-
Additional Valkey configuration parameters
https://github.com/valkey-io/valkey-operator/issues/50
type: object
type: object
type: object
status:
default:
Expand Down
4 changes: 4 additions & 0 deletions config/samples/v1alpha1_valkeycluster.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ metadata:
spec:
shards: 3
replicas: 1
valkey:
config:
maxmemory: 50mb
maxmemory-policy: allkeys-lfu
resources:
requests:
memory: "256Mi"
Expand Down
188 changes: 188 additions & 0 deletions internal/controller/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/*
Copyright 2025 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"
"crypto/sha256"
"embed"
"fmt"
"maps"
"slices"

corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
logf "sigs.k8s.io/controller-runtime/pkg/log"
valkeyiov1alpha1 "valkey.io/valkey-operator/api/v1alpha1"
)

const (
configHashKey = "valkey.io/config-hash"
configFileKey = "valkey.conf"
)

//go:embed scripts/*
var scripts embed.FS

func getConfigMapName(clusterName string) string {
return clusterName + "-config"
}

// Create or update a default valkey.conf
// If additional config is provided, append to the default map
func (r *ValkeyClusterReconciler) upsertConfigMap(ctx context.Context, cluster *valkeyiov1alpha1.ValkeyCluster) error {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we rename this file to configmap.go

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point

log := logf.FromContext(ctx)

// Hash writer for embedded scripts, and configuration parameters
hashConfigContents := sha256.New()

// Embed readiness check script
readiness, err := scripts.ReadFile("scripts/readiness-check.sh")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if this is nitpicking, but does it make sense to have scripts in a configmap named mycluster-config? That to me indicates that the ConfigMap contains only configurations.

I would argue to keep -config out of the resource name and just keep it to the name of the cluster.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is your suggestion to move the scripts to their own configMap? I have no objection with that.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine to leave as is. We can refactor later on

if err != nil {
return err
}
hashConfigContents.Write(readiness)

// Embed liveness check script
liveness, err := scripts.ReadFile("scripts/liveness-check.sh")
if err != nil {
return err
}
hashConfigContents.Write(liveness)

// Base config
serverConfig := `# Base operator config
cluster-enabled yes
protected-mode no
cluster-node-timeout 2000
`

// Local copy
specConfig := cluster.Spec.ValkeySpec.Configuration

// If there are any user-defined config parameters
if len(specConfig) > 0 {

// Sort the config keys to keep consistent processing order
sortedKeys := slices.Sorted(maps.Keys(specConfig))

// Build the config
serverConfig += "\n# Extra config\n"
for _, k := range sortedKeys {

if slices.Contains(valkeyiov1alpha1.NonUserOverrideConfigParameters, k) {
log.Error(nil, "Prohibited valkey server config", "parameter", k)
r.Recorder.Eventf(cluster, nil, corev1.EventTypeWarning, "ConfigMapUpdateFailed", "UpsertConfigMap", "Prohibited config: %v", k)
continue
}

serverConfig += k + " " + specConfig[k] + "\n"
}
}
Comment on lines +82 to +99
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be extracted into its own function? Something like:

if len(cluster.Spec.ValkeySpec.Configuration) > 0 {
    appendSpecConfig(&serverConfig, cluster.Spec.ValkeySpec.Configuration)
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it could. What's the benefit though of having the same code but in another function? Doesn't that introduce additional code jumps?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The benefit is readability - ideally we want to keep isolated units of work behind functions that are named well so that when reading the entirety of upsertConfigMap it is easy to follow along and not get bogged down on the low-level implementation of building the extra config :)


// Look for, and fetch existing configMap for this cluster
serverConfigMapName := getConfigMapName(cluster.Name)
needCreateConfigMap := false

serverConfigMap := &corev1.ConfigMap{}
if err := r.Get(ctx, types.NamespacedName{
Name: serverConfigMapName,
Namespace: cluster.Namespace,
}, serverConfigMap); err != nil {
if !apierrors.IsNotFound(err) {
log.Error(err, "failed to fetch server configmap")
return err
}

// ConfigMap not found. Create configMap object with contents
needCreateConfigMap = true
log.V(2).Info("creating server configMap", "name", serverConfigMapName)

serverConfigMap.ObjectMeta = metav1.ObjectMeta{
Name: serverConfigMapName,
Namespace: cluster.Namespace,
Labels: labels(cluster),
}
serverConfigMap.Data = map[string]string{
"readiness-check.sh": string(readiness),
"liveness-check.sh": string(liveness),
configFileKey: serverConfig,
}
}

// Register ownership of the configMap
if err := controllerutil.SetControllerReference(cluster, serverConfigMap, r.Scheme); err != nil {
log.Error(err, "Failed to grab ownership of server configMap")
r.Recorder.Eventf(cluster, nil, corev1.EventTypeWarning, "ConfigMapCreationFailed", "UpsertConfigMap", "Failed to grab ownership of server configMap: %v", err)
return err
}

// Calculate hash of existing config parameters
origServerConfigHash := fmt.Sprintf("%x", sha256.Sum256([]byte(serverConfigMap.Data[configFileKey])))

// Calculate hash of new config parameters
hashConfigContents.Write([]byte(serverConfig))
newServerConfigHash := fmt.Sprintf("%x", hashConfigContents.Sum(nil))

// Was the configMap changed? This is an invalid action, as users should modify
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit hard to follow with a lot of flags (maybe I need more coffee..)
It possibly simpler to just create the configmap at the start of this function, including its annotation, and then return thereafter.
This part would only handle the cases when the configmap already exists in some form.
I think that would remove the need of needCreateConfigMap and origConfigModified

// the ValkeyCluster CR to change parameters, not the configMap itself.
origConfigModified := !hasAnnotation(serverConfigMap, configHashKey, origServerConfigHash)

// Compare hash to the one already attached to the configMap (if previously exists)
needsUpdate := upsertAnnotation(serverConfigMap, configHashKey, newServerConfigHash)

// Original config is not modified, and new config doesn't change anything
if !origConfigModified && !needsUpdate {
log.V(1).Info("server config unchanged")
return nil
}

// In all other cases (ie: user modified configMap, or modified CR),
// update the config with the newly changed config. Also sync the
// check scripts in case they are updated in a later operator version.
serverConfigMap.Data["readiness-check.sh"] = string(readiness)
serverConfigMap.Data["liveness-check.sh"] = string(liveness)
serverConfigMap.Data[configFileKey] = serverConfig

// Need to create new configMap
if needCreateConfigMap {
if err := r.Create(ctx, serverConfigMap); err != nil {
log.Error(err, "Failed to create server configMap")
r.Recorder.Eventf(cluster, nil, corev1.EventTypeWarning, "ConfigMapCreationFailed", "UpsertConfigMap", "Failed to create server configMap: %v", err)
return err
} else {
r.Recorder.Eventf(cluster, nil, corev1.EventTypeNormal, "ConfigMapCreated", "UpsertConfigMap", "Created server configMap")
return nil
}
}

// Otherwise, update it
if err := r.Update(ctx, serverConfigMap); err != nil {
log.Error(err, "Failed to update server configMap")
r.Recorder.Eventf(cluster, nil, corev1.EventTypeWarning, "ConfigMapUpdateFailed", "UpsertConfigMap", "Failed to update server configMap: %v", err)
return err
}

r.Recorder.Eventf(cluster, nil, corev1.EventTypeNormal, "ConfigMapUpdated", "UpsertConfigMap", "Synchronized server configMap")

// All is good. Server configMap will be auto-mounted in the deployment
return nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont think updated configmap will be auto mounted in the existing deployments during update. Did u test this

Copy link
Collaborator

@bjosv bjosv Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seem to update the configfile in the pods when I test it on kind minikube now, but it takes some time before the file is getting updated.

But, I now see that we are not using SubPath in the VolumeMounts on containers..
We probably should (?) since both /scripts and /configs contains all files from the configmap.
The problem then will be that the volume mount will not receive updates when the ConfigMap changes though..

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating the configmap is one thing, but the Valkey instances will not use these updates anyway in the current form. Is that what you where thinking on @sandeepkunusoth ?
Maybe the update part in this PR could be moved to its own PR, which could include the pod restart/config reload depending on how we decide.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that we should omit live applying changes to the config for another PR

}
6 changes: 4 additions & 2 deletions internal/controller/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ func generateContainersDef(cluster *valkeyiov1alpha1.ValkeyCluster) []corev1.Con

func createClusterDeployment(cluster *valkeyiov1alpha1.ValkeyCluster) *appsv1.Deployment {
containers := generateContainersDef(cluster)
baseConfigMapName := getConfigMapName(cluster.Name)

deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
GenerateName: cluster.Name + "-",
Expand All @@ -144,7 +146,7 @@ func createClusterDeployment(cluster *valkeyiov1alpha1.ValkeyCluster) *appsv1.De
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: cluster.Name,
Name: baseConfigMapName,
},
DefaultMode: func(i int32) *int32 { return &i }(0755),
},
Expand All @@ -155,7 +157,7 @@ func createClusterDeployment(cluster *valkeyiov1alpha1.ValkeyCluster) *appsv1.De
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: cluster.Name,
Name: baseConfigMapName,
},
},
},
Expand Down
38 changes: 38 additions & 0 deletions internal/controller/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package controller
import (
"maps"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
valkeyv1 "valkey.io/valkey-operator/api/v1alpha1"
)

Expand All @@ -43,3 +44,40 @@ func labels(cluster *valkeyv1.ValkeyCluster) map[string]string {
func annotations(cluster *valkeyv1.ValkeyCluster) map[string]string {
return maps.Clone(cluster.Annotations)
}

// This function takes a K8S object reference (eg: pod, secret, configmap, etc),
// and a key, and value to compare to an existing annotation within the object.
// Returns true if the annotation is present, and matches the value.
func hasAnnotation(obj metav1.Object, key string, value string) bool {
annotations := obj.GetAnnotations()
if annotations == nil {
return false
}
return annotations[key] == value
}

// This function takes a K8S object reference (eg: pod, secret, configmap, etc),
// and a key, and value to add to, or replace an existing, annotation within the object.
// Returns true if the annotation was added, or updated
func upsertAnnotation(o metav1.Object, key string, val string) bool {

updated := false

// Get current annotations
annotations := o.GetAnnotations()
if annotations == nil {
annotations = make(map[string]string)
}

// If not found, insert, or update
if orig := annotations[key]; orig != val {

updated = true
annotations[key] = val

// Set annotations
o.SetAnnotations(annotations)
}

return updated
}
8 changes: 8 additions & 0 deletions internal/controller/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,11 @@ func TestAnnotations(t *testing.T) {
t.Errorf("Expected %v, got %v", testAnnotations["app"], result["app"])
}
}

func TestConfigMapName(t *testing.T) {
cmn := "test-resource-config"
result := getConfigMapName("test-resource")
if result != cmn {
t.Errorf("Expected '%v', got '%v'", cmn, result)
}
}
Loading