From 4e0ed114ae5daf2adf59d3a8c2ee795b3b1f0488 Mon Sep 17 00:00:00 2001 From: dpishchenkov Date: Thu, 5 Feb 2026 12:28:21 +0100 Subject: [PATCH 1/5] Cluster controller first iteration --- PROJECT | 9 + api/v4/cluster_types.go | 111 ++++++ api/v4/zz_generated.deepcopy.go | 133 +++++++ cmd/main.go | 7 + .../bases/enterprise.splunk.com_clusters.yaml | 279 +++++++++++++ config/crd/kustomization.yaml | 1 + config/rbac/cluster_admin_role.yaml | 27 ++ config/rbac/cluster_editor_role.yaml | 33 ++ config/rbac/cluster_viewer_role.yaml | 29 ++ config/rbac/kustomization.yaml | 3 + config/rbac/role.yaml | 3 + .../enterprise_v4_cluster_default.yaml | 9 + .../enterprise_v4_cluster_override.yaml | 20 + config/samples/kustomization.yaml | 1 + go.sum | 10 - internal/controller/cluster_controller.go | 365 ++++++++++++++++++ .../controller/cluster_controller_test.go | 84 ++++ 17 files changed, 1114 insertions(+), 10 deletions(-) create mode 100644 api/v4/cluster_types.go create mode 100644 config/crd/bases/enterprise.splunk.com_clusters.yaml create mode 100644 config/rbac/cluster_admin_role.yaml create mode 100644 config/rbac/cluster_editor_role.yaml create mode 100644 config/rbac/cluster_viewer_role.yaml create mode 100644 config/samples/enterprise_v4_cluster_default.yaml create mode 100644 config/samples/enterprise_v4_cluster_override.yaml create mode 100644 internal/controller/cluster_controller.go create mode 100644 internal/controller/cluster_controller_test.go diff --git a/PROJECT b/PROJECT index 730a93335..e20c7ed67 100644 --- a/PROJECT +++ b/PROJECT @@ -131,4 +131,13 @@ resources: kind: DatabaseClass path: github.com/splunk/splunk-operator/api/v4 version: v4 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: splunk.com + group: enterprise + kind: Cluster + path: github.com/splunk/splunk-operator/api/v4 + version: v4 version: "3" diff --git a/api/v4/cluster_types.go b/api/v4/cluster_types.go new file mode 100644 index 000000000..26fad6c30 --- /dev/null +++ b/api/v4/cluster_types.go @@ -0,0 +1,111 @@ +/* +Copyright 2021. + +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 v4 + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ClusterSpec defines the desired state of Cluster. +type ClusterSpec struct { + // Reference to ClusterClass for default configuration by name. + // This field is IMMUTABLE after creation. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="class is immutable" + Class string `json:"class"` + + // Storage overrides the storage size from ClusterClass. + // Example: "5Gi" + // +optional + // +kubebuilder:validation:XValidation:rule="self == null || oldSelf == null || quantity(self).compareTo(quantity(oldSelf)) >= 0",message="storage size can only be increased" + Storage *resource.Quantity `json:"storage,omitempty"` + + // Instances overrides the number of PostgreSQL instances from ClusterClass. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=10 + Instances *int32 `json:"instances,omitempty"` + + // PostgresVersion overrides the PostgreSQL version from ClusterClass. + // Example: "16" + // +optional + PostgresVersion *string `json:"postgresVersion,omitempty"` + + // Resources overrides CPU/memory resources from ClusterClass. + // +optional + Resources *corev1.ResourceRequirements `json:"resources,omitempty"` + + // PostgreSQL overrides PostgreSQL engine parameters from ClusterClass. + // Maps to postgresql.conf settings. + // +optional + PostgreSQLConfig map[string]string `json:"postgresqlConfig,omitempty"` + + // PgHBA contains pg_hba.conf host-based authentication rules. + // Defines client authentication and connection security (cluster-wide). + // Example: ["hostssl all all 0.0.0.0/0 scram-sha-256"] + // +optional + PgHBA []string `json:"pgHBA,omitempty"` +} + +// ClusterStatus defines the observed state of Cluster. +type ClusterStatus struct { + // Phase represents the current phase of the Cluster. + // Values: "Pending", "Provisioning", "Failed", "Ready", "Deleting" + // +optional + Phase string `json:"phase,omitempty"` + + // Conditions represent the latest available observations of the Cluster's state. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ProvisionerRef contains reference to the provisioner resource managing this cluster. + // Right now, only CNPG is supported. + // +optional + ProvisionerRef *corev1.ObjectReference `json:"provisionerRef,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced +// +kubebuilder:printcolumn:name="Class",type=string,JSONPath=`.spec.class` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// Cluster is the Schema for the clusters API. +type Cluster struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ClusterSpec `json:"spec,omitempty"` + Status ClusterStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ClusterList contains a list of Cluster. +type ClusterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Cluster `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Cluster{}, &ClusterList{}) +} diff --git a/api/v4/zz_generated.deepcopy.go b/api/v4/zz_generated.deepcopy.go index 28f701ff0..273e91068 100644 --- a/api/v4/zz_generated.deepcopy.go +++ b/api/v4/zz_generated.deepcopy.go @@ -212,6 +212,33 @@ func (in *CacheManagerSpec) DeepCopy() *CacheManagerSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Cluster) DeepCopyInto(out *Cluster) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Cluster. +func (in *Cluster) DeepCopy() *Cluster { + if in == nil { + return nil + } + out := new(Cluster) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Cluster) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterClass) DeepCopyInto(out *ClusterClass) { *out = *in @@ -361,6 +388,38 @@ func (in *ClusterConfig) DeepCopy() *ClusterConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterList) DeepCopyInto(out *ClusterList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Cluster, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterList. +func (in *ClusterList) DeepCopy() *ClusterList { + if in == nil { + return nil + } + out := new(ClusterList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterManager) DeepCopyInto(out *ClusterManager) { *out = *in @@ -463,6 +522,80 @@ func (in *ClusterManagerStatus) DeepCopy() *ClusterManagerStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterSpec) DeepCopyInto(out *ClusterSpec) { + *out = *in + if in.Storage != nil { + in, out := &in.Storage, &out.Storage + x := (*in).DeepCopy() + *out = &x + } + if in.Instances != nil { + in, out := &in.Instances, &out.Instances + *out = new(int32) + **out = **in + } + if in.PostgresVersion != nil { + in, out := &in.PostgresVersion, &out.PostgresVersion + *out = new(string) + **out = **in + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = new(v1.ResourceRequirements) + (*in).DeepCopyInto(*out) + } + if in.PostgreSQLConfig != nil { + in, out := &in.PostgreSQLConfig, &out.PostgreSQLConfig + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.PgHBA != nil { + in, out := &in.PgHBA, &out.PgHBA + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterSpec. +func (in *ClusterSpec) DeepCopy() *ClusterSpec { + if in == nil { + return nil + } + out := new(ClusterSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterStatus) DeepCopyInto(out *ClusterStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ProvisionerRef != nil { + in, out := &in.ProvisionerRef, &out.ProvisionerRef + *out = new(v1.ObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterStatus. +func (in *ClusterStatus) DeepCopy() *ClusterStatus { + if in == nil { + return nil + } + out := new(ClusterStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CommonSplunkSpec) DeepCopyInto(out *CommonSplunkSpec) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index 6d4b27980..87ef96e13 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -243,6 +243,13 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Database") os.Exit(1) } + if err := (&controller.ClusterReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Cluster") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/enterprise.splunk.com_clusters.yaml b/config/crd/bases/enterprise.splunk.com_clusters.yaml new file mode 100644 index 000000000..fe6f4845c --- /dev/null +++ b/config/crd/bases/enterprise.splunk.com_clusters.yaml @@ -0,0 +1,279 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: clusters.enterprise.splunk.com +spec: + group: enterprise.splunk.com + names: + kind: Cluster + listKind: ClusterList + plural: clusters + singular: cluster + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.class + name: Class + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v4 + schema: + openAPIV3Schema: + description: Cluster is the Schema for the clusters API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ClusterSpec defines the desired state of Cluster. + properties: + class: + description: |- + Reference to ClusterClass for default configuration by name. + This field is IMMUTABLE after creation. + minLength: 1 + type: string + x-kubernetes-validations: + - message: class is immutable + rule: self == oldSelf + instances: + description: Instances overrides the number of PostgreSQL instances + from ClusterClass. + format: int32 + maximum: 10 + minimum: 1 + type: integer + pgHBA: + description: |- + PgHBA contains pg_hba.conf host-based authentication rules. + Defines client authentication and connection security (cluster-wide). + Example: ["hostssl all all 0.0.0.0/0 scram-sha-256"] + items: + type: string + type: array + postgresVersion: + description: |- + PostgresVersion overrides the PostgreSQL version from ClusterClass. + Example: "16" + type: string + postgresqlConfig: + additionalProperties: + type: string + description: |- + PostgreSQL overrides PostgreSQL engine parameters from ClusterClass. + Maps to postgresql.conf settings. + type: object + resources: + description: Resources overrides CPU/memory resources from ClusterClass. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + storage: + anyOf: + - type: integer + - type: string + description: |- + Storage overrides the storage size from ClusterClass. + Example: "5Gi" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + x-kubernetes-validations: + - message: storage size can only be increased + rule: self == null || oldSelf == null || quantity(self).compareTo(quantity(oldSelf)) + >= 0 + required: + - class + type: object + status: + description: ClusterStatus defines the observed state of Cluster. + properties: + conditions: + description: Conditions represent the latest available observations + of the Cluster's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + phase: + description: |- + Phase represents the current phase of the Cluster. + Values: "Pending", "Provisioning", "Failed", "Ready", "Deleting" + type: string + provisionerRef: + description: |- + ProvisionerRef contains reference to the provisioner resource managing this cluster. + Right now, only CNPG is supported. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: |- + If referring to a piece of an object instead of an entire object, this string + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within a pod, this would take on a value like: + "spec.containers{name}" (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" (container with + index 2 in this pod). This syntax is chosen only to have some well-defined way of + referencing a part of an object. + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + namespace: + description: |- + Namespace of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + type: string + resourceVersion: + description: |- + Specific resourceVersion to which this reference is made, if any. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + type: string + type: object + x-kubernetes-map-type: atomic + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index a934dfda8..4ab53be38 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -12,6 +12,7 @@ resources: - bases/enterprise.splunk.com_standalones.yaml - bases/enterprise.splunk.com_databases.yaml - bases/enterprise.splunk.com_databaseclasses.yaml +- bases/enterprise.splunk.com_clusters.yaml #+kubebuilder:scaffold:crdkustomizeresource diff --git a/config/rbac/cluster_admin_role.yaml b/config/rbac/cluster_admin_role.yaml new file mode 100644 index 000000000..c3c195b79 --- /dev/null +++ b/config/rbac/cluster_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project splunk-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over enterprise.splunk.com. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: cluster-admin-role +rules: +- apiGroups: + - enterprise.splunk.com + resources: + - clusters + verbs: + - '*' +- apiGroups: + - enterprise.splunk.com + resources: + - clusters/status + verbs: + - get diff --git a/config/rbac/cluster_editor_role.yaml b/config/rbac/cluster_editor_role.yaml new file mode 100644 index 000000000..89c6aaada --- /dev/null +++ b/config/rbac/cluster_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project splunk-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the enterprise.splunk.com. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: cluster-editor-role +rules: +- apiGroups: + - enterprise.splunk.com + resources: + - clusters + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - enterprise.splunk.com + resources: + - clusters/status + verbs: + - get diff --git a/config/rbac/cluster_viewer_role.yaml b/config/rbac/cluster_viewer_role.yaml new file mode 100644 index 000000000..dde9acf1d --- /dev/null +++ b/config/rbac/cluster_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project splunk-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to enterprise.splunk.com resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: cluster-viewer-role +rules: +- apiGroups: + - enterprise.splunk.com + resources: + - clusters + verbs: + - get + - list + - watch +- apiGroups: + - enterprise.splunk.com + resources: + - clusters/status + verbs: + - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 2ba1e97d0..3ffe85041 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -22,6 +22,9 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the splunk-operator itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- cluster_admin_role.yaml +- cluster_editor_role.yaml +- cluster_viewer_role.yaml - databaseclass_admin_role.yaml - databaseclass_editor_role.yaml - databaseclass_viewer_role.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 1da0355cb..626d59ae4 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -57,6 +57,7 @@ rules: resources: - clustermanagers - clustermasters + - clusters - databases - indexerclusters - licensemanagers @@ -77,6 +78,7 @@ rules: resources: - clustermanagers/finalizers - clustermasters/finalizers + - clusters/finalizers - databases/finalizers - indexerclusters/finalizers - licensemanagers/finalizers @@ -91,6 +93,7 @@ rules: resources: - clustermanagers/status - clustermasters/status + - clusters/status - databases/status - indexerclusters/status - licensemanagers/status diff --git a/config/samples/enterprise_v4_cluster_default.yaml b/config/samples/enterprise_v4_cluster_default.yaml new file mode 100644 index 000000000..66ce477e8 --- /dev/null +++ b/config/samples/enterprise_v4_cluster_default.yaml @@ -0,0 +1,9 @@ +apiVersion: enterprise.splunk.com/v4 +kind: Cluster +metadata: + labels: + app.kubernetes.io/name: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: cluster-sample +spec: + class: postgresql-dev diff --git a/config/samples/enterprise_v4_cluster_override.yaml b/config/samples/enterprise_v4_cluster_override.yaml new file mode 100644 index 000000000..c59f7774b --- /dev/null +++ b/config/samples/enterprise_v4_cluster_override.yaml @@ -0,0 +1,20 @@ +# Sample Cluster using Development ClusterClass with Overrides +apiVersion: enterprise.splunk.com/v4 +kind: Cluster +metadata: + labels: + app.kubernetes.io/name: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: cluster-sample-overriden +spec: + class: postgresql-dev + instances: 2 + storage: 2Gi + postgresVersion: "15" + resources: + requests: + cpu: "250m" + memory: "512Mi" + limits: + cpu: "500m" + memory: "1Gi" \ No newline at end of file diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 925304393..362248e7c 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -15,4 +15,5 @@ resources: - enterprise_v4_licensemanager.yaml - enterprise_v4_database.yaml - enterprise_v4_databaseclass.yaml +- enterprise_v4_cluster.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/go.sum b/go.sum index 5fe702be6..d908caadc 100644 --- a/go.sum +++ b/go.sum @@ -200,8 +200,6 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -279,12 +277,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= -github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= -github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -401,8 +395,6 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -444,8 +436,6 @@ golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/controller/cluster_controller.go b/internal/controller/cluster_controller.go new file mode 100644 index 000000000..20d2c37eb --- /dev/null +++ b/internal/controller/cluster_controller.go @@ -0,0 +1,365 @@ +/* +Copyright 2021. + +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" + "fmt" + + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + enterprisev4 "github.com/splunk/splunk-operator/api/v4" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + client "sigs.k8s.io/controller-runtime/pkg/client" + logs "sigs.k8s.io/controller-runtime/pkg/log" +) + +// ClusterReconciler reconciles a Cluster object +type ClusterReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=enterprise.splunk.com,resources=clusters,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=enterprise.splunk.com,resources=clusters/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=enterprise.splunk.com,resources=clusters/finalizers,verbs=update +// +kubebuilder:rbac:groups=enterprise.splunk.com,resources=clusterclasses,verbs=get;list;watch +// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=clusters,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=clusters/status,verbs=get + +// Reconcile is part of the main kubernetes reconciliation loop for Cluster resources. +func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := logs.FromContext(ctx) + logger.Info("Reconciling Cluster", "name", req.Name, "namespace", req.Namespace) + + // Fetch the Cluster instance + cluster := &enterprisev4.Cluster{} + if err := r.Get(ctx, req.NamespacedName, cluster); err != nil { + if errors.IsNotFound(err) { + logger.Info("Cluster resource not found. Ignoring since object must be deleted.") + return ctrl.Result{}, nil + } + logger.Error(err, "Failed to get Cluster.") + return ctrl.Result{}, err + } + + // Fetch the corresponding ClusterClass + clusterClass := &enterprisev4.ClusterClass{} + if err := r.Get(ctx, client.ObjectKey{Name: cluster.Spec.Class}, clusterClass); err != nil { + logger.Error(err, "Failed to get ClusterClass for Cluster.", "ClusterClass", cluster.Spec.Class) + return ctrl.Result{}, err + } + + // Merge ClusterClass spec into Cluster spec + mergedConfig := r.getMergedConfig(clusterClass, cluster) + + if err := validateClusterConfig(mergedConfig, clusterClass); err != nil { + logger.Error(err, "Merged config invalid") + return ctrl.Result{}, err + } + + // Check if the CNPG Cluster already exists and create one if not + cnpgCluster := &cnpgv1.Cluster{} + err := r.Get(ctx, types.NamespacedName{ + Name: cluster.Name, + Namespace: cluster.Namespace, + }, cnpgCluster) + + if err != nil && errors.IsNotFound(err) { + // Define a new CNPG Cluster + logger.Info("Defining a new CNPG Cluster", "Cluster.Name", cluster.Name, "Cluster.Namespace", cluster.Namespace) + cnpgCluster, err = r.defineNewCNPGCluster(clusterClass, cluster, mergedConfig) + if err != nil { + logger.Error(err, "Failed to define CNPG Cluster.") + return ctrl.Result{}, err + } + + // Create the CNPG Cluster + logger.Info("Creating a new CNPG Cluster", "CNPGCluster.Name", cnpgCluster.Name) + if err := r.Create(ctx, cnpgCluster); err != nil { + logger.Error(err, "Failed to create CNPG Cluster.") + return ctrl.Result{}, err + } + // Update Cluster status with provisioner reference + cluster.Status.ProvisionerRef = &corev1.ObjectReference{ + APIVersion: "postgresql.cnpg.io/v1", + Kind: "Cluster", + Namespace: cnpgCluster.Namespace, + Name: cnpgCluster.Name, + UID: cnpgCluster.UID, + } + cluster.Status.Phase = "Provisioning" + + if err := r.Status().Update(ctx, cluster); err != nil { + logger.Error(err, "Failed to update Cluster status after CNPG Cluster creation.") + return ctrl.Result{}, err + } + + logger.Info("CNPG Cluster created successfully", "CNPGCluster.Name", cnpgCluster.Name) + // Requeue to check the status of the CNPG cluster + return ctrl.Result{Requeue: true}, nil + } else if err != nil { + logger.Error(err, "Failed to get CNPG Cluster.") + return ctrl.Result{}, err + } + + // CNPG Cluster exists, ensure it matches the desired state + logger.Info("CNPG Cluster already exists. Ensuring it is up to date.", "CNPGCluster.Name", cnpgCluster.Name) + updated, err := r.ensureClusterUpToDate(ctx, clusterClass, cnpgCluster, mergedConfig) + if err != nil { + logger.Error(err, "Failed to ensure CNPG Cluster is up to date.") + return ctrl.Result{}, err + } + if updated { + logger.Info("CNPG Cluster updated successfully", "CNPGCluster.Name", cnpgCluster.Name) + } else { + logger.Info("CNPG Cluster is already up to date", "CNPGCluster.Name", cnpgCluster.Name) + } + + // Update CNPG cluster status if it was changed + logger.Info("Updating Cluster status based on CNPG state", "Phase", cnpgCluster.Status.Phase) + if err := r.updateClusterStatus(ctx, cluster, cnpgCluster); err != nil { + logger.Error(err, "Failed to update Cluster status.") + return ctrl.Result{}, err + } else { + logger.Info("Cluster is up to date", "Cluster.Name", cluster.Name) + } + + return ctrl.Result{}, nil +} + +// getMergedConfig merges the configuration from the ClusterClass into the ClusterSpec, giving precedence to the ClusterSpec values. +func (r *ClusterReconciler) getMergedConfig(clusterClass *enterprisev4.ClusterClass, cluster *enterprisev4.Cluster) *enterprisev4.ClusterSpec { + instances := clusterClass.Spec.ClusterConfig.Instances + engineConfig := clusterClass.Spec.ClusterConfig.PostgreSQLConfig + postgresVersion := clusterClass.Spec.ClusterConfig.PostgresVersion + resources := clusterClass.Spec.ClusterConfig.Resources + storage := clusterClass.Spec.ClusterConfig.Storage + pgHBA := clusterClass.Spec.ClusterConfig.PgHBA + + if cluster.Spec.Instances != nil { + instances = cluster.Spec.Instances + } + if cluster.Spec.PostgresVersion != nil { + postgresVersion = cluster.Spec.PostgresVersion + } + if cluster.Spec.Resources != nil { + resources = cluster.Spec.Resources + } + if cluster.Spec.Storage != nil { + storage = cluster.Spec.Storage + } + if cluster.Spec.PostgreSQLConfig != nil { + engineConfig = cluster.Spec.PostgreSQLConfig + } + if cluster.Spec.PgHBA != nil { + pgHBA = cluster.Spec.PgHBA + } + + return &enterprisev4.ClusterSpec{ + Instances: instances, + PostgresVersion: postgresVersion, + Resources: resources, + Storage: storage, + PostgreSQLConfig: engineConfig, + PgHBA: pgHBA, + } + +} + +// defineNewCNPGCluster defines a new CNPG Cluster resource based on the merged configuration. +func (r *ClusterReconciler) defineNewCNPGCluster( + clusterClass *enterprisev4.ClusterClass, + cluster *enterprisev4.Cluster, + mergedConfig *enterprisev4.ClusterSpec, +) (*cnpgv1.Cluster, error) { + + // Validate that required fields are present in the merged configuration before creating the CNPG Cluster. + if err := validateClusterConfig(mergedConfig, clusterClass); err != nil { + return nil, err + } + + resources := corev1.ResourceRequirements{} + if mergedConfig.Resources != nil { + resources = *mergedConfig.Resources + } + + cnpgCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster.Name, + Namespace: cluster.Namespace, + }, + Spec: cnpgv1.ClusterSpec{ + ImageName: fmt.Sprintf("ghcr.io/cloudnative-pg/postgresql:%s", *mergedConfig.PostgresVersion), + Instances: int(*mergedConfig.Instances), + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Parameters: mergedConfig.PostgreSQLConfig, + PgHBA: mergedConfig.PgHBA, + }, + Bootstrap: &cnpgv1.BootstrapConfiguration{ + InitDB: &cnpgv1.BootstrapInitDB{ + Database: "postgres", + Owner: "postgres", + }, + }, + StorageConfiguration: cnpgv1.StorageConfiguration{ + Size: mergedConfig.Storage.String(), + }, + Resources: resources, + }, + } + + if err := ctrl.SetControllerReference(cluster, cnpgCluster, r.Scheme); err != nil { + return nil, err + } + return cnpgCluster, nil +} + +// ensureClusterUpToDate ensures that the CNPG Cluster matches the desired state based on the merged configuration. +func (r *ClusterReconciler) ensureClusterUpToDate( + ctx context.Context, + clusterClass *enterprisev4.ClusterClass, + cnpgCluster *cnpgv1.Cluster, + mergedConfig *enterprisev4.ClusterSpec, +) (bool, error) { + + // Validate that required fields are present in the merged configuration before updating the CNPG Cluster. + if err := validateClusterConfig(mergedConfig, clusterClass); err != nil { + return false, err + } + + resources := corev1.ResourceRequirements{} + if mergedConfig.Resources != nil { + resources = *mergedConfig.Resources + } + + desiredState := cnpgv1.ClusterSpec{ + ImageName: fmt.Sprintf("ghcr.io/cloudnative-pg/postgresql:%s", *mergedConfig.PostgresVersion), + Instances: int(*mergedConfig.Instances), + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Parameters: mergedConfig.PostgreSQLConfig, + PgHBA: mergedConfig.PgHBA, + }, + StorageConfiguration: cnpgv1.StorageConfiguration{ + Size: mergedConfig.Storage.String(), + }, + Resources: resources, + } + + if !equality.Semantic.DeepEqual(cnpgCluster.Spec, desiredState) { + cnpgCluster.Spec = desiredState + if err := r.Update(ctx, cnpgCluster); err != nil { + return false, err + } + return true, nil + } + return false, nil +} + +// updateClusterStatus updates the status of the Cluster resource based on the status of the CNPG Cluster. +func (r *ClusterReconciler) updateClusterStatus(ctx context.Context, cluster *enterprisev4.Cluster, cnpgCluster *cnpgv1.Cluster) error { + switch cnpgCluster.Status.Phase { + case cnpgv1.PhaseHealthy: + cluster.Status.Phase = "Ready" + meta.SetStatusCondition(&cluster.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "ClusterHealthy", + Message: "CNPG cluster is in healthy state", + ObservedGeneration: cluster.Generation, + LastTransitionTime: cluster.CreationTimestamp, + }) + case cnpgv1.PhaseUnrecoverable: + cluster.Status.Phase = "Failed" + meta.SetStatusCondition(&cluster.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: "ClusterCreationFailed", + Message: "CNPG cluster is in unrecoverable state", + ObservedGeneration: cluster.Generation, + LastTransitionTime: cluster.CreationTimestamp, + }) + case "": + cluster.Status.Phase = "Pending" + meta.SetStatusCondition(&cluster.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: "ClusterPending", + Message: "CNPG cluster is pending creation", + ObservedGeneration: cluster.Generation, + LastTransitionTime: cluster.CreationTimestamp, + }) + default: + cluster.Status.Phase = "Provisioning" + meta.SetStatusCondition(&cluster.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: "ClusterProvisioning", + Message: "CNPG cluster is being provisioned", + ObservedGeneration: cluster.Generation, + LastTransitionTime: cluster.CreationTimestamp, + }) + } + + cluster.Status.ProvisionerRef = &corev1.ObjectReference{ + APIVersion: "postgresql.cnpg.io/v1", + Kind: "Cluster", + Namespace: cnpgCluster.Namespace, + Name: cnpgCluster.Name, + UID: cnpgCluster.UID, + } + if err := r.Status().Update(ctx, cluster); err != nil { + return err + } + return nil +} + +// validateClusterConfig checks that all required fields are present in the merged configuration. +func validateClusterConfig(mergedConfig *enterprisev4.ClusterSpec, clusterClass *enterprisev4.ClusterClass) error { + if mergedConfig == nil { + return fmt.Errorf("mergedConfig is nil") + } + if clusterClass == nil { + return fmt.Errorf("clusterClass is nil") + } + cfg := mergedConfig + + switch { + case cfg.Instances == nil: + return fmt.Errorf("missing required field in merged configuration: Instances") + case cfg.Storage == nil: + return fmt.Errorf("missing required field in merged configuration: Storage") + case cfg.PostgresVersion == nil: + return fmt.Errorf("missing required field in merged configuration: PostgresVersion") + } + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&enterprisev4.Cluster{}). + Owns(&cnpgv1.Cluster{}). + Named("cluster"). + Complete(r) +} diff --git a/internal/controller/cluster_controller_test.go b/internal/controller/cluster_controller_test.go new file mode 100644 index 000000000..31766ef73 --- /dev/null +++ b/internal/controller/cluster_controller_test.go @@ -0,0 +1,84 @@ +/* +Copyright 2021. + +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" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + enterprisev4 "github.com/splunk/splunk-operator/api/v4" +) + +var _ = Describe("Cluster Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", // TODO(user):Modify as needed + } + cluster := &enterprisev4.Cluster{} + + BeforeEach(func() { + By("creating the custom resource for the Kind Cluster") + err := k8sClient.Get(ctx, typeNamespacedName, cluster) + if err != nil && errors.IsNotFound(err) { + resource := &enterprisev4.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + // TODO(user): Specify other spec details if needed. + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + // TODO(user): Cleanup logic after each test, like removing the resource instance. + resource := &enterprisev4.Cluster{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance Cluster") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &ClusterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. + // Example: If you expect a certain status condition after reconciliation, verify it here. + }) + }) +}) From 213230958ba22e8712358e9c6628724bf01b1af2 Mon Sep 17 00:00:00 2001 From: dpishchenkov Date: Thu, 5 Feb 2026 15:06:51 +0100 Subject: [PATCH 2/5] Update validation logic for size and pg version changes. --- internal/controller/cluster_controller.go | 94 +++++++++++++++++++---- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/internal/controller/cluster_controller.go b/internal/controller/cluster_controller.go index 20d2c37eb..0559193a2 100644 --- a/internal/controller/cluster_controller.go +++ b/internal/controller/cluster_controller.go @@ -26,6 +26,7 @@ import ( "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -73,9 +74,14 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct // Merge ClusterClass spec into Cluster spec mergedConfig := r.getMergedConfig(clusterClass, cluster) - if err := validateClusterConfig(mergedConfig, clusterClass); err != nil { - logger.Error(err, "Merged config invalid") - return ctrl.Result{}, err + validationErr := validateClusterConfig(mergedConfig, clusterClass) + if validationErr != nil { + logger.Error(validationErr, "Merged config invalid") + // Update status to show validation failure + if err := r.updateClusterStatus(ctx, cluster, nil, validationErr); err != nil { + logger.Error(err, "Failed to update status after validation error") + } + return ctrl.Result{}, validationErr } // Check if the CNPG Cluster already exists and create one if not @@ -128,6 +134,9 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct updated, err := r.ensureClusterUpToDate(ctx, clusterClass, cnpgCluster, mergedConfig) if err != nil { logger.Error(err, "Failed to ensure CNPG Cluster is up to date.") + if statuErr := r.updateClusterStatus(ctx, cluster, cnpgCluster, err); statuErr != nil { + logger.Error(statuErr, "Failed to update Cluster status after ensure up to date error") + } return ctrl.Result{}, err } if updated { @@ -138,7 +147,7 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct // Update CNPG cluster status if it was changed logger.Info("Updating Cluster status based on CNPG state", "Phase", cnpgCluster.Status.Phase) - if err := r.updateClusterStatus(ctx, cluster, cnpgCluster); err != nil { + if err := r.updateClusterStatus(ctx, cluster, cnpgCluster, validationErr); err != nil { logger.Error(err, "Failed to update Cluster status.") return ctrl.Result{}, err } else { @@ -247,6 +256,14 @@ func (r *ClusterReconciler) ensureClusterUpToDate( if err := validateClusterConfig(mergedConfig, clusterClass); err != nil { return false, err } + // Validate that storage size is not decreased + if err := validateStorageSize(mergedConfig.Storage, cnpgCluster.Spec.StorageConfiguration.Size); err != nil { + return false, err + } + // Validate that PostgresVersion is not decreased + if err := validatePostgresVersion(*mergedConfig.PostgresVersion, cnpgCluster.Spec.ImageName); err != nil { + return false, err + } resources := corev1.ResourceRequirements{} if mergedConfig.Resources != nil { @@ -277,7 +294,19 @@ func (r *ClusterReconciler) ensureClusterUpToDate( } // updateClusterStatus updates the status of the Cluster resource based on the status of the CNPG Cluster. -func (r *ClusterReconciler) updateClusterStatus(ctx context.Context, cluster *enterprisev4.Cluster, cnpgCluster *cnpgv1.Cluster) error { +func (r *ClusterReconciler) updateClusterStatus(ctx context.Context, cluster *enterprisev4.Cluster, cnpgCluster *cnpgv1.Cluster, specErr error) error { + if specErr != nil { + cluster.Status.Phase = "Failed" + meta.SetStatusCondition(&cluster.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + Reason: "ValidationFailed", + Message: fmt.Sprintf("Cluster configuration is invalid: %v", specErr), + ObservedGeneration: cluster.Generation, + }) + return r.Status().Update(ctx, cluster) + } + switch cnpgCluster.Status.Phase { case cnpgv1.PhaseHealthy: cluster.Status.Phase = "Ready" @@ -287,7 +316,6 @@ func (r *ClusterReconciler) updateClusterStatus(ctx context.Context, cluster *en Reason: "ClusterHealthy", Message: "CNPG cluster is in healthy state", ObservedGeneration: cluster.Generation, - LastTransitionTime: cluster.CreationTimestamp, }) case cnpgv1.PhaseUnrecoverable: cluster.Status.Phase = "Failed" @@ -297,7 +325,6 @@ func (r *ClusterReconciler) updateClusterStatus(ctx context.Context, cluster *en Reason: "ClusterCreationFailed", Message: "CNPG cluster is in unrecoverable state", ObservedGeneration: cluster.Generation, - LastTransitionTime: cluster.CreationTimestamp, }) case "": cluster.Status.Phase = "Pending" @@ -307,7 +334,6 @@ func (r *ClusterReconciler) updateClusterStatus(ctx context.Context, cluster *en Reason: "ClusterPending", Message: "CNPG cluster is pending creation", ObservedGeneration: cluster.Generation, - LastTransitionTime: cluster.CreationTimestamp, }) default: cluster.Status.Phase = "Provisioning" @@ -317,16 +343,17 @@ func (r *ClusterReconciler) updateClusterStatus(ctx context.Context, cluster *en Reason: "ClusterProvisioning", Message: "CNPG cluster is being provisioned", ObservedGeneration: cluster.Generation, - LastTransitionTime: cluster.CreationTimestamp, }) } - cluster.Status.ProvisionerRef = &corev1.ObjectReference{ - APIVersion: "postgresql.cnpg.io/v1", - Kind: "Cluster", - Namespace: cnpgCluster.Namespace, - Name: cnpgCluster.Name, - UID: cnpgCluster.UID, + if cnpgCluster != nil { + cluster.Status.ProvisionerRef = &corev1.ObjectReference{ + APIVersion: "postgresql.cnpg.io/v1", + Kind: "Cluster", + Namespace: cnpgCluster.Namespace, + Name: cnpgCluster.Name, + UID: cnpgCluster.UID, + } } if err := r.Status().Update(ctx, cluster); err != nil { return err @@ -355,6 +382,43 @@ func validateClusterConfig(mergedConfig *enterprisev4.ClusterSpec, clusterClass return nil } +// validateStorageSize ensures that the storage size is not decreased after cluster creation. +func validateStorageSize(desired *resource.Quantity, existing string) error { + if desired == nil { + return nil + } + existingQty, err := resource.ParseQuantity(existing) + if err != nil { + return fmt.Errorf("failed to parse existing storage size: %v", err) + } + // Cmp returns -1 if desired < existingQty + if desired.Cmp(existingQty) < 0 { + return fmt.Errorf( + "storage size cannot be decreased from %s to %s", + existingQty.String(), + desired.String(), + ) + } + return nil +} + +// validatePostgresVersion ensures that the Postgres version is not decreased after cluster creation. +func validatePostgresVersion(desired string, existing string) error { + var existingVersion string + _, err := fmt.Sscanf(existing, "ghcr.io/cloudnative-pg/postgresql:%s", &existingVersion) + if err != nil { + return fmt.Errorf("failed to parse existing Postgres version: %v", err) + } + if desired < existingVersion { + return fmt.Errorf( + "Postgres version cannot be decreased from %s to %s", + existingVersion, + desired, + ) + } + return nil +} + // SetupWithManager sets up the controller with the Manager. func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). From 90ebcc0da19a186c7a5d3692132f8a9e69807e78 Mon Sep 17 00:00:00 2001 From: dpishchenkov Date: Thu, 5 Feb 2026 18:36:07 +0100 Subject: [PATCH 3/5] Refactored getMergedConfig method in cluster_controller.go, set current copyright year. --- api/v3/zz_generated.deepcopy.go | 2 +- api/v4/cluster_types.go | 7 ++- api/v4/clusterclass_types.go | 2 +- api/v4/database_types.go | 2 +- api/v4/groupversion_info.go | 2 +- api/v4/zz_generated.deepcopy.go | 9 +++- .../bases/enterprise.splunk.com_clusters.yaml | 22 ++++++-- hack/boilerplate.go.txt | 2 +- internal/controller/cluster_controller.go | 53 +++++++++---------- .../controller/cluster_controller_test.go | 2 +- .../controller/database_cluster_controller.go | 2 +- .../database_cluster_controller_test.go | 2 +- internal/controller/suite_test.go | 2 +- 13 files changed, 64 insertions(+), 45 deletions(-) diff --git a/api/v3/zz_generated.deepcopy.go b/api/v3/zz_generated.deepcopy.go index b2c842da2..e7cd1c6ae 100644 --- a/api/v3/zz_generated.deepcopy.go +++ b/api/v3/zz_generated.deepcopy.go @@ -1,7 +1,7 @@ //go:build !ignore_autogenerated /* -Copyright 2021. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/v4/cluster_types.go b/api/v4/cluster_types.go index 26fad6c30..dae507d36 100644 --- a/api/v4/cluster_types.go +++ b/api/v4/cluster_types.go @@ -1,5 +1,5 @@ /* -Copyright 2021. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -24,7 +24,6 @@ import ( // ClusterSpec defines the desired state of Cluster. type ClusterSpec struct { - // Reference to ClusterClass for default configuration by name. // This field is IMMUTABLE after creation. // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 @@ -79,6 +78,10 @@ type ClusterStatus struct { // Right now, only CNPG is supported. // +optional ProvisionerRef *corev1.ObjectReference `json:"provisionerRef,omitempty"` + + // CNPG config status, for reference when getting cluster status. + // +optional + CNPGConfigStatus *CNPGConfig `json:"cnpgConfigStatus,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v4/clusterclass_types.go b/api/v4/clusterclass_types.go index 77d77ff6c..6fc26b5b3 100644 --- a/api/v4/clusterclass_types.go +++ b/api/v4/clusterclass_types.go @@ -1,5 +1,5 @@ /* -Copyright 2021. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/v4/database_types.go b/api/v4/database_types.go index 0a9102c9d..78e5c8c36 100644 --- a/api/v4/database_types.go +++ b/api/v4/database_types.go @@ -1,5 +1,5 @@ /* -Copyright 2021. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/v4/groupversion_info.go b/api/v4/groupversion_info.go index 2122b71c3..576bb47e6 100644 --- a/api/v4/groupversion_info.go +++ b/api/v4/groupversion_info.go @@ -1,5 +1,5 @@ /* -Copyright 2021. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/v4/zz_generated.deepcopy.go b/api/v4/zz_generated.deepcopy.go index 273e91068..b2ce4dafe 100644 --- a/api/v4/zz_generated.deepcopy.go +++ b/api/v4/zz_generated.deepcopy.go @@ -1,7 +1,7 @@ //go:build !ignore_autogenerated /* -Copyright 2021. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,7 +21,7 @@ limitations under the License. package v4 import ( - "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -584,6 +584,11 @@ func (in *ClusterStatus) DeepCopyInto(out *ClusterStatus) { *out = new(v1.ObjectReference) **out = **in } + if in.CNPGConfigStatus != nil { + in, out := &in.CNPGConfigStatus, &out.CNPGConfigStatus + *out = new(CNPGConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterStatus. diff --git a/config/crd/bases/enterprise.splunk.com_clusters.yaml b/config/crd/bases/enterprise.splunk.com_clusters.yaml index fe6f4845c..bd98162c9 100644 --- a/config/crd/bases/enterprise.splunk.com_clusters.yaml +++ b/config/crd/bases/enterprise.splunk.com_clusters.yaml @@ -50,9 +50,7 @@ spec: description: ClusterSpec defines the desired state of Cluster. properties: class: - description: |- - Reference to ClusterClass for default configuration by name. - This field is IMMUTABLE after creation. + description: This field is IMMUTABLE after creation. minLength: 1 type: string x-kubernetes-validations: @@ -163,6 +161,24 @@ spec: status: description: ClusterStatus defines the observed state of Cluster. properties: + cnpgConfigStatus: + description: CNPG config status, for reference when getting cluster + status. + properties: + primaryUpdateMethod: + default: switchover + description: |- + PrimaryUpdateMethod determines how the primary instance is updated. + "restart" - tolerate brief downtime (suitable for development) + "switchover" - minimal downtime via automated failover (production-grade) + + NOTE: When using "switchover", ensure clusterConfig.instances > 1. + Switchover requires at least one replica to fail over to. + enum: + - restart + - switchover + type: string + type: object conditions: description: Conditions represent the latest available observations of the Cluster's state. diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt index 45dbbbbcf..978679816 100644 --- a/hack/boilerplate.go.txt +++ b/hack/boilerplate.go.txt @@ -1,5 +1,5 @@ /* -Copyright 2021. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/controller/cluster_controller.go b/internal/controller/cluster_controller.go index 0559193a2..13a541bdf 100644 --- a/internal/controller/cluster_controller.go +++ b/internal/controller/cluster_controller.go @@ -1,5 +1,5 @@ /* -Copyright 2021. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -134,8 +134,8 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct updated, err := r.ensureClusterUpToDate(ctx, clusterClass, cnpgCluster, mergedConfig) if err != nil { logger.Error(err, "Failed to ensure CNPG Cluster is up to date.") - if statuErr := r.updateClusterStatus(ctx, cluster, cnpgCluster, err); statuErr != nil { - logger.Error(statuErr, "Failed to update Cluster status after ensure up to date error") + if statusErr := r.updateClusterStatus(ctx, cluster, cnpgCluster, err); statusErr != nil { + logger.Error(statusErr, "Failed to update Cluster status after ensure up to date error") } return ctrl.Result{}, err } @@ -147,7 +147,7 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct // Update CNPG cluster status if it was changed logger.Info("Updating Cluster status based on CNPG state", "Phase", cnpgCluster.Status.Phase) - if err := r.updateClusterStatus(ctx, cluster, cnpgCluster, validationErr); err != nil { + if err := r.updateClusterStatus(ctx, cluster, cnpgCluster, nil); err != nil { logger.Error(err, "Failed to update Cluster status.") return ctrl.Result{}, err } else { @@ -159,41 +159,36 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct // getMergedConfig merges the configuration from the ClusterClass into the ClusterSpec, giving precedence to the ClusterSpec values. func (r *ClusterReconciler) getMergedConfig(clusterClass *enterprisev4.ClusterClass, cluster *enterprisev4.Cluster) *enterprisev4.ClusterSpec { - instances := clusterClass.Spec.ClusterConfig.Instances - engineConfig := clusterClass.Spec.ClusterConfig.PostgreSQLConfig - postgresVersion := clusterClass.Spec.ClusterConfig.PostgresVersion - resources := clusterClass.Spec.ClusterConfig.Resources - storage := clusterClass.Spec.ClusterConfig.Storage - pgHBA := clusterClass.Spec.ClusterConfig.PgHBA + clusterClassDefaulfConfig := clusterClass.Spec.ClusterConfig.DeepCopy() // Get a deep copy to avoid mutating the original + resultConfig := cluster.Spec.DeepCopy() - if cluster.Spec.Instances != nil { - instances = cluster.Spec.Instances + if resultConfig.Instances == nil { + resultConfig.Instances = clusterClassDefaulfConfig.Instances } - if cluster.Spec.PostgresVersion != nil { - postgresVersion = cluster.Spec.PostgresVersion + if resultConfig.PostgresVersion == nil { + resultConfig.PostgresVersion = clusterClassDefaulfConfig.PostgresVersion } - if cluster.Spec.Resources != nil { - resources = cluster.Spec.Resources + if resultConfig.Resources == nil { + resultConfig.Resources = clusterClassDefaulfConfig.Resources } - if cluster.Spec.Storage != nil { - storage = cluster.Spec.Storage + if resultConfig.Storage == nil { + resultConfig.Storage = clusterClassDefaulfConfig.Storage } - if cluster.Spec.PostgreSQLConfig != nil { - engineConfig = cluster.Spec.PostgreSQLConfig + if len(resultConfig.PostgreSQLConfig) == 0 { + resultConfig.PostgreSQLConfig = clusterClassDefaulfConfig.PostgreSQLConfig } - if cluster.Spec.PgHBA != nil { - pgHBA = cluster.Spec.PgHBA + if len(resultConfig.PgHBA) == 0 { + resultConfig.PgHBA = clusterClassDefaulfConfig.PgHBA } return &enterprisev4.ClusterSpec{ - Instances: instances, - PostgresVersion: postgresVersion, - Resources: resources, - Storage: storage, - PostgreSQLConfig: engineConfig, - PgHBA: pgHBA, + Instances: resultConfig.Instances, + PostgresVersion: resultConfig.PostgresVersion, + Resources: resultConfig.Resources, + Storage: resultConfig.Storage, + PostgreSQLConfig: resultConfig.PostgreSQLConfig, + PgHBA: resultConfig.PgHBA, } - } // defineNewCNPGCluster defines a new CNPG Cluster resource based on the merged configuration. diff --git a/internal/controller/cluster_controller_test.go b/internal/controller/cluster_controller_test.go index 31766ef73..2f2b30a61 100644 --- a/internal/controller/cluster_controller_test.go +++ b/internal/controller/cluster_controller_test.go @@ -1,5 +1,5 @@ /* -Copyright 2021. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/controller/database_cluster_controller.go b/internal/controller/database_cluster_controller.go index 043dc46f3..05561c2d7 100644 --- a/internal/controller/database_cluster_controller.go +++ b/internal/controller/database_cluster_controller.go @@ -1,5 +1,5 @@ /* -Copyright 2021. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/controller/database_cluster_controller_test.go b/internal/controller/database_cluster_controller_test.go index 595eb3e15..21b99a8f0 100644 --- a/internal/controller/database_cluster_controller_test.go +++ b/internal/controller/database_cluster_controller_test.go @@ -1,5 +1,5 @@ /* -Copyright 2021. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index cd9db58e9..94db6a730 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -1,5 +1,5 @@ /* -Copyright 2021. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 800e3d5cd08a53c218be8a5555ad55756dc60358 Mon Sep 17 00:00:00 2001 From: dpishchenkov Date: Fri, 6 Feb 2026 11:01:52 +0100 Subject: [PATCH 4/5] Fixed incorrect passford issue --- api/v4/zz_generated.deepcopy.go | 2 +- internal/controller/cluster_controller.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/api/v4/zz_generated.deepcopy.go b/api/v4/zz_generated.deepcopy.go index b2ce4dafe..0eca9250e 100644 --- a/api/v4/zz_generated.deepcopy.go +++ b/api/v4/zz_generated.deepcopy.go @@ -21,7 +21,7 @@ limitations under the License. package v4 import ( - v1 "k8s.io/api/core/v1" + "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/internal/controller/cluster_controller.go b/internal/controller/cluster_controller.go index 13a541bdf..5ce0bca50 100644 --- a/internal/controller/cluster_controller.go +++ b/internal/controller/cluster_controller.go @@ -272,6 +272,12 @@ func (r *ClusterReconciler) ensureClusterUpToDate( Parameters: mergedConfig.PostgreSQLConfig, PgHBA: mergedConfig.PgHBA, }, + Bootstrap: &cnpgv1.BootstrapConfiguration{ + InitDB: &cnpgv1.BootstrapInitDB{ + Database: "postgres", + Owner: "postgres", + }, + }, StorageConfiguration: cnpgv1.StorageConfiguration{ Size: mergedConfig.Storage.String(), }, From 1ff49be99ac56ace895230c486e096c9cd3a27e3 Mon Sep 17 00:00:00 2001 From: dpishchenkov Date: Mon, 9 Feb 2026 15:33:01 +0100 Subject: [PATCH 5/5] Removed redundant validation steps, simplified reconciliation logic. --- api/v4/cluster_types.go | 17 +- api/v4/zz_generated.deepcopy.go | 5 - .../bases/enterprise.splunk.com_clusters.yaml | 47 ++- .../enterprise_v4_cluster_default.yaml | 4 +- .../enterprise_v4_cluster_override.yaml | 10 +- internal/controller/cluster_controller.go | 280 +++++------------- 6 files changed, 119 insertions(+), 244 deletions(-) diff --git a/api/v4/cluster_types.go b/api/v4/cluster_types.go index dae507d36..fe7a7d1c6 100644 --- a/api/v4/cluster_types.go +++ b/api/v4/cluster_types.go @@ -23,6 +23,9 @@ import ( ) // ClusterSpec defines the desired state of Cluster. +// Validation rules ensure immutability of Class, and that Storage and PostgresVersion can only be set once and cannot be removed or downgraded. +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.postgresVersion) || (has(self.postgresVersion) && int(self.postgresVersion) >= int(oldSelf.postgresVersion))",message="Postgres version cannot be removed or downgraded" +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.storage) || (has(self.storage) && quantity(self.storage).compareTo(quantity(oldSelf.storage)) >= 0)",message="Storage size cannot be removed and can only be increased" type ClusterSpec struct { // This field is IMMUTABLE after creation. // +kubebuilder:validation:Required @@ -33,7 +36,6 @@ type ClusterSpec struct { // Storage overrides the storage size from ClusterClass. // Example: "5Gi" // +optional - // +kubebuilder:validation:XValidation:rule="self == null || oldSelf == null || quantity(self).compareTo(quantity(oldSelf)) >= 0",message="storage size can only be increased" Storage *resource.Quantity `json:"storage,omitempty"` // Instances overrides the number of PostgreSQL instances from ClusterClass. @@ -43,8 +45,9 @@ type ClusterSpec struct { Instances *int32 `json:"instances,omitempty"` // PostgresVersion overrides the PostgreSQL version from ClusterClass. - // Example: "16" + // Supported versions: "14", "15", "16", "17", "18" // +optional + // +kubebuilder:validation:Enum="14";"15";"16";"17";"18" PostgresVersion *string `json:"postgresVersion,omitempty"` // Resources overrides CPU/memory resources from ClusterClass. @@ -53,13 +56,19 @@ type ClusterSpec struct { // PostgreSQL overrides PostgreSQL engine parameters from ClusterClass. // Maps to postgresql.conf settings. + // Default empty map prevents panic. + // Example: {"shared_buffers": "128MB", "log_min_duration_statement": "500ms"} // +optional + // +kubebuilder:default={} PostgreSQLConfig map[string]string `json:"postgresqlConfig,omitempty"` // PgHBA contains pg_hba.conf host-based authentication rules. // Defines client authentication and connection security (cluster-wide). + // Maps to pg_hba.conf settings. + // Default empty array prevents panic. // Example: ["hostssl all all 0.0.0.0/0 scram-sha-256"] // +optional + // +kubebuilder:default={} PgHBA []string `json:"pgHBA,omitempty"` } @@ -78,10 +87,6 @@ type ClusterStatus struct { // Right now, only CNPG is supported. // +optional ProvisionerRef *corev1.ObjectReference `json:"provisionerRef,omitempty"` - - // CNPG config status, for reference when getting cluster status. - // +optional - CNPGConfigStatus *CNPGConfig `json:"cnpgConfigStatus,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v4/zz_generated.deepcopy.go b/api/v4/zz_generated.deepcopy.go index 0eca9250e..ef6081816 100644 --- a/api/v4/zz_generated.deepcopy.go +++ b/api/v4/zz_generated.deepcopy.go @@ -584,11 +584,6 @@ func (in *ClusterStatus) DeepCopyInto(out *ClusterStatus) { *out = new(v1.ObjectReference) **out = **in } - if in.CNPGConfigStatus != nil { - in, out := &in.CNPGConfigStatus, &out.CNPGConfigStatus - *out = new(CNPGConfig) - **out = **in - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterStatus. diff --git a/config/crd/bases/enterprise.splunk.com_clusters.yaml b/config/crd/bases/enterprise.splunk.com_clusters.yaml index bd98162c9..bcba6d85d 100644 --- a/config/crd/bases/enterprise.splunk.com_clusters.yaml +++ b/config/crd/bases/enterprise.splunk.com_clusters.yaml @@ -47,7 +47,9 @@ spec: metadata: type: object spec: - description: ClusterSpec defines the desired state of Cluster. + description: |- + ClusterSpec defines the desired state of Cluster. + Validation rules ensure immutability of Class, and that Storage and PostgresVersion can only be set once and cannot be removed or downgraded. properties: class: description: This field is IMMUTABLE after creation. @@ -64,9 +66,12 @@ spec: minimum: 1 type: integer pgHBA: + default: [] description: |- PgHBA contains pg_hba.conf host-based authentication rules. Defines client authentication and connection security (cluster-wide). + Maps to pg_hba.conf settings. + Default empty array prevents panic. Example: ["hostssl all all 0.0.0.0/0 scram-sha-256"] items: type: string @@ -74,14 +79,23 @@ spec: postgresVersion: description: |- PostgresVersion overrides the PostgreSQL version from ClusterClass. - Example: "16" + Supported versions: "14", "15", "16", "17", "18" + enum: + - "14" + - "15" + - "16" + - "17" + - "18" type: string postgresqlConfig: additionalProperties: type: string + default: {} description: |- PostgreSQL overrides PostgreSQL engine parameters from ClusterClass. Maps to postgresql.conf settings. + Default empty map prevents panic. + Example: {"shared_buffers": "128MB", "log_min_duration_statement": "500ms"} type: object resources: description: Resources overrides CPU/memory resources from ClusterClass. @@ -151,34 +165,19 @@ spec: Example: "5Gi" pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true - x-kubernetes-validations: - - message: storage size can only be increased - rule: self == null || oldSelf == null || quantity(self).compareTo(quantity(oldSelf)) - >= 0 required: - class type: object + x-kubernetes-validations: + - message: Postgres version cannot be removed or downgraded + rule: '!has(oldSelf.postgresVersion) || (has(self.postgresVersion) && + int(self.postgresVersion) >= int(oldSelf.postgresVersion))' + - message: Storage size cannot be removed and can only be increased + rule: '!has(oldSelf.storage) || (has(self.storage) && quantity(self.storage).compareTo(quantity(oldSelf.storage)) + >= 0)' status: description: ClusterStatus defines the observed state of Cluster. properties: - cnpgConfigStatus: - description: CNPG config status, for reference when getting cluster - status. - properties: - primaryUpdateMethod: - default: switchover - description: |- - PrimaryUpdateMethod determines how the primary instance is updated. - "restart" - tolerate brief downtime (suitable for development) - "switchover" - minimal downtime via automated failover (production-grade) - - NOTE: When using "switchover", ensure clusterConfig.instances > 1. - Switchover requires at least one replica to fail over to. - enum: - - restart - - switchover - type: string - type: object conditions: description: Conditions represent the latest available observations of the Cluster's state. diff --git a/config/samples/enterprise_v4_cluster_default.yaml b/config/samples/enterprise_v4_cluster_default.yaml index 66ce477e8..2a10434ab 100644 --- a/config/samples/enterprise_v4_cluster_default.yaml +++ b/config/samples/enterprise_v4_cluster_default.yaml @@ -1,9 +1,11 @@ +# This is a sample Cluster manifest with default values for all fields. +# Defaulrs are inherited from the ClusterClass "postgresql-dev" (see enterprise_v4_clusterclass_dev.yaml) and can be overridden here. apiVersion: enterprise.splunk.com/v4 kind: Cluster metadata: labels: app.kubernetes.io/name: splunk-operator app.kubernetes.io/managed-by: kustomize - name: cluster-sample + name: postgresql-cluster-dev spec: class: postgresql-dev diff --git a/config/samples/enterprise_v4_cluster_override.yaml b/config/samples/enterprise_v4_cluster_override.yaml index c59f7774b..c41b9ea16 100644 --- a/config/samples/enterprise_v4_cluster_override.yaml +++ b/config/samples/enterprise_v4_cluster_override.yaml @@ -1,15 +1,19 @@ -# Sample Cluster using Development ClusterClass with Overrides +# Sample Cluster using Postgres-dev ClusterClass with Overridind defaults +# This sample demonstrates how to override default values from the ClusterClass "postgresql-dev" (see enterprise_v4_clusterclass_dev.yaml) in a Cluster manifest. +# Overrides include changing storage, changing PostgreSQL version, and modifying resources. apiVersion: enterprise.splunk.com/v4 kind: Cluster metadata: labels: app.kubernetes.io/name: splunk-operator app.kubernetes.io/managed-by: kustomize - name: cluster-sample-overriden + name: postgresql-cluster-dev-overriden spec: + # Reference the ClusterClass to inherit defaults - this is required, immutable, and must match the name of an existing ClusterClass class: postgresql-dev instances: 2 - storage: 2Gi + # Storage and PostgreSQL version are overridden from the ClusterClass defaults. Validation rules on the Cluster resource will prevent removing these fields or setting them to lower values than the original overrides. + storage: 1Gi postgresVersion: "15" resources: requests: diff --git a/internal/controller/cluster_controller.go b/internal/controller/cluster_controller.go index 5ce0bca50..e6377ae57 100644 --- a/internal/controller/cluster_controller.go +++ b/internal/controller/cluster_controller.go @@ -18,21 +18,25 @@ package controller import ( "context" + "errors" "fmt" - cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" enterprisev4 "github.com/splunk/splunk-operator/api/v4" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" - "k8s.io/apimachinery/pkg/api/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" client "sigs.k8s.io/controller-runtime/pkg/client" logs "sigs.k8s.io/controller-runtime/pkg/log" + "time" +) + +const ( + retryDelay = time.Second * 10 ) // ClusterReconciler reconciles a Cluster object @@ -56,7 +60,7 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct // Fetch the Cluster instance cluster := &enterprisev4.Cluster{} if err := r.Get(ctx, req.NamespacedName, cluster); err != nil { - if errors.IsNotFound(err) { + if apierrors.IsNotFound(err) { logger.Info("Cluster resource not found. Ignoring since object must be deleted.") return ctrl.Result{}, nil } @@ -74,198 +78,117 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct // Merge ClusterClass spec into Cluster spec mergedConfig := r.getMergedConfig(clusterClass, cluster) - validationErr := validateClusterConfig(mergedConfig, clusterClass) - if validationErr != nil { - logger.Error(validationErr, "Merged config invalid") - // Update status to show validation failure - if err := r.updateClusterStatus(ctx, cluster, nil, validationErr); err != nil { - logger.Error(err, "Failed to update status after validation error") - } - return ctrl.Result{}, validationErr - } - - // Check if the CNPG Cluster already exists and create one if not + // Get or create the CNPG Cluster cnpgCluster := &cnpgv1.Cluster{} err := r.Get(ctx, types.NamespacedName{ Name: cluster.Name, Namespace: cluster.Namespace, }, cnpgCluster) - if err != nil && errors.IsNotFound(err) { - // Define a new CNPG Cluster - logger.Info("Defining a new CNPG Cluster", "Cluster.Name", cluster.Name, "Cluster.Namespace", cluster.Namespace) - cnpgCluster, err = r.defineNewCNPGCluster(clusterClass, cluster, mergedConfig) - if err != nil { - logger.Error(err, "Failed to define CNPG Cluster.") - return ctrl.Result{}, err - } - - // Create the CNPG Cluster - logger.Info("Creating a new CNPG Cluster", "CNPGCluster.Name", cnpgCluster.Name) + isNewCluster := false + if err != nil && apierrors.IsNotFound(err) { + // Create new CNPG Cluster + cnpgCluster = r.buildCNPGCluster(cluster, mergedConfig) if err := r.Create(ctx, cnpgCluster); err != nil { - logger.Error(err, "Failed to create CNPG Cluster.") - return ctrl.Result{}, err - } - // Update Cluster status with provisioner reference - cluster.Status.ProvisionerRef = &corev1.ObjectReference{ - APIVersion: "postgresql.cnpg.io/v1", - Kind: "Cluster", - Namespace: cnpgCluster.Namespace, - Name: cnpgCluster.Name, - UID: cnpgCluster.UID, - } - cluster.Status.Phase = "Provisioning" - - if err := r.Status().Update(ctx, cluster); err != nil { - logger.Error(err, "Failed to update Cluster status after CNPG Cluster creation.") + logger.Error(err, "Failed to create CNPG Cluster") return ctrl.Result{}, err } - + isNewCluster = true logger.Info("CNPG Cluster created successfully", "CNPGCluster.Name", cnpgCluster.Name) - // Requeue to check the status of the CNPG cluster - return ctrl.Result{Requeue: true}, nil } else if err != nil { logger.Error(err, "Failed to get CNPG Cluster.") return ctrl.Result{}, err } - // CNPG Cluster exists, ensure it matches the desired state - logger.Info("CNPG Cluster already exists. Ensuring it is up to date.", "CNPGCluster.Name", cnpgCluster.Name) - updated, err := r.ensureClusterUpToDate(ctx, clusterClass, cnpgCluster, mergedConfig) - if err != nil { - logger.Error(err, "Failed to ensure CNPG Cluster is up to date.") - if statusErr := r.updateClusterStatus(ctx, cluster, cnpgCluster, err); statusErr != nil { - logger.Error(statusErr, "Failed to update Cluster status after ensure up to date error") + // Reconcile Cluster to desired state + desiredSpec := r.buildCNPGClusterSpec(mergedConfig) + if !equality.Semantic.DeepEqual(cnpgCluster.Spec, desiredSpec) { + logger.Info("CNPG Cluster spec differs from desired state, updating", "name", cnpgCluster.Name) + cnpgCluster.Spec = desiredSpec + if err := r.Update(ctx, cnpgCluster); err != nil { + logger.Error(err, "Failed to update CNPG Cluster") + + // Update status with error + if statusErr := r.updateClusterStatus(ctx, cluster, cnpgCluster, err); statusErr != nil { + logger.Error(statusErr, "Failed to update status after sync error") + return ctrl.Result{}, errors.Join(err, statusErr) + } + return ctrl.Result{}, err } - return ctrl.Result{}, err - } - if updated { - logger.Info("CNPG Cluster updated successfully", "CNPGCluster.Name", cnpgCluster.Name) + logger.Info("CNPG Cluster updated successfully", "name", cnpgCluster.Name) } else { - logger.Info("CNPG Cluster is already up to date", "CNPGCluster.Name", cnpgCluster.Name) + logger.Info("CNPG Cluster is already up to date", "name", cnpgCluster.Name) } - // Update CNPG cluster status if it was changed - logger.Info("Updating Cluster status based on CNPG state", "Phase", cnpgCluster.Status.Phase) + // Update Cluster status if err := r.updateClusterStatus(ctx, cluster, cnpgCluster, nil); err != nil { logger.Error(err, "Failed to update Cluster status.") return ctrl.Result{}, err - } else { - logger.Info("Cluster is up to date", "Cluster.Name", cluster.Name) } + // Requeue for new clusters to check provisioning status + if isNewCluster { + return ctrl.Result{RequeueAfter: retryDelay}, nil + } + + logger.Info("Cluster reconciliation complete", "Cluster.Name", cluster.Name) + return ctrl.Result{}, nil } // getMergedConfig merges the configuration from the ClusterClass into the ClusterSpec, giving precedence to the ClusterSpec values. func (r *ClusterReconciler) getMergedConfig(clusterClass *enterprisev4.ClusterClass, cluster *enterprisev4.Cluster) *enterprisev4.ClusterSpec { - clusterClassDefaulfConfig := clusterClass.Spec.ClusterConfig.DeepCopy() // Get a deep copy to avoid mutating the original resultConfig := cluster.Spec.DeepCopy() + classDefaulfs := clusterClass.Spec.ClusterConfig if resultConfig.Instances == nil { - resultConfig.Instances = clusterClassDefaulfConfig.Instances + resultConfig.Instances = classDefaulfs.Instances } if resultConfig.PostgresVersion == nil { - resultConfig.PostgresVersion = clusterClassDefaulfConfig.PostgresVersion + resultConfig.PostgresVersion = classDefaulfs.PostgresVersion } if resultConfig.Resources == nil { - resultConfig.Resources = clusterClassDefaulfConfig.Resources + resultConfig.Resources = classDefaulfs.Resources } if resultConfig.Storage == nil { - resultConfig.Storage = clusterClassDefaulfConfig.Storage + resultConfig.Storage = classDefaulfs.Storage } if len(resultConfig.PostgreSQLConfig) == 0 { - resultConfig.PostgreSQLConfig = clusterClassDefaulfConfig.PostgreSQLConfig + resultConfig.PostgreSQLConfig = classDefaulfs.PostgreSQLConfig } if len(resultConfig.PgHBA) == 0 { - resultConfig.PgHBA = clusterClassDefaulfConfig.PgHBA + resultConfig.PgHBA = classDefaulfs.PgHBA } - return &enterprisev4.ClusterSpec{ - Instances: resultConfig.Instances, - PostgresVersion: resultConfig.PostgresVersion, - Resources: resultConfig.Resources, - Storage: resultConfig.Storage, - PostgreSQLConfig: resultConfig.PostgreSQLConfig, - PgHBA: resultConfig.PgHBA, + // Ensure that maps and slices are initialized to empty if they are still nil after merging, to prevent potential nil pointer dereferences later on. + if resultConfig.PostgreSQLConfig == nil { + resultConfig.PostgreSQLConfig = make(map[string]string) } -} - -// defineNewCNPGCluster defines a new CNPG Cluster resource based on the merged configuration. -func (r *ClusterReconciler) defineNewCNPGCluster( - clusterClass *enterprisev4.ClusterClass, - cluster *enterprisev4.Cluster, - mergedConfig *enterprisev4.ClusterSpec, -) (*cnpgv1.Cluster, error) { - - // Validate that required fields are present in the merged configuration before creating the CNPG Cluster. - if err := validateClusterConfig(mergedConfig, clusterClass); err != nil { - return nil, err + if resultConfig.PgHBA == nil { + resultConfig.PgHBA = make([]string, 0) } - - resources := corev1.ResourceRequirements{} - if mergedConfig.Resources != nil { - resources = *mergedConfig.Resources + // Ensure that Resources is initialized to an empty struct if it's still nil after merging, to prevent potential nil pointer dereferences later on. + if resultConfig.Resources == nil { + resultConfig.Resources = &corev1.ResourceRequirements{} } - - cnpgCluster := &cnpgv1.Cluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: cluster.Name, - Namespace: cluster.Namespace, - }, - Spec: cnpgv1.ClusterSpec{ - ImageName: fmt.Sprintf("ghcr.io/cloudnative-pg/postgresql:%s", *mergedConfig.PostgresVersion), - Instances: int(*mergedConfig.Instances), - PostgresConfiguration: cnpgv1.PostgresConfiguration{ - Parameters: mergedConfig.PostgreSQLConfig, - PgHBA: mergedConfig.PgHBA, - }, - Bootstrap: &cnpgv1.BootstrapConfiguration{ - InitDB: &cnpgv1.BootstrapInitDB{ - Database: "postgres", - Owner: "postgres", - }, - }, - StorageConfiguration: cnpgv1.StorageConfiguration{ - Size: mergedConfig.Storage.String(), - }, - Resources: resources, - }, + if resultConfig.Resources.Requests == nil { + resultConfig.Resources.Requests = make(corev1.ResourceList) } - - if err := ctrl.SetControllerReference(cluster, cnpgCluster, r.Scheme); err != nil { - return nil, err + if resultConfig.Resources.Limits == nil { + resultConfig.Resources.Limits = make(corev1.ResourceList) } - return cnpgCluster, nil -} -// ensureClusterUpToDate ensures that the CNPG Cluster matches the desired state based on the merged configuration. -func (r *ClusterReconciler) ensureClusterUpToDate( - ctx context.Context, - clusterClass *enterprisev4.ClusterClass, - cnpgCluster *cnpgv1.Cluster, - mergedConfig *enterprisev4.ClusterSpec, -) (bool, error) { - - // Validate that required fields are present in the merged configuration before updating the CNPG Cluster. - if err := validateClusterConfig(mergedConfig, clusterClass); err != nil { - return false, err - } - // Validate that storage size is not decreased - if err := validateStorageSize(mergedConfig.Storage, cnpgCluster.Spec.StorageConfiguration.Size); err != nil { - return false, err - } - // Validate that PostgresVersion is not decreased - if err := validatePostgresVersion(*mergedConfig.PostgresVersion, cnpgCluster.Spec.ImageName); err != nil { - return false, err - } + return resultConfig +} +// buildCNPGClusterSpec builds the desired CNPG ClusterSpec based on the merged configuration. +func (r *ClusterReconciler) buildCNPGClusterSpec(mergedConfig *enterprisev4.ClusterSpec) cnpgv1.ClusterSpec { resources := corev1.ResourceRequirements{} if mergedConfig.Resources != nil { resources = *mergedConfig.Resources } - desiredState := cnpgv1.ClusterSpec{ + return cnpgv1.ClusterSpec{ ImageName: fmt.Sprintf("ghcr.io/cloudnative-pg/postgresql:%s", *mergedConfig.PostgresVersion), Instances: int(*mergedConfig.Instances), PostgresConfiguration: cnpgv1.PostgresConfiguration{ @@ -283,15 +206,20 @@ func (r *ClusterReconciler) ensureClusterUpToDate( }, Resources: resources, } +} - if !equality.Semantic.DeepEqual(cnpgCluster.Spec, desiredState) { - cnpgCluster.Spec = desiredState - if err := r.Update(ctx, cnpgCluster); err != nil { - return false, err - } - return true, nil +// build CNPGCluster builds the CNPG Cluster object based on the Cluster resource and merged configuration. +func (r *ClusterReconciler) buildCNPGCluster(cluster *enterprisev4.Cluster, mergedConfig *enterprisev4.ClusterSpec) *cnpgv1.Cluster { + cnpgCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster.Name, + Namespace: cluster.Namespace, + }, + Spec: r.buildCNPGClusterSpec(mergedConfig), } - return false, nil + + ctrl.SetControllerReference(cluster, cnpgCluster, r.Scheme) + return cnpgCluster } // updateClusterStatus updates the status of the Cluster resource based on the status of the CNPG Cluster. @@ -362,64 +290,6 @@ func (r *ClusterReconciler) updateClusterStatus(ctx context.Context, cluster *en return nil } -// validateClusterConfig checks that all required fields are present in the merged configuration. -func validateClusterConfig(mergedConfig *enterprisev4.ClusterSpec, clusterClass *enterprisev4.ClusterClass) error { - if mergedConfig == nil { - return fmt.Errorf("mergedConfig is nil") - } - if clusterClass == nil { - return fmt.Errorf("clusterClass is nil") - } - cfg := mergedConfig - - switch { - case cfg.Instances == nil: - return fmt.Errorf("missing required field in merged configuration: Instances") - case cfg.Storage == nil: - return fmt.Errorf("missing required field in merged configuration: Storage") - case cfg.PostgresVersion == nil: - return fmt.Errorf("missing required field in merged configuration: PostgresVersion") - } - return nil -} - -// validateStorageSize ensures that the storage size is not decreased after cluster creation. -func validateStorageSize(desired *resource.Quantity, existing string) error { - if desired == nil { - return nil - } - existingQty, err := resource.ParseQuantity(existing) - if err != nil { - return fmt.Errorf("failed to parse existing storage size: %v", err) - } - // Cmp returns -1 if desired < existingQty - if desired.Cmp(existingQty) < 0 { - return fmt.Errorf( - "storage size cannot be decreased from %s to %s", - existingQty.String(), - desired.String(), - ) - } - return nil -} - -// validatePostgresVersion ensures that the Postgres version is not decreased after cluster creation. -func validatePostgresVersion(desired string, existing string) error { - var existingVersion string - _, err := fmt.Sscanf(existing, "ghcr.io/cloudnative-pg/postgresql:%s", &existingVersion) - if err != nil { - return fmt.Errorf("failed to parse existing Postgres version: %v", err) - } - if desired < existingVersion { - return fmt.Errorf( - "Postgres version cannot be decreased from %s to %s", - existingVersion, - desired, - ) - } - return nil -} - // SetupWithManager sets up the controller with the Manager. func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr).