diff --git a/ssa/manager_apply.go b/ssa/manager_apply.go index 76e5a5fb..b2781e1a 100644 --- a/ssa/manager_apply.go +++ b/ssa/manager_apply.go @@ -108,6 +108,13 @@ func (m *ResourceManager) Apply(ctx context.Context, object *unstructured.Unstru return m.changeSetEntry(object, SkippedAction), nil } + // Migrate managed fields API version if the object exists and has a different API version + if getError == nil && existingObject.GetUID() != "" { + if err := m.migrateAPIVersion(ctx, existingObject, object.GetAPIVersion()); err != nil { + return nil, fmt.Errorf("%s failed to migrate API version: %w", utils.FmtUnstructured(object), err) + } + } + dryRunObject := object.DeepCopy() if err := m.dryRunApply(ctx, dryRunObject); err != nil { if !errors.IsNotFound(getError) && m.shouldForceApply(object, existingObject, opts, err) { @@ -172,6 +179,13 @@ func (m *ResourceManager) ApplyAll(ctx context.Context, objects []*unstructured. return nil } + // Migrate managed fields API version if the object exists and has a different API version + if getError == nil && existingObject.GetUID() != "" { + if err := m.migrateAPIVersion(ctx, existingObject, object.GetAPIVersion()); err != nil { + return fmt.Errorf("%s failed to migrate API version: %w", utils.FmtUnstructured(object), err) + } + } + dryRunObject := object.DeepCopy() if err := m.dryRunApply(ctx, dryRunObject); err != nil { // We cannot have an immutable error (and therefore shouldn't force-apply) if the resource doesn't @@ -345,6 +359,43 @@ func (m *ResourceManager) apply(ctx context.Context, object *unstructured.Unstru return m.client.Patch(ctx, object, client.Apply, opts...) } +// migrateAPIVersion updates the managed fields API version when the existing object +// has a different API version than the desired API version. This is necessary because +// Kubernetes server-side apply validates managed fields against the schema of the +// API version they reference, and when upgrading CRD versions, the old API version +// may have fields that don't exist in the new schema, causing dry-run to fail. +func (m *ResourceManager) migrateAPIVersion(ctx context.Context, existingObject *unstructured.Unstructured, desiredAPIVersion string) error { + existingAPIVersion := existingObject.GetAPIVersion() + + // Skip if API versions are the same + if desiredAPIVersion == existingAPIVersion { + return nil + } + + // Check if managed fields need migration + patches, err := PatchMigrateToVersion(existingObject, desiredAPIVersion) + if err != nil { + return fmt.Errorf("failed to create managed fields migration patch: %w", err) + } + + if len(patches) == 0 { + return nil + } + + // Apply the migration patch to update managed fields API version + rawPatch, err := json.Marshal(patches) + if err != nil { + return fmt.Errorf("failed to marshal managed fields migration patch: %w", err) + } + patch := client.RawPatch(types.JSONPatchType, rawPatch) + + if err := m.client.Patch(ctx, existingObject, patch, client.FieldOwner(m.owner.Field)); err != nil { + return fmt.Errorf("failed to migrate managed fields API version from %s to %s: %w", existingAPIVersion, desiredAPIVersion, err) + } + + return nil +} + // cleanupMetadata performs an HTTP PATCH request to remove entries from metadata annotations, labels and managedFields. func (m *ResourceManager) cleanupMetadata(ctx context.Context, desiredObject *unstructured.Unstructured, diff --git a/ssa/manager_apply_test.go b/ssa/manager_apply_test.go index 16021f70..85d07538 100644 --- a/ssa/manager_apply_test.go +++ b/ssa/manager_apply_test.go @@ -1660,3 +1660,124 @@ func TestApplyAllStaged_AppliesRoleAndRoleBinding(t *testing.T) { } }) } + +func TestPatchMigrateToVersion(t *testing.T) { + tests := []struct { + name string + object *unstructured.Unstructured + targetVersion string + expectPatches bool + expectedLen int + }{ + { + name: "no managed fields", + object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "default", + }, + }, + }, + targetVersion: "v1", + expectPatches: false, + }, + { + name: "same API version", + object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "default", + "managedFields": []interface{}{ + map[string]interface{}{ + "apiVersion": "v1", + "manager": "test-manager", + "operation": "Apply", + }, + }, + }, + }, + }, + targetVersion: "v1", + expectPatches: false, + }, + { + name: "different API version - single managed field", + object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "policy.linkerd.io/v1beta1", + "kind": "Server", + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "default", + "managedFields": []interface{}{ + map[string]interface{}{ + "apiVersion": "policy.linkerd.io/v1beta1", + "manager": "kustomize-controller", + "operation": "Apply", + }, + }, + }, + }, + }, + targetVersion: "policy.linkerd.io/v1beta3", + expectPatches: true, + expectedLen: 1, + }, + { + name: "different API version - multiple managed fields", + object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "policy.linkerd.io/v1beta3", + "kind": "Server", + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "default", + "managedFields": []interface{}{ + map[string]interface{}{ + "apiVersion": "policy.linkerd.io/v1beta1", + "manager": "kubectl", + "operation": "Update", + }, + map[string]interface{}{ + "apiVersion": "policy.linkerd.io/v1beta1", + "manager": "kustomize-controller", + "operation": "Apply", + }, + }, + }, + }, + }, + targetVersion: "policy.linkerd.io/v1beta3", + expectPatches: true, + expectedLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + patches, err := PatchMigrateToVersion(tt.object, tt.targetVersion) + if err != nil { + t.Fatalf("PatchMigrateToVersion returned error: %v", err) + } + + if tt.expectPatches { + if len(patches) == 0 { + t.Errorf("Expected patches to be returned, got none") + } + if tt.expectedLen > 0 && len(patches) != tt.expectedLen { + t.Errorf("Expected %d patches, got %d", tt.expectedLen, len(patches)) + } + } else { + if len(patches) > 0 { + t.Errorf("Expected no patches, got %d", len(patches)) + } + } + }) + } +} diff --git a/ssa/testdata/test15-crd.yaml b/ssa/testdata/test15-crd.yaml new file mode 100644 index 00000000..4602fbe3 --- /dev/null +++ b/ssa/testdata/test15-crd.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: "%[1]s" +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: "tests.apimigration.example.com" +spec: + group: apimigration.example.com + names: + kind: Test + listKind: TestList + plural: tests + singular: test + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + value: + type: string