From 618822a7006444940841c6f196e6ca1e62f19e78 Mon Sep 17 00:00:00 2001 From: AiRanthem Date: Sat, 8 Feb 2025 11:18:11 +0800 Subject: [PATCH] add rollout rollback command Signed-off-by: AiRanthem --- pkg/cmd/rollout/rollout.go | 1 + pkg/cmd/rollout/rollout_approve.go | 2 +- pkg/cmd/rollout/rollout_rollback.go | 206 ++++++++++++++++++ pkg/internal/polymorphichelpers/interface.go | 6 + pkg/internal/polymorphichelpers/steps.go | 79 +++++++ pkg/internal/polymorphichelpers/steps_test.go | 162 ++++++++++++++ 6 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/rollout/rollout_rollback.go create mode 100644 pkg/internal/polymorphichelpers/steps.go create mode 100644 pkg/internal/polymorphichelpers/steps_test.go diff --git a/pkg/cmd/rollout/rollout.go b/pkg/cmd/rollout/rollout.go index b02f95b..7af4bf6 100644 --- a/pkg/cmd/rollout/rollout.go +++ b/pkg/cmd/rollout/rollout.go @@ -69,6 +69,7 @@ func NewCmdRollout(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobr cmd.AddCommand(NewCmdRolloutStatus(f, streams)) cmd.AddCommand(NewCmdRolloutRestart(f, streams)) cmd.AddCommand(NewCmdRolloutApprove(f, streams)) + cmd.AddCommand(NewCmdRolloutRollback(f, streams)) return cmd } diff --git a/pkg/cmd/rollout/rollout_approve.go b/pkg/cmd/rollout/rollout_approve.go index b9fbccc..0087554 100644 --- a/pkg/cmd/rollout/rollout_approve.go +++ b/pkg/cmd/rollout/rollout_approve.go @@ -128,7 +128,7 @@ func (o *ApproveOptions) Validate() error { } // RunApprove performs the execution of 'rollout approve' sub command -func (o ApproveOptions) RunApprove() error { +func (o *ApproveOptions) RunApprove() error { r := o.Builder(). WithScheme(internalapi.GetScheme(), scheme.Scheme.PrioritizedVersionsAllGroups()...). NamespaceParam(o.Namespace).DefaultNamespace(). diff --git a/pkg/cmd/rollout/rollout_rollback.go b/pkg/cmd/rollout/rollout_rollback.go new file mode 100644 index 0000000..cfb28e6 --- /dev/null +++ b/pkg/cmd/rollout/rollout_rollback.go @@ -0,0 +1,206 @@ +/* +Copyright 2025 The Kruise 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 rollout + +import ( + "fmt" + + internalapi "github.com/openkruise/kruise-tools/pkg/api" + "github.com/openkruise/kruise-tools/pkg/cmd/util" + internalpolymorphichelpers "github.com/openkruise/kruise-tools/pkg/internal/polymorphichelpers" + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/kubectl/pkg/cmd/set" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// RollbackOptions is the start of the data required to perform the operation. As new fields are added, add them here instead of +// referencing the cmd.Flags() +type RollbackOptions struct { + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinter, error) + + Resources []string + + Builder func() *resource.Builder + RollbackFunc internalpolymorphichelpers.RolloutRollbackFuncGetter + Namespace string + EnforceNamespace bool + + resource.FilenameOptions + genericclioptions.IOStreams +} + +var ( + rollbackLong = templates.LongDesc(` + Rollback a rollout to a previous step during release progress. + + **CAUTION** This command only works on rollouts version >= 0.6.0 + + You can rollback to a specific previous step using the --step flag. + If not specified, kubectl-kruise will look for a previous step that has no traffic and the most replicas to perform the rollback.`) + + rollbackExample = templates.Examples(` + # Rollback a kruise rollout named "rollout-demo" in "ns-demo" namespace + + kubectl-kruise rollout rollback rollout/rollout-demo -n ns-demo + + # Rollback a kruise rollout resource to a specific step + + kubectl-kruise rollout rollback rollout/rollout-demo --step 1`) + + targetStep int32 +) + +// NewRollbackOptions returns an initialized RollbackOptions instance +func NewRollbackOptions(streams genericclioptions.IOStreams) *RollbackOptions { + return &RollbackOptions{ + PrintFlags: genericclioptions.NewPrintFlags("rolled back").WithTypeSetter(internalapi.GetScheme()), + IOStreams: streams, + } +} + +// NewCmdRolloutRollback returns a Command instance for 'rollout rollback' sub command +func NewCmdRolloutRollback(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewRollbackOptions(streams) + + cmd := &cobra.Command{ + Use: "rollback RESOURCE", + DisableFlagsInUseLine: true, + Short: i18n.T("Rollback a resource"), + Long: rollbackLong, + Example: rollbackExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunRollback()) + }, + } + + usage := "identifying the resource to get from a server." + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + o.PrintFlags.AddFlags(cmd) + cmd.Flags().Int32Var(&targetStep, "step", -1, "Rollback to a specific previous step") + return cmd +} + +// Complete completes all the required options +func (o *RollbackOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + o.Resources = args + + o.RollbackFunc = internalpolymorphichelpers.RolloutRollbackGetter + + var err error + o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + return o.PrintFlags.ToPrinter() + } + + o.Builder = f.NewBuilder + + return nil +} + +func (o *RollbackOptions) Validate() error { + if len(o.Resources) == 0 && cmdutil.IsFilenameSliceEmpty(o.Filenames, o.Kustomize) { + return fmt.Errorf("required resource not specified") + } + return nil +} + +// RunRollback performs the execution of 'rollout rollback' sub command +func (o *RollbackOptions) RunRollback() error { + r := o.Builder(). + WithScheme(internalapi.GetScheme(), scheme.Scheme.PrioritizedVersionsAllGroups()...). + NamespaceParam(o.Namespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, &o.FilenameOptions). + ResourceTypeOrNameArgs(true, o.Resources...). + ContinueOnError(). + Latest(). + Flatten(). + Do() + if err := r.Err(); err != nil { + return err + } + + allErrs := []error{} + infos, err := r.Infos() + if err != nil { + // restore previous command behavior where + // an error caused by retrieving infos due to + // at least a single broken object did not result + // in an immediate return, but rather an overall + // aggregation of errors. + allErrs = append(allErrs, err) + } + + for _, patch := range set.CalculatePatches(infos, scheme.DefaultJSONEncoder(), o.RollbackFunc(targetStep)) { + info := patch.Info + + if patch.Err != nil { + resourceString := info.Mapping.Resource.Resource + if len(info.Mapping.Resource.Group) > 0 { + resourceString = resourceString + "." + info.Mapping.Resource.Group + } + allErrs = append(allErrs, fmt.Errorf("error: %s %q %v", resourceString, info.Name, patch.Err)) + continue + } + + if string(patch.Patch) == "{}" || len(patch.Patch) == 0 { + printer, err := o.ToPrinter("already rolled back") + if err != nil { + allErrs = append(allErrs, err) + continue + } + if err = printer.PrintObj(info.Object, o.Out); err != nil { + allErrs = append(allErrs, err) + } + continue + } + + obj, err := util.PatchSubResource(info.Client, info.Mapping.Resource.Resource, "status", info.Namespace, info.Name, info.Namespaced(), types.MergePatchType, patch.Patch, nil) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to patch: %v", err)) + continue + } + + info.Refresh(obj, true) + printer, err := o.ToPrinter("rolled back" + + "") + if err != nil { + allErrs = append(allErrs, err) + continue + } + if err = printer.PrintObj(info.Object, o.Out); err != nil { + allErrs = append(allErrs, err) + } + } + + return utilerrors.NewAggregate(allErrs) +} diff --git a/pkg/internal/polymorphichelpers/interface.go b/pkg/internal/polymorphichelpers/interface.go index 523e0b4..854846a 100644 --- a/pkg/internal/polymorphichelpers/interface.go +++ b/pkg/internal/polymorphichelpers/interface.go @@ -124,3 +124,9 @@ type ObjectRestarterFunc func(runtime.Object) ([]byte, error) // ObjectRestarterFn gives a way to easily override the function for unit testing if needed. // Returns the patched object in bytes and any error that occurred during the encoding. var ObjectRestarterFn ObjectRestarterFunc = defaultObjectRestarter + +// RolloutRollbackFuncGetter is a function type that rollbacks a rollout process. +type RolloutRollbackFuncGetter func(int32) func(runtime.Object) ([]byte, error) + +// RolloutRollbackGetter gives a way to easily override the function for unit testing if needed. +var RolloutRollbackGetter RolloutRollbackFuncGetter = rolloutRollbackGetter diff --git a/pkg/internal/polymorphichelpers/steps.go b/pkg/internal/polymorphichelpers/steps.go new file mode 100644 index 0000000..f5eadcf --- /dev/null +++ b/pkg/internal/polymorphichelpers/steps.go @@ -0,0 +1,79 @@ +package polymorphichelpers + +import ( + "fmt" + + "github.com/openkruise/kruise-rollout-api/rollouts/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/klog/v2" + "k8s.io/kubectl/pkg/scheme" +) + +func rolloutRollbackGetter(targetStep int32) func(runtime.Object) ([]byte, error) { + return func(obj runtime.Object) ([]byte, error) { + switch rollout := obj.(type) { + case *v1beta1.Rollout: + steps := rollout.Spec.Strategy.GetSteps() + curStep := rollout.Status.CurrentStepIndex + if len(steps) < int(curStep) { + return nil, fmt.Errorf("has %d steps, but current step is too large %d", len(steps), curStep) + } + if curStep <= 1 { + return nil, fmt.Errorf("already at the first step, use kubectl-kruise rollout undo to cancel the release") + } + if targetStep == -1 { + s, err := findPreviousStepWithNoTrafficAndMostReplicas(steps, rollout.Status.CurrentStepIndex) + if err != nil { + return nil, err + } + targetStep = s + } + if targetStep >= rollout.Status.CurrentStepIndex { + return nil, fmt.Errorf("specified step %d is not a previous step (current step is %d)", targetStep, rollout.Status.CurrentStepIndex) + } + style := rollout.Spec.Strategy.GetRollingStyle() + switch style { + case v1beta1.BlueGreenRollingStyle: + rollout.Status.BlueGreenStatus.NextStepIndex = targetStep + default: + // canary and partition + rollout.Status.CanaryStatus.NextStepIndex = targetStep + } + return runtime.Encode(scheme.Codecs.LegacyCodec(v1beta1.GroupVersion), rollout) + default: + return nil, fmt.Errorf("rollback is not supported on given object") + } + } +} + +func findPreviousStepWithNoTrafficAndMostReplicas(steps []v1beta1.CanaryStep, curStep int32) (int32, error) { + maxReplicas := 0 + var targetStep int32 = -1 + for i := curStep - 2; i >= 0; i-- { + step := steps[i] + if hasTraffic(step) { + klog.V(5).InfoS("has traffic", "step", i+1) + continue + } + replicas, _ := intstr.GetScaledValueFromIntOrPercent(step.Replicas, 100, true) + klog.V(5).InfoS("replicas percent", "percent", replicas, "step", i+1, "obj", step) + if replicas > maxReplicas { + maxReplicas = replicas + targetStep = i + 1 + } + } + if targetStep == -1 { + return 0, fmt.Errorf("no previous step with no traffic found") + } + return targetStep, nil +} + +func hasTraffic(step v1beta1.CanaryStep) bool { + if step.Traffic == nil { + return false + } + is := intstr.FromString(*step.Traffic) + trafficPercent, _ := intstr.GetScaledValueFromIntOrPercent(&is, 100, true) + return trafficPercent != 0 +} diff --git a/pkg/internal/polymorphichelpers/steps_test.go b/pkg/internal/polymorphichelpers/steps_test.go new file mode 100644 index 0000000..07316fb --- /dev/null +++ b/pkg/internal/polymorphichelpers/steps_test.go @@ -0,0 +1,162 @@ +package polymorphichelpers + +import ( + "encoding/json" + "testing" + + "github.com/openkruise/kruise-rollout-api/rollouts/v1alpha1" + "github.com/openkruise/kruise-rollout-api/rollouts/v1beta1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/kubectl/pkg/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func init() { + _ = v1beta1.AddToScheme(scheme.Scheme) +} + +func TestRolloutRollbackGetter(t *testing.T) { + getRollout := func(currentIdx int32, steps []v1beta1.CanaryStep) []client.Object { + canary := &v1beta1.Rollout{ + Status: v1beta1.RolloutStatus{ + CurrentStepIndex: currentIdx, + }, + } + canary.Spec.Strategy.Canary = &v1beta1.CanaryStrategy{ + Steps: steps, + EnableExtraWorkloadForCanary: true, + } + canary.Status.CanaryStatus = &v1beta1.CanaryStatus{} + + blueGreen := canary.DeepCopy() + blueGreen.Spec.Strategy.BlueGreen = &v1beta1.BlueGreenStrategy{ + Steps: steps, + } + blueGreen.Status.BlueGreenStatus = &v1beta1.BlueGreenStatus{} + + return []client.Object{canary, blueGreen} + } + newStep := func(replicas string, traffic string) v1beta1.CanaryStep { + r := intstr.FromString(replicas) + var trafficPtr *string + if traffic != "" { + trafficPtr = &traffic + } + return v1beta1.CanaryStep{ + Replicas: &r, + TrafficRoutingStrategy: v1beta1.TrafficRoutingStrategy{ + Traffic: trafficPtr, + }, + } + } + + tests := []struct { + name string + rollout []client.Object + targetStep int32 + expectedStep int32 + expectedErr string + }{ + { + name: "valid rollback to previous step", + rollout: getRollout(3, []v1beta1.CanaryStep{ + newStep("10%", ""), + newStep("20%", ""), + newStep("30%", ""), + }), + targetStep: 2, + expectedStep: 2, + }, + { + name: "invalid rollback to same or future step", + rollout: getRollout(3, []v1beta1.CanaryStep{ + newStep("10%", ""), + newStep("20%", ""), + newStep("30%", ""), + }), + targetStep: 3, + expectedErr: "specified step 3 is not a previous step (current step is 3)", + }, + { + name: "rollback to previous step with no traffic and most replicas", + rollout: getRollout(4, []v1beta1.CanaryStep{ + newStep("10%", ""), + newStep("20%", ""), + newStep("30%", "50%"), + newStep("40%", "50%"), + }), + targetStep: -1, + expectedStep: 2, + }, + { + name: "no previous step with no traffic found", + rollout: getRollout(5, []v1beta1.CanaryStep{ + newStep("10%", "10%"), + newStep("20%", "10%"), + newStep("30%", "10%"), + newStep("40%", "10%"), + newStep("50%", "10%"), + }), + targetStep: -1, + expectedErr: "no previous step with no traffic found", + }, + { + name: "already at the first step", + rollout: getRollout(1, []v1beta1.CanaryStep{ + newStep("10%", ""), + }), + targetStep: 2, + expectedErr: "already at the first step, use kubectl-kruise rollout undo to cancel the release", + }, + { + name: "current step index out of range", + rollout: getRollout(3, []v1beta1.CanaryStep{ + newStep("10%", ""), + newStep("20%", ""), + }), + targetStep: 2, + expectedErr: "has 2 steps, but current step is too large 3", + }, + { + name: "not supported object", + rollout: []client.Object{&v1alpha1.Rollout{}}, + expectedErr: "rollback is not supported on given object", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rollback := rolloutRollbackGetter(tt.targetStep) + for _, rollout := range tt.rollout { + data, err := rollback(rollout) + if tt.expectedErr != "" { + if err == nil || err.Error() != tt.expectedErr { + t.Errorf("expected error %v, got %v", tt.expectedErr, err) + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + var updatedRollout v1beta1.Rollout + if err := json.Unmarshal(data, &updatedRollout); err != nil { + t.Errorf("failed to unmarshal updated rollout: %v", err) + return + } + + var nextStepIndex int32 + if updatedRollout.Spec.Strategy.GetRollingStyle() == v1beta1.BlueGreenRollingStyle { + nextStepIndex = updatedRollout.Status.BlueGreenStatus.NextStepIndex + } else { + nextStepIndex = updatedRollout.Status.CanaryStatus.NextStepIndex + } + + if nextStepIndex != tt.expectedStep { + t.Errorf("expected next step index %d, got %d", tt.expectedStep, nextStepIndex) + } + } + }) + } +}