diff --git a/Makefile b/Makefile index 627737950..b058162b2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VERSION := 0.3.0 +VERSION := 0.4.0 # Name of this service/application SERVICE_NAME := redis-operator @@ -144,6 +144,6 @@ endif # Generate kubernetes code for types.. .PHONY: update-codegen -update-codegen: build +update-codegen: docker-build @echo ">> Generating code for Kubernetes CRD types..." docker run --rm -v $(PWD):/go/src/github.com/spotahome/redis-operator/ $(REPOSITORY)-dev /bin/bash -c '$(UPDATE_CODEGEN_CMD)' diff --git a/README.md b/README.md index a801023dd..0e4acbde6 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,9 @@ helm install --name redisfailover charts/redisoperator ## Usage Once the operator is deployed inside a Kubernetes cluster, a new API will be accesible, so you'll be able to create, update and delete redisfailovers. -In order to deploy a new redis-failover a [specification](example/redisfailover.yaml) has to be created: +In order to deploy a new redis-failover a [specification](example/redisfailover/all-options.yaml) has to be created: ``` -kubectl create -f https://raw.githubusercontent.com/spotahome/redis-operator/master/example/redisfailover.yaml +kubectl create -f https://raw.githubusercontent.com/spotahome/redis-operator/master/redisfailover/all-options.yaml ``` This redis-failover will be managed by the operator, resulting in the following elements created inside Kubernetes: @@ -51,6 +51,13 @@ This redis-failover will be managed by the operator, resulting in the following **NOTE**: `NAME` is the named provided when creating the RedisFailover. +### Persistance +The operator has the ability of add persistance to Redis data. By default an `emptyDir` will be used, so the data is not saved. + +In order to have persistance, a PersistentVolumeClaim usage is allowed. The full [PVC definition has to be added](example/redisfailover/persistant-storage.yaml) to the Redis Failover Spec under the `Storage` section. + +**IMPORTANT**: By default, the persistent volume claims will be deleted when the Redis Failover is. If this is not the expected usage, a `keepAfterDeletion` flag can be added under the `storage` section of Redis. [An example is given](example/redisfailover/persistant-storage-no-pvc-deletion.yaml). + ### Connection In order to connect to the redis-failover and use it, a [Sentinel-ready](https://redis.io/topics/sentinel-clients) library has to be used. This will connect through the Sentinel service to the Redis node working as a master. The connection parameters are the following: diff --git a/api/redisfailover/v1alpha2/types.go b/api/redisfailover/v1alpha2/types.go index 7b1127397..4d2aff8a9 100644 --- a/api/redisfailover/v1alpha2/types.go +++ b/api/redisfailover/v1alpha2/types.go @@ -43,7 +43,7 @@ type RedisSettings struct { Image string `json:"image,omitempty"` Version string `json:"version,omitempty"` ConfigMap string `json:"configMap,omitempty"` - DataVolume corev1.Volume `json:"dataVolume,omitempty"` + Storage RedisStorage `json:"storage,omitempty"` } // SentinelSettings defines the specification of the sentinel cluster @@ -65,6 +65,13 @@ type CPUAndMem struct { Memory string `json:"memory"` } +// RedisStorage defines the structure used to store the Redis Data +type RedisStorage struct { + KeepAfterDeletion bool `json:"keepAfterDeletion,omitempty"` + EmptyDir *corev1.EmptyDirVolumeSource `json:"emptyDir,omitempty"` + PersistentVolumeClaim *corev1.PersistentVolumeClaim `json:"persistentVolumeClaim,omitempty"` +} + // RedisFailoverStatus has the status of the cluster type RedisFailoverStatus struct { Phase Phase `json:"phase"` diff --git a/api/redisfailover/v1alpha2/zz_generated.deepcopy.go b/api/redisfailover/v1alpha2/zz_generated.deepcopy.go index 6794f35b5..765094c86 100644 --- a/api/redisfailover/v1alpha2/zz_generated.deepcopy.go +++ b/api/redisfailover/v1alpha2/zz_generated.deepcopy.go @@ -188,7 +188,7 @@ func (in *RedisFailoverStatus) DeepCopy() *RedisFailoverStatus { func (in *RedisSettings) DeepCopyInto(out *RedisSettings) { *out = *in out.Resources = in.Resources - in.DataVolume.DeepCopyInto(&out.DataVolume) + in.Storage.DeepCopyInto(&out.Storage) return } @@ -202,6 +202,40 @@ func (in *RedisSettings) DeepCopy() *RedisSettings { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisStorage) DeepCopyInto(out *RedisStorage) { + *out = *in + if in.EmptyDir != nil { + in, out := &in.EmptyDir, &out.EmptyDir + if *in == nil { + *out = nil + } else { + *out = new(v1.EmptyDirVolumeSource) + (*in).DeepCopyInto(*out) + } + } + if in.PersistentVolumeClaim != nil { + in, out := &in.PersistentVolumeClaim, &out.PersistentVolumeClaim + if *in == nil { + *out = nil + } else { + *out = new(v1.PersistentVolumeClaim) + (*in).DeepCopyInto(*out) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisStorage. +func (in *RedisStorage) DeepCopy() *RedisStorage { + if in == nil { + return nil + } + out := new(RedisStorage) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SentinelSettings) DeepCopyInto(out *SentinelSettings) { *out = *in diff --git a/example/operator.yaml b/example/operator/operator.yaml similarity index 100% rename from example/operator.yaml rename to example/operator/operator.yaml diff --git a/example/rolebinding.yaml b/example/operator/rolebinding.yaml similarity index 100% rename from example/rolebinding.yaml rename to example/operator/rolebinding.yaml diff --git a/example/roles.yaml b/example/operator/roles.yaml similarity index 100% rename from example/roles.yaml rename to example/operator/roles.yaml diff --git a/example/serviceaccount.yaml b/example/operator/serviceaccount.yaml similarity index 100% rename from example/serviceaccount.yaml rename to example/operator/serviceaccount.yaml diff --git a/example/redisfailover.yaml b/example/redisfailover/all-options.yaml similarity index 93% rename from example/redisfailover.yaml rename to example/redisfailover/all-options.yaml index bb21aad70..2c4785340 100644 --- a/example/redisfailover.yaml +++ b/example/redisfailover/all-options.yaml @@ -25,6 +25,6 @@ spec: exporter: false # Optional. False by default. Adds a redis-exporter container to export metrics. exporterImage: oliver006/redis_exporter # Optional. oliver006/redis_exporter by default. exporterVersion: v0.11.3 # Optional. v0.11.3 by default. - dataVolume: - name: redis-data - emptyDir: {} + storage: + emptyDir: {} # Optional. emptyDir by default. + diff --git a/example/minimum.yaml b/example/redisfailover/minimum.yaml similarity index 100% rename from example/minimum.yaml rename to example/redisfailover/minimum.yaml diff --git a/example/redisfailover/persistant-storage-no-pvc-deletion.yaml b/example/redisfailover/persistant-storage-no-pvc-deletion.yaml new file mode 100644 index 000000000..98aca799c --- /dev/null +++ b/example/redisfailover/persistant-storage-no-pvc-deletion.yaml @@ -0,0 +1,21 @@ +apiVersion: storage.spotahome.com/v1alpha2 +kind: RedisFailover +metadata: + name: redisfailover-persistant-keep +spec: + sentinel: + replicas: 3 + redis: + replicas: 3 + storage: + keepAfterDeletion: true + persistentVolumeClaim: + metadata: + name: redisfailover-persistant-keep-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + diff --git a/example/redisfailover/persistant-storage.yaml b/example/redisfailover/persistant-storage.yaml new file mode 100644 index 000000000..e327a60b7 --- /dev/null +++ b/example/redisfailover/persistant-storage.yaml @@ -0,0 +1,20 @@ +apiVersion: storage.spotahome.com/v1alpha2 +kind: RedisFailover +metadata: + name: redisfailover-persistant +spec: + sentinel: + replicas: 3 + redis: + replicas: 3 + storage: + persistentVolumeClaim: + metadata: + name: redisfailover-persistant-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + diff --git a/operator/redisfailover/service/generator.go b/operator/redisfailover/service/generator.go index 0035a9757..2a7febc21 100644 --- a/operator/redisfailover/service/generator.go +++ b/operator/redisfailover/service/generator.go @@ -14,6 +14,11 @@ import ( "github.com/spotahome/redis-operator/operator/redisfailover/util" ) +const ( + redisConfigurationVolumeName = "redis-config" + redisStorageVolumeName = "redis-data" +) + func generateSentinelService(rf *redisfailoverv1alpha2.RedisFailover, labels map[string]string, ownerRefs []metav1.OwnerReference) *corev1.Service { name := GetSentinelName(rf) namespace := rf.Namespace @@ -205,6 +210,16 @@ func generateRedisStatefulSet(rf *redisfailoverv1alpha2.RedisFailover, labels ma }, } + if rf.Spec.Redis.Storage.PersistentVolumeClaim != nil { + if !rf.Spec.Redis.Storage.KeepAfterDeletion { + // Set an owner reference so the persistent volumes are deleted when the RF is + rf.Spec.Redis.Storage.PersistentVolumeClaim.OwnerReferences = ownerRefs + } + ss.Spec.VolumeClaimTemplates = []corev1.PersistentVolumeClaim{ + *rf.Spec.Redis.Storage.PersistentVolumeClaim, + } + } + if rf.Spec.Redis.Exporter { exporter := createRedisExporterContainer(rf) ss.Spec.Template.Spec.Containers = append(ss.Spec.Template.Spec.Containers, exporter) @@ -523,17 +538,13 @@ func getRedisExporterImage(rf *redisfailoverv1alpha2.RedisFailover) string { func getRedisVolumeMounts(rf *redisfailoverv1alpha2.RedisFailover) []corev1.VolumeMount { volumeMounts := []corev1.VolumeMount{ { - Name: "redis-config", + Name: redisConfigurationVolumeName, MountPath: "/redis", }, - } - - // check if data volume is set, if set, mount to /data - if rf.Spec.Redis.DataVolume.Name != "" { - volumeMounts = append(volumeMounts, corev1.VolumeMount{ - Name: rf.Spec.Redis.DataVolume.Name, + { + Name: getRedisDataVolumeName(rf), MountPath: "/data", - }) + }, } return volumeMounts @@ -544,7 +555,7 @@ func getRedisVolumes(rf *redisfailoverv1alpha2.RedisFailover) []corev1.Volume { volumes := []corev1.Volume{ { - Name: "redis-config", + Name: redisConfigurationVolumeName, VolumeSource: corev1.VolumeSource{ ConfigMap: &corev1.ConfigMapVolumeSource{ LocalObjectReference: corev1.LocalObjectReference{ @@ -555,10 +566,44 @@ func getRedisVolumes(rf *redisfailoverv1alpha2.RedisFailover) []corev1.Volume { }, } - // check if data volume is set, if not set skip it - if rf.Spec.Redis.DataVolume.Name != "" { - volumes = append(volumes, rf.Spec.Redis.DataVolume) + dataVolume := getRedisDataVolume(rf) + if dataVolume != nil { + volumes = append(volumes, *dataVolume) } return volumes } + +func getRedisDataVolume(rf *redisfailoverv1alpha2.RedisFailover) *corev1.Volume { + // This will find the volumed desired by the user. If no volume defined + // an EmptyDir will be used by default + switch { + case rf.Spec.Redis.Storage.PersistentVolumeClaim != nil: + return nil + case rf.Spec.Redis.Storage.EmptyDir != nil: + return &corev1.Volume{ + Name: redisStorageVolumeName, + VolumeSource: corev1.VolumeSource{ + EmptyDir: rf.Spec.Redis.Storage.EmptyDir, + }, + } + default: + return &corev1.Volume{ + Name: redisStorageVolumeName, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + } + } +} + +func getRedisDataVolumeName(rf *redisfailoverv1alpha2.RedisFailover) string { + switch { + case rf.Spec.Redis.Storage.PersistentVolumeClaim != nil: + return rf.Spec.Redis.Storage.PersistentVolumeClaim.Name + case rf.Spec.Redis.Storage.EmptyDir != nil: + return redisStorageVolumeName + default: + return redisStorageVolumeName + } +} diff --git a/operator/redisfailover/service/generator_test.go b/operator/redisfailover/service/generator_test.go new file mode 100644 index 000000000..826587606 --- /dev/null +++ b/operator/redisfailover/service/generator_test.go @@ -0,0 +1,377 @@ +package service_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + appsv1beta2 "k8s.io/api/apps/v1beta2" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + redisfailoverv1alpha2 "github.com/spotahome/redis-operator/api/redisfailover/v1alpha2" + "github.com/spotahome/redis-operator/log" + mK8SService "github.com/spotahome/redis-operator/mocks/service/k8s" + rfservice "github.com/spotahome/redis-operator/operator/redisfailover/service" +) + +func TestRedisStatefulSetStorageGeneration(t *testing.T) { + configMapName := rfservice.GetRedisConfigMapName(generateRF()) + tests := []struct { + name string + ownerRefs []metav1.OwnerReference + expectedSS appsv1beta2.StatefulSet + rfRedisStorage redisfailoverv1alpha2.RedisStorage + }{ + { + name: "Default values", + expectedSS: appsv1beta2.StatefulSet{ + Spec: appsv1beta2.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + VolumeMounts: []corev1.VolumeMount{ + { + Name: "redis-config", + MountPath: "/redis", + }, + { + Name: "redis-data", + MountPath: "/data", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "redis-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + }, + }, + }, + { + Name: "redis-data", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, + }, + }, + }, + }, + }, + rfRedisStorage: redisfailoverv1alpha2.RedisStorage{}, + }, + { + name: "Defined an emptydir with storage on memory", + expectedSS: appsv1beta2.StatefulSet{ + Spec: appsv1beta2.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + VolumeMounts: []corev1.VolumeMount{ + { + Name: "redis-config", + MountPath: "/redis", + }, + { + Name: "redis-data", + MountPath: "/data", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "redis-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + }, + }, + }, + { + Name: "redis-data", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + }, + }, + }, + }, + }, + rfRedisStorage: redisfailoverv1alpha2.RedisStorage{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + { + name: "Defined an persistentvolumeclaim", + expectedSS: appsv1beta2.StatefulSet{ + Spec: appsv1beta2.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + VolumeMounts: []corev1.VolumeMount{ + { + Name: "redis-config", + MountPath: "/redis", + }, + { + Name: "pvc-data", + MountPath: "/data", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "redis-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + }, + }, + }, + }, + }, + }, + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pvc-data", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + "ReadWriteOnce", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }, + }, + }, + }, + }, + }, + }, + rfRedisStorage: redisfailoverv1alpha2.RedisStorage{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pvc-data", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + "ReadWriteOnce", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }, + }, + }, + }, + }, + }, + { + name: "Defined an persistentvolumeclaim with ownerRefs", + ownerRefs: []metav1.OwnerReference{ + { + Name: "testing", + }, + }, + expectedSS: appsv1beta2.StatefulSet{ + Spec: appsv1beta2.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + VolumeMounts: []corev1.VolumeMount{ + { + Name: "redis-config", + MountPath: "/redis", + }, + { + Name: "pvc-data", + MountPath: "/data", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "redis-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + }, + }, + }, + }, + }, + }, + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pvc-data", + OwnerReferences: []metav1.OwnerReference{ + { + Name: "testing", + }, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + "ReadWriteOnce", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }, + }, + }, + }, + }, + }, + }, + rfRedisStorage: redisfailoverv1alpha2.RedisStorage{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pvc-data", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + "ReadWriteOnce", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }, + }, + }, + }, + }, + }, + { + name: "Defined an persistentvolumeclaim with ownerRefs keeping the pvc", + ownerRefs: []metav1.OwnerReference{ + { + Name: "testing", + }, + }, + expectedSS: appsv1beta2.StatefulSet{ + Spec: appsv1beta2.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + VolumeMounts: []corev1.VolumeMount{ + { + Name: "redis-config", + MountPath: "/redis", + }, + { + Name: "pvc-data", + MountPath: "/data", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "redis-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + }, + }, + }, + }, + }, + }, + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pvc-data", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + "ReadWriteOnce", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }, + }, + }, + }, + }, + }, + }, + rfRedisStorage: redisfailoverv1alpha2.RedisStorage{ + KeepAfterDeletion: true, + PersistentVolumeClaim: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pvc-data", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + "ReadWriteOnce", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }, + }, + }, + }, + }, + }, + } + + for _, test := range tests { + assert := assert.New(t) + + // Generate a default RedisFailover and attaching the required storage + rf := generateRF() + rf.Spec.Redis.Storage = test.rfRedisStorage + + generatedStatefulSet := appsv1beta2.StatefulSet{} + + ms := &mK8SService.Services{} + ms.On("CreateOrUpdatePodDisruptionBudget", namespace, mock.Anything).Once().Return(nil, nil) + ms.On("CreateOrUpdateStatefulSet", namespace, mock.Anything).Once().Run(func(args mock.Arguments) { + ss := args.Get(1).(*appsv1beta2.StatefulSet) + generatedStatefulSet = *ss + }).Return(nil) + + client := rfservice.NewRedisFailoverKubeClient(ms, log.Dummy) + err := client.EnsureRedisStatefulset(rf, nil, test.ownerRefs) + + // Check that the storage-related fields are as spected + assert.Equal(test.expectedSS.Spec.Template.Spec.Volumes, generatedStatefulSet.Spec.Template.Spec.Volumes) + assert.Equal(test.expectedSS.Spec.Template.Spec.Containers[0].VolumeMounts, generatedStatefulSet.Spec.Template.Spec.Containers[0].VolumeMounts) + assert.Equal(test.expectedSS.Spec.VolumeClaimTemplates, generatedStatefulSet.Spec.VolumeClaimTemplates) + assert.NoError(err) + } +}