From 274bf1e3f3ed30015f6dc011d8e6d15df079c6f7 Mon Sep 17 00:00:00 2001 From: Yin Da Date: Thu, 15 Jun 2023 13:07:30 +0800 Subject: [PATCH] Feat: add apply Signed-off-by: Yin Da --- util/builder/option.go | 53 +++++++++ util/builder/option_test.go | 47 ++++++++ util/jsonutil/setter.go | 35 ++++++ util/jsonutil/setter_test.go | 74 +++++++++++++ util/k8s/apply/apply.go | 150 +++++++++++++++++++++++++ util/k8s/apply/apply_test.go | 205 +++++++++++++++++++++++++++++++++++ util/k8s/apply/types.go | 93 ++++++++++++++++ util/k8s/apply/types_test.go | 40 +++++++ util/k8s/patch/patch.go | 24 ++-- util/k8s/patch/patch_test.go | 8 +- 10 files changed, 720 insertions(+), 9 deletions(-) create mode 100644 util/builder/option.go create mode 100644 util/builder/option_test.go create mode 100644 util/jsonutil/setter.go create mode 100644 util/jsonutil/setter_test.go create mode 100644 util/k8s/apply/apply.go create mode 100644 util/k8s/apply/apply_test.go create mode 100644 util/k8s/apply/types.go create mode 100644 util/k8s/apply/types_test.go diff --git a/util/builder/option.go b/util/builder/option.go new file mode 100644 index 0000000..10695b0 --- /dev/null +++ b/util/builder/option.go @@ -0,0 +1,53 @@ +/* +Copyright 2023 The KubeVela 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 builder + +// Option the generic type for option type T +type Option[T any] interface { + ApplyTo(*T) +} + +// Constructor the constructor for option interface +type Constructor[T any] interface { + New() *T +} + +// NewOptions create options T with given Option args. If T implements +// Constructor, it will call its construct function to initialize first +func NewOptions[T any](opts ...Option[T]) *T { + t := new(T) + if c, ok := any(t).(Constructor[T]); ok { + t = c.New() + } + ApplyTo(t, opts...) + return t +} + +// ApplyTo run all option args for setting the options +func ApplyTo[T any](t *T, opts ...Option[T]) { + for _, opt := range opts { + opt.ApplyTo(t) + } +} + +// OptionFn wrapper for ApplyTo function +type OptionFn[T any] func(*T) + +// ApplyTo implements Option interface +func (in OptionFn[T]) ApplyTo(t *T) { + (in)(t) +} diff --git a/util/builder/option_test.go b/util/builder/option_test.go new file mode 100644 index 0000000..21b8a92 --- /dev/null +++ b/util/builder/option_test.go @@ -0,0 +1,47 @@ +/* +Copyright 2023 The KubeVela 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 builder_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kubevela/pkg/util/builder" +) + +type B struct { + key string + val string +} + +func (in *B) New() *B { + return &B{key: "key"} +} + +type Suffix string + +func (in Suffix) ApplyTo(b *B) { + b.key += string(in) +} + +func TestOption(t *testing.T) { + opt := builder.OptionFn[B](func(b *B) { b.val = "val" }) + b := builder.NewOptions[B](opt, Suffix("-x")) + require.Equal(t, "key-x", b.key) + require.Equal(t, "val", b.val) +} diff --git a/util/jsonutil/setter.go b/util/jsonutil/setter.go new file mode 100644 index 0000000..37214e5 --- /dev/null +++ b/util/jsonutil/setter.go @@ -0,0 +1,35 @@ +/* +Copyright 2023 The KubeVela 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 jsonutil + +// DropField remove field inside a given nested map +func DropField(obj map[string]any, fields ...string) { + if len(fields) == 0 { + return + } + var cur any = obj + for _, field := range fields[:len(fields)-1] { + if next, ok := cur.(map[string]any); ok { + cur = next[field] + } else { + return + } + } + if m, ok := cur.(map[string]any); ok { + delete(m, fields[len(fields)-1]) + } +} diff --git a/util/jsonutil/setter_test.go b/util/jsonutil/setter_test.go new file mode 100644 index 0000000..be926e6 --- /dev/null +++ b/util/jsonutil/setter_test.go @@ -0,0 +1,74 @@ +/* +Copyright 2023 The KubeVela 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 jsonutil_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kubevela/pkg/util/jsonutil" +) + +func TestDropField(t *testing.T) { + testCases := map[string]struct { + Input string + Fields []string + Output string + }{ + "empty": { + Input: `{"a":1}`, + Fields: nil, + Output: `{"a":1}`, + }, + "not-found": { + Input: `{"a":1}`, + Fields: []string{"b"}, + Output: `{"a":1}`, + }, + "type-not-match": { + Input: `{"a":[1]}`, + Fields: []string{"a", "b", "c"}, + Output: `{"a":[1]}`, + }, + "key-not-found": { + Input: `{"a":{"b":3}}`, + Fields: []string{"b", "a", "b"}, + Output: `{"a":{"b":3}}`, + }, + "nil": { + Input: `{"a":null}`, + Fields: []string{"a", "b"}, + Output: `{"a":null}`, + }, + "nested-drop": { + Input: `{"a":{"b":3}}`, + Fields: []string{"a", "b"}, + Output: `{"a":{}}`, + }, + } + for name, tt := range testCases { + t.Run(name, func(t *testing.T) { + m1, m2 := map[string]any{}, map[string]any{} + require.NoError(t, json.Unmarshal([]byte(tt.Input), &m1)) + require.NoError(t, json.Unmarshal([]byte(tt.Output), &m2)) + jsonutil.DropField(m1, tt.Fields...) + require.Equal(t, m2, m1) + }) + } +} diff --git a/util/k8s/apply/apply.go b/util/k8s/apply/apply.go new file mode 100644 index 0000000..f3fd5cb --- /dev/null +++ b/util/k8s/apply/apply.go @@ -0,0 +1,150 @@ +/* +Copyright 2023 The KubeVela 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 apply + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + + "github.com/kubevela/pkg/util/k8s/patch" +) + +// Apply try to get desired object and update it, if not exist, create it +// The procedure of Apply is +// 1. Get the desired object +// 2. Run the PreApplyHook +// 3. If not exist, run the PreCreateHook and create the target object, exit +// 4. If exists, run the PreUpdateHook, get the UpdateStrategy, decide how to update +// 5. If Patch, get patch.PatchAction (implemented by PatchActionProvider) +// 6. Do the update operation +// The above procedure will also be affected by the DryRunOption +func Apply(ctx context.Context, c client.Client, desired client.Object, opts Options) error { + // wrap client for apply spec & status + c = &Client{c} + + // pre-fill types + if desired.GetObjectKind().GroupVersionKind().Kind == "" { + if gvk, err := apiutil.GVKForObject(desired, c.Scheme()); err == nil { + desired.GetObjectKind().SetGroupVersionKind(gvk) + } + } + + // get existing + existing, err := get(ctx, c, desired) + if err != nil { + return fmt.Errorf("cannot get object: %w", err) + } + + if hook, ok := opts.(PreApplyHook); ok { + if err = hook.PreApply(desired); err != nil { + return err + } + } + + // create + if existing == nil { + if err = create(ctx, c, desired, opts); err != nil { + return fmt.Errorf("cannot create object: %w", err) + } + return nil + } + + // update + if err = update(ctx, c, existing, desired, opts); err != nil { + return fmt.Errorf("cannot update object: %w", err) + } + return nil +} + +func get(ctx context.Context, c client.Client, desired client.Object) (client.Object, error) { + o := &unstructured.Unstructured{} + o.SetGroupVersionKind(desired.GetObjectKind().GroupVersionKind()) + if err := c.Get(ctx, client.ObjectKeyFromObject(desired), o); err != nil { + return nil, client.IgnoreNotFound(err) + } + return o, nil +} + +func create(ctx context.Context, c client.Client, desired client.Object, act Options) error { + if hook, ok := act.(PreCreateHook); ok { + if err := hook.PreCreate(desired); err != nil { + return err + } + } + klog.V(4).InfoS("creating object", "resource", klog.KObj(desired)) + return c.Create(ctx, desired, act.DryRun()) +} + +func update(ctx context.Context, c client.Client, existing client.Object, desired client.Object, act Options) error { + if hook, ok := act.(PreUpdateHook); ok { + if err := hook.PreUpdate(existing, desired); err != nil { + return err + } + } + strategy, err := act.GetUpdateStrategy(existing, desired) + if err != nil { + return err + } + switch strategy { + case Recreate: + klog.V(4).InfoS("recreating object", "resource", klog.KObj(desired)) + if act.DryRun() { // recreate does not support dryrun + return nil + } + if existing.GetDeletionTimestamp() == nil { + if err := c.Delete(ctx, existing); err != nil { + return fmt.Errorf("failed to delete object: %w", err) + } + } + return c.Create(ctx, desired) + case Replace: + klog.V(4).InfoS("replacing object", "resource", klog.KObj(desired)) + desired.SetResourceVersion(existing.GetResourceVersion()) + return c.Update(ctx, desired, act.DryRun()) + case Patch: + klog.V(4).InfoS("patching object", "resource", klog.KObj(desired)) + patchAction := patch.PatchAction{} + if prd, ok := act.(PatchActionProvider); ok { + patchAction = prd.GetPatchAction() + } + pat, err := patch.ThreeWayMergePatch(existing, desired, &patchAction) + if err != nil { + return fmt.Errorf("cannot calculate patch by computing a three way diff: %w", err) + } + if isEmptyPatch(pat) { + return nil + } + return c.Patch(ctx, desired, pat, act.DryRun()) + case Skip: + return nil + default: + return fmt.Errorf("unrecognizable update strategy: %v", strategy) + } +} + +func isEmptyPatch(patch client.Patch) bool { + if patch == nil { + return true + } + data, _ := patch.Data(nil) + return data == nil || string(data) == "{}" +} diff --git a/util/k8s/apply/apply_test.go b/util/k8s/apply/apply_test.go new file mode 100644 index 0000000..d89d66e --- /dev/null +++ b/util/k8s/apply/apply_test.go @@ -0,0 +1,205 @@ +/* +Copyright 2023 The KubeVela 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 apply_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/kubevela/pkg/util/jsonutil" + "github.com/kubevela/pkg/util/k8s" + "github.com/kubevela/pkg/util/k8s/apply" + "github.com/kubevela/pkg/util/k8s/patch" +) + +type FakeOptions struct { + dryRun bool + applyErr bool + createErr bool + updateErr bool + updateStrategy string +} + +func (in *FakeOptions) GetPatchAction() patch.PatchAction { + return patch.PatchAction{ + UpdateAnno: true, + AnnoLastAppliedConfig: "lac", + AnnoLastAppliedTime: "lat", + } +} + +func (in *FakeOptions) PreUpdate(existing, desired client.Object) error { + if in.updateErr { + return fmt.Errorf("pre-update error") + } + return nil +} + +func (in *FakeOptions) PreCreate(desired client.Object) error { + if in.createErr { + return fmt.Errorf("pre-create error") + } + return nil +} + +func (in *FakeOptions) PreApply(desired client.Object) error { + if in.applyErr { + return fmt.Errorf("pre-apply error") + } + return patch.AddLastAppliedConfiguration(desired, "lac", "lat") +} + +func (in *FakeOptions) DryRun() apply.DryRunOption { + return apply.DryRunOption(in.dryRun) +} + +func (in *FakeOptions) GetUpdateStrategy(existing, desired client.Object) (apply.UpdateStrategy, error) { + switch in.updateStrategy { + case "patch": + return apply.Patch, nil + case "replace": + return apply.Replace, nil + case "recreate": + return apply.Recreate, nil + case "skip": + return apply.Skip, nil + default: + return apply.Skip, fmt.Errorf("unexpected update-strategy") + } +} + +var _ apply.Options = &FakeOptions{} +var _ apply.PreApplyHook = &FakeOptions{} +var _ apply.PreCreateHook = &FakeOptions{} +var _ apply.PreUpdateHook = &FakeOptions{} +var _ apply.PatchActionProvider = &FakeOptions{} + +func TestApply(t *testing.T) { + testCases := map[string]struct { + Existing client.Object + Desired client.Object + Expected client.Object + Options apply.Options + HasLastAppliedAnno bool + Err string + }{ + "pre-apply err": { + Desired: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"key": "value"}}, + Options: &FakeOptions{applyErr: true}, + Err: "pre-apply err", + }, + "pre-create err": { + Desired: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"key": "value"}}, + Options: &FakeOptions{createErr: true}, + Err: "pre-create err", + }, + "pre-update err": { + Existing: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{}}, + Desired: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"key": "value"}}, + Options: &FakeOptions{updateErr: true}, + Err: "pre-update err", + }, + "invalid-update-strategy": { + Existing: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{}}, + Desired: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"key": "value"}}, + Options: &FakeOptions{updateStrategy: "bad"}, + Err: "unexpected update-strategy", + }, + "create": { + Desired: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"key": "value"}}, + Expected: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"key": "value"}}, + Options: &FakeOptions{}, + HasLastAppliedAnno: true, + }, + "skip-update": { + Existing: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{}}, + Desired: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"key": "value"}}, + Expected: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{}}, + Options: &FakeOptions{updateStrategy: "skip"}, + }, + "patch": { + Existing: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"old": "0"}}, + Desired: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"key": "value"}}, + Expected: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"old": "0", "key": "value"}}, + Options: &FakeOptions{updateStrategy: "patch"}, + HasLastAppliedAnno: true, + }, + "empty-patch": { + Existing: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default", Annotations: map[string]string{"lac": `{"apiVersion":"v1","data":{"old":"0"},"kind":"ConfigMap","metadata":{"creationTimestamp":null,"name":"default","namespace":"default"}}`}}, Data: map[string]string{"old": "0"}}, + Desired: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"old": "0"}}, + Expected: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default", Annotations: map[string]string{"lac": `{"apiVersion":"v1","data":{"old":"0"},"kind":"ConfigMap","metadata":{"creationTimestamp":null,"name":"default","namespace":"default"}}`}}, Data: map[string]string{"old": "0"}}, + Options: &FakeOptions{updateStrategy: "patch"}, + }, + "recreate": { + Existing: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"old": "0"}}, + Desired: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"key": "value"}}, + Expected: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"key": "value"}}, + Options: &FakeOptions{updateStrategy: "recreate"}, + HasLastAppliedAnno: true, + }, + "replace": { + Existing: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"old": "0"}}, + Desired: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"key": "value"}}, + Expected: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, Data: map[string]string{"key": "value"}}, + Options: &FakeOptions{updateStrategy: "replace"}, + HasLastAppliedAnno: true, + }, + } + + for name, tt := range testCases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + cli := fake.NewClientBuilder().Build() + if tt.Existing != nil { + require.NoError(t, cli.Create(ctx, tt.Existing)) + } + + err := apply.Apply(ctx, cli, tt.Desired, tt.Options) + if len(tt.Err) > 0 { + require.ErrorContains(t, err, tt.Err) + return + } + require.NoError(t, err) + + if tt.Expected != nil { + key := client.ObjectKeyFromObject(tt.Desired) + un := &unstructured.Unstructured{} + un.SetGroupVersionKind(tt.Desired.GetObjectKind().GroupVersionKind()) + require.NoError(t, cli.Get(ctx, key, un)) + un.SetResourceVersion("") + if tt.HasLastAppliedAnno { + require.NotEmpty(t, k8s.GetAnnotation(un, "lac")) + require.NotEmpty(t, k8s.GetAnnotation(un, "lat")) + un.SetAnnotations(nil) + } + _un, _ := jsonutil.AsType[map[string]interface{}](un) + delete(*_un, "apiVersion") + delete(*_un, "kind") + _exp, _ := jsonutil.AsType[map[string]interface{}](tt.Expected) + require.Equal(t, _exp, _un) + } + }) + } +} diff --git a/util/k8s/apply/types.go b/util/k8s/apply/types.go new file mode 100644 index 0000000..a0a8f7f --- /dev/null +++ b/util/k8s/apply/types.go @@ -0,0 +1,93 @@ +/* +Copyright 2023 The KubeVela 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 apply + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kubevela/pkg/util/k8s/patch" +) + +// UpdateStrategy the strategy for updating +type UpdateStrategy int + +const ( + // Patch use the three-way-merge-patch to update the target resource + Patch UpdateStrategy = iota + // Replace directly replacing the whole target resource + Replace + // Recreate delete the resource first and create it + Recreate + // Skip do not make update to the target resource + Skip +) + +// Options interface for doing Apply +type Options interface { + // DryRun whether the current apply is a dry-run operation + DryRun() DryRunOption + // GetUpdateStrategy decide how the target object should be updated + GetUpdateStrategy(existing, desired client.Object) (UpdateStrategy, error) +} + +// PatchActionProvider if the given action implement this interface, the PatchAction +// will be used during three-way-merge-patch +type PatchActionProvider interface { + GetPatchAction() patch.PatchAction +} + +// PreApplyHook run before creating/updating the object, could be used to make +// validation or mutation +type PreApplyHook interface { + PreApply(desired client.Object) error +} + +// PreCreateHook run before creating the object, could be used to make validation +// or mutation +type PreCreateHook interface { + PreCreate(desired client.Object) error +} + +// PreUpdateHook run before updating the object, could be used to propagating +// existing configuration, make mutation to desired object or validate it +type PreUpdateHook interface { + PreUpdate(existing, desired client.Object) error +} + +// DryRunOption a bool option for client.DryRunAll +type DryRunOption bool + +// ApplyToCreate implements client.CreateOption +func (in DryRunOption) ApplyToCreate(options *client.CreateOptions) { + if in { + client.DryRunAll.ApplyToCreate(options) + } +} + +// ApplyToUpdate implements client.UpdateOption +func (in DryRunOption) ApplyToUpdate(options *client.UpdateOptions) { + if in { + client.DryRunAll.ApplyToUpdate(options) + } +} + +// ApplyToPatch implements client.PatchOption +func (in DryRunOption) ApplyToPatch(options *client.PatchOptions) { + if in { + client.DryRunAll.ApplyToPatch(options) + } +} diff --git a/util/k8s/apply/types_test.go b/util/k8s/apply/types_test.go new file mode 100644 index 0000000..85b3354 --- /dev/null +++ b/util/k8s/apply/types_test.go @@ -0,0 +1,40 @@ +/* +Copyright 2023 The KubeVela 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 apply_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kubevela/pkg/util/k8s/apply" +) + +func TestDryRunOption(t *testing.T) { + opt := apply.DryRunOption(true) + createOptions := &client.CreateOptions{} + opt.ApplyToCreate(createOptions) + require.Equal(t, []string{metav1.DryRunAll}, createOptions.DryRun) + updateOptions := &client.UpdateOptions{} + opt.ApplyToUpdate(updateOptions) + require.Equal(t, []string{metav1.DryRunAll}, updateOptions.DryRun) + patchOptions := &client.PatchOptions{} + opt.ApplyToPatch(patchOptions) + require.Equal(t, []string{metav1.DryRunAll}, patchOptions.DryRun) +} diff --git a/util/k8s/patch/patch.go b/util/k8s/patch/patch.go index bfd2438..d3bc4b4 100644 --- a/util/k8s/patch/patch.go +++ b/util/k8s/patch/patch.go @@ -21,6 +21,7 @@ import ( "time" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/jsonmergepatch" @@ -51,7 +52,7 @@ func ThreeWayMergePatch(currentObj, modifiedObj runtime.Object, a *PatchAction) return nil, err } original := GetOriginalConfiguration(currentObj, a.AnnoLastAppliedConfig) - modified, err := GetModifiedConfiguration(modifiedObj, a.UpdateAnno, a.AnnoLastAppliedConfig) + modified, err := GetModifiedConfiguration(modifiedObj, a.AnnoLastAppliedConfig, a.AnnoLastAppliedTime) if err != nil { return nil, err } @@ -87,12 +88,23 @@ func ThreeWayMergePatch(currentObj, modifiedObj runtime.Object, a *PatchAction) return nil, err } } + if a.UpdateAnno && patchData != nil && string(patchData) != "{}" { + _data := map[string]any{} + if err = json.Unmarshal(patchData, &_data); err != nil { + return nil, err + } + _ = unstructured.SetNestedField(_data, string(modified), "metadata", "annotations", a.AnnoLastAppliedConfig) + _ = unstructured.SetNestedField(_data, time.Now().Format(time.RFC3339), "metadata", "annotations", a.AnnoLastAppliedTime) + if patchData, err = json.Marshal(_data); err != nil { + return nil, err + } + } return client.RawPatch(patchType, patchData), nil } // AddLastAppliedConfiguration add last-applied-configuration and last-applied-time annotation func AddLastAppliedConfiguration(obj runtime.Object, annoAppliedConfig string, annoAppliedTime string) error { - modified, err := GetModifiedConfiguration(obj, false, annoAppliedConfig) + modified, err := GetModifiedConfiguration(obj, annoAppliedConfig, annoAppliedTime) if err != nil { return err } @@ -104,19 +116,15 @@ func AddLastAppliedConfiguration(obj runtime.Object, annoAppliedConfig string, a // GetModifiedConfiguration serializes the object into byte stream. // If `updateAnnotation` is true, it embeds the result as an annotation in the // modified configuration. -func GetModifiedConfiguration(obj runtime.Object, updateAnnotation bool, annoAppliedConfig string) ([]byte, error) { +func GetModifiedConfiguration(obj runtime.Object, annoAppliedConfig string, annoAppliedTime string) ([]byte, error) { // copy the original one, remove last-applied-configuration and serialize it o := obj.DeepCopyObject() _ = k8s.DeleteAnnotation(o, annoAppliedConfig) + _ = k8s.DeleteAnnotation(o, annoAppliedTime) modified, err := json.Marshal(o) if err != nil { return nil, err } - // if updateAnno set, serialize the object with the last-applied-configuration - if updateAnnotation { - _ = k8s.AddAnnotation(o, annoAppliedConfig, string(modified)) - modified, err = json.Marshal(o) - } return modified, err } diff --git a/util/k8s/patch/patch_test.go b/util/k8s/patch/patch_test.go index 40f3511..67a310a 100644 --- a/util/k8s/patch/patch_test.go +++ b/util/k8s/patch/patch_test.go @@ -17,6 +17,7 @@ limitations under the License. package patch_test import ( + "encoding/json" "testing" "github.com/google/go-cmp/cmp" @@ -25,6 +26,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + "github.com/kubevela/pkg/util/jsonutil" "github.com/kubevela/pkg/util/k8s/patch" "github.com/kubevela/pkg/util/test/object" ) @@ -153,7 +155,11 @@ func TestThreeWayMerge(t *testing.T) { r.NoError(err) data, err := result.Data(nil) r.NoError(err) - r.Equal(tc.result, string(data)) + m1, m2 := map[string]any{}, map[string]any{} + r.NoError(json.Unmarshal([]byte(tc.result), &m1)) + r.NoError(json.Unmarshal(data, &m2)) + jsonutil.DropField(m2, "metadata", "annotations", "last-applied/time") + r.Equal(m1, m2) }) } }