Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions ssa/manager_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
121 changes: 121 additions & 0 deletions ssa/manager_apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
})
}
}
31 changes: 31 additions & 0 deletions ssa/testdata/test15-crd.yaml
Original file line number Diff line number Diff line change
@@ -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