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/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 new file mode 100644 index 000000000..0a47e63e5 --- /dev/null +++ b/api/v4/cluster_types.go @@ -0,0 +1,120 @@ +/* +Copyright 2026. + +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. +// 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) || semver(self.postgresVersion, true).compareTo(semver(oldSelf.postgresVersion, true)) >= 0",message="Postgres version cannot be 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 + // +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 + 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 is the PostgreSQL version (major or major.minor). + // Examples: "18" (latest 18.x), "18.1" (specific minor), "17", "16" + // +kubebuilder:validation:Pattern=`^[0-9]+(\.[0-9]+)?$` + // +kubebuilder:default="18" + // +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. + // 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"` +} + +// 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/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 28f701ff0..ef6081816 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. @@ -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..7f6c545b5 --- /dev/null +++ b/config/crd/bases/enterprise.splunk.com_clusters.yaml @@ -0,0 +1,290 @@ +--- +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. + 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. + 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: + 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 + type: array + postgresVersion: + default: "18" + description: |- + PostgresVersion is the PostgreSQL version (major or major.minor). + Examples: "18" (latest 18.x), "18.1" (specific minor), "17", "16" + pattern: ^[0-9]+(\.[0-9]+)?$ + 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. + 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 + required: + - class + type: object + x-kubernetes-validations: + - message: Postgres version cannot be downgraded + rule: '!has(oldSelf.postgresVersion) || semver(self.postgresVersion, + true).compareTo(semver(oldSelf.postgresVersion, true)) >= 0' + - 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: + 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..2a10434ab --- /dev/null +++ b/config/samples/enterprise_v4_cluster_default.yaml @@ -0,0 +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: 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 new file mode 100644 index 000000000..454a09190 --- /dev/null +++ b/config/samples/enterprise_v4_cluster_override.yaml @@ -0,0 +1,24 @@ +# 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: 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 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.10" + 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/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 new file mode 100644 index 000000000..3b092f33b --- /dev/null +++ b/internal/controller/cluster_controller.go @@ -0,0 +1,291 @@ +/* +Copyright 2026. + +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" + apierrors "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" + "time" +) + +const ( + retryDelay = time.Second * 10 +) + +// 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) + + // 1. Fetch the Cluster instance + cluster := &enterprisev4.Cluster{} + if err := r.Get(ctx, req.NamespacedName, cluster); err != nil { + if apierrors.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 + } + + // 2. 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 + } + + // 3. Merge ClusterClass spec into Cluster spec + mergedConfig := r.getMergedConfig(clusterClass, cluster) + + // 4. Build the desired CNPG ClusterSpec based on the merged configuration + desiredSpec := r.buildCNPGClusterSpec(mergedConfig) + cnpgCluster := &cnpgv1.Cluster{} + + // 5. Ensure CNPG Cluster exists + err := r.Get(ctx, types.NamespacedName{ + Name: cluster.Name, + Namespace: cluster.Namespace}, + cnpgCluster, + ) + + if apierrors.IsNotFound(err) { + logger.Info("CNPG Cluster not found, creating:", "name", cluster.Name) + 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 + } + } else if err != nil { + logger.Error(err, "Failed to get CNPG Cluster") + return ctrl.Result{}, err + } else { + // 6. If CNPG Cluster exists, check if the spec needs to be updated + if !equality.Semantic.DeepEqual(cnpgCluster.Spec, desiredSpec) { + logger.Info("Updating CNPG Cluster spec", "name", cnpgCluster.Name) + cnpgCluster.Spec = desiredSpec + + if err := r.Update(ctx, cnpgCluster); err != nil { + logger.Error(err, "Failed to update CNPG Cluster") + return ctrl.Result{}, err + } + } + } + + // 7. Final status sync based on CNPG Cluster status + if err := r.updateClusterStatus(ctx, cluster, cnpgCluster); err != nil { + logger.Error(err, "Failed to update Cluster status") + return ctrl.Result{}, err + } + + // 8. Determine if we need to requeue based on the Status Phase. + switch cnpgCluster.Status.Phase { + case cnpgv1.PhaseUnrecoverable: + logger.Error(nil, "CNPG Cluster is in unrecoverable state", "name", cnpgCluster.Name) + return ctrl.Result{}, nil + case cnpgv1.PhaseHealthy: + return ctrl.Result{}, nil + case "": + logger.Info("CNPG Cluster is pending creation, requeuing in", "delay", retryDelay, "name", cnpgCluster.Name) + return ctrl.Result{RequeueAfter: retryDelay}, nil + default: + logger.Info("CNPG Cluster is in provisioning state", "phase", cnpgCluster.Status.Phase, "name", cnpgCluster.Name) + return ctrl.Result{RequeueAfter: retryDelay}, 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 { + resultConfig := cluster.Spec.DeepCopy() + classDefaults := clusterClass.Spec.ClusterConfig + + if resultConfig.Instances == nil { + resultConfig.Instances = classDefaults.Instances + } + if resultConfig.PostgresVersion == nil { + resultConfig.PostgresVersion = classDefaults.PostgresVersion + } + if resultConfig.Resources == nil { + resultConfig.Resources = classDefaults.Resources + } + if resultConfig.Storage == nil { + resultConfig.Storage = classDefaults.Storage + } + if len(resultConfig.PostgreSQLConfig) == 0 { + resultConfig.PostgreSQLConfig = classDefaults.PostgreSQLConfig + } + if len(resultConfig.PgHBA) == 0 { + resultConfig.PgHBA = classDefaults.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) + } + if resultConfig.PgHBA == nil { + resultConfig.PgHBA = make([]string, 0) + } + // 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{} + } + if resultConfig.Resources.Requests == nil { + resultConfig.Resources.Requests = make(corev1.ResourceList) + } + if resultConfig.Resources.Limits == nil { + resultConfig.Resources.Limits = make(corev1.ResourceList) + } + + return resultConfig +} + +// buildCNPGClusterSpec builds the desired CNPG ClusterSpec and returns an error if mandatory fields are missing. +func (r *ClusterReconciler) buildCNPGClusterSpec(mergedConfig *enterprisev4.ClusterSpec) cnpgv1.ClusterSpec { + + resources := corev1.ResourceRequirements{} + if mergedConfig.Resources != nil { + resources = *mergedConfig.Resources + } + + // 3. Build the Spec + 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, + } + + return spec +} + +// 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), + } + + ctrl.SetControllerReference(cluster, cnpgCluster, r.Scheme) + return cnpgCluster +} + +// 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, + }) + 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, + }) + 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, + }) + 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, + }) + } + + 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 + } + 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..2f2b30a61 --- /dev/null +++ b/internal/controller/cluster_controller_test.go @@ -0,0 +1,84 @@ +/* +Copyright 2026. + +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. + }) + }) +}) 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.