Skip to content

Commit 3dc9d83

Browse files
Fix SA1019: server-side apply required, needs generated apply configurations
1 parent 23dd207 commit 3dc9d83

File tree

2 files changed

+118
-31
lines changed

2 files changed

+118
-31
lines changed

internal/operator-controller/applier/boxcutter.go

Lines changed: 98 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -439,37 +439,106 @@ func (bc *Boxcutter) createOrUpdate(ctx context.Context, user user.Info, rev *oc
439439
return err
440440
}
441441

442-
// DEPRECATION NOTICE: Using client.Apply (deprecated in controller-runtime v0.23.0+)
442+
// Build apply configuration by manually constructing an unstructured object.
443443
//
444-
// WHY WE CAN'T FIX THIS YET:
445-
// The recommended replacement is the new typed Apply() method that requires generated
446-
// apply configurations (ApplyConfiguration types). However, this project does not
447-
// currently generate these apply configurations for its API types.
444+
// We cannot safely convert the typed ClusterExtensionRevision struct directly to unstructured
445+
// because that would serialize all fields, including zero values for fields without omitempty tags.
446+
// This would incorrectly claim field ownership for fields we don't manage, violating Server-Side
447+
// Apply semantics.
448448
//
449-
// WHY WE NEED SERVER-SIDE APPLY SEMANTICS:
450-
// This controller requires server-side apply with field ownership management to:
451-
// 1. Track which controller owns which fields (via client.FieldOwner)
452-
// 2. Take ownership of fields from other managers during upgrades (via client.ForceOwnership)
453-
// 3. Automatically create-or-update without explicit Get/Create/Update logic
454-
//
455-
// WHY ALTERNATIVES DON'T WORK:
456-
// - client.MergeFrom(): Lacks field ownership - causes conflicts during controller upgrades
457-
// - client.StrategicMergePatch(): No field management - upgrade tests fail with ownership errors
458-
// - Manual Create/Update: Loses server-side apply benefits, complex to implement correctly
459-
//
460-
// WHAT'S REQUIRED TO FIX PROPERLY:
461-
// 1. Generate apply configurations for all API types (ClusterExtensionRevision, etc.)
462-
// - Requires running controller-gen with --with-applyconfig flag
463-
// - Generates ClusterExtensionRevisionApplyConfiguration types
464-
// 2. Update all resource creation/update code to use typed Apply methods
465-
// 3. Update all tests to work with new patterns
466-
// This is a project-wide effort beyond the scope of the k8s v1.35 upgrade.
467-
//
468-
// MIGRATION PATH:
469-
// Track in a future issue: "Generate apply configurations and migrate to typed Apply methods"
470-
//
471-
// nolint:staticcheck // SA1019: server-side apply required, needs generated apply configurations
472-
return bc.Client.Patch(ctx, rev, client.Apply, client.FieldOwner(bc.FieldOwner), client.ForceOwnership)
449+
// Instead, we manually construct the unstructured object with only the fields we explicitly manage.
450+
u := &unstructured.Unstructured{
451+
Object: map[string]interface{}{
452+
"apiVersion": rev.APIVersion,
453+
"kind": rev.Kind,
454+
"metadata": map[string]interface{}{
455+
"name": rev.Name,
456+
},
457+
"spec": map[string]interface{}{
458+
"revision": rev.Spec.Revision,
459+
"phases": convertPhasesToUnstructured(rev.Spec.Phases),
460+
},
461+
},
462+
}
463+
464+
// Only set optional fields if they have non-zero values
465+
if rev.Spec.LifecycleState != "" {
466+
spec := u.Object["spec"].(map[string]interface{})
467+
spec["lifecycleState"] = rev.Spec.LifecycleState
468+
}
469+
if rev.Spec.ProgressDeadlineMinutes > 0 {
470+
spec := u.Object["spec"].(map[string]interface{})
471+
spec["progressDeadlineMinutes"] = rev.Spec.ProgressDeadlineMinutes
472+
}
473+
474+
// Add metadata fields if present
475+
if len(rev.Labels) > 0 {
476+
metadata := u.Object["metadata"].(map[string]interface{})
477+
metadata["labels"] = rev.Labels
478+
}
479+
if len(rev.Annotations) > 0 {
480+
metadata := u.Object["metadata"].(map[string]interface{})
481+
metadata["annotations"] = rev.Annotations
482+
}
483+
if len(rev.OwnerReferences) > 0 {
484+
metadata := u.Object["metadata"].(map[string]interface{})
485+
metadata["ownerReferences"] = convertOwnerReferencesToUnstructured(rev.OwnerReferences)
486+
}
487+
488+
applyConfig := client.ApplyConfigurationFromUnstructured(u)
489+
490+
// Use Server-Side Apply with field ownership to manage the revision.
491+
return bc.Client.Apply(ctx, applyConfig, client.FieldOwner(bc.FieldOwner), client.ForceOwnership)
492+
}
493+
494+
// convertPhasesToUnstructured converts phases to unstructured format, including only managed fields.
495+
func convertPhasesToUnstructured(phases []ocv1.ClusterExtensionRevisionPhase) []interface{} {
496+
if len(phases) == 0 {
497+
return nil
498+
}
499+
500+
result := make([]interface{}, 0, len(phases))
501+
for _, phase := range phases {
502+
objects := make([]interface{}, 0, len(phase.Objects))
503+
for _, obj := range phase.Objects {
504+
objMap := map[string]interface{}{
505+
"object": obj.Object.Object,
506+
}
507+
// Only include collisionProtection if non-empty
508+
if obj.CollisionProtection != "" {
509+
objMap["collisionProtection"] = obj.CollisionProtection
510+
}
511+
objects = append(objects, objMap)
512+
}
513+
514+
result = append(result, map[string]interface{}{
515+
"name": phase.Name,
516+
"objects": objects,
517+
})
518+
}
519+
return result
520+
}
521+
522+
// convertOwnerReferencesToUnstructured converts owner references to unstructured format.
523+
func convertOwnerReferencesToUnstructured(ownerRefs []metav1.OwnerReference) []interface{} {
524+
result := make([]interface{}, 0, len(ownerRefs))
525+
for _, ref := range ownerRefs {
526+
refMap := map[string]interface{}{
527+
"apiVersion": ref.APIVersion,
528+
"kind": ref.Kind,
529+
"name": ref.Name,
530+
"uid": ref.UID,
531+
}
532+
// Only include optional fields if set
533+
if ref.Controller != nil {
534+
refMap["controller"] = *ref.Controller
535+
}
536+
if ref.BlockOwnerDeletion != nil {
537+
refMap["blockOwnerDeletion"] = *ref.BlockOwnerDeletion
538+
}
539+
result = append(result, refMap)
540+
}
541+
return result
473542
}
474543

475544
func (bc *Boxcutter) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (bool, string, error) {

internal/operator-controller/applier/boxcutter_test.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package applier_test
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
78
"io"
@@ -557,13 +558,30 @@ func TestBoxcutter_Apply(t *testing.T) {
557558
if !ok {
558559
return fmt.Errorf("expected ClusterExtensionRevision, got %T", obj)
559560
}
560-
fmt.Println(cer.Spec.Revision)
561561
if cer.Spec.Revision != revNum {
562-
fmt.Println("AAA")
563562
return apierrors.NewInvalid(cer.GroupVersionKind().GroupKind(), cer.GetName(), field.ErrorList{field.Invalid(field.NewPath("spec.phases"), "immutable", "spec.phases is immutable")})
564563
}
565564
return client.Patch(ctx, obj, patch, opts...)
566565
},
566+
Apply: func(ctx context.Context, client client.WithWatch, obj runtime.ApplyConfiguration, opts ...client.ApplyOption) error {
567+
// Extract revision number from apply configuration to validate it matches expected value
568+
cer := &ocv1.ClusterExtensionRevision{}
569+
data, err := json.Marshal(obj)
570+
if err != nil {
571+
return fmt.Errorf("failed to marshal apply configuration: %w", err)
572+
}
573+
if err := json.Unmarshal(data, cer); err != nil {
574+
return fmt.Errorf("failed to unmarshal to ClusterExtensionRevision: %w", err)
575+
}
576+
577+
// Set GVK explicitly since unmarshal doesn't populate TypeMeta
578+
cer.SetGroupVersionKind(ocv1.GroupVersion.WithKind(ocv1.ClusterExtensionRevisionKind))
579+
580+
if cer.Spec.Revision != revNum {
581+
return apierrors.NewInvalid(cer.GroupVersionKind().GroupKind(), cer.GetName(), field.ErrorList{field.Invalid(field.NewPath("spec.phases"), "immutable", "spec.phases is immutable")})
582+
}
583+
return client.Apply(ctx, obj, opts...)
584+
},
567585
}
568586
}
569587
testCases := []struct {

0 commit comments

Comments
 (0)