diff --git a/README.md b/README.md index ed1a3f3d..48b4871a 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,39 @@ kubectl patch dragonfly dragonfly-sample --type merge -p '{"spec":{"resources":{ To add authentication to the dragonfly pods, you either set the `DFLY_requirepass` environment variable, or add the `--requirepass` argument. +### Customising health-check probe scripts + +The operator generates default liveness, readiness, and startup probe scripts and mounts them via ConfigMaps. You can replace any probe with your own script using the `custom*ProbeConfigMap` fields. + +Scripts run inside the Dragonfly container and have access to `HEALTHCHECK_PORT` (admin port 9999 — no TLS, no auth). + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: dragonfly-sample-probes + namespace: default +data: + liveness-check.sh: | + #!/bin/sh + RESPONSE=$(timeout 4 redis-cli -h localhost -p ${HEALTHCHECK_PORT:-9999} PING 2>/dev/null) + case "$RESPONSE" in + PONG|*LOADING*) exit 0 ;; + *) exit 1 ;; + esac +--- +apiVersion: dragonflydb.io/v1alpha1 +kind: Dragonfly +metadata: + name: dragonfly-sample +spec: + replicas: 1 + customLivenessProbeConfigMap: + name: dragonfly-sample-probes +``` + +> **Override precedence:** `spec.additionalVolumes` with a matching volume name (`liveness-probe`, `readiness-probe`, `startup-probe`) takes precedence over `custom*ProbeConfigMap`. Do not use both for the same probe. + ### Deleting a Dragonfly instance To delete a Dragonfly instance, you can run diff --git a/api/v1alpha1/dragonfly_types.go b/api/v1alpha1/dragonfly_types.go index 9377a9ef..4d1a2c8b 100644 --- a/api/v1alpha1/dragonfly_types.go +++ b/api/v1alpha1/dragonfly_types.go @@ -196,6 +196,21 @@ type DragonflySpec struct { // +optional // +kubebuilder:validation:Optional Pdb *PdbSpec `json:"pdb,omitempty"` + + // (Optional) Custom ConfigMap with key "liveness-check.sh" to override the default liveness probe. + // +optional + // +kubebuilder:validation:Optional + CustomLivenessProbeConfigMap *corev1.LocalObjectReference `json:"customLivenessProbeConfigMap,omitempty"` + + // (Optional) Custom ConfigMap with key "readiness-check.sh" to override the default readiness probe. + // +optional + // +kubebuilder:validation:Optional + CustomReadinessProbeConfigMap *corev1.LocalObjectReference `json:"customReadinessProbeConfigMap,omitempty"` + + // (Optional) Custom ConfigMap with key "startup-check.sh" to override the default startup probe. + // +optional + // +kubebuilder:validation:Optional + CustomStartupProbeConfigMap *corev1.LocalObjectReference `json:"customStartupProbeConfigMap,omitempty"` } // PdbSpec defines the desired state of the PodDisruptionBudget diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 454ab246..adb31c0b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -251,6 +251,21 @@ func (in *DragonflySpec) DeepCopyInto(out *DragonflySpec) { *out = new(PdbSpec) (*in).DeepCopyInto(*out) } + if in.CustomLivenessProbeConfigMap != nil { + in, out := &in.CustomLivenessProbeConfigMap, &out.CustomLivenessProbeConfigMap + *out = new(v1.LocalObjectReference) + **out = **in + } + if in.CustomReadinessProbeConfigMap != nil { + in, out := &in.CustomReadinessProbeConfigMap, &out.CustomReadinessProbeConfigMap + *out = new(v1.LocalObjectReference) + **out = **in + } + if in.CustomStartupProbeConfigMap != nil { + in, out := &in.CustomStartupProbeConfigMap, &out.CustomStartupProbeConfigMap + *out = new(v1.LocalObjectReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DragonflySpec. diff --git a/config/crd/bases/dragonflydb.io_dragonflies.yaml b/config/crd/bases/dragonflydb.io_dragonflies.yaml index de2ff375..51a01c58 100644 --- a/config/crd/bases/dragonflydb.io_dragonflies.yaml +++ b/config/crd/bases/dragonflydb.io_dragonflies.yaml @@ -4461,6 +4461,51 @@ spec: type: string type: object type: object + customLivenessProbeConfigMap: + description: (Optional) Custom ConfigMap with key "liveness-check.sh" + to override the default liveness probe. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + customReadinessProbeConfigMap: + description: (Optional) Custom ConfigMap with key "readiness-check.sh" + to override the default readiness probe. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + customStartupProbeConfigMap: + description: (Optional) Custom ConfigMap with key "startup-check.sh" + to override the default startup probe. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic enableReplicationReadinessGate: description: |- (Optional) When enabled, adds a custom readiness gate to pods that prevents diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 47bc122d..dd342efe 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -7,13 +7,7 @@ rules: - apiGroups: - "" resources: - - events - verbs: - - create - - patch -- apiGroups: - - "" - resources: + - configmaps - pods - services verbs: @@ -24,6 +18,13 @@ rules: - patch - update - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch - apiGroups: - "" resources: diff --git a/internal/controller/dragonfly_controller.go b/internal/controller/dragonfly_controller.go index f77ef6e9..788b6636 100644 --- a/internal/controller/dragonfly_controller.go +++ b/internal/controller/dragonfly_controller.go @@ -45,6 +45,7 @@ type DragonflyReconciler struct { //+kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups="",resources=events,verbs=create;patch +//+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,verbs=get;list;watch;create;update;patch;delete // Reconcile is part of the main kubernetes reconciliation loop which aims to @@ -158,6 +159,7 @@ func (r *DragonflyReconciler) SetupWithManager(mgr ctrl.Manager) error { For(&dfv1alpha1.Dragonfly{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). Owns(&appsv1.StatefulSet{}, builder.MatchEveryOwner). Owns(&corev1.Service{}, builder.MatchEveryOwner). + Owns(&corev1.ConfigMap{}, builder.MatchEveryOwner). Owns(&networkingv1.NetworkPolicy{}, builder.MatchEveryOwner). Named("Dragonfly"). Complete(r) diff --git a/internal/controller/dragonfly_instance.go b/internal/controller/dragonfly_instance.go index d78b9408..674395d7 100644 --- a/internal/controller/dragonfly_instance.go +++ b/internal/controller/dragonfly_instance.go @@ -745,6 +745,12 @@ func resourceSpecsEqual(desired, existing client.Object) bool { if !reflect.DeepEqual(desired.GetLabels(), existing.GetLabels()) || !reflect.DeepEqual(desired.GetAnnotations(), existing.GetAnnotations()) { return false } + // ConfigMaps store content in .Data, not .Spec — compare Data directly. + if cmDesired, ok := desired.(*corev1.ConfigMap); ok { + if cmExisting, ok := existing.(*corev1.ConfigMap); ok { + return reflect.DeepEqual(cmDesired.Data, cmExisting.Data) + } + } // Compare only the .Spec field using reflection desiredV := reflect.ValueOf(desired).Elem() existingV := reflect.ValueOf(existing).Elem() diff --git a/internal/resources/const.go b/internal/resources/const.go index 751a7438..7a8cc72a 100644 --- a/internal/resources/const.go +++ b/internal/resources/const.go @@ -99,6 +99,24 @@ const ( OperatorControlPlaneLabelKey = "control-plane" OperatorControlPlaneLabelValue = "controller-manager" + + // Probe ConfigMap suffixes — appended to df.Name + LivenessProbeConfigMapSuffix = "liveness-probe" + ReadinessProbeConfigMapSuffix = "readiness-probe" + StartupProbeConfigMapSuffix = "startup-probe" + + // Script keys — must match the filename in the ConfigMap data + LivenessScriptKey = "liveness-check.sh" + ReadinessScriptKey = "readiness-check.sh" + StartupScriptKey = "startup-check.sh" + + // ProbeMountPath is the directory where all probe scripts are mounted + ProbeMountPath = "/etc/dragonfly/probes" + + // Volume names for the three probe ConfigMaps + LivenessProbeVolumeName = "liveness-probe" + ReadinessProbeVolumeName = "readiness-probe" + StartupProbeVolumeName = "startup-probe" ) var DefaultDragonflyArgs = []string{ diff --git a/internal/resources/resources.go b/internal/resources/resources.go index d89a7fa6..c1f310c4 100644 --- a/internal/resources/resources.go +++ b/internal/resources/resources.go @@ -33,6 +33,76 @@ var ( dflyUserGroup int64 = 999 ) +func hasCustomProbes(df *resourcesv1.Dragonfly) bool { + return (df.Spec.CustomLivenessProbeConfigMap != nil && df.Spec.CustomLivenessProbeConfigMap.Name != "") || + (df.Spec.CustomReadinessProbeConfigMap != nil && df.Spec.CustomReadinessProbeConfigMap.Name != "") || + (df.Spec.CustomStartupProbeConfigMap != nil && df.Spec.CustomStartupProbeConfigMap.Name != "") +} + +// returns the custom ConfigMap name if set, or "-" otherwise +func probeConfigMapName(dfName, suffix string, custom *corev1.LocalObjectReference) string { + if custom != nil && custom.Name != "" { + return custom.Name + } + return fmt.Sprintf("%s-%s", dfName, suffix) +} + +// append probe ConfigMap volumes and mounts into the StatefulSet +func appendProbeVolumesAndMounts(sts *appsv1.StatefulSet, livenessCM, readinessCM, startupCM string) { + probes := []struct { + volumeName string + configMapName string + scriptKey string + }{ + {LivenessProbeVolumeName, livenessCM, LivenessScriptKey}, + {ReadinessProbeVolumeName, readinessCM, ReadinessScriptKey}, + {StartupProbeVolumeName, startupCM, StartupScriptKey}, + } + for _, p := range probes { + sts.Spec.Template.Spec.Volumes = append(sts.Spec.Template.Spec.Volumes, + corev1.Volume{ + Name: p.volumeName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: p.configMapName}, + }, + }, + }, + ) + sts.Spec.Template.Spec.Containers[0].VolumeMounts = append( + sts.Spec.Template.Spec.Containers[0].VolumeMounts, + corev1.VolumeMount{ + Name: p.volumeName, + MountPath: ProbeMountPath + "/" + p.scriptKey, + SubPath: p.scriptKey, + }, + ) + } +} + +// generateProbeConfigMap builds a ConfigMap owned by the Dragonfly instance for a single probe script. +func generateProbeConfigMap(df *resourcesv1.Dragonfly, name, key, script string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: df.Namespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: df.APIVersion, + Kind: df.Kind, + Name: df.Name, + UID: df.UID, + }, + }, + Labels: generateResourceLabels(df), + Annotations: generateResourceAnnotations(df), + }, + Data: map[string]string{ + key: script, + }, + } +} + // GenerateDragonflyResources returns the resources required for a Dragonfly // Instance func GenerateDragonflyResources(df *resourcesv1.Dragonfly, defaultDragonflyImage string) ([]client.Object, error) { @@ -103,9 +173,13 @@ func GenerateDragonflyResources(df *resourcesv1.Dragonfly, defaultDragonflyImage }, Args: DefaultDragonflyArgs, Env: append(df.Spec.Env, corev1.EnvVar{ + // Use the admin port for health checks — it never requires TLS, + // making probes work correctly on TLS-enabled clusters. Name: "HEALTHCHECK_PORT", Value: fmt.Sprintf("%d", DragonflyAdminPort), }), + // Default probes use the image-embedded healthcheck script. + // When custom probe ConfigMaps are set, these are replaced below. ReadinessProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ Exec: &corev1.ExecAction{ @@ -398,6 +472,46 @@ func GenerateDragonflyResources(df *resourcesv1.Dragonfly, defaultDragonflyImage } } + // When custom probe ConfigMaps are set, switch probes to use mounted scripts + // and wire volumes/mounts. Otherwise keep the default image-embedded healthcheck + // to avoid triggering a rolling update on operator upgrade. + if hasCustomProbes(df) { + c := &statefulset.Spec.Template.Spec.Containers[0] + c.ReadinessProbe.Exec.Command = []string{"/bin/sh", ProbeMountPath + "/" + ReadinessScriptKey} + c.LivenessProbe.Exec.Command = []string{"/bin/sh", ProbeMountPath + "/" + LivenessScriptKey} + c.StartupProbe = &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + Exec: &corev1.ExecAction{ + Command: []string{"/bin/sh", ProbeMountPath + "/" + StartupScriptKey}, + }, + }, + FailureThreshold: 60, + InitialDelaySeconds: 0, + PeriodSeconds: 5, + SuccessThreshold: 1, + TimeoutSeconds: 5, + } + + livenessConfigMapName := probeConfigMapName(df.Name, LivenessProbeConfigMapSuffix, df.Spec.CustomLivenessProbeConfigMap) + readinessConfigMapName := probeConfigMapName(df.Name, ReadinessProbeConfigMapSuffix, df.Spec.CustomReadinessProbeConfigMap) + startupConfigMapName := probeConfigMapName(df.Name, StartupProbeConfigMapSuffix, df.Spec.CustomStartupProbeConfigMap) + + appendProbeVolumesAndMounts(&statefulset, + livenessConfigMapName, readinessConfigMapName, startupConfigMapName, + ) + + // Generate default ConfigMaps for probes not overridden by the user. + if df.Spec.CustomLivenessProbeConfigMap == nil || df.Spec.CustomLivenessProbeConfigMap.Name == "" { + resources = append(resources, generateProbeConfigMap(df, livenessConfigMapName, LivenessScriptKey, defaultLivenessScript)) + } + if df.Spec.CustomReadinessProbeConfigMap == nil || df.Spec.CustomReadinessProbeConfigMap.Name == "" { + resources = append(resources, generateProbeConfigMap(df, readinessConfigMapName, ReadinessScriptKey, defaultReadinessScript)) + } + if df.Spec.CustomStartupProbeConfigMap == nil || df.Spec.CustomStartupProbeConfigMap.Name == "" { + resources = append(resources, generateProbeConfigMap(df, startupConfigMapName, StartupScriptKey, defaultStartupScript)) + } + } + statefulset.Spec.Template.Spec.Containers = mergeNamedSlices( statefulset.Spec.Template.Spec.Containers, df.Spec.AdditionalContainers, func(c corev1.Container) string { return c.Name }) diff --git a/internal/resources/resources_test.go b/internal/resources/resources_test.go index 6226af7e..207e1e40 100644 --- a/internal/resources/resources_test.go +++ b/internal/resources/resources_test.go @@ -1,6 +1,7 @@ package resources import ( + "fmt" "testing" resourcesv1 "github.com/dragonflydb/dragonfly-operator/api/v1alpha1" @@ -243,3 +244,262 @@ func TestGenerateDragonflyResources_NetworkPolicyWithMemcached(t *testing.T) { assert.Equal(t, &protocolTCP, memcachedRule.Ports[0].Protocol) assert.Equal(t, intstr.FromInt32(11211), *memcachedRule.Ports[0].Port) } + +func findStatefulSet(objs []client.Object) *appsv1.StatefulSet { + for _, obj := range objs { + if sts, ok := obj.(*appsv1.StatefulSet); ok { + return sts + } + } + return nil +} + +func findConfigMap(objs []client.Object, name string) *corev1.ConfigMap { + for _, obj := range objs { + if cm, ok := obj.(*corev1.ConfigMap); ok && cm.Name == name { + return cm + } + } + return nil +} + +func TestProbeConfigMaps_NotGeneratedByDefault(t *testing.T) { + df := newTestDragonfly(1) + objs, err := GenerateDragonflyResources(df, "") + require.NoError(t, err) + + assert.Nil(t, findConfigMap(objs, "test-df-liveness-probe"), "no ConfigMaps without custom probes") + assert.Nil(t, findConfigMap(objs, "test-df-readiness-probe")) + assert.Nil(t, findConfigMap(objs, "test-df-startup-probe")) +} + +func TestProbeConfigMaps_GeneratedWhenCustomProbeSet(t *testing.T) { + df := newTestDragonfly(1) + df.Spec.CustomLivenessProbeConfigMap = &corev1.LocalObjectReference{Name: "my-liveness"} + objs, err := GenerateDragonflyResources(df, "") + require.NoError(t, err) + + // custom liveness overrides the default, so no default ConfigMap generated + assert.Nil(t, findConfigMap(objs, "test-df-liveness-probe")) + + // defaults generated for probes not overridden + readiness := findConfigMap(objs, "test-df-readiness-probe") + require.NotNil(t, readiness) + assert.Contains(t, readiness.Data, "readiness-check.sh") + + startup := findConfigMap(objs, "test-df-startup-probe") + require.NotNil(t, startup) + assert.Contains(t, startup.Data, "startup-check.sh") +} + +func TestProbeConfigMaps_AllCustomNoneGenerated(t *testing.T) { + df := newTestDragonfly(1) + df.Spec.CustomLivenessProbeConfigMap = &corev1.LocalObjectReference{Name: "my-liveness"} + df.Spec.CustomReadinessProbeConfigMap = &corev1.LocalObjectReference{Name: "my-readiness"} + df.Spec.CustomStartupProbeConfigMap = &corev1.LocalObjectReference{Name: "my-startup"} + objs, err := GenerateDragonflyResources(df, "") + require.NoError(t, err) + + assert.Nil(t, findConfigMap(objs, "test-df-liveness-probe"), "no default when all custom") + assert.Nil(t, findConfigMap(objs, "test-df-readiness-probe")) + assert.Nil(t, findConfigMap(objs, "test-df-startup-probe")) +} + +func TestProbeConfigMaps_EmptyNameTreatedAsNoCustom(t *testing.T) { + df := newTestDragonfly(1) + df.Spec.CustomLivenessProbeConfigMap = &corev1.LocalObjectReference{Name: ""} + objs, err := GenerateDragonflyResources(df, "") + require.NoError(t, err) + + // empty name is treated as no custom probe, keeps default path + assert.Nil(t, findConfigMap(objs, "test-df-liveness-probe"), "empty name should not trigger custom probes") + + sts := findStatefulSet(objs) + require.NotNil(t, sts) + c := sts.Spec.Template.Spec.Containers[0] + assert.Equal(t, []string{"/bin/sh", "/usr/local/bin/healthcheck.sh"}, c.LivenessProbe.Exec.Command) + assert.Nil(t, c.StartupProbe, "no startup probe with empty custom name") +} + +func TestHealthcheckPortEnvVar_IsAdminPort(t *testing.T) { + // HEALTHCHECK_PORT always points to the admin port (never requires TLS). + df := newTestDragonfly(1) + objs, err := GenerateDragonflyResources(df, "") + require.NoError(t, err) + + sts := findStatefulSet(objs) + require.NotNil(t, sts) + + var hcPort *corev1.EnvVar + for i := range sts.Spec.Template.Spec.Containers[0].Env { + env := &sts.Spec.Template.Spec.Containers[0].Env[i] + if env.Name == "HEALTHCHECK_PORT" { + hcPort = env + break + } + } + require.NotNil(t, hcPort, "HEALTHCHECK_PORT env var must be set") + assert.Equal(t, fmt.Sprintf("%d", DragonflyAdminPort), hcPort.Value) +} + +func TestHealthcheckPortEnvVar_UnchangedByCustomRedisPort(t *testing.T) { + // HEALTHCHECK_PORT stays at the admin port even when the Redis port is customised. + df := newTestDragonfly(1) + df.Spec.Args = []string{"--port=6380"} + objs, err := GenerateDragonflyResources(df, "") + require.NoError(t, err) + + sts := findStatefulSet(objs) + require.NotNil(t, sts) + + var hcPort *corev1.EnvVar + for i := range sts.Spec.Template.Spec.Containers[0].Env { + env := &sts.Spec.Template.Spec.Containers[0].Env[i] + if env.Name == "HEALTHCHECK_PORT" { + hcPort = env + break + } + } + require.NotNil(t, hcPort) + assert.Equal(t, fmt.Sprintf("%d", DragonflyAdminPort), hcPort.Value) +} + +func TestProbeVolumesAndMounts_DefaultHasNoProbeVolumes(t *testing.T) { + df := newTestDragonfly(1) + objs, err := GenerateDragonflyResources(df, "") + require.NoError(t, err) + + sts := findStatefulSet(objs) + require.NotNil(t, sts) + + for _, v := range sts.Spec.Template.Spec.Volumes { + assert.NotEqual(t, LivenessProbeVolumeName, v.Name, "no probe volumes without custom probes") + assert.NotEqual(t, ReadinessProbeVolumeName, v.Name) + assert.NotEqual(t, StartupProbeVolumeName, v.Name) + } +} + +func TestProbeVolumesAndMounts_PresentWhenCustomProbeSet(t *testing.T) { + df := newTestDragonfly(1) + df.Spec.CustomReadinessProbeConfigMap = &corev1.LocalObjectReference{Name: "my-readiness"} + objs, err := GenerateDragonflyResources(df, "") + require.NoError(t, err) + + sts := findStatefulSet(objs) + require.NotNil(t, sts) + + volumeNames := make(map[string]string) + for _, v := range sts.Spec.Template.Spec.Volumes { + if v.ConfigMap != nil { + volumeNames[v.Name] = v.ConfigMap.Name + } + } + assert.Equal(t, "test-df-liveness-probe", volumeNames[LivenessProbeVolumeName]) + assert.Equal(t, "my-readiness", volumeNames[ReadinessProbeVolumeName]) + assert.Equal(t, "test-df-startup-probe", volumeNames[StartupProbeVolumeName]) + + mountPaths := make(map[string]corev1.VolumeMount) + for _, m := range sts.Spec.Template.Spec.Containers[0].VolumeMounts { + mountPaths[m.Name] = m + } + assert.Equal(t, ProbeMountPath+"/"+LivenessScriptKey, mountPaths[LivenessProbeVolumeName].MountPath) + assert.Equal(t, ProbeMountPath+"/"+ReadinessScriptKey, mountPaths[ReadinessProbeVolumeName].MountPath) + assert.Equal(t, ProbeMountPath+"/"+StartupScriptKey, mountPaths[StartupProbeVolumeName].MountPath) +} + +func TestProbes_DefaultUseImageHealthcheck(t *testing.T) { + df := newTestDragonfly(1) + objs, err := GenerateDragonflyResources(df, "") + require.NoError(t, err) + + sts := findStatefulSet(objs) + require.NotNil(t, sts) + c := sts.Spec.Template.Spec.Containers[0] + + require.NotNil(t, c.LivenessProbe) + assert.Equal(t, []string{"/bin/sh", "/usr/local/bin/healthcheck.sh"}, c.LivenessProbe.Exec.Command) + + require.NotNil(t, c.ReadinessProbe) + assert.Equal(t, []string{"/bin/sh", "/usr/local/bin/healthcheck.sh"}, c.ReadinessProbe.Exec.Command) + + assert.Nil(t, c.StartupProbe, "no startup probe without custom probes") +} + +func TestProbes_PointToMountedScriptsWhenCustomSet(t *testing.T) { + df := newTestDragonfly(1) + df.Spec.CustomStartupProbeConfigMap = &corev1.LocalObjectReference{Name: "my-startup"} + objs, err := GenerateDragonflyResources(df, "") + require.NoError(t, err) + + sts := findStatefulSet(objs) + require.NotNil(t, sts) + c := sts.Spec.Template.Spec.Containers[0] + + assert.Equal(t, []string{"/bin/sh", ProbeMountPath + "/" + LivenessScriptKey}, c.LivenessProbe.Exec.Command) + assert.Equal(t, []string{"/bin/sh", ProbeMountPath + "/" + ReadinessScriptKey}, c.ReadinessProbe.Exec.Command) + + require.NotNil(t, c.StartupProbe) + assert.Equal(t, []string{"/bin/sh", ProbeMountPath + "/" + StartupScriptKey}, c.StartupProbe.Exec.Command) + assert.Equal(t, int32(60), c.StartupProbe.FailureThreshold) +} + +func TestProbeVolumes_CustomConfigMapOverride(t *testing.T) { + tests := []struct { + name string + setup func(df *resourcesv1.Dragonfly) + volumeName string + wantCM string + defaultCMName string // should NOT appear in generated resources + }{ + { + name: "custom liveness", + setup: func(df *resourcesv1.Dragonfly) { df.Spec.CustomLivenessProbeConfigMap = &corev1.LocalObjectReference{Name: "my-liveness"} }, + volumeName: LivenessProbeVolumeName, + wantCM: "my-liveness", + defaultCMName: "test-df-" + LivenessProbeConfigMapSuffix, + }, + { + name: "custom readiness", + setup: func(df *resourcesv1.Dragonfly) { df.Spec.CustomReadinessProbeConfigMap = &corev1.LocalObjectReference{Name: "my-readiness"} }, + volumeName: ReadinessProbeVolumeName, + wantCM: "my-readiness", + defaultCMName: "test-df-" + ReadinessProbeConfigMapSuffix, + }, + { + name: "custom startup", + setup: func(df *resourcesv1.Dragonfly) { df.Spec.CustomStartupProbeConfigMap = &corev1.LocalObjectReference{Name: "my-startup"} }, + volumeName: StartupProbeVolumeName, + wantCM: "my-startup", + defaultCMName: "test-df-" + StartupProbeConfigMapSuffix, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + df := newTestDragonfly(1) + tc.setup(df) + + objs, err := GenerateDragonflyResources(df, "") + require.NoError(t, err) + + // volume should reference the custom ConfigMap + sts := findStatefulSet(objs) + require.NotNil(t, sts) + + var found bool + for _, v := range sts.Spec.Template.Spec.Volumes { + if v.Name == tc.volumeName { + require.NotNil(t, v.ConfigMap) + assert.Equal(t, tc.wantCM, v.ConfigMap.Name) + found = true + break + } + } + assert.True(t, found, "volume %q not found", tc.volumeName) + + // default ConfigMap should NOT be generated + assert.Nil(t, findConfigMap(objs, tc.defaultCMName), + "default ConfigMap %q should not be generated when custom is set", tc.defaultCMName) + }) + } +} diff --git a/internal/resources/scripts.go b/internal/resources/scripts.go new file mode 100644 index 00000000..d77520b2 --- /dev/null +++ b/internal/resources/scripts.go @@ -0,0 +1,12 @@ +package resources + +import _ "embed" + +//go:embed scripts/liveness-check.sh +var defaultLivenessScript string + +//go:embed scripts/readiness-check.sh +var defaultReadinessScript string + +//go:embed scripts/startup-check.sh +var defaultStartupScript string diff --git a/internal/resources/scripts/liveness-check.sh b/internal/resources/scripts/liveness-check.sh new file mode 100644 index 00000000..120cec08 --- /dev/null +++ b/internal/resources/scripts/liveness-check.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +# Liveness: succeeds if Dragonfly responds (including LOADING). + +HOST="localhost" +PORT=${HEALTHCHECK_PORT:-9999} # injected by operator — admin port, no TLS, no auth + +RESPONSE=$(redis-cli -h "$HOST" -p "$PORT" PING 2>/dev/null) + +case "$RESPONSE" in + PONG|*LOADING*) exit 0 ;; + *) exit 1 ;; +esac diff --git a/internal/resources/scripts/readiness-check.sh b/internal/resources/scripts/readiness-check.sh new file mode 100644 index 00000000..e6fcf9ee --- /dev/null +++ b/internal/resources/scripts/readiness-check.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +# Readiness: succeeds only when dataset loading is complete (loading:0). + +HOST="localhost" +PORT=${HEALTHCHECK_PORT:-9999} # injected by operator — admin port, no TLS, no auth + +RESPONSE=$(redis-cli -h "$HOST" -p "$PORT" INFO persistence 2>/dev/null) + +if echo "$RESPONSE" | grep -q "^loading:0"; then + exit 0 +fi +exit 1 diff --git a/internal/resources/scripts/startup-check.sh b/internal/resources/scripts/startup-check.sh new file mode 100644 index 00000000..02a7da84 --- /dev/null +++ b/internal/resources/scripts/startup-check.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +# Startup: succeeds if Dragonfly responds (including LOADING). + +HOST="localhost" +PORT=${HEALTHCHECK_PORT:-9999} # injected by operator — admin port, no TLS, no auth + +RESPONSE=$(redis-cli -h "$HOST" -p "$PORT" PING 2>/dev/null) + +case "$RESPONSE" in + PONG|*LOADING*) exit 0 ;; + *) exit 1 ;; +esac diff --git a/manifests/crd.yaml b/manifests/crd.yaml index c67bf187..e3a5bfa1 100644 --- a/manifests/crd.yaml +++ b/manifests/crd.yaml @@ -4460,6 +4460,51 @@ spec: type: string type: object type: object + customLivenessProbeConfigMap: + description: (Optional) Custom ConfigMap with key "liveness-check.sh" + to override the default liveness probe. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + customReadinessProbeConfigMap: + description: (Optional) Custom ConfigMap with key "readiness-check.sh" + to override the default readiness probe. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + customStartupProbeConfigMap: + description: (Optional) Custom ConfigMap with key "startup-check.sh" + to override the default startup probe. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic enableReplicationReadinessGate: description: |- (Optional) When enabled, adds a custom readiness gate to pods that prevents diff --git a/manifests/dragonfly-operator.yaml b/manifests/dragonfly-operator.yaml index 2a7f1e98..ac4ac942 100644 --- a/manifests/dragonfly-operator.yaml +++ b/manifests/dragonfly-operator.yaml @@ -4473,6 +4473,51 @@ spec: type: string type: object type: object + customLivenessProbeConfigMap: + description: (Optional) Custom ConfigMap with key "liveness-check.sh" + to override the default liveness probe. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + customReadinessProbeConfigMap: + description: (Optional) Custom ConfigMap with key "readiness-check.sh" + to override the default readiness probe. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + customStartupProbeConfigMap: + description: (Optional) Custom ConfigMap with key "startup-check.sh" + to override the default startup probe. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic enableReplicationReadinessGate: description: |- (Optional) When enabled, adds a custom readiness gate to pods that prevents @@ -7178,13 +7223,7 @@ rules: - apiGroups: - "" resources: - - events - verbs: - - create - - patch -- apiGroups: - - "" - resources: + - configmaps - pods - services verbs: @@ -7195,6 +7234,13 @@ rules: - patch - update - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch - apiGroups: - "" resources: