From 841edffab4441740a4edc93badaf44675a7a6ad7 Mon Sep 17 00:00:00 2001 From: Arvinderpal Wander Date: Wed, 21 Oct 2020 09:31:40 -0700 Subject: [PATCH] Add command and client for `clusterctl alpha rollout`. --- cmd/clusterctl/client/client.go | 9 ++ cmd/clusterctl/client/rollout.go | 72 +++++++++++++ cmd/clusterctl/client/rollout/restarter.go | 102 ++++++++++++++++++ cmd/clusterctl/cmd/alpha.go | 35 ++++++ cmd/clusterctl/cmd/rollout.go | 61 +++++++++++ cmd/clusterctl/cmd/rollout/restart.go | 85 +++++++++++++++ .../internal/util/resource_tuples.go | 87 +++++++++++++++ .../internal/util/resource_tuples_test.go | 86 +++++++++++++++ 8 files changed, 537 insertions(+) create mode 100644 cmd/clusterctl/client/rollout.go create mode 100644 cmd/clusterctl/client/rollout/restarter.go create mode 100644 cmd/clusterctl/cmd/alpha.go create mode 100644 cmd/clusterctl/cmd/rollout.go create mode 100644 cmd/clusterctl/cmd/rollout/restart.go create mode 100644 cmd/clusterctl/internal/util/resource_tuples.go create mode 100644 cmd/clusterctl/internal/util/resource_tuples_test.go diff --git a/cmd/clusterctl/client/client.go b/cmd/clusterctl/client/client.go index 496ef1c21e88..1def30e690e6 100644 --- a/cmd/clusterctl/client/client.go +++ b/cmd/clusterctl/client/client.go @@ -65,6 +65,15 @@ type Client interface { // ProcessYAML provides a direct way to process a yaml and inspect its // variables. ProcessYAML(options ProcessYAMLOptions) (YamlPrinter, error) + + // Interface for alpha features in clusterctl + AlphaClient +} + +// AlphaClient exposes the alpha features in clusterctl high-level client library. +type AlphaClient interface { + // RolloutRestart provides rollout restart of cluster-api resources + RolloutRestart(options RolloutRestartOptions) error } // YamlPrinter exposes methods that prints the processed template and diff --git a/cmd/clusterctl/client/rollout.go b/cmd/clusterctl/client/rollout.go new file mode 100644 index 000000000000..61e6303f2844 --- /dev/null +++ b/cmd/clusterctl/client/rollout.go @@ -0,0 +1,72 @@ +/* +Copyright 2020 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 client + +import ( + "fmt" + + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/rollout" + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/util" +) + +var validResourceTypes = []string{"machinedeployment"} + +// RolloutRestartOptions carries the options supported by rollout restart. +type RolloutRestartOptions struct { + // Kubeconfig defines the kubeconfig to use for accessing the management cluster. If empty, + // default rules for kubeconfig discovery will be used. + Kubeconfig Kubeconfig + + // Resources to be rollout restarted. + Resources []string + + // Namespace where the resource(s) live. If unspecified, the namespace name will be inferred + // from the current configuration. + Namespace string +} + +func (c *clusterctlClient) RolloutRestart(options RolloutRestartOptions) error { + clusterClient, err := c.clusterClientFactory(ClusterClientFactoryInput{Kubeconfig: options.Kubeconfig}) + if err != nil { + return err + } + + // If the option specifying the Namespace is empty, try to detect it. + if options.Namespace == "" { + currentNamespace, err := clusterClient.Proxy().CurrentNamespace() + if err != nil { + return err + } + options.Namespace = currentNamespace + } + + if len(options.Resources) == 0 { + return fmt.Errorf("required resource not specified") + } + // TODO: normailzie options.Resources to all lowercase. + tuples, err := util.ResourceTypeAndNameArgs(options.Resources...) + if err != nil { + return err + } + + for _, t := range tuples { + if err := rollout.ObjectRestarter(clusterClient.Proxy(), t, options.Namespace); err != nil { + return err + } + } + return nil +} diff --git a/cmd/clusterctl/client/rollout/restarter.go b/cmd/clusterctl/client/rollout/restarter.go new file mode 100644 index 000000000000..e6ffcf4f0269 --- /dev/null +++ b/cmd/clusterctl/client/rollout/restarter.go @@ -0,0 +1,102 @@ +/* +Copyright 2020 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 rollout + +import ( + "context" + "fmt" + "time" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/types" + clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha3" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/cluster" + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/util" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var validResourceTypes = []string{"machinedeployment"} + +func ObjectRestarter(proxy cluster.Proxy, t util.ResourceTuple, namespace string) error { + switch t.Resource { + case "machinedeployment": + mdObj := &clusterv1.MachineDeployment{} + if err := getMachineDeployment(proxy, t.Name, namespace, mdObj); err != nil { + return errors.Wrapf(err, "failed to fetch %v/%v", t.Resource, t.Name) + } + if mdObj.Spec.Paused { + fmt.Printf("can't restart paused machinedeployment (run rollout resume first): %v/%v\n", t.Resource, t.Name) + return nil + } + if err := setRestartedAtAnnotation(proxy, t.Name, namespace); err != nil { + return err + } + case "kubeadmcontrolplane": + return fmt.Errorf("restarting is not supported") + default: + return errors.Errorf("Invalid resource type %v. Valid values: %v", t.Resource, validResourceTypes) + } + return nil +} + +// getMachineDeployment retrieves the MachineDeployment object corresponding to the name and namespace specified. +func getMachineDeployment(proxy cluster.Proxy, name, namespace string, mdObj *clusterv1.MachineDeployment) error { + c, err := proxy.NewClient() + if err != nil { + return err + } + mdObjKey := client.ObjectKey{ + Namespace: namespace, + Name: name, + } + if err := c.Get(context.TODO(), mdObjKey, mdObj); err != nil { + return errors.Wrapf(err, "error reading %q %s/%s", + mdObj.GroupVersionKind(), mdObj.GetNamespace(), mdObj.GetName()) + } + return nil +} + +// setRestartedAtAnnotation sets the restartedAt annotation in the MachineDeployment's spec.template.objectmeta. +func setRestartedAtAnnotation(proxy cluster.Proxy, name, namespace string) error { + patch := client.RawPatch(types.MergePatchType, []byte(fmt.Sprintf("{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"cluster.x-k8s.io/restartedAt\":\"%v\"}}}}}", time.Now().Format(time.RFC3339)))) + data, _ := patch.Data(nil) + fmt.Printf("patch: %v\n", string(data)) + return patchMachineDeployemt(proxy, name, namespace, patch) +} + +// patchMachineDeployemt applies a patch to a machinedeployment +func patchMachineDeployemt(proxy cluster.Proxy, name, namespace string, patch client.Patch) error { + cFrom, err := proxy.NewClient() + if err != nil { + return err + } + mdObj := &clusterv1.MachineDeployment{} + mdObjKey := client.ObjectKey{ + Namespace: namespace, + Name: name, + } + if err := cFrom.Get(context.TODO(), mdObjKey, mdObj); err != nil { + return errors.Wrapf(err, "error reading %q %s/%s", + mdObj.GroupVersionKind(), mdObj.GetNamespace(), mdObj.GetName()) + } + + if err := cFrom.Patch(context.TODO(), mdObj, patch); err != nil { + return errors.Wrapf(err, "error while patching %q %s/%s", + mdObj.GroupVersionKind(), mdObj.GetNamespace(), mdObj.GetName()) + } + return nil +} diff --git a/cmd/clusterctl/cmd/alpha.go b/cmd/clusterctl/cmd/alpha.go new file mode 100644 index 000000000000..f83122e663a3 --- /dev/null +++ b/cmd/clusterctl/cmd/alpha.go @@ -0,0 +1,35 @@ +/* +Copyright 2020 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 cmd + +import ( + "github.com/spf13/cobra" +) + +var alphaCmd = &cobra.Command{ + Use: "alpha", + Short: "Commands for features in alpha.", + Long: `These commands correspond to alpha features in clusterctl.`, +} + +func init() { + + // Alpha commands should be added here. + alphaCmd.AddCommand(rolloutCmd) + + RootCmd.AddCommand(alphaCmd) +} diff --git a/cmd/clusterctl/cmd/rollout.go b/cmd/clusterctl/cmd/rollout.go new file mode 100644 index 000000000000..21f8959adfa2 --- /dev/null +++ b/cmd/clusterctl/cmd/rollout.go @@ -0,0 +1,61 @@ +/* +Copyright 2020 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 cmd + +import ( + "github.com/lithammer/dedent" + "github.com/spf13/cobra" + "sigs.k8s.io/cluster-api/cmd/clusterctl/cmd/rollout" +) + +var ( + rolloutLong = LongDesc(` + Manage the rollout of a cluster-api resource.` + rolloutValidResources) + + rolloutExample = Examples(` + # Force an immediate rollout of machinedeployment + clusterctl alpha rollout restart machinedeployment/my-md-0 + + # Rollback to the previous revision of a machinedeployment + clusterctl alpha rollout undo machinedeployment/my-md-0 + + # Check the rollout status of a machinedeployment + clusterctl alpha rollout status machinedeployment/my-md-0`) + + rolloutValidResources = dedent.Dedent(` + Valid resource types include: + + * machinedeployments + `) + + rolloutCmd = &cobra.Command{ + Use: "rollout SUBCOMMAND", + Short: "Manage the rollout of a cluster-api resource", + Long: rolloutLong, + Example: rolloutExample, + } +) + +func init() { + // subcommands + rolloutCmd.AddCommand(rollout.NewCmdRolloutRestart(cfgFile)) + // rolloutCmd.AddCommand(rolloutHistoryCmd) + // rolloutCmd.AddCommand(rolloutPauseCmd) + // rolloutCmd.AddCommand(rolloutResumeCmd) + // rolloutCmd.AddCommand(rolloutUndoCmd) + // rolloutCmd.AddCommand(rolloutStatusCmd) +} diff --git a/cmd/clusterctl/cmd/rollout/restart.go b/cmd/clusterctl/cmd/rollout/restart.go new file mode 100644 index 000000000000..80e8907c5b8d --- /dev/null +++ b/cmd/clusterctl/cmd/rollout/restart.go @@ -0,0 +1,85 @@ +/* +Copyright 2020 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 rollout + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client" +) + +// restartOptions is the start of the data required to perform the operation. +type restartOptions struct { + kubeconfig string + kubeconfigContext string + resources []string + namespace string +} + +var ro = &restartOptions{} + +var ( + restartLong = templates.LongDesc(` + Restart of cluser-api resources. + + Resources will be rollout restarted.`) + + restartExample = templates.Examples(` + # Restart a machinedeployment + clusterctl alpha rollout restart machinedeployment/my-md-0`) +) + +// NewCmdRolloutRestart returns a Command instance for 'rollout restart' sub command +func NewCmdRolloutRestart(cfgFile string) *cobra.Command { + + cmd := &cobra.Command{ + Use: "restart RESOURCE", + DisableFlagsInUseLine: true, + Short: "Restart a cluster-api resource", + Long: restartLong, + Example: restartExample, + RunE: func(cmd *cobra.Command, args []string) error { + return runRestart(cfgFile, cmd, args) + }, + } + cmd.Flags().StringVar(&ro.kubeconfig, "kubeconfig", "", + "Path to the kubeconfig file to use for accessing the management cluster. If unspecified, default discovery rules apply.") + cmd.Flags().StringVar(&ro.kubeconfigContext, "kubeconfig-context", "", + "Context to be used within the kubeconfig file. If empty, current context will be used.") + cmd.Flags().StringVar(&ro.namespace, "namespace", "n", + "Namespace where the resource(s) reside. If unspecified, the defult namespace will be used.") + + return cmd +} + +func runRestart(cfgFile string, cmd *cobra.Command, args []string) error { + ro.resources = args + + c, err := client.New(cfgFile) + if err != nil { + return err + } + + if err := c.RolloutRestart(client.RolloutRestartOptions{ + Kubeconfig: client.Kubeconfig{Path: ro.kubeconfig, Context: ro.kubeconfigContext}, + Namespace: ro.namespace, + Resources: ro.resources, + }); err != nil { + return err + } + return nil +} diff --git a/cmd/clusterctl/internal/util/resource_tuples.go b/cmd/clusterctl/internal/util/resource_tuples.go new file mode 100644 index 000000000000..a4792e4b4e78 --- /dev/null +++ b/cmd/clusterctl/internal/util/resource_tuples.go @@ -0,0 +1,87 @@ +/* +Copyright 2020 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 util + +import ( + "fmt" + "os" + "strings" +) + +type ResourceTuple struct { + Resource string + Name string +} + +// Accepts arguments in resource/name form (e.g. 'resource/'). +func ResourceTypeAndNameArgs(args ...string) ([]ResourceTuple, error) { + var tuples []ResourceTuple + if ok, err := hasCombinedTypeArgs(args); ok { + if err != nil { + return tuples, err + } + for _, s := range args { + t, ok, err := splitResourceTypeName(s) + if err != nil { + return tuples, err + } + if ok { + tuples = append(tuples, t) + } + } + } + return tuples, nil +} + +func hasCombinedTypeArgs(args []string) (bool, error) { + hasSlash := 0 + for _, s := range args { + if strings.Contains(s, "/") { + hasSlash++ + } + } + switch { + case hasSlash > 0 && hasSlash == len(args): + return true, nil + case hasSlash > 0 && hasSlash != len(args): + baseCmd := "cmd" + if len(os.Args) > 0 { + baseCmdSlice := strings.Split(os.Args[0], "/") + baseCmd = baseCmdSlice[len(baseCmdSlice)-1] + } + return true, fmt.Errorf("there is no need to specify a resource type as a separate argument when passing arguments in resource/name form (e.g. '%s get resource/' instead of '%s get resource resource/'", baseCmd, baseCmd) + default: + return false, nil + } +} + +// splitResourceTypeName handles type/name resource formats and returns a resource tuple +// (empty or not), whether it successfully found one, and an error +func splitResourceTypeName(s string) (ResourceTuple, bool, error) { + if !strings.Contains(s, "/") { + return ResourceTuple{}, false, nil + } + seg := strings.Split(s, "/") + if len(seg) != 2 { + return ResourceTuple{}, false, fmt.Errorf("arguments in resource/name form may not have more than one slash") + } + resource, name := seg[0], seg[1] + if len(resource) == 0 || len(name) == 0 { + return ResourceTuple{}, false, fmt.Errorf("arguments in resource/name form must have a single resource and name") + } + return ResourceTuple{Resource: resource, Name: name}, true, nil +} diff --git a/cmd/clusterctl/internal/util/resource_tuples_test.go b/cmd/clusterctl/internal/util/resource_tuples_test.go new file mode 100644 index 000000000000..e70ae92d37ca --- /dev/null +++ b/cmd/clusterctl/internal/util/resource_tuples_test.go @@ -0,0 +1,86 @@ +/* +Copyright 2020 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 util + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestResourceTypeAndNameArgs(t *testing.T) { + + tests := []struct { + name string + args []string + want []ResourceTuple + wantErr bool + }{ + { + name: "valid", + args: []string{"machinedeployment/foo"}, + want: []ResourceTuple{ + { + Resource: "machinedeployment", + Name: "foo", + }, + }, + wantErr: false, + }, + { + name: "valid multiple with name indirection", + args: []string{"machinedeployment/foo", "machinedeployment/bar"}, + want: []ResourceTuple{ + { + Resource: "machinedeployment", + Name: "foo", + }, + { + Resource: "machinedeployment", + Name: "bar", + }, + }, + wantErr: false, + }, + { + name: "trailing slash", + args: []string{",foo/"}, + wantErr: true, + }, + { + name: "leading slash", + args: []string{"/foo"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + got, err := ResourceTypeAndNameArgs(tt.args...) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(len(got)).To(Equal(len(tt.want))) + for i, _ := range got { + g.Expect(got[i].Resource).To(Equal(tt.want[i].Resource)) + g.Expect(got[i].Name).To(Equal(tt.want[i].Name)) + } + }) + } +}