@@ -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
475551func (bc * Boxcutter ) Apply (ctx context.Context , contentFS fs.FS , ext * ocv1.ClusterExtension , objectLabels , revisionAnnotations map [string ]string ) (bool , string , error ) {
0 commit comments