@@ -68,6 +68,16 @@ 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, when enabled, rewrites every managed fields entry
73+ // on the existing object to match the API version of the applied object
74+ // before each apply.
75+ //
76+ // This is needed after a CRD adds a new version that introduces fields
77+ // with default values: without migration, any managed fields entry still
78+ // tagged with the old API version causes the API server to fail the
79+ // apply with "field not declared in schema" for the new defaulted field.
80+ MigrateAPIVersion bool `json:"migrateAPIVersion,omitempty"`
7181}
7282
7383// ApplyCleanupOptions defines which metadata entries are to be removed before applying objects.
@@ -108,6 +118,15 @@ func (m *ResourceManager) Apply(ctx context.Context, object *unstructured.Unstru
108118 return m .changeSetEntry (object , SkippedAction ), nil
109119 }
110120
121+ var patched bool
122+ if opts .MigrateAPIVersion && getError == nil {
123+ var err error
124+ patched , err = m .migrateAPIVersion (ctx , existingObject , object .GetAPIVersion ())
125+ if err != nil {
126+ return nil , fmt .Errorf ("%s failed to migrate API version: %w" , utils .FmtUnstructured (existingObject ), err )
127+ }
128+ }
129+
111130 dryRunObject := object .DeepCopy ()
112131 if err := m .dryRunApply (ctx , dryRunObject ); err != nil {
113132 if ! errors .IsNotFound (getError ) && m .shouldForceApply (object , existingObject , opts , err ) {
@@ -121,11 +140,12 @@ func (m *ResourceManager) Apply(ctx context.Context, object *unstructured.Unstru
121140 return nil , ssaerrors .NewDryRunErr (err , dryRunObject )
122141 }
123142
124- patched , err := m .cleanupMetadata (ctx , object , existingObject , opts .Cleanup )
143+ patchedCleanupMetadata , err := m .cleanupMetadata (ctx , object , existingObject , opts .Cleanup )
125144 if err != nil {
126145 return nil , fmt .Errorf ("%s metadata.managedFields cleanup failed: %w" ,
127146 utils .FmtUnstructured (existingObject ), err )
128147 }
148+ patched = patched || patchedCleanupMetadata
129149
130150 // do not apply objects that have not drifted to avoid bumping the resource version
131151 if ! patched && ! m .hasDrifted (existingObject , dryRunObject ) {
@@ -172,6 +192,15 @@ func (m *ResourceManager) ApplyAll(ctx context.Context, objects []*unstructured.
172192 return nil
173193 }
174194
195+ var patched bool
196+ if opts .MigrateAPIVersion && getError == nil {
197+ var err error
198+ patched , err = m .migrateAPIVersion (ctx , existingObject , object .GetAPIVersion ())
199+ if err != nil {
200+ return fmt .Errorf ("%s failed to migrate API version: %w" , utils .FmtUnstructured (existingObject ), err )
201+ }
202+ }
203+
175204 dryRunObject := object .DeepCopy ()
176205 if err := m .dryRunApply (ctx , dryRunObject ); err != nil {
177206 // We cannot have an immutable error (and therefore shouldn't force-apply) if the resource doesn't
@@ -207,11 +236,12 @@ func (m *ResourceManager) ApplyAll(ctx context.Context, objects []*unstructured.
207236 }
208237 }
209238
210- patched , err := m .cleanupMetadata (ctx , object , existingObject , opts .Cleanup )
239+ patchedCleanupMetadata , err := m .cleanupMetadata (ctx , object , existingObject , opts .Cleanup )
211240 if err != nil {
212241 return fmt .Errorf ("%s metadata.managedFields cleanup failed: %w" ,
213242 utils .FmtUnstructured (existingObject ), err )
214243 }
244+ patched = patched || patchedCleanupMetadata
215245
216246 if patched || m .hasDrifted (existingObject , dryRunObject ) {
217247 toApply [i ] = object
@@ -345,6 +375,40 @@ func (m *ResourceManager) apply(ctx context.Context, object *unstructured.Unstru
345375 return m .client .Patch (ctx , object , client .Apply , opts ... )
346376}
347377
378+ // migrateAPIVersion rewrites every managed fields entry on existingObject
379+ // to desiredAPIVersion via a JSON patch. See ApplyOptions.MigrateAPIVersion
380+ // for the motivation. Every entry is rewritten, regardless of the field
381+ // manager that owns it or whether it sits on a subresource: any entry
382+ // left at an older API version can make the next apply fail with "field
383+ // not declared in schema". On success existingObject is updated in-place
384+ // with the server's response. Returns whether a patch was actually
385+ // applied (false means there was nothing to migrate).
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+ if err != nil {
393+ return false , fmt .Errorf ("failed to create patch for migrating managed fields API version: %w" , err )
394+ }
395+ if len (patch ) == 0 {
396+ return false , nil
397+ }
398+
399+ // Apply patch.
400+ patchBytes , err := json .Marshal (patch )
401+ if err != nil {
402+ return false , fmt .Errorf ("failed to marshal patch for migrating managed fields API version: %w" , err )
403+ }
404+ rawPatch := client .RawPatch (types .JSONPatchType , patchBytes )
405+ if err := m .client .Patch (ctx , existingObject , rawPatch , client .FieldOwner (m .owner .Field )); err != nil {
406+ return false , fmt .Errorf ("failed to migrate managed fields API version to %s: %w" , desiredAPIVersion , err )
407+ }
408+
409+ return true , nil
410+ }
411+
348412// cleanupMetadata performs an HTTP PATCH request to remove entries from metadata annotations, labels and managedFields.
349413func (m * ResourceManager ) cleanupMetadata (ctx context.Context ,
350414 desiredObject * unstructured.Unstructured ,
0 commit comments