@@ -575,52 +575,19 @@ func (r *REST) Update(ctx context.Context, name string, objInfo rest.UpdatedObje
575575 return nil , false , fmt .Errorf ("conversion error: %v" , err )
576576 }
577577
578- // The conversion above rebuilds the HelmRelease from the Application
579- // alone, but parts of the HelmRelease metadata are owned by other
580- // controllers and must survive the update. Fetch the current object
581- // and carry them over before issuing the full-replace PUT:
582- // - finalizers: helm-controller's finalizers.fluxcd.io guarantees
583- // `helm uninstall` runs on deletion. Stripping it here lets a
584- // subsequent delete remove the HelmRelease instantly, orphaning
585- // every resource of the release.
586- // - ownerReferences: garbage collection and lineage.
587- // - labels/annotations outside the apps.cozystack.io- prefix: set
588- // directly on the HelmRelease by Flux or other controllers.
589- // Keys under the prefix are owned by this API: stale ones are dropped
590- // so deleting a label or annotation on the Application propagates.
578+ // Keep the conversion output pristine: the metadata assembly below reads the
579+ // live object, so a conflict retry has to redo it from this copy rather than
580+ // re-apply it on top of its own previous result.
581+ converted := helmRelease .DeepCopy ()
582+
591583 cur := & helmv2.HelmRelease {}
592584 if err := r .c .Get (ctx , client.ObjectKey {Namespace : helmRelease .Namespace , Name : helmRelease .Name }, cur , & client.GetOptions {Raw : & metav1.GetOptions {}}); err != nil {
593585 return nil , false , registry .WrapPreservingStatus ("failed to fetch current HelmRelease" , err , r .gvr .GroupResource (), name )
594586 }
595587 if helmRelease .ResourceVersion == "" {
596588 helmRelease .SetResourceVersion (cur .GetResourceVersion ())
597589 }
598- helmRelease .Finalizers = cur .Finalizers
599- helmRelease .OwnerReferences = cur .OwnerReferences
600- helmRelease .Labels = mergeMaps (omitPrefixedMap (cur .Labels , LabelPrefix ), helmRelease .Labels )
601- helmRelease .Annotations = mergeMaps (omitPrefixedMap (cur .Annotations , AnnotationPrefix ), helmRelease .Annotations )
602-
603- // Merge system labels (from config) directly
604- helmRelease .Labels = mergeMaps (r .releaseConfig .Labels , helmRelease .Labels )
605- // Merge user labels with prefix
606- helmRelease .Labels = mergeMaps (helmRelease .Labels , addPrefixedMap (app .Labels , LabelPrefix ))
607- // Add application metadata labels
608- if helmRelease .Labels == nil {
609- helmRelease .Labels = make (map [string ]string )
610- }
611- helmRelease .Labels [ApplicationKindLabel ] = r .kindName
612- helmRelease .Labels [ApplicationGroupLabel ] = r .gvk .Group
613- helmRelease .Labels [ApplicationNameLabel ] = app .Name
614- // Note: Annotations from config are not handled as r.releaseConfig.Annotations is undefined
615-
616- // The flux-shard-operator assigns each tenant HelmRelease to a
617- // helm-controller shard by rewriting this label at runtime. Rebuilding
618- // the object from the Application reverts it to the ApplicationDefinition
619- // default, which would bounce the HelmRelease off its shard on every
620- // update, so the live value wins.
621- if shard , ok := cur .Labels [fluxshard .ShardKeyLabel ]; ok {
622- helmRelease .Labels [fluxshard .ShardKeyLabel ] = shard
623- }
590+ r .applyLiveMetadata (helmRelease , cur , app )
624591
625592 // Suspension is not part of the Application, so the rebuilt object always
626593 // says suspend=false, and sending it as is resumes a release that an
@@ -646,13 +613,22 @@ func (r *REST) Update(ctx context.Context, name string, objInfo rest.UpdatedObje
646613 // is never a real spec conflict here: refresh the resourceVersion from the
647614 // live object and retry. Suspend is refreshed with it, because the write
648615 // that caused the conflict may be the one that suspended the release.
616+ //
617+ // The carried-over metadata is not derived from the Application, though, and
618+ // the writer that caused the conflict is usually the controller that owns it
619+ // — helm-controller re-adding finalizers.fluxcd.io is the case this whole
620+ // carry-over exists for. Keeping the copy read before the conflict would put
621+ // the stale metadata back and undo that write, so rebuild from the
622+ // conversion output against the object just read.
649623 err = retry .RetryOnConflict (retry .DefaultBackoff , func () error {
650624 updateErr := r .c .Update (ctx , helmRelease , registry .ClientUpdateOptions (options ))
651625 if apierrors .IsConflict (updateErr ) {
652626 cur := & helmv2.HelmRelease {}
653627 if getErr := r .c .Get (ctx , client.ObjectKey {Namespace : helmRelease .Namespace , Name : helmRelease .Name }, cur , & client.GetOptions {Raw : & metav1.GetOptions {}}); getErr != nil {
654628 return getErr
655629 }
630+ helmRelease = converted .DeepCopy ()
631+ r .applyLiveMetadata (helmRelease , cur , app )
656632 helmRelease .SetResourceVersion (cur .GetResourceVersion ())
657633 helmRelease .Spec .Suspend = cur .Spec .Suspend
658634 }
@@ -1244,6 +1220,49 @@ func filterPrefixedMap(original map[string]string, prefix string) map[string]str
12441220 return processed
12451221}
12461222
1223+ // applyLiveMetadata assembles the metadata of a HelmRelease rebuilt from an
1224+ // Application, given the live object it is about to replace. The PUT is a full
1225+ // replace, so anything not restated here is dropped:
1226+ // - finalizers: helm-controller's finalizers.fluxcd.io guarantees
1227+ // `helm uninstall` runs on deletion. Stripping it lets a subsequent delete
1228+ // remove the HelmRelease instantly, orphaning every resource of the release.
1229+ // - ownerReferences: garbage collection and lineage.
1230+ // - labels/annotations outside the apps.cozystack.io- prefix: set directly on
1231+ // the HelmRelease by Flux or other controllers. Keys under the prefix are
1232+ // owned by this API and recomputed from the Application, so stale ones are
1233+ // dropped and a deletion on the Application propagates.
1234+ //
1235+ // hr must be a fresh conversion output: the function overlays the live metadata
1236+ // and is not idempotent against its own result.
1237+ func (r * REST ) applyLiveMetadata (hr , cur * helmv2.HelmRelease , app * appsv1alpha1.Application ) {
1238+ hr .Finalizers = cur .Finalizers
1239+ hr .OwnerReferences = cur .OwnerReferences
1240+ hr .Labels = mergeMaps (omitPrefixedMap (cur .Labels , LabelPrefix ), hr .Labels )
1241+ hr .Annotations = mergeMaps (omitPrefixedMap (cur .Annotations , AnnotationPrefix ), hr .Annotations )
1242+
1243+ // Merge system labels (from config) directly
1244+ hr .Labels = mergeMaps (r .releaseConfig .Labels , hr .Labels )
1245+ // Merge user labels with prefix
1246+ hr .Labels = mergeMaps (hr .Labels , addPrefixedMap (app .Labels , LabelPrefix ))
1247+ // Add application metadata labels
1248+ if hr .Labels == nil {
1249+ hr .Labels = make (map [string ]string )
1250+ }
1251+ hr .Labels [ApplicationKindLabel ] = r .kindName
1252+ hr .Labels [ApplicationGroupLabel ] = r .gvk .Group
1253+ hr .Labels [ApplicationNameLabel ] = app .Name
1254+ // Note: Annotations from config are not handled as r.releaseConfig.Annotations is undefined
1255+
1256+ // The flux-shard-operator assigns each tenant HelmRelease to a
1257+ // helm-controller shard by rewriting this label at runtime. Rebuilding
1258+ // the object from the Application reverts it to the ApplicationDefinition
1259+ // default, which would bounce the HelmRelease off its shard on every
1260+ // update, so the live value wins.
1261+ if shard , ok := cur .Labels [fluxshard .ShardKeyLabel ]; ok {
1262+ hr .Labels [fluxshard .ShardKeyLabel ] = shard
1263+ }
1264+ }
1265+
12471266// omitPrefixedMap returns the entries of a map whose keys do NOT carry the
12481267// predefined prefix, keeping the keys as-is. It is the complement of
12491268// filterPrefixedMap: prefixed keys are owned by this API and recomputed from
0 commit comments