Skip to content

Commit

Permalink
Add PDB to EtcdCluster spec and implement validation webhhoks (#93)
Browse files Browse the repository at this point in the history
Added PDB to spec, implemented webhooks and tests.

Fixes #61
  • Loading branch information
sircthulhu committed Apr 2, 2024
1 parent 3e172b5 commit 39aec69
Show file tree
Hide file tree
Showing 12 changed files with 663 additions and 22 deletions.
37 changes: 35 additions & 2 deletions api/v1alpha1/etcdcluster_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package v1alpha1
import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
)

const defaultEtcdImage = "quay.io/coreos/etcd:v3.5.12"
Expand All @@ -31,8 +32,11 @@ type EtcdClusterSpec struct {
// +kubebuilder:validation:Minimum:=0
Replicas *int32 `json:"replicas,omitempty"`
// PodSpec defines the desired state of PodSpec for etcd members. If not specified, default values will be used.
PodSpec PodSpec `json:"podSpec,omitempty"`
Storage StorageSpec `json:"storage"`
PodSpec PodSpec `json:"podSpec,omitempty"`
// PodDisruptionBudget describes PDB resource to create for etcd cluster members. Nil to disable.
//+optional
PodDisruptionBudget *EmbeddedPodDisruptionBudget `json:"podDisruptionBudget,omitempty"`
Storage StorageSpec `json:"storage"`
}

const (
Expand Down Expand Up @@ -76,6 +80,11 @@ type EtcdCluster struct {
Status EtcdClusterStatus `json:"status,omitempty"`
}

// CalculateQuorumSize returns minimum quorum size for current number of replicas
func (r *EtcdCluster) CalculateQuorumSize() int {
return int(*r.Spec.Replicas)/2 + 1
}

// +kubebuilder:object:root=true

// EtcdClusterList contains a list of EtcdCluster
Expand Down Expand Up @@ -202,6 +211,30 @@ type EmbeddedPersistentVolumeClaim struct {
Status corev1.PersistentVolumeClaimStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"`
}

// EmbeddedPodDisruptionBudget describes PDB resource for etcd cluster members
type EmbeddedPodDisruptionBudget struct {
// EmbeddedMetadata contains metadata relevant to an EmbeddedResource.
// +optional
EmbeddedObjectMetadata `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
// Spec defines the desired characteristics of a PDB.
// More info: https://kubernetes.io/docs/concepts/workloads/pods/disruptions/#pod-disruption-budgets
//+optional
Spec PodDisruptionBudgetSpec `json:"spec"`
}

type PodDisruptionBudgetSpec struct {
// MinAvailable describes minimum ready replicas. If both are empty, controller will implicitly
// calculate MaxUnavailable based on number of replicas
// Mutually exclusive with MaxUnavailable.
// +optional
MinAvailable *intstr.IntOrString `json:"minAvailable,omitempty"`
// MinAvailable describes maximum not ready replicas. If both are empty, controller will implicitly
// calculate MaxUnavailable based on number of replicas
// Mutually exclusive with MinAvailable
// +optional
MaxUnavailable *intstr.IntOrString `json:"maxUnavailable,omitempty"`
}

func init() {
SchemeBuilder.Register(&EtcdCluster{}, &EtcdClusterList{})
}
22 changes: 22 additions & 0 deletions api/v1alpha1/etcdcluster_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package v1alpha1

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"k8s.io/utils/ptr"
)

var _ = Context("CalculateQuorumSize", func() {
It("should return correct result for odd number of replicas", func() {
etcdCluster := EtcdCluster{
Spec: EtcdClusterSpec{Replicas: ptr.To(int32(3))},
}
Expect(etcdCluster.CalculateQuorumSize()).To(Equal(2))
})
It("should return correct result for even number of replicas", func() {
etcdCluster := EtcdCluster{
Spec: EtcdClusterSpec{Replicas: ptr.To(int32(4))},
}
Expect(etcdCluster.CalculateQuorumSize()).To(Equal(3))
})
})
115 changes: 114 additions & 1 deletion api/v1alpha1/etcdcluster_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,15 @@ limitations under the License.
package v1alpha1

import (
"fmt"
"math"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/apimachinery/pkg/util/validation/field"
ctrl "sigs.k8s.io/controller-runtime"
logf "sigs.k8s.io/controller-runtime/pkg/log"
Expand Down Expand Up @@ -69,7 +73,14 @@ var _ webhook.Validator = &EtcdCluster{}
// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
func (r *EtcdCluster) ValidateCreate() (admission.Warnings, error) {
etcdclusterlog.Info("validate create", "name", r.Name)
return nil, nil
warnings, err := r.validatePdb()
if err != nil {
return nil, errors.NewInvalid(
schema.GroupKind{Group: GroupVersion.Group, Kind: "EtcdCluster"},
r.Name, err)
}

return warnings, nil
}

// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
Expand All @@ -91,6 +102,14 @@ func (r *EtcdCluster) ValidateUpdate(old runtime.Object) (admission.Warnings, er
)
}

pdbWarnings, pdbErr := r.validatePdb()
if pdbErr != nil {
allErrors = append(allErrors, pdbErr...)
}
if len(pdbWarnings) > 0 {
warnings = append(warnings, pdbWarnings...)
}

if len(allErrors) > 0 {
err := errors.NewInvalid(
schema.GroupKind{Group: GroupVersion.Group, Kind: "EtcdCluster"},
Expand All @@ -106,3 +125,97 @@ func (r *EtcdCluster) ValidateDelete() (admission.Warnings, error) {
etcdclusterlog.Info("validate delete", "name", r.Name)
return nil, nil
}

// validatePdb validates PDB fields
func (r *EtcdCluster) validatePdb() (admission.Warnings, field.ErrorList) {
if r.Spec.PodDisruptionBudget == nil {
return nil, nil
}
pdb := r.Spec.PodDisruptionBudget
var warnings admission.Warnings
var allErrors field.ErrorList

if pdb.Spec.MinAvailable != nil && pdb.Spec.MaxUnavailable != nil {
allErrors = append(allErrors, field.Invalid(
field.NewPath("spec", "podDisruptionBudget", "minAvailable"),
pdb.Spec.MinAvailable.IntValue(),
"minAvailable is mutually exclusive with maxUnavailable"),
)
return nil, allErrors
}

minQuorumSize := r.CalculateQuorumSize()
if pdb.Spec.MinAvailable != nil {
minAvailable := pdb.Spec.MinAvailable.IntValue()
if pdb.Spec.MinAvailable.Type == intstr.String && minAvailable == 0 && pdb.Spec.MinAvailable.StrVal != "0" {
var percentage int
_, err := fmt.Sscanf(pdb.Spec.MinAvailable.StrVal, "%d%%", &percentage)
if err != nil {
allErrors = append(allErrors, field.Invalid(
field.NewPath("spec", "podDisruptionBudget", "minAvailable"),
pdb.Spec.MinAvailable.StrVal,
"invalid percentage value"),
)
} else {
minAvailable = int(math.Ceil(float64(*r.Spec.Replicas) * (float64(percentage) / 100)))
}
}

if minAvailable < 0 {
allErrors = append(allErrors, field.Invalid(
field.NewPath("spec", "podDisruptionBudget", "minAvailable"),
pdb.Spec.MinAvailable.IntValue(),
"value cannot be less than zero"),
)
}
if minAvailable > int(*r.Spec.Replicas) {
allErrors = append(allErrors, field.Invalid(
field.NewPath("spec", "podDisruptionBudget", "minAvailable"),
pdb.Spec.MinAvailable.IntValue(),
"value cannot be larger than number of replicas"),
)
}
if minAvailable < minQuorumSize {
warnings = append(warnings, "current number of spec.podDisruptionBudget.minAvailable can lead to loss of quorum")
}
}
if pdb.Spec.MaxUnavailable != nil {
maxUnavailable := pdb.Spec.MaxUnavailable.IntValue()
if pdb.Spec.MaxUnavailable.Type == intstr.String && maxUnavailable == 0 && pdb.Spec.MaxUnavailable.StrVal != "0" {
var percentage int
_, err := fmt.Sscanf(pdb.Spec.MaxUnavailable.StrVal, "%d%%", &percentage)
if err != nil {
allErrors = append(allErrors, field.Invalid(
field.NewPath("spec", "podDisruptionBudget", "maxUnavailable"),
pdb.Spec.MaxUnavailable.StrVal,
"invalid percentage value"),
)
} else {
maxUnavailable = int(math.Ceil(float64(*r.Spec.Replicas) * (float64(percentage) / 100)))
}
}
if maxUnavailable < 0 {
allErrors = append(allErrors, field.Invalid(
field.NewPath("spec", "podDisruptionBudget", "maxUnavailable"),
pdb.Spec.MaxUnavailable.IntValue(),
"value cannot be less than zero"),
)
}
if maxUnavailable > int(*r.Spec.Replicas) {
allErrors = append(allErrors, field.Invalid(
field.NewPath("spec", "podDisruptionBudget", "maxUnavailable"),
pdb.Spec.MaxUnavailable.IntValue(),
"value cannot be larger than number of replicas"),
)
}
if int(*r.Spec.Replicas)-maxUnavailable < minQuorumSize {
warnings = append(warnings, "current number of spec.podDisruptionBudget.maxUnavailable can lead to loss of quorum")
}
}

if len(allErrors) > 0 {
return nil, allErrors
}

return warnings, nil
}
Loading

0 comments on commit 39aec69

Please sign in to comment.