@@ -68,6 +68,11 @@ type ApplyOptions struct {
6868 // CustomStageKinds defines a set of Kubernetes resource types that should be applied
6969 // in a separate stage after CRDs and before namespaced objects.
7070 CustomStageKinds map [schema.GroupKind ]struct {} `json:"customStageKinds,omitempty"`
71+
72+ // MigrateAPIVersion configures the engine to migrate the API version
73+ // in managed fields to the API version of the applied object when they
74+ // differ.
75+ MigrateAPIVersion bool `json:"migrateAPIVersion,omitempty"`
7176}
7277
7378// ApplyCleanupOptions defines which metadata entries are to be removed before applying objects.
@@ -108,6 +113,15 @@ func (m *ResourceManager) Apply(ctx context.Context, object *unstructured.Unstru
108113 return m .changeSetEntry (object , SkippedAction ), nil
109114 }
110115
116+ var patched bool
117+ if opts .MigrateAPIVersion && getError == nil {
118+ var err error
119+ patched , err = m .migrateAPIVersion (ctx , existingObject , object .GetAPIVersion ())
120+ if err != nil {
121+ return nil , fmt .Errorf ("%s failed to migrate API version: %w" , utils .FmtUnstructured (existingObject ), err )
122+ }
123+ }
124+
111125 dryRunObject := object .DeepCopy ()
112126 if err := m .dryRunApply (ctx , dryRunObject ); err != nil {
113127 if ! errors .IsNotFound (getError ) && m .shouldForceApply (object , existingObject , opts , err ) {
@@ -121,11 +135,12 @@ func (m *ResourceManager) Apply(ctx context.Context, object *unstructured.Unstru
121135 return nil , ssaerrors .NewDryRunErr (err , dryRunObject )
122136 }
123137
124- patched , err := m .cleanupMetadata (ctx , object , existingObject , opts .Cleanup )
138+ patchedCleanupMetadata , err := m .cleanupMetadata (ctx , object , existingObject , opts .Cleanup )
125139 if err != nil {
126140 return nil , fmt .Errorf ("%s metadata.managedFields cleanup failed: %w" ,
127141 utils .FmtUnstructured (existingObject ), err )
128142 }
143+ patched = patched || patchedCleanupMetadata
129144
130145 // do not apply objects that have not drifted to avoid bumping the resource version
131146 if ! patched && ! m .hasDrifted (existingObject , dryRunObject ) {
@@ -172,6 +187,15 @@ func (m *ResourceManager) ApplyAll(ctx context.Context, objects []*unstructured.
172187 return nil
173188 }
174189
190+ var patched bool
191+ if opts .MigrateAPIVersion && getError == nil {
192+ var err error
193+ patched , err = m .migrateAPIVersion (ctx , existingObject , object .GetAPIVersion ())
194+ if err != nil {
195+ return fmt .Errorf ("%s failed to migrate API version: %w" , utils .FmtUnstructured (existingObject ), err )
196+ }
197+ }
198+
175199 dryRunObject := object .DeepCopy ()
176200 if err := m .dryRunApply (ctx , dryRunObject ); err != nil {
177201 // We cannot have an immutable error (and therefore shouldn't force-apply) if the resource doesn't
@@ -207,11 +231,12 @@ func (m *ResourceManager) ApplyAll(ctx context.Context, objects []*unstructured.
207231 }
208232 }
209233
210- patched , err := m .cleanupMetadata (ctx , object , existingObject , opts .Cleanup )
234+ patchedCleanupMetadata , err := m .cleanupMetadata (ctx , object , existingObject , opts .Cleanup )
211235 if err != nil {
212236 return fmt .Errorf ("%s metadata.managedFields cleanup failed: %w" ,
213237 utils .FmtUnstructured (existingObject ), err )
214238 }
239+ patched = patched || patchedCleanupMetadata
215240
216241 if patched || m .hasDrifted (existingObject , dryRunObject ) {
217242 toApply [i ] = object
@@ -345,6 +370,46 @@ func (m *ResourceManager) apply(ctx context.Context, object *unstructured.Unstru
345370 return m .client .Patch (ctx , object , client .Apply , opts ... )
346371}
347372
373+ // migrateAPIVersion updates the API version in managed field entries
374+ // whose API version differs from the desired API version using a raw
375+ // patch. This is important for making the API server validate the
376+ // managed fields against the schema of the desired API version on
377+ // dry-run. If this operation succeeds, existingObject will be updated
378+ // with the content returned by the API server. The function returns a
379+ // boolean indicating whether a patch was applied. All managed field
380+ // entries owned by this ResourceManager's field owner are migrated,
381+ // including entries on subresources like status — leaving any of our
382+ // own entries at an older API version can cause dry-run failures on
383+ // subsequent applies, and a stale main-resource entry can even trigger
384+ // failures on a status subresource apply (and vice versa). Entries
385+ // owned by other field managers are left untouched.
386+ func (m * ResourceManager ) migrateAPIVersion (ctx context.Context ,
387+ existingObject * unstructured.Unstructured ,
388+ desiredAPIVersion string ) (bool , error ) {
389+
390+ // Build patch.
391+ patch , err := PatchMigrateToVersion (existingObject , desiredAPIVersion ,
392+ WithMigrateManager (m .owner .Field ))
393+ if err != nil {
394+ return false , fmt .Errorf ("failed to create patch for migrating managed fields API version: %w" , err )
395+ }
396+ if len (patch ) == 0 {
397+ return false , nil
398+ }
399+
400+ // Apply patch.
401+ patchBytes , err := json .Marshal (patch )
402+ if err != nil {
403+ return false , fmt .Errorf ("failed to marshal patch for migrating managed fields API version: %w" , err )
404+ }
405+ rawPatch := client .RawPatch (types .JSONPatchType , patchBytes )
406+ if err := m .client .Patch (ctx , existingObject , rawPatch , client .FieldOwner (m .owner .Field )); err != nil {
407+ return false , fmt .Errorf ("failed to migrate managed fields API version to %s: %w" , desiredAPIVersion , err )
408+ }
409+
410+ return true , nil
411+ }
412+
348413// cleanupMetadata performs an HTTP PATCH request to remove entries from metadata annotations, labels and managedFields.
349414func (m * ResourceManager ) cleanupMetadata (ctx context.Context ,
350415 desiredObject * unstructured.Unstructured ,
0 commit comments