From f7046a9266895bd86481f65f1ab4c36af4bdc0cf Mon Sep 17 00:00:00 2001 From: Tamal Saha Date: Sat, 15 Aug 2026 23:38:38 +0600 Subject: [PATCH] Add Etcd resource calculator and ops-request path mapping Registers Etcd (kubedb.com/v1alpha2 and gitops.kubedb.com/v1alpha1) with the same shape as ZooKeeper -- the closest architectural sibling already in this repo, a single-role consensus ensemble with an optional Prometheus exporter sidecar, standalone below 2 replicas and ensemble at or above. Also adds the ops.kubedb.com/v1alpha1 EtcdOpsRequest path mapping for horizontal/vertical scaling and volume expansion, using the field names EtcdVerticalScalingSpec and EtcdVolumeExpansionSpec actually declare (etcd/exporter), mirroring how Redis's mapping is shaped rather than ZooKeeper's single 'node' field. Signed-off-by: Tamal Saha --- kubedb.com/v1alpha2/constants.go | 1 + kubedb.com/v1alpha2/etcd.go | 99 ++++++++++++ kubedb.com/v1alpha2/etcd_test.go | 146 ++++++++++++++++++ ops.kubedb.com/v1alpha1/etcd_mapping.go | 58 +++++++ .../kubedb.com/v1alpha2/etcd/ensemble.yaml | 45 ++++++ .../kubedb.com/v1alpha2/etcd/standalone.yaml | 43 ++++++ 6 files changed, 392 insertions(+) create mode 100644 kubedb.com/v1alpha2/etcd.go create mode 100644 kubedb.com/v1alpha2/etcd_test.go create mode 100644 ops.kubedb.com/v1alpha1/etcd_mapping.go create mode 100644 testdata/kubedb.com/v1alpha2/etcd/ensemble.yaml create mode 100644 testdata/kubedb.com/v1alpha2/etcd/standalone.yaml diff --git a/kubedb.com/v1alpha2/constants.go b/kubedb.com/v1alpha2/constants.go index 2d09fc5..1636772 100644 --- a/kubedb.com/v1alpha2/constants.go +++ b/kubedb.com/v1alpha2/constants.go @@ -36,6 +36,7 @@ const ( DB2ContainerName = "db2" DruidContainerName = "druid" DocumentDBContainerName = "documentdb" + EtcdContainerName = "etcd" HazelcastContainerName = "hazelcast" HanaDBContainerName = "hanadb" IgniteContainerName = "ignite" diff --git a/kubedb.com/v1alpha2/etcd.go b/kubedb.com/v1alpha2/etcd.go new file mode 100644 index 0000000..95f6f15 --- /dev/null +++ b/kubedb.com/v1alpha2/etcd.go @@ -0,0 +1,99 @@ +/* +Copyright AppsCode Inc. and 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 v1alpha2 + +import ( + "fmt" + + "kmodules.xyz/resource-metrics/api" + + core "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func init() { + api.Register(schema.GroupVersionKind{ + Group: "kubedb.com", + Version: "v1alpha2", + Kind: "Etcd", + }, Etcd{}.ResourceCalculator()) + api.Register(schema.GroupVersionKind{ + Group: "gitops.kubedb.com", + Version: "v1alpha1", + Kind: "Etcd", + }, Etcd{}.ResourceCalculator()) +} + +type Etcd struct{} + +func (e Etcd) ResourceCalculator() api.ResourceCalculator { + return &api.ResourceCalculatorFuncs{ + AppRoles: []api.PodRole{api.PodRoleDefault}, + RuntimeRoles: []api.PodRole{api.PodRoleDefault, api.PodRoleExporter}, + RoleReplicasFn: e.roleReplicasFn, + ModeFn: e.modeFn, + UsesTLSFn: e.usesTLSFn, + RoleResourceLimitsFn: e.roleResourceFn(api.ResourceLimits), + RoleResourceRequestsFn: e.roleResourceFn(api.ResourceRequests), + } +} + +func (e Etcd) roleReplicasFn(obj map[string]any) (api.ReplicaList, error) { + replicas, found, err := unstructured.NestedInt64(obj, "spec", "replicas") + if err != nil { + return nil, fmt.Errorf("failed to read spec.replicas %v: %w", obj, err) + } + if !found { + return api.ReplicaList{api.PodRoleDefault: 1}, nil + } + return api.ReplicaList{api.PodRoleDefault: replicas}, nil +} + +func (e Etcd) modeFn(obj map[string]any) (string, error) { + replicas, _, err := unstructured.NestedInt64(obj, "spec", "replicas") + if err != nil { + return "", err + } + if replicas > 1 { + return DBModeEnsemble, nil + } + return DBModeStandalone, nil +} + +func (e Etcd) usesTLSFn(obj map[string]any) (bool, error) { + _, found, err := unstructured.NestedFieldNoCopy(obj, "spec", "tls") + return found, err +} + +func (e Etcd) roleResourceFn(fn func(rr core.ResourceRequirements) core.ResourceList) func(obj map[string]any) (map[api.PodRole]api.PodInfo, error) { + return func(obj map[string]any) (map[api.PodRole]api.PodInfo, error) { + container, replicas, err := api.AppNodeResourcesV2(obj, fn, EtcdContainerName, "spec") + if err != nil { + return nil, err + } + + exporter, err := api.ContainerResources(obj, fn, "spec", "monitor", "prometheus", "exporter") + if err != nil { + return nil, err + } + return map[api.PodRole]api.PodInfo{ + api.PodRoleDefault: {Resource: container, Replicas: replicas}, + api.PodRoleExporter: {Resource: exporter, Replicas: replicas}, + }, nil + } +} diff --git a/kubedb.com/v1alpha2/etcd_test.go b/kubedb.com/v1alpha2/etcd_test.go new file mode 100644 index 0000000..129a7fb --- /dev/null +++ b/kubedb.com/v1alpha2/etcd_test.go @@ -0,0 +1,146 @@ +/* +Copyright AppsCode Inc. and 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 v1alpha2 + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + tl "gomodules.xyz/testing" + core "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +func TestEtcd(t *testing.T) { + type want struct { + replicas int64 + mode string + totalResources core.ResourceRequirements + appResources core.ResourceRequirements + } + tests := []struct { + name string + want want + }{ + { + name: "testdata/kubedb.com/v1alpha2/etcd/standalone.yaml", + want: want{ + replicas: 1, + mode: DBModeStandalone, + totalResources: core.ResourceRequirements{ + Limits: core.ResourceList{ + core.ResourceCPU: resource.MustParse("600m"), + core.ResourceMemory: resource.MustParse("600Mi"), + core.ResourceStorage: resource.MustParse("2Gi"), + }, + Requests: core.ResourceList{ + core.ResourceCPU: resource.MustParse("500m"), + core.ResourceMemory: resource.MustParse("500Mi"), + core.ResourceStorage: resource.MustParse("2Gi"), + }, + }, + appResources: core.ResourceRequirements{ + Limits: core.ResourceList{ + core.ResourceCPU: resource.MustParse("450m"), + core.ResourceMemory: resource.MustParse("450Mi"), + core.ResourceStorage: resource.MustParse("2Gi"), + }, + Requests: core.ResourceList{ + core.ResourceCPU: resource.MustParse("400m"), + core.ResourceMemory: resource.MustParse("400Mi"), + core.ResourceStorage: resource.MustParse("2Gi"), + }, + }, + }, + }, + { + name: "testdata/kubedb.com/v1alpha2/etcd/ensemble.yaml", + want: want{ + replicas: 3, + mode: DBModeEnsemble, + totalResources: core.ResourceRequirements{ + Limits: core.ResourceList{ + core.ResourceCPU: resource.MustParse("1800m"), + core.ResourceMemory: resource.MustParse("1800Mi"), + core.ResourceStorage: resource.MustParse("6Gi"), + }, + Requests: core.ResourceList{ + core.ResourceCPU: resource.MustParse("1500m"), + core.ResourceMemory: resource.MustParse("1500Mi"), + core.ResourceStorage: resource.MustParse("6Gi"), + }, + }, + appResources: core.ResourceRequirements{ + Limits: core.ResourceList{ + core.ResourceCPU: resource.MustParse("1350m"), + core.ResourceMemory: resource.MustParse("1350Mi"), + core.ResourceStorage: resource.MustParse("6Gi"), + }, + Requests: core.ResourceList{ + core.ResourceCPU: resource.MustParse("1200m"), + core.ResourceMemory: resource.MustParse("1200Mi"), + core.ResourceStorage: resource.MustParse("6Gi"), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj, err := tl.LoadFile(tt.name) + if err != nil { + t.Error(err) + return + } + c := Etcd{}.ResourceCalculator() + + if got, err := c.Replicas(obj); err != nil { + t.Errorf("Replicas() error = %v", err) + } else if got != tt.want.replicas { + t.Errorf("Replicas found = %v, expected = %v", got, tt.want.replicas) + } + + if got, err := c.Mode(obj); err != nil { + t.Errorf("Mode() error = %v", err) + } else if got != tt.want.mode { + t.Errorf("Mode found = %v, expected = %v", got, tt.want.mode) + } + + if got, err := c.TotalResourceLimits(obj); err != nil { + t.Errorf("TotalResourceLimits() error = %v", err) + } else if !cmp.Equal(tt.want.totalResources.Limits, got) { + t.Errorf("TotalResourceLimits() difference = %v", cmp.Diff(tt.want.totalResources.Limits, got)) + } + if got, err := c.TotalResourceRequests(obj); err != nil { + t.Errorf("TotalResourceRequests() error = %v", err) + } else if !cmp.Equal(tt.want.totalResources.Requests, got) { + t.Errorf("TotalResourceRequests() difference = %v", cmp.Diff(tt.want.totalResources.Requests, got)) + } + + if got, err := c.AppResourceLimits(obj); err != nil { + t.Errorf("AppResourceLimits() error = %v", err) + } else if !cmp.Equal(tt.want.appResources.Limits, got) { + t.Errorf("AppResourceLimits() difference = %v", cmp.Diff(tt.want.appResources.Limits, got)) + } + if got, err := c.AppResourceRequests(obj); err != nil { + t.Errorf("AppResourceRequests() error = %v", err) + } else if !cmp.Equal(tt.want.appResources.Requests, got) { + t.Errorf("AppResourceRequests() difference = %v", cmp.Diff(tt.want.appResources.Requests, got)) + } + }) + } +} diff --git a/ops.kubedb.com/v1alpha1/etcd_mapping.go b/ops.kubedb.com/v1alpha1/etcd_mapping.go new file mode 100644 index 0000000..e6b0c4b --- /dev/null +++ b/ops.kubedb.com/v1alpha1/etcd_mapping.go @@ -0,0 +1,58 @@ +/* +Copyright AppsCode Inc. and 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 v1alpha1 + +import "k8s.io/apimachinery/pkg/runtime/schema" + +func init() { + RegisterOpsPathMapperToPlugins(&EtcdOpsRequest{}) +} + +type EtcdOpsRequest struct{} + +var _ OpsPathMapper = (*EtcdOpsRequest)(nil) + +func (m *EtcdOpsRequest) HorizontalPathMapping() map[OpsReqPath]ReferencedObjPath { + return map[OpsReqPath]ReferencedObjPath{ + "spec.horizontalScaling.replicas": "spec.replicas", + } +} + +func (m *EtcdOpsRequest) VerticalPathMapping() map[OpsReqPath]ReferencedObjPath { + return map[OpsReqPath]ReferencedObjPath{ + "spec.verticalScaling.etcd": "spec.podTemplate.spec.resources", + "spec.verticalScaling.exporter": "spec.monitor.prometheus.exporter.resources", + } +} + +func (m *EtcdOpsRequest) VolumeExpansionPathMapping() map[OpsReqPath]ReferencedObjPath { + return map[OpsReqPath]ReferencedObjPath{ + "spec.volumeExpansion.etcd": "spec.storage.resources.requests.storage", + } +} + +func (m *EtcdOpsRequest) GetAppRefPath() []string { + return []string{"spec", "databaseRef"} +} + +func (m *EtcdOpsRequest) GroupVersionKind() schema.GroupVersionKind { + return schema.GroupVersionKind{ + Group: "ops.kubedb.com", + Version: "v1alpha1", + Kind: "EtcdOpsRequest", + } +} diff --git a/testdata/kubedb.com/v1alpha2/etcd/ensemble.yaml b/testdata/kubedb.com/v1alpha2/etcd/ensemble.yaml new file mode 100644 index 0000000..090aebc --- /dev/null +++ b/testdata/kubedb.com/v1alpha2/etcd/ensemble.yaml @@ -0,0 +1,45 @@ +apiVersion: kubedb.com/v1alpha2 +kind: Etcd +metadata: + name: etcd-ensemble + namespace: demo +spec: + deletionPolicy: WipeOut + monitor: + agent: prometheus.io/operator + prometheus: + exporter: + resources: + limits: + cpu: 150m + memory: 150Mi + requests: + cpu: 100m + memory: 100Mi + serviceMonitor: + interval: 30s + labels: + release: kube-prometheus-stack + podTemplate: + spec: + containers: + - name: etcd + resources: + limits: + cpu: 450m + memory: 450Mi + requests: + cpu: 400m + memory: 400Mi + nodeSelector: + kubernetes.io/os: linux + replicas: 3 + storage: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi + storageClassName: local-path + storageType: Durable + version: 3.6.4 diff --git a/testdata/kubedb.com/v1alpha2/etcd/standalone.yaml b/testdata/kubedb.com/v1alpha2/etcd/standalone.yaml new file mode 100644 index 0000000..c59af98 --- /dev/null +++ b/testdata/kubedb.com/v1alpha2/etcd/standalone.yaml @@ -0,0 +1,43 @@ +apiVersion: kubedb.com/v1alpha2 +kind: Etcd +metadata: + name: etcd-standalone + namespace: demo +spec: + deletionPolicy: WipeOut + monitor: + agent: prometheus.io/operator + prometheus: + exporter: + resources: + limits: + cpu: 150m + memory: 150Mi + requests: + cpu: 100m + memory: 100Mi + serviceMonitor: + interval: 30s + labels: + release: kube-prometheus-stack + podTemplate: + spec: + containers: + - name: etcd + resources: + limits: + cpu: 450m + memory: 450Mi + requests: + cpu: 400m + memory: 400Mi + replicas: 1 + storage: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi + storageClassName: local-path + storageType: Durable + version: 3.6.4