diff --git a/ssa/manager_apply.go b/ssa/manager_apply.go index fb6a76030..7e6306972 100644 --- a/ssa/manager_apply.go +++ b/ssa/manager_apply.go @@ -45,10 +45,14 @@ import ( // ApplyOptions contains options for server-side apply requests. type ApplyOptions struct { // Force configures the engine to recreate objects that contain immutable field changes. + // The propagation policy used for deleting the existing object can be configured + // with the kustomize.toolkit.fluxcd.io/propagationPolicy annotation. Force bool `json:"force"` // ForceSelector determines which in-cluster objects are Force applied // based on the matching labels or annotations. + // The propagation policy used for deleting the existing object can be configured + // with the kustomize.toolkit.fluxcd.io/propagationPolicy annotation. ForceSelector map[string]string `json:"forceSelector"` // ExclusionSelector determines which in-cluster objects are skipped from apply @@ -141,7 +145,12 @@ func (m *ResourceManager) Apply(ctx context.Context, object *unstructured.Unstru dryRunObject := object.DeepCopy() if err := m.dryRunApply(ctx, dryRunObject); err != nil { if !errors.IsNotFound(getError) && m.shouldForceApply(object, existingObject, opts, err) { - if err := m.client.Delete(ctx, existingObject, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil && !errors.IsNotFound(err) { + deleteOpts, err := forceApplyDeleteOptions(object) + if err != nil { + return nil, fmt.Errorf("%s immutable field detected, %w", + utils.FmtUnstructured(dryRunObject), err) + } + if err := m.client.Delete(ctx, existingObject, deleteOpts...); err != nil && !errors.IsNotFound(err) { return nil, fmt.Errorf("%s immutable field detected, failed to delete object: %w", utils.FmtUnstructured(dryRunObject), err) } @@ -256,7 +265,12 @@ func (m *ResourceManager) ApplyAll(ctx context.Context, objects []*unstructured. // as immutable and deleted it when ApplyAll was called the last time (the check for ImmutableError // returns false positives) if !errors.IsNotFound(getError) && m.shouldForceApply(object, existingObject, opts, err) { - if err := m.client.Delete(ctx, existingObject, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil && !errors.IsNotFound(err) { + deleteOpts, err := forceApplyDeleteOptions(object) + if err != nil { + return fmt.Errorf("%s immutable field detected, %w", + utils.FmtUnstructured(dryRunObject), err) + } + if err := m.client.Delete(ctx, existingObject, deleteOpts...); err != nil && !errors.IsNotFound(err) { return fmt.Errorf("%s immutable field detected, failed to delete object: %w", utils.FmtUnstructured(dryRunObject), err) } @@ -581,6 +595,28 @@ func removeIgnoredFields(matchObj, obj *unstructured.Unstructured, rules jsondif return nil } +const forceApplyPropagationPolicyAnnotation = "kustomize.toolkit.fluxcd.io/propagationPolicy" + +// forceApplyDeleteOptions returns delete options for forced immutable object replacement. +// The kustomize.toolkit.fluxcd.io/propagationPolicy annotation accepts 'background' +// and 'orphan'. When the annotation is absent, PropagationPolicy remains unset so +// the API server owns the default. +func forceApplyDeleteOptions(object *unstructured.Unstructured) ([]client.DeleteOption, error) { + value, ok := object.GetAnnotations()[forceApplyPropagationPolicyAnnotation] + if !ok { + return nil, nil + } + + switch { + case strings.EqualFold(value, string(metav1.DeletePropagationBackground)): + return []client.DeleteOption{client.PropagationPolicy(metav1.DeletePropagationBackground)}, nil + case strings.EqualFold(value, string(metav1.DeletePropagationOrphan)): + return []client.DeleteOption{client.PropagationPolicy(metav1.DeletePropagationOrphan)}, nil + default: + return nil, fmt.Errorf("unsupported propagation policy %q, must be background or orphan", value) + } +} + // lookupJSONPointer resolves an RFC 6901 JSON pointer against the unstructured // object's content. A missing path is reported as (nil, false, nil). func lookupJSONPointer(obj *unstructured.Unstructured, pointer string) (any, bool, error) { diff --git a/ssa/manager_apply_test.go b/ssa/manager_apply_test.go index 09731984d..7caab9d37 100644 --- a/ssa/manager_apply_test.go +++ b/ssa/manager_apply_test.go @@ -532,6 +532,71 @@ func TestApply_Force(t *testing.T) { }) } +func TestForceApplyDeleteOptions(t *testing.T) { + background := metav1.DeletePropagationBackground + orphan := metav1.DeletePropagationOrphan + + tests := []struct { + name string + annotation string + want *metav1.DeletionPropagation + wantErr string + }{ + { + name: "no annotation uses API server default", + }, + { + name: "background annotation sets background policy", + annotation: "background", + want: &background, + }, + { + name: "orphan annotation sets orphan policy", + annotation: "orphan", + want: &orphan, + }, + { + name: "invalid annotation errors", + annotation: "foreground", + wantErr: `unsupported propagation policy "foreground", must be background or orphan`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + object := &unstructured.Unstructured{} + if tt.annotation != "" { + object.SetAnnotations(map[string]string{ + forceApplyPropagationPolicyAnnotation: tt.annotation, + }) + } + + opts, err := forceApplyDeleteOptions(object) + if tt.wantErr != "" { + if err == nil { + t.Fatal("expected error but got none") + } + if diff := cmp.Diff(tt.wantErr, err.Error()); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + return + } + if err != nil { + t.Fatal(err) + } + + deleteOpts := &client.DeleteOptions{} + for _, opt := range opts { + opt.ApplyToDelete(deleteOpts) + } + + if diff := cmp.Diff(tt.want, deleteOpts.PropagationPolicy); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + }) + } +} + func TestApply_SetNativeKindsDefaults(t *testing.T) { timeout := 10 * time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout)