Skip to content

Commit 30e7965

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

File tree

2 files changed

+125
-31
lines changed

2 files changed

+125
-31
lines changed

internal/operator-controller/applier/boxcutter.go

Lines changed: 105 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -439,37 +439,113 @@ 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+
},
460+
},
461+
}
462+
463+
// Only include phases if non-nil to respect omitempty semantics.
464+
// When phases is nil, we don't claim ownership of the field.
465+
// When phases is [] (empty but non-nil), we claim ownership with an empty array.
466+
if rev.Spec.Phases != nil {
467+
spec := u.Object["spec"].(map[string]interface{})
468+
spec["phases"] = convertPhasesToUnstructured(rev.Spec.Phases)
469+
}
470+
471+
// Only set optional fields if they have non-zero values
472+
if rev.Spec.LifecycleState != "" {
473+
spec := u.Object["spec"].(map[string]interface{})
474+
spec["lifecycleState"] = rev.Spec.LifecycleState
475+
}
476+
if rev.Spec.ProgressDeadlineMinutes > 0 {
477+
spec := u.Object["spec"].(map[string]interface{})
478+
spec["progressDeadlineMinutes"] = rev.Spec.ProgressDeadlineMinutes
479+
}
480+
481+
// Add metadata fields if present
482+
if len(rev.Labels) > 0 {
483+
metadata := u.Object["metadata"].(map[string]interface{})
484+
metadata["labels"] = rev.Labels
485+
}
486+
if len(rev.Annotations) > 0 {
487+
metadata := u.Object["metadata"].(map[string]interface{})
488+
metadata["annotations"] = rev.Annotations
489+
}
490+
if len(rev.OwnerReferences) > 0 {
491+
metadata := u.Object["metadata"].(map[string]interface{})
492+
metadata["ownerReferences"] = convertOwnerReferencesToUnstructured(rev.OwnerReferences)
493+
}
494+
495+
applyConfig := client.ApplyConfigurationFromUnstructured(u)
496+
497+
// Use Server-Side Apply with field ownership to manage the revision.
498+
return bc.Client.Apply(ctx, applyConfig, client.FieldOwner(bc.FieldOwner), client.ForceOwnership)
499+
}
500+
501+
// convertPhasesToUnstructured converts phases to unstructured format, including only managed fields.
502+
// Returns an empty slice (not nil) when phases is empty to distinguish from a nil phases field.
503+
func convertPhasesToUnstructured(phases []ocv1.ClusterExtensionRevisionPhase) []interface{} {
504+
// Return empty slice for empty phases to maintain the distinction between
505+
// nil (field not set) and [] (field explicitly set to empty array).
506+
result := make([]interface{}, 0, len(phases))
507+
508+
for _, phase := range phases {
509+
objects := make([]interface{}, 0, len(phase.Objects))
510+
for _, obj := range phase.Objects {
511+
objMap := map[string]interface{}{
512+
"object": obj.Object.Object,
513+
}
514+
// Only include collisionProtection if non-empty
515+
if obj.CollisionProtection != "" {
516+
objMap["collisionProtection"] = obj.CollisionProtection
517+
}
518+
objects = append(objects, objMap)
519+
}
520+
521+
result = append(result, map[string]interface{}{
522+
"name": phase.Name,
523+
"objects": objects,
524+
})
525+
}
526+
return result
527+
}
528+
529+
// convertOwnerReferencesToUnstructured converts owner references to unstructured format.
530+
func convertOwnerReferencesToUnstructured(ownerRefs []metav1.OwnerReference) []interface{} {
531+
result := make([]interface{}, 0, len(ownerRefs))
532+
for _, ref := range ownerRefs {
533+
refMap := map[string]interface{}{
534+
"apiVersion": ref.APIVersion,
535+
"kind": ref.Kind,
536+
"name": ref.Name,
537+
"uid": ref.UID,
538+
}
539+
// Only include optional fields if set
540+
if ref.Controller != nil {
541+
refMap["controller"] = *ref.Controller
542+
}
543+
if ref.BlockOwnerDeletion != nil {
544+
refMap["blockOwnerDeletion"] = *ref.BlockOwnerDeletion
545+
}
546+
result = append(result, refMap)
547+
}
548+
return result
473549
}
474550

475551
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)