diff --git a/util/csaupgrade/upgrade.go b/util/csaupgrade/upgrade.go new file mode 100644 index 00000000..40cb4984 --- /dev/null +++ b/util/csaupgrade/upgrade.go @@ -0,0 +1,213 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package csaupgrade + +import ( + "bytes" + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/structured-merge-diff/v4/fieldpath" +) + +const csaAnnotationName = "kubectl.kubernetes.io/last-applied-configuration" + +var csaAnnotationFieldSet = fieldpath.NewSet(fieldpath.MakePathOrDie("metadata", "annotations", csaAnnotationName)) + +// Upgrades the Manager information for fields managed with client-side-apply (CSA) +// Prepares fields owned by `csaManager` for 'Update' operations for use now +// with the given `ssaManager` for `Apply` operations. +// +// This transformation should be performed on an object if it has been previously +// managed using client-side-apply to prepare it for future use with +// server-side-apply. +// +// Caveats: +// 1. This operation is not reversible. Information about which fields the client +// owned will be lost in this operation. +// 2. Supports being performed either before or after initial server-side apply. +// 3. Client-side apply tends to own more fields (including fields that are defaulted), +// this will possibly remove this defaults, they will be re-defaulted, that's fine. +// 4. Care must be taken to not overwrite the managed fields on the server if they +// have changed before sending a patch. +// +// obj - Target of the operation which has been managed with CSA in the past +// csaManagerName - Name of FieldManager formerly used for `Update` operations +// ssaManagerName - Name of FieldManager formerly used for `Apply` operations +func UpgradeManagedFields( + obj runtime.Object, + csaManagerName string, + ssaManagerName string, +) error { + accessor, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("error accessing object metadata: %w", err) + } + + // Create managed fields clone since we modify the values + var managedFields []metav1.ManagedFieldsEntry + managedFields = append(managedFields, accessor.GetManagedFields()...) + + // Locate SSA manager + replaceIndex, managerExists := findFirstIndex(managedFields, + func(entry metav1.ManagedFieldsEntry) bool { + return entry.Manager == ssaManagerName && + entry.Operation == metav1.ManagedFieldsOperationApply && + entry.Subresource == "" + }) + + if !managerExists { + // SSA manager does not exist. Find the most recent matching CSA manager, + // convert it to an SSA manager. + // + // (find first index, since managed fields are sorted so that most recent is + // first in the list) + replaceIndex, managerExists = findFirstIndex(managedFields, + func(entry metav1.ManagedFieldsEntry) bool { + return entry.Manager == csaManagerName && + entry.Operation == metav1.ManagedFieldsOperationUpdate && + entry.Subresource == "" + }) + + if !managerExists { + // There are no CSA managers that need to be converted. Nothing to do + // Return early + return nil + } + + // Convert CSA manager into SSA manager + managedFields[replaceIndex].Operation = metav1.ManagedFieldsOperationApply + managedFields[replaceIndex].Manager = ssaManagerName + } + err = unionManagerIntoIndex(managedFields, replaceIndex, csaManagerName) + if err != nil { + return err + } + + // Create version of managed fields which has no CSA managers with the given name + filteredManagers := filter(managedFields, func(entry metav1.ManagedFieldsEntry) bool { + return !(entry.Manager == csaManagerName && + entry.Operation == metav1.ManagedFieldsOperationUpdate && + entry.Subresource == "") + }) + + // Wipe out last-applied-configuration annotation if it exists + annotations := accessor.GetAnnotations() + delete(annotations, csaAnnotationName) + + // Commit changes to object + accessor.SetAnnotations(annotations) + accessor.SetManagedFields(filteredManagers) + + return nil +} + +// Locates an Update manager entry named `csaManagerName` with the same APIVersion +// as the manager at the targetIndex. Unions both manager's fields together +// into the manager specified by `targetIndex`. No other managers are modified. +func unionManagerIntoIndex(entries []metav1.ManagedFieldsEntry, targetIndex int, csaManagerName string) error { + ssaManager := entries[targetIndex] + + // find Update manager of same APIVersion, union ssa fields with it. + // discard all other Update managers of the same name + csaManagerIndex, csaManagerExists := findFirstIndex(entries, + func(entry metav1.ManagedFieldsEntry) bool { + return entry.Manager == csaManagerName && + entry.Operation == metav1.ManagedFieldsOperationUpdate && + entry.Subresource == "" && + entry.APIVersion == ssaManager.APIVersion + }) + + targetFieldSet, err := decodeManagedFieldsEntrySet(ssaManager) + if err != nil { + return fmt.Errorf("failed to convert fields to set: %w", err) + } + + combinedFieldSet := &targetFieldSet + + // Union the csa manager with the existing SSA manager. Do nothing if + // there was no good candidate found + if csaManagerExists { + csaManager := entries[csaManagerIndex] + + csaFieldSet, err := decodeManagedFieldsEntrySet(csaManager) + if err != nil { + return fmt.Errorf("failed to convert fields to set: %w", err) + } + + combinedFieldSet = combinedFieldSet.Union(&csaFieldSet) + } + + // Ensure that the resultant fieldset does not include the + // last applied annotation + combinedFieldSet = combinedFieldSet.Difference(csaAnnotationFieldSet) + + // Encode the fields back to the serialized format + err = encodeManagedFieldsEntrySet(&entries[targetIndex], *combinedFieldSet) + if err != nil { + return fmt.Errorf("failed to encode field set: %w", err) + } + + return nil +} + +func findFirstIndex[T any]( + collection []T, + predicate func(T) bool, +) (int, bool) { + for idx, entry := range collection { + if predicate(entry) { + return idx, true + } + } + + return -1, false +} + +func filter[T any]( + collection []T, + predicate func(T) bool, +) []T { + result := make([]T, 0, len(collection)) + + for _, value := range collection { + if predicate(value) { + result = append(result, value) + } + } + + if len(result) == 0 { + return nil + } + + return result +} + +// Included from fieldmanager.internal to avoid dependency cycle +// FieldsToSet creates a set paths from an input trie of fields +func decodeManagedFieldsEntrySet(f metav1.ManagedFieldsEntry) (s fieldpath.Set, err error) { + err = s.FromJSON(bytes.NewReader(f.FieldsV1.Raw)) + return s, err +} + +// SetToFields creates a trie of fields from an input set of paths +func encodeManagedFieldsEntrySet(f *metav1.ManagedFieldsEntry, s fieldpath.Set) (err error) { + f.FieldsV1.Raw, err = s.ToJSON() + return err +} diff --git a/util/csaupgrade/upgrade_test.go b/util/csaupgrade/upgrade_test.go new file mode 100644 index 00000000..e333fed0 --- /dev/null +++ b/util/csaupgrade/upgrade_test.go @@ -0,0 +1,482 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package csaupgrade_test + +import ( + "reflect" + "testing" + + "github.com/google/go-cmp/cmp" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/util/csaupgrade" +) + +func TestUpgradeCSA(t *testing.T) { + + cases := []struct { + Name string + CSAManager string + SSAManager string + OriginalObject []byte + ExpectedObject []byte + }{ + { + // Case where there is a CSA entry with the given name, but no SSA entry + // is found. Expect that the CSA entry is converted to an SSA entry + // and renamed. + Name: "csa-basic-direct-conversion", + CSAManager: "kubectl-client-side-apply", + SSAManager: "kubectl", + OriginalObject: []byte(` +apiVersion: v1 +data: {} +kind: ConfigMap +metadata: + annotations: + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"v1","data":{"key":"value","legacy":"unused"},"kind":"ConfigMap","metadata":{"annotations":{},"name":"test","namespace":"default"}} + creationTimestamp: "2022-08-22T23:08:23Z" + managedFields: + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:data: + .: {} + f:key: {} + f:legacy: {} + f:metadata: + f:annotations: + .: {} + f:kubectl.kubernetes.io/last-applied-configuration: {} + manager: kubectl-client-side-apply + operation: Update + time: "2022-08-22T23:08:23Z" + name: test + namespace: default +`), + ExpectedObject: []byte(` +apiVersion: v1 +data: {} +kind: ConfigMap +metadata: + annotations: {} + creationTimestamp: "2022-08-22T23:08:23Z" + managedFields: + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:data: + .: {} + f:key: {} + f:legacy: {} + f:metadata: + f:annotations: {} + manager: kubectl + operation: Apply + time: "2022-08-22T23:08:23Z" + name: test + namespace: default +`), + }, + { + // This is the case when kubectl --server-side is used for the first time + // Server creates duplicate managed fields entry - one for Update and another + // for Apply. Expect entries to be merged into one entry, which is unchanged + // from initial SSA. + Name: "csa-combine-with-ssa-duplicate-keys", + CSAManager: "kubectl-client-side-apply", + SSAManager: "kubectl", + OriginalObject: []byte(` +apiVersion: v1 +data: {} +kind: ConfigMap +metadata: + annotations: + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"v1","data":{"key":"value","legacy":"unused"},"kind":"ConfigMap","metadata":{"annotations":{},"name":"test","namespace":"default"}} + creationTimestamp: "2022-08-22T23:08:23Z" + managedFields: + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:data: + .: {} + f:key: {} + f:legacy: {} + f:metadata: + f:annotations: + .: {} + f:kubectl.kubernetes.io/last-applied-configuration: {} + manager: kubectl + operation: Apply + time: "2022-08-23T23:08:23Z" + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:data: + .: {} + f:key: {} + f:legacy: {} + f:metadata: + f:annotations: + .: {} + f:kubectl.kubernetes.io/last-applied-configuration: {} + manager: kubectl-client-side-apply + operation: Update + time: "2022-08-22T23:08:23Z" + name: test + namespace: default +`), + ExpectedObject: []byte(` +apiVersion: v1 +data: {} +kind: ConfigMap +metadata: + annotations: {} + creationTimestamp: "2022-08-22T23:08:23Z" + managedFields: + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:data: + .: {} + f:key: {} + f:legacy: {} + f:metadata: + f:annotations: {} + manager: kubectl + operation: Apply + time: "2022-08-23T23:08:23Z" + name: test + namespace: default +`), + }, + { + // This is the case when kubectl --server-side is used for the first time, + // but then a key is removed. A bug would take place where key is left in + // CSA entry but no longer present in SSA entry, so it would not be pruned. + // This shows that upgrading such an object results in correct behavior next + // time SSA applier + // Expect final object to have unioned keys from both entries + Name: "csa-combine-with-ssa-additional-keys", + CSAManager: "kubectl-client-side-apply", + SSAManager: "kubectl", + OriginalObject: []byte(` +apiVersion: v1 +data: {} +kind: ConfigMap +metadata: + annotations: + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"v1","data":{"key":"value","legacy":"unused"},"kind":"ConfigMap","metadata":{"annotations":{},"name":"test","namespace":"default"}} + creationTimestamp: "2022-08-22T23:08:23Z" + managedFields: + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:data: + .: {} + f:key: {} + f:metadata: + f:annotations: + .: {} + f:kubectl.kubernetes.io/last-applied-configuration: {} + manager: kubectl + operation: Apply + time: "2022-08-23T23:08:23Z" + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:data: + .: {} + f:key: {} + f:legacy: {} + f:metadata: + f:annotations: + .: {} + f:kubectl.kubernetes.io/last-applied-configuration: {} + manager: kubectl-client-side-apply + operation: Update + time: "2022-08-22T23:08:23Z" + name: test + namespace: default +`), + ExpectedObject: []byte(` +apiVersion: v1 +data: {} +kind: ConfigMap +metadata: + annotations: {} + creationTimestamp: "2022-08-22T23:08:23Z" + managedFields: + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:data: + .: {} + f:key: {} + f:legacy: {} + f:metadata: + f:annotations: {} + manager: kubectl + operation: Apply + time: "2022-08-23T23:08:23Z" + name: test + namespace: default +`), + }, + { + // Case when there are multiple CSA versions on the object which do not + // match the version from the apply entry. Shows they are tossed away + // without being merged. + Name: "csa-no-applicable-version", + CSAManager: "kubectl-client-side-apply", + SSAManager: "kubectl", + OriginalObject: []byte(` +apiVersion: v1 +data: {} +kind: ConfigMap +metadata: + annotations: + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"v1","data":{"key":"value","legacy":"unused"},"kind":"ConfigMap","metadata":{"annotations":{},"name":"test","namespace":"default"}} + creationTimestamp: "2022-08-22T23:08:23Z" + managedFields: + - apiVersion: v5 + fieldsType: FieldsV1 + fieldsV1: + f:data: + .: {} + f:key: {} + f:legacy: {} + f:metadata: + f:annotations: + .: {} + f:kubectl.kubernetes.io/last-applied-configuration: {} + manager: kubectl + operation: Apply + time: "2022-08-23T23:08:23Z" + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:data: + f:key2: {} + f:metadata: + f:annotations: + f:hello2: {} + manager: kubectl-client-side-apply + operation: Update + time: "2022-08-22T23:08:23Z" + - apiVersion: v2 + fieldsType: FieldsV1 + fieldsV1: + f:data: + f:key3: {} + f:metadata: + f:annotations: + f:hello3: {} + manager: kubectl-client-side-apply + operation: Update + time: "2022-08-22T23:08:23Z" + - apiVersion: v3 + fieldsType: FieldsV1 + fieldsV1: + f:data: + f:key4: {} + f:metadata: + f:annotations: + f:hello3: {} + manager: kubectl-client-side-apply + operation: Update + time: "2022-08-22T23:08:23Z" + - apiVersion: v4 + fieldsType: FieldsV1 + fieldsV1: + f:data: + f:key5: {} + f:metadata: + f:annotations: + f:hello4: {} + manager: kubectl-client-side-apply + operation: Update + time: "2022-08-22T23:08:23Z" + name: test + namespace: default +`), + ExpectedObject: []byte(` +apiVersion: v1 +data: {} +kind: ConfigMap +metadata: + annotations: {} + creationTimestamp: "2022-08-22T23:08:23Z" + managedFields: + - apiVersion: v5 + fieldsType: FieldsV1 + fieldsV1: + f:data: + .: {} + f:key: {} + f:legacy: {} + f:metadata: + f:annotations: {} + manager: kubectl + operation: Apply + time: "2022-08-23T23:08:23Z" + name: test + namespace: default +`), + }, + { + // Case when there are multiple CSA versions on the object which do not + // match the version from the apply entry, and one which does. + // Shows that CSA entry with matching version is unioned into the SSA entry. + Name: "csa-single-applicable-version", + CSAManager: "kubectl-client-side-apply", + SSAManager: "kubectl", + OriginalObject: []byte(` +apiVersion: v1 +data: {} +kind: ConfigMap +metadata: + annotations: + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"v1","data":{"key":"value","legacy":"unused"},"kind":"ConfigMap","metadata":{"annotations":{},"name":"test","namespace":"default"}} + creationTimestamp: "2022-08-22T23:08:23Z" + managedFields: + - apiVersion: v5 + fieldsType: FieldsV1 + fieldsV1: + f:data: + .: {} + f:key: {} + f:legacy: {} + f:metadata: + f:annotations: + .: {} + f:kubectl.kubernetes.io/last-applied-configuration: {} + manager: kubectl + operation: Apply + time: "2022-08-23T23:08:23Z" + - apiVersion: v5 + fieldsType: FieldsV1 + fieldsV1: + f:data: + f:key2: {} + f:metadata: + f:annotations: + f:hello2: {} + manager: kubectl-client-side-apply + operation: Update + time: "2022-08-22T23:08:23Z" + - apiVersion: v2 + fieldsType: FieldsV1 + fieldsV1: + f:data: + f:key3: {} + f:metadata: + f:annotations: + f:hello3: {} + manager: kubectl-client-side-apply + operation: Update + time: "2022-08-22T23:08:23Z" + - apiVersion: v3 + fieldsType: FieldsV1 + fieldsV1: + f:data: + f:key4: {} + f:metadata: + f:annotations: + f:hello4: {} + manager: kubectl-client-side-apply + operation: Update + time: "2022-08-22T23:08:23Z" + - apiVersion: v4 + fieldsType: FieldsV1 + fieldsV1: + f:data: + f:key5: {} + f:metadata: + f:annotations: + f:hello5: {} + manager: kubectl-client-side-apply + operation: Update + time: "2022-08-22T23:08:23Z" + name: test + namespace: default +`), + ExpectedObject: []byte(` +apiVersion: v1 +data: {} +kind: ConfigMap +metadata: + annotations: {} + creationTimestamp: "2022-08-22T23:08:23Z" + managedFields: + - apiVersion: v5 + fieldsType: FieldsV1 + fieldsV1: + f:data: + .: {} + f:key: {} + f:key2: {} + f:legacy: {} + f:metadata: + f:annotations: + .: {} + f:hello2: {} + manager: kubectl + operation: Apply + time: "2022-08-23T23:08:23Z" + name: test + namespace: default +`), + }, + } + + for _, testCase := range cases { + t.Run(testCase.Name, func(t *testing.T) { + initialObject := unstructured.Unstructured{} + err := yaml.Unmarshal(testCase.OriginalObject, &initialObject) + if err != nil { + t.Fatal(err) + } + + upgraded := initialObject.DeepCopy() + err = csaupgrade.UpgradeManagedFields( + upgraded, + testCase.CSAManager, + testCase.SSAManager, + ) + + if err != nil { + t.Fatal(err) + } + + expectedObject := unstructured.Unstructured{} + err = yaml.Unmarshal(testCase.ExpectedObject, &expectedObject) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(&expectedObject, upgraded) { + t.Fatal(cmp.Diff(&expectedObject, upgraded)) + } + }) + } +}