Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions ssa/manager_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down
65 changes: 65 additions & 0 deletions ssa/manager_apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down