This is an automated email from the ASF dual-hosted git repository.

adheipsingh pushed a commit to branch 1.3.0-pvc-annotations
in repository https://gitbox.apache.org/repos/asf/druid-operator.git

commit c3072f98059a557c59bff0c9764b70cc4ca46515
Author: AdheipSingh <[email protected]>
AuthorDate: Tue Sep 30 17:00:50 2025 +0530

    add support to add pvc annotations on UPDATES to druid CR
---
 apis/druid/v1alpha1/druid_types.go                 |   6 +
 chart/crds/druid.apache.org_druids.yaml            |   6 +
 config/crd/bases/druid.apache.org_druids.yaml      |   6 +
 controllers/druid/druid_controller.go              |   6 +-
 controllers/druid/handler.go                       |  14 ++
 controllers/druid/interface.go                     |   5 +
 controllers/druid/volume_annotation_update.go      | 278 +++++++++++++++++++++
 controllers/druid/volume_annotation_update_test.go | 203 +++++++++++++++
 docs/api_specifications/druid.md                   |  26 ++
 docs/features.md                                   |  74 +++++-
 go.mod                                             |   1 +
 go.sum                                             |   1 +
 12 files changed, 622 insertions(+), 4 deletions(-)

diff --git a/apis/druid/v1alpha1/druid_types.go 
b/apis/druid/v1alpha1/druid_types.go
index 0fd457c..aacc91f 100644
--- a/apis/druid/v1alpha1/druid_types.go
+++ b/apis/druid/v1alpha1/druid_types.go
@@ -115,6 +115,12 @@ type DruidSpec struct {
        // +kubebuilder:default:=false
        DisablePVCDeletionFinalizer bool 
`json:"disablePVCDeletionFinalizer,omitempty"`
 
+       // DisablePVCAnnotationUpdate When set to true, operator will not patch 
PVC annotations when VolumeClaimTemplate annotations are updated.
+       // When disabled (false), updating VolumeClaimTemplate annotations will 
patch existing PVCs and recreate the StatefulSet.
+       // +optional
+       // +kubebuilder:default:=false
+       DisablePVCAnnotationUpdate bool 
`json:"disablePVCAnnotationUpdate,omitempty"`
+
        // DeleteOrphanPvc Orphaned (unmounted PVCs) shall be cleaned up by the 
operator.
        // +optional
        // +kubebuilder:default:=true
diff --git a/chart/crds/druid.apache.org_druids.yaml 
b/chart/crds/druid.apache.org_druids.yaml
index 742756d..d14ed98 100644
--- a/chart/crds/druid.apache.org_druids.yaml
+++ b/chart/crds/druid.apache.org_druids.yaml
@@ -1496,6 +1496,12 @@ spec:
                 description: DeleteOrphanPvc Orphaned (unmounted PVCs) shall 
be cleaned
                   up by the operator.
                 type: boolean
+              disablePVCAnnotationUpdate:
+                default: false
+                description: |-
+                  DisablePVCAnnotationUpdate When set to true, operator will 
not patch PVC annotations when VolumeClaimTemplate annotations are updated.
+                  When disabled (false), updating VolumeClaimTemplate 
annotations will patch existing PVCs and recreate the StatefulSet.
+                type: boolean
               disablePVCDeletionFinalizer:
                 default: false
                 description: DisablePVCDeletionFinalizer Whether PVCs shall be 
deleted
diff --git a/config/crd/bases/druid.apache.org_druids.yaml 
b/config/crd/bases/druid.apache.org_druids.yaml
index 742756d..d14ed98 100644
--- a/config/crd/bases/druid.apache.org_druids.yaml
+++ b/config/crd/bases/druid.apache.org_druids.yaml
@@ -1496,6 +1496,12 @@ spec:
                 description: DeleteOrphanPvc Orphaned (unmounted PVCs) shall 
be cleaned
                   up by the operator.
                 type: boolean
+              disablePVCAnnotationUpdate:
+                default: false
+                description: |-
+                  DisablePVCAnnotationUpdate When set to true, operator will 
not patch PVC annotations when VolumeClaimTemplate annotations are updated.
+                  When disabled (false), updating VolumeClaimTemplate 
annotations will patch existing PVCs and recreate the StatefulSet.
+                type: boolean
               disablePVCDeletionFinalizer:
                 default: false
                 description: DisablePVCDeletionFinalizer Whether PVCs shall be 
deleted
diff --git a/controllers/druid/druid_controller.go 
b/controllers/druid/druid_controller.go
index 4959d00..1cd6073 100644
--- a/controllers/druid/druid_controller.go
+++ b/controllers/druid/druid_controller.go
@@ -78,9 +78,9 @@ func (r *DruidReconciler) Reconcile(ctx context.Context, 
request reconcile.Reque
        }
 
        // Update Druid Dynamic Configs
-       if err := updateDruidDynamicConfigs(ctx, r.Client, instance, 
emitEvent); err != nil {
-               return ctrl.Result{}, err
-       }
+       // if err := updateDruidDynamicConfigs(ctx, r.Client, instance, 
emitEvent); err != nil {
+       //      return ctrl.Result{}, err
+       // }
 
        // If both operations succeed, requeue after specified wait time
        return ctrl.Result{RequeueAfter: r.ReconcileWait}, nil
diff --git a/controllers/druid/handler.go b/controllers/druid/handler.go
index c47032c..5d8807f 100644
--- a/controllers/druid/handler.go
+++ b/controllers/druid/handler.go
@@ -165,6 +165,20 @@ func deployDruidCluster(ctx context.Context, sdk 
client.Client, m *v1alpha1.Drui
                                }
                        }
 
+                       // Handle VolumeClaimTemplate annotation updates 
(enabled by default unless disabled)
+                       // If StatefulSet was deleted for annotation updates, 
skip creation in this reconcile to avoid race condition
+                       if m.Generation > 1 {
+                               deleted, err := 
patchStatefulSetVolumeClaimTemplateAnnotations(ctx, sdk, m, &nodeSpec, 
emitEvents, nodeSpecUniqueStr)
+                               if err != nil {
+                                       return err
+                               }
+                               if deleted {
+                                       // StatefulSet was deleted, skip 
creation in this reconcile loop
+                                       // It will be created in the next 
reconcile with updated annotations
+                                       continue
+                               }
+                       }
+
                        // Create/Update StatefulSet
                        if stsCreateUpdateStatus, err := 
sdkCreateOrUpdateAsNeeded(ctx, sdk,
                                func() (object, error) {
diff --git a/controllers/druid/interface.go b/controllers/druid/interface.go
index 6f32b2d..07a7446 100644
--- a/controllers/druid/interface.go
+++ b/controllers/druid/interface.go
@@ -49,6 +49,11 @@ const (
        druidConfigComparisonFailed    druidEventReason = 
"DruidAPIConfigComparisonFailed"
        druidUpdateConfigsFailed       druidEventReason = 
"DruidAPIUpdateConfigsFailed"
        druidUpdateConfigsSuccess      druidEventReason = 
"DruidAPIUpdateConfigsSuccess"
+
+       // PVC Annotation Update Events
+       druidPvcAnnotationChangeDetected druidEventReason = 
"DruidOperatorPvcAnnotationChangeDetected"
+       druidPvcAnnotationsUpdated       druidEventReason = 
"DruidOperatorPvcAnnotationsUpdated"
+       druidStsOrphanedForAnnotations   druidEventReason = 
"DruidOperatorStsOrphanedForAnnotations"
 )
 
 // Reader Interface
diff --git a/controllers/druid/volume_annotation_update.go 
b/controllers/druid/volume_annotation_update.go
new file mode 100644
index 0000000..f024def
--- /dev/null
+++ b/controllers/druid/volume_annotation_update.go
@@ -0,0 +1,278 @@
+package druid
+
+import (
+       "context"
+       "fmt"
+       "reflect"
+       "strings"
+
+       "github.com/datainfrahq/druid-operator/apis/druid/v1alpha1"
+       appsv1 "k8s.io/api/apps/v1"
+       v1 "k8s.io/api/core/v1"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/apimachinery/pkg/runtime"
+       "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// patchStatefulSetVolumeClaimTemplateAnnotations handles annotation updates 
for StatefulSet VolumeClaimTemplates
+// Returns (deleted bool, error) - deleted indicates if the StatefulSet was 
deleted in this call
+func patchStatefulSetVolumeClaimTemplateAnnotations(ctx context.Context, sdk 
client.Client, m *v1alpha1.Druid,
+       nodeSpec *v1alpha1.DruidNodeSpec, emitEvent EventEmitter, 
nodeSpecUniqueStr string) (bool, error) {
+
+       // Skip if PVC annotation update is disabled
+       if m.Spec.DisablePVCAnnotationUpdate {
+               return false, nil
+       }
+
+       // Only process StatefulSets (default kind is StatefulSet when not 
specified or anything other than "Deployment")
+       if nodeSpec.Kind == "Deployment" {
+               return false, nil
+       }
+
+       // Get the existing StatefulSet
+       sts, err := readers.Get(ctx, sdk, nodeSpecUniqueStr, m, func() object { 
return &appsv1.StatefulSet{} }, emitEvent)
+       if err != nil {
+               // StatefulSet doesn't exist yet, will be created with proper 
annotations
+               // This is expected after we delete it for recreation
+               return false, nil
+       }
+
+       statefulSet := sts.(*appsv1.StatefulSet)
+
+       // Check if annotation changes are needed
+       annotationChangesNeeded, annotationDetails := 
detectAnnotationChanges(statefulSet, nodeSpec)
+       if !annotationChangesNeeded {
+               return false, nil
+       }
+
+       // Before proceeding, check if PVCs already have the desired annotations
+       // This prevents re-processing after StatefulSet deletion
+       pvcAlreadyUpdated, err := checkPVCAnnotationsAlreadyUpdated(ctx, sdk, 
statefulSet, nodeSpec, m, emitEvent, nodeSpecUniqueStr)
+       if err != nil {
+               return false, err
+       }
+       if pvcAlreadyUpdated {
+               // PVCs already have the desired annotations, likely from a 
previous reconcile
+               // The StatefulSet will be recreated with correct annotations 
by sdkCreateOrUpdateAsNeeded
+               return false, nil
+       }
+
+       // Don't proceed unless all statefulsets are up and running
+       getSTSList, err := readers.List(ctx, sdk, m, makeLabelsForDruid(m), 
emitEvent, func() objectList { return &appsv1.StatefulSetList{} }, func(listObj 
runtime.Object) []object {
+               items := listObj.(*appsv1.StatefulSetList).Items
+               result := make([]object, len(items))
+               for i := 0; i < len(items); i++ {
+                       result[i] = &items[i]
+               }
+               return result
+       })
+       if err != nil {
+               return false, nil
+       }
+
+       for _, sts := range getSTSList {
+               if sts.(*appsv1.StatefulSet).Status.Replicas != 
sts.(*appsv1.StatefulSet).Status.ReadyReplicas {
+                       return false, nil
+               }
+       }
+
+       // Emit event for annotation change detection
+       msg := fmt.Sprintf("Detected annotation changes in VolumeClaimTemplates 
for StatefulSet [%s] in Namespace [%s]: %s",
+               statefulSet.Name, statefulSet.Namespace, annotationDetails)
+       emitEvent.EmitEventGeneric(m, string(druidPvcAnnotationChangeDetected), 
msg, nil)
+
+       // First, delete the StatefulSet with cascade=orphan (similar to volume 
expansion)
+       msg = fmt.Sprintf("Deleting StatefulSet [%s] with cascade=orphan to 
apply annotation changes", statefulSet.Name)
+       emitEvent.EmitEventGeneric(m, string(druidStsOrphanedForAnnotations), 
msg, nil)
+
+       if err := writers.Delete(ctx, sdk, m, statefulSet, emitEvent, 
client.PropagationPolicy(metav1.DeletePropagationOrphan)); err != nil {
+               return false, err
+       }
+
+       msg = fmt.Sprintf("StatefulSet [%s] successfully deleted with 
cascade=orphan for annotation updates", statefulSet.Name)
+       emitEvent.EmitEventGeneric(m, string(druidStsOrphanedForAnnotations), 
msg, nil)
+
+       // Then update PVC annotations after deletion (similar to volume 
expansion)
+       if err := patchPVCAnnotations(ctx, sdk, statefulSet, nodeSpec, m, 
emitEvent, nodeSpecUniqueStr); err != nil {
+               return false, err
+       }
+
+       // Return true to indicate StatefulSet was deleted
+       return true, nil
+}
+
+// detectAnnotationChanges compares current and desired annotations for 
VolumeClaimTemplates
+func detectAnnotationChanges(sts *appsv1.StatefulSet, nodeSpec 
*v1alpha1.DruidNodeSpec) (bool, string) {
+       var changeDetails []string
+
+       // Create a map of current VCT annotations by name
+       currentVCTAnnotations := make(map[string]map[string]string)
+       for _, vct := range sts.Spec.VolumeClaimTemplates {
+               currentVCTAnnotations[vct.Name] = vct.ObjectMeta.Annotations
+       }
+
+       // Check each desired VCT for annotation changes
+       for _, desiredVCT := range nodeSpec.VolumeClaimTemplates {
+               currentAnnotations, exists := 
currentVCTAnnotations[desiredVCT.Name]
+
+               // If VCT doesn't exist in current StatefulSet, skip (it's a 
new VCT)
+               if !exists {
+                       continue
+               }
+
+               // Compare annotations
+               if !reflect.DeepEqual(currentAnnotations, 
desiredVCT.ObjectMeta.Annotations) {
+                       changeDetails = append(changeDetails, fmt.Sprintf("VCT 
%s: annotations changed", desiredVCT.Name))
+               }
+       }
+
+       if len(changeDetails) > 0 {
+               return true, strings.Join(changeDetails, "; ")
+       }
+
+       return false, ""
+}
+
+// patchPVCAnnotations patches existing PVCs with new annotations from 
VolumeClaimTemplates
+func patchPVCAnnotations(ctx context.Context, sdk client.Client, sts 
*appsv1.StatefulSet,
+       nodeSpec *v1alpha1.DruidNodeSpec, m *v1alpha1.Druid, emitEvent 
EventEmitter, nodeSpecUniqueStr string) error {
+
+       // Get PVCs for this StatefulSet
+       pvcLabels := map[string]string{
+               "nodeSpecUniqueStr": nodeSpecUniqueStr,
+       }
+
+       pvcList, err := readers.List(ctx, sdk, m, pvcLabels, emitEvent, func() 
objectList { return &v1.PersistentVolumeClaimList{} }, func(listObj 
runtime.Object) []object {
+               items := listObj.(*v1.PersistentVolumeClaimList).Items
+               result := make([]object, len(items))
+               for i := 0; i < len(items); i++ {
+                       result[i] = &items[i]
+               }
+               return result
+       })
+       if err != nil {
+               return err
+       }
+
+       // Create a map of desired annotations by VCT name
+       desiredAnnotationsByVCT := make(map[string]map[string]string)
+       for _, vct := range nodeSpec.VolumeClaimTemplates {
+               desiredAnnotationsByVCT[vct.Name] = vct.ObjectMeta.Annotations
+       }
+
+       // Patch each PVC with new annotations
+       for _, pvcObj := range pvcList {
+               pvc := pvcObj.(*v1.PersistentVolumeClaim)
+
+               // Determine which VolumeClaimTemplate this PVC belongs to
+               vctName := extractVCTNameFromPVC(pvc.Name, sts.Name)
+               if vctName == "" {
+                       continue
+               }
+
+               desiredAnnotations, exists := desiredAnnotationsByVCT[vctName]
+               if !exists {
+                       continue
+               }
+
+               // Check if annotations need updating
+               if reflect.DeepEqual(pvc.ObjectMeta.Annotations, 
desiredAnnotations) {
+                       continue
+               }
+
+               // Create patch for annotations
+               pvcCopy := pvc.DeepCopy()
+               patch := client.MergeFrom(pvcCopy)
+
+               // Update or set annotations
+               if pvc.ObjectMeta.Annotations == nil {
+                       pvc.ObjectMeta.Annotations = make(map[string]string)
+               }
+
+               // Apply desired annotations
+               for key, value := range desiredAnnotations {
+                       pvc.ObjectMeta.Annotations[key] = value
+               }
+
+               // Remove annotations that are not in desired state
+               for key := range pvc.ObjectMeta.Annotations {
+                       if _, exists := desiredAnnotations[key]; !exists {
+                               delete(pvc.ObjectMeta.Annotations, key)
+                       }
+               }
+
+               // Patch the PVC
+               if err := writers.Patch(ctx, sdk, m, pvc, false, patch, 
emitEvent); err != nil {
+                       return err
+               }
+
+               msg := fmt.Sprintf("PVC [%s] successfully patched with updated 
annotations", pvc.Name)
+               emitEvent.EmitEventGeneric(m, 
string(druidPvcAnnotationsUpdated), msg, nil)
+       }
+
+       return nil
+}
+
+// checkPVCAnnotationsAlreadyUpdated checks if PVCs already have the desired 
annotations
+func checkPVCAnnotationsAlreadyUpdated(ctx context.Context, sdk client.Client, 
sts *appsv1.StatefulSet,
+       nodeSpec *v1alpha1.DruidNodeSpec, m *v1alpha1.Druid, emitEvent 
EventEmitter, nodeSpecUniqueStr string) (bool, error) {
+
+       // Get PVCs for this StatefulSet
+       pvcLabels := map[string]string{
+               "nodeSpecUniqueStr": nodeSpecUniqueStr,
+       }
+
+       pvcList, err := readers.List(ctx, sdk, m, pvcLabels, emitEvent, func() 
objectList { return &v1.PersistentVolumeClaimList{} }, func(listObj 
runtime.Object) []object {
+               items := listObj.(*v1.PersistentVolumeClaimList).Items
+               result := make([]object, len(items))
+               for i := 0; i < len(items); i++ {
+                       result[i] = &items[i]
+               }
+               return result
+       })
+       if err != nil {
+               return false, err
+       }
+
+       // Create a map of desired annotations by VCT name
+       desiredAnnotationsByVCT := make(map[string]map[string]string)
+       for _, vct := range nodeSpec.VolumeClaimTemplates {
+               desiredAnnotationsByVCT[vct.Name] = vct.ObjectMeta.Annotations
+       }
+
+       // Check each PVC to see if it already has the desired annotations
+       for _, pvcObj := range pvcList {
+               pvc := pvcObj.(*v1.PersistentVolumeClaim)
+
+               // Determine which VolumeClaimTemplate this PVC belongs to
+               vctName := extractVCTNameFromPVC(pvc.Name, sts.Name)
+               if vctName == "" {
+                       continue
+               }
+
+               desiredAnnotations, exists := desiredAnnotationsByVCT[vctName]
+               if !exists {
+                       continue
+               }
+
+               // If any PVC doesn't have the desired annotations, return false
+               if !reflect.DeepEqual(pvc.ObjectMeta.Annotations, 
desiredAnnotations) {
+                       return false, nil
+               }
+       }
+
+       // All PVCs have the desired annotations
+       return true, nil
+}
+
+// extractVCTNameFromPVC extracts the VolumeClaimTemplate name from a PVC name
+// PVC naming format: {vctName}-{statefulSetName}-{ordinal}
+func extractVCTNameFromPVC(pvcName, stsName string) string {
+       // Remove the StatefulSet name and ordinal suffix
+       suffix := fmt.Sprintf("-%s-", stsName)
+       idx := strings.LastIndex(pvcName, suffix)
+       if idx == -1 {
+               return ""
+       }
+       return pvcName[:idx]
+}
diff --git a/controllers/druid/volume_annotation_update_test.go 
b/controllers/druid/volume_annotation_update_test.go
new file mode 100644
index 0000000..2a81685
--- /dev/null
+++ b/controllers/druid/volume_annotation_update_test.go
@@ -0,0 +1,203 @@
+package druid
+
+import (
+       "context"
+       "testing"
+
+       "github.com/datainfrahq/druid-operator/apis/druid/v1alpha1"
+       "github.com/stretchr/testify/assert"
+       appsv1 "k8s.io/api/apps/v1"
+       v1 "k8s.io/api/core/v1"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// mockEventEmitter provides a no-op implementation of EventEmitter for testing
+type mockEventEmitter struct{}
+
+func (m *mockEventEmitter) EmitEventGeneric(obj object, eventReason, msg 
string, err error)         {}
+func (m *mockEventEmitter) EmitEventRollingDeployWait(obj, k8sObj object, 
nodeSpecUniqueStr string) {}
+func (m *mockEventEmitter) EmitEventOnGetError(obj, getObj object, err error)  
                     {}
+func (m *mockEventEmitter) EmitEventOnUpdate(obj, updateObj object, err error) 
                     {}
+func (m *mockEventEmitter) EmitEventOnDelete(obj, deleteObj object, err error) 
                     {}
+func (m *mockEventEmitter) EmitEventOnCreate(obj, createObj object, err error) 
                     {}
+func (m *mockEventEmitter) EmitEventOnPatch(obj, patchObj object, err error)   
                     {}
+func (m *mockEventEmitter) EmitEventOnList(obj object, listObj objectList, err 
error)               {}
+
+func TestDetectAnnotationChanges(t *testing.T) {
+       tests := []struct {
+               name               string
+               currentStatefulSet *appsv1.StatefulSet
+               nodeSpec           *v1alpha1.DruidNodeSpec
+               expectChanges      bool
+               expectDetails      string
+       }{
+               {
+                       name: "No changes when annotations are identical",
+                       currentStatefulSet: &appsv1.StatefulSet{
+                               Spec: appsv1.StatefulSetSpec{
+                                       VolumeClaimTemplates: 
[]v1.PersistentVolumeClaim{
+                                               {
+                                                       ObjectMeta: 
metav1.ObjectMeta{
+                                                               Name: 
"segment-cache",
+                                                               Annotations: 
map[string]string{
+                                                                       
"volume.beta.kubernetes.io/storage-class": "fast-ssd",
+                                                               },
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+                       nodeSpec: &v1alpha1.DruidNodeSpec{
+                               VolumeClaimTemplates: 
[]v1.PersistentVolumeClaim{
+                                       {
+                                               ObjectMeta: metav1.ObjectMeta{
+                                                       Name: "segment-cache",
+                                                       Annotations: 
map[string]string{
+                                                               
"volume.beta.kubernetes.io/storage-class": "fast-ssd",
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+                       expectChanges: false,
+                       expectDetails: "",
+               },
+               {
+                       name: "Detect annotation changes",
+                       currentStatefulSet: &appsv1.StatefulSet{
+                               Spec: appsv1.StatefulSetSpec{
+                                       VolumeClaimTemplates: 
[]v1.PersistentVolumeClaim{
+                                               {
+                                                       ObjectMeta: 
metav1.ObjectMeta{
+                                                               Name: 
"segment-cache",
+                                                               Annotations: 
map[string]string{
+                                                                       
"volume.beta.kubernetes.io/storage-class": "fast-ssd",
+                                                               },
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+                       nodeSpec: &v1alpha1.DruidNodeSpec{
+                               VolumeClaimTemplates: 
[]v1.PersistentVolumeClaim{
+                                       {
+                                               ObjectMeta: metav1.ObjectMeta{
+                                                       Name: "segment-cache",
+                                                       Annotations: 
map[string]string{
+                                                               
"volume.beta.kubernetes.io/storage-class": "ultra-fast-ssd",
+                                                               
"backup.policy": "enabled",
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+                       expectChanges: true,
+                       expectDetails: "VCT segment-cache: annotations changed",
+               },
+               {
+                       name: "No changes when VCT is new",
+                       currentStatefulSet: &appsv1.StatefulSet{
+                               Spec: appsv1.StatefulSetSpec{
+                                       VolumeClaimTemplates: 
[]v1.PersistentVolumeClaim{
+                                               {
+                                                       ObjectMeta: 
metav1.ObjectMeta{
+                                                               Name: 
"segment-cache",
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+                       nodeSpec: &v1alpha1.DruidNodeSpec{
+                               VolumeClaimTemplates: 
[]v1.PersistentVolumeClaim{
+                                       {
+                                               ObjectMeta: metav1.ObjectMeta{
+                                                       Name: "segment-cache",
+                                               },
+                                       },
+                                       {
+                                               ObjectMeta: metav1.ObjectMeta{
+                                                       Name: "new-volume",
+                                                       Annotations: 
map[string]string{
+                                                               "new": 
"annotation",
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+                       expectChanges: false,
+                       expectDetails: "",
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       hasChanges, details := 
detectAnnotationChanges(tt.currentStatefulSet, tt.nodeSpec)
+                       assert.Equal(t, tt.expectChanges, hasChanges)
+                       assert.Equal(t, tt.expectDetails, details)
+               })
+       }
+}
+
+func TestExtractVCTNameFromPVC(t *testing.T) {
+       tests := []struct {
+               name        string
+               pvcName     string
+               stsName     string
+               expectedVCT string
+       }{
+               {
+                       name:        "Extract VCT name from standard PVC",
+                       pvcName:     "segment-cache-druid-historicals-0",
+                       stsName:     "druid-historicals",
+                       expectedVCT: "segment-cache",
+               },
+               {
+                       name:        "Extract VCT name with complex naming",
+                       pvcName:     "data-volume-druid-middlemanager-2",
+                       stsName:     "druid-middlemanager",
+                       expectedVCT: "data-volume",
+               },
+               {
+                       name:        "Return empty when pattern doesn't match",
+                       pvcName:     "random-pvc-name",
+                       stsName:     "druid-historicals",
+                       expectedVCT: "",
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       result := extractVCTNameFromPVC(tt.pvcName, tt.stsName)
+                       assert.Equal(t, tt.expectedVCT, result)
+               })
+       }
+}
+
+// TestPatchStatefulSetVolumeClaimTemplateAnnotations_Integration would be 
better suited as an integration test
+// The actual function requires complex mocking of readers/writers which are 
global variables
+
+func TestPatchStatefulSetVolumeClaimTemplateAnnotationsDisabled(t *testing.T) {
+       ctx := context.TODO()
+
+       druid := &v1alpha1.Druid{
+               ObjectMeta: metav1.ObjectMeta{
+                       Name:      "test-druid",
+                       Namespace: "default",
+               },
+               Spec: v1alpha1.DruidSpec{
+                       DisablePVCAnnotationUpdate: true, // Feature disabled
+               },
+       }
+
+       nodeSpec := &v1alpha1.DruidNodeSpec{
+               Kind: "", // Default is StatefulSet
+       }
+
+       // Mock event emitter - we just need a no-op implementation
+       emitter := &mockEventEmitter{}
+
+       // Test should return immediately when feature is disabled
+       deleted, err := patchStatefulSetVolumeClaimTemplateAnnotations(ctx, 
nil, druid, nodeSpec, emitter, "test")
+       assert.NoError(t, err)
+       assert.False(t, deleted) // Should not delete when disabled
+}
diff --git a/docs/api_specifications/druid.md b/docs/api_specifications/druid.md
index fd57ded..9778fc8 100644
--- a/docs/api_specifications/druid.md
+++ b/docs/api_specifications/druid.md
@@ -399,6 +399,19 @@ bool
 </tr>
 <tr>
 <td>
+<code>disablePVCAnnotationUpdate</code><br>
+<em>
+bool
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>DisablePVCAnnotationUpdate When set to true, operator will not patch PVC 
annotations when VolumeClaimTemplate annotations are updated.
+When disabled (false), updating VolumeClaimTemplate annotations will patch 
existing PVCs and recreate the StatefulSet.</p>
+</td>
+</tr>
+<tr>
+<td>
 <code>deleteOrphanPvc</code><br>
 <em>
 bool
@@ -2118,6 +2131,19 @@ bool
 </tr>
 <tr>
 <td>
+<code>disablePVCAnnotationUpdate</code><br>
+<em>
+bool
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>DisablePVCAnnotationUpdate When set to true, operator will not patch PVC 
annotations when VolumeClaimTemplate annotations are updated.
+When disabled (false), updating VolumeClaimTemplate annotations will patch 
existing PVCs and recreate the StatefulSet.</p>
+</td>
+</tr>
+<tr>
+<td>
 <code>deleteOrphanPvc</code><br>
 <em>
 bool
diff --git a/docs/features.md b/docs/features.md
index 4038b6c..441b6e3 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -8,6 +8,7 @@
 - [Force Delete of Sts Pods](#force-delete-of-sts-pods)
 - [Horizontal Scaling of Druid Pods](#horizontal-scaling-of-druid-pods)
 - [Volume Expansion of Druid Pods Running As 
StatefulSets](#volume-expansion-of-druid-pods-running-as-statefulsets)
+- [PVC Annotation Updates for 
StatefulSets](#pvc-annotation-updates-for-statefulsets)
 - [Add Additional Containers to Druid 
Pods](#add-additional-containers-to-druid-pods)
 - [Default Yet Configurable Probes](#default-yet-configurable-probes)
 
@@ -95,9 +96,80 @@ in the druid CR, only then will it perform expansion.
 This feature is disabled by default. To enable it set `scalePvcSts: true` in 
the Druid CR.
 By default, this feature is disabled.
 ```
-IMPORTANT: Shrinkage of pvc's isnt supported - desiredSize cannot be less than 
currentSize as well as counts. 
+IMPORTANT: Shrinkage of pvc's isnt supported - desiredSize cannot be less than 
currentSize as well as counts.
 ```
 
+## PVC Annotation Updates for StatefulSets
+
+The Druid operator supports updating annotations on PersistentVolumeClaims 
(PVCs) that are managed by StatefulSets. This feature is useful for scenarios 
such as:
+- Adding backup policies to existing PVCs
+- Updating storage tier annotations
+- Adding monitoring or compliance labels
+- Modifying cloud provider-specific annotations (e.g., AWS EBS volume tags)
+
+### How It Works
+
+When you update the annotations in `volumeClaimTemplates` within the Druid CR, 
the operator will:
+1. Detect the annotation changes in the VolumeClaimTemplates
+2. Delete the StatefulSet with `cascade=orphan` (preserving pods and PVCs)
+3. Patch existing PVCs with the new annotations
+4. Recreate the StatefulSet with the updated VolumeClaimTemplate specifications
+
+This process ensures no downtime as the pods continue running throughout the 
update.
+
+### Configuration
+
+This feature is **enabled by default**. To disable it, set 
`disablePVCAnnotationUpdate: true` in the Druid CR:
+
+```yaml
+apiVersion: druid.apache.org/v1alpha1
+kind: Druid
+metadata:
+  name: tiny-cluster
+spec:
+  # Disable PVC annotation updates (default is false - enabled)
+  disablePVCAnnotationUpdate: false
+
+  nodes:
+    historicals:
+      nodeType: historical
+      druid.port: 8088
+      kind: StatefulSet
+      replicas: 1
+      volumeClaimTemplates:
+        - metadata:
+            name: data-volume
+            annotations:
+              # These annotations will be applied to the PVCs
+              volume.beta.kubernetes.io/storage-class: "fast-ssd"
+              backup.velero.io/backup-volumes: "data-volume"
+              custom.annotation/tier: "hot"
+          spec:
+            accessModes: ["ReadWriteOnce"]
+            resources:
+              requests:
+                storage: 10Gi
+            storageClassName: standard
+```
+
+### Important Notes
+
+- This feature only applies to StatefulSets (not Deployments)
+- The operator will wait for all StatefulSets to be ready before performing 
the update
+- Changes are applied using Kubernetes' orphan deletion strategy to avoid pod 
disruption
+- The StatefulSet will be unavailable briefly during recreation, but pods 
continue serving traffic
+- PVC annotations can only be added or updated, not removed (due to Kubernetes 
limitations)
+
+### Comparison with Volume Expansion
+
+| Feature | Volume Expansion | PVC Annotation Updates |
+|---------|-----------------|------------------------|
+| Default State | Disabled (`scalePvcSts: false`) | Enabled 
(`disablePVCAnnotationUpdate: false`) |
+| What Changes | PVC storage size | PVC annotations |
+| StatefulSet Deletion | Yes (orphan) | Yes (orphan) |
+| Pod Disruption | No | No |
+| Rollback Support | No (size can't shrink) | Yes (update annotations again) |
+
 ## Add Additional Containers to Druid Pods
 The operator supports adding additional containers to run along with the druid 
pods. This helps support co-located, 
 co-managed helper processes for the primary druid application. This can be 
used for init containers, sidecars, 
diff --git a/go.mod b/go.mod
index 14ca62f..01f3448 100644
--- a/go.mod
+++ b/go.mod
@@ -20,6 +20,7 @@ require (
        github.com/cespare/xxhash/v2 v2.2.0 // indirect
        github.com/davecgh/go-spew v1.1.1 // indirect
        github.com/emicklei/go-restful/v3 v3.9.0 // indirect
+       github.com/evanphx/json-patch v4.12.0+incompatible // indirect
        github.com/evanphx/json-patch/v5 v5.6.0 // indirect
        github.com/fsnotify/fsnotify v1.6.0 // indirect
        github.com/go-logr/zapr v1.2.4 // indirect
diff --git a/go.sum b/go.sum
index 76aaa3b..43ed29b 100644
--- a/go.sum
+++ b/go.sum
@@ -23,6 +23,7 @@ github.com/emicklei/go-restful/v3 v3.9.0/go.mod 
h1:6n3XBCmQQb25CM2LCACGz8ukIrRry
 github.com/envoyproxy/go-control-plane 
v0.9.1-0.20191026205805-5f8ba28d4473/go.mod 
h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod 
h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/evanphx/json-patch v4.12.0+incompatible 
h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=
+github.com/evanphx/json-patch v4.12.0+incompatible/go.mod 
h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
 github.com/evanphx/json-patch/v5 v5.6.0 
h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
 github.com/evanphx/json-patch/v5 v5.6.0/go.mod 
h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
 github.com/fsnotify/fsnotify v1.6.0 
h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to