diff --git a/pkg/cli/cmd/root.go b/pkg/cli/cmd/root.go index 76c1dc4858..21c381cf8b 100644 --- a/pkg/cli/cmd/root.go +++ b/pkg/cli/cmd/root.go @@ -23,6 +23,7 @@ import ( "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/cli/cmd/bulkexport/parameters" "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/cli/log" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/cli/powertools" "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/execution" tfversion "github.com/hashicorp/terraform-provider-google-beta/version" @@ -57,6 +58,9 @@ func init() { rootCmd.AddCommand(printResourcesCmd) rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(applyCmd) + + powertools.AddCommands(rootCmd) + rootCmd.SilenceErrors = true } diff --git a/pkg/cli/powertools/cmd.go b/pkg/cli/powertools/cmd.go new file mode 100644 index 0000000000..fdaa3a23c9 --- /dev/null +++ b/pkg/cli/powertools/cmd.go @@ -0,0 +1,31 @@ +// Copyright 2024 Google LLC +// +// 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 powertools + +import ( + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/cli/powertools/forcesetfield" + "github.com/spf13/cobra" +) + +func AddCommands(parent *cobra.Command) { + powertoolsCmd := &cobra.Command{ + Use: "powertools", + Short: "Powertools holds our experimental / dangerous tools", + Args: cobra.NoArgs, + } + parent.AddCommand(powertoolsCmd) + + forcesetfield.AddCommand(powertoolsCmd) +} diff --git a/pkg/cli/powertools/diffs/fieldpath.go b/pkg/cli/powertools/diffs/fieldpath.go new file mode 100644 index 0000000000..9c9b0226a0 --- /dev/null +++ b/pkg/cli/powertools/diffs/fieldpath.go @@ -0,0 +1,42 @@ +// Copyright 2024 Google LLC +// +// 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 diffs + +type FieldPath struct { + parent *FieldPath + part string +} + +func (f *FieldPath) With(part string) *FieldPath { + return &FieldPath{parent: f, part: part} +} + +func (f *FieldPath) asSlice() []string { + n := 0 + pos := f + for pos != nil { + pos = pos.parent + n++ + } + ret := make([]string, n) + i := n - 1 + pos = f + for i >= 0 { + ret[i] = pos.part + pos = pos.parent + i-- + } + return ret +} diff --git a/pkg/cli/powertools/diffs/objectdiff.go b/pkg/cli/powertools/diffs/objectdiff.go new file mode 100644 index 0000000000..32ed932369 --- /dev/null +++ b/pkg/cli/powertools/diffs/objectdiff.go @@ -0,0 +1,185 @@ +// Copyright 2024 Google LLC +// +// 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 diffs + +import ( + "fmt" + "io" + "reflect" + "sort" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/klog/v2" +) + +type ObjectDiff struct { + OldObject *unstructured.Unstructured `json:"oldObject"` + NewObject *unstructured.Unstructured `json:"newObject"` + + fieldDiffs []fieldDiff +} + +type fieldDiff struct { + Path *FieldPath `json:"path"` + OldValue any `json:"oldValue,omitempty"` + NewValue any `json:"newValue,omitempty"` +} + +func (d *ObjectDiff) walkMap(oldObj, newObj map[string]any, fieldPath *FieldPath) { + for k, oldValue := range oldObj { + newValue := newObj[k] + d.walkAny(oldValue, newValue, fieldPath.With(k)) + } + for k, newValue := range newObj { + oldValue, found := oldObj[k] + if found { + // Dealt with in previous loop + continue + } + d.walkAny(oldValue, newValue, fieldPath.With(k)) + } +} + +func (d *ObjectDiff) walkSlice(oldSlice, newSlice []any, fieldPath *FieldPath) { + minLen := min(len(oldSlice), len(newSlice)) + for i := 0; i < minLen; i++ { + oldValue := newSlice[i] + newValue := newSlice[i] + d.walkAny(oldValue, newValue, fieldPath.With(fmt.Sprintf("[%d]", i))) + } + for i := minLen; i < len(newSlice); i++ { + newValue := newSlice[i] + d.walkAny(nil, newValue, fieldPath.With(fmt.Sprintf("[%d]", i))) + } + for i := minLen; i < len(oldSlice); i++ { + oldValue := newSlice[i] + d.walkAny(oldValue, nil, fieldPath.With(fmt.Sprintf("[%d]", i))) + } +} + +func (d *ObjectDiff) walkAny(oldVal, newVal any, fieldPath *FieldPath) { + addDiff := true + + switch oldVal := oldVal.(type) { + case map[string]any: + newMap, ok := newVal.(map[string]any) + if ok { + d.walkMap(oldVal, newMap, fieldPath) + addDiff = false + } + + case []any: + newSlice, ok := newVal.([]any) + if ok { + d.walkSlice(oldVal, newSlice, fieldPath) + addDiff = false + } + + case string, int64, bool: + if reflect.DeepEqual(oldVal, newVal) { + addDiff = false + } + + default: + klog.Warningf("type %T not handled", oldVal) + } + + if addDiff { + d.fieldDiffs = append(d.fieldDiffs, fieldDiff{Path: fieldPath, OldValue: oldVal, NewValue: newVal}) + } +} + +func BuildObjectDiff(oldObj, newObj *unstructured.Unstructured) (*ObjectDiff, error) { + d := &ObjectDiff{ + OldObject: oldObj, + NewObject: newObj, + } + d.walkMap(oldObj.Object, newObj.Object, nil) + return d, nil +} + +type prettyPrintFieldPath struct { + fieldDiff + keyPath []string +} + +type PrettyPrintOptions struct { + PrintObjectInfo bool + Indent string +} + +func (d *ObjectDiff) PrettyPrintTo(options PrettyPrintOptions, out io.Writer) { + diffs := d.sortFieldPaths() + + fieldIndent := options.Indent + + if options.PrintObjectInfo { + indent := options.Indent + info := fmt.Sprintf("%s %s/%s", d.NewObject.GroupVersionKind().Kind, d.NewObject.GetNamespace(), d.NewObject.GetName()) + fmt.Fprintf(out, "%s%s:\n", indent, info) + + // Indent fields under object info + fieldIndent += " " + } + + var previousKeyPath []string + if len(diffs) == 0 { + fmt.Fprintf(out, "%s(no changes)\n", fieldIndent) + } + for _, diff := range diffs { + indent := fieldIndent + n := min(len(previousKeyPath), len(diff.keyPath)) + i := 0 + for i < n { + if previousKeyPath[i] != diff.keyPath[i] { + break + } + + indent += " " + i++ + } + for ; i < len(diff.keyPath)-1; i++ { + fmt.Fprintf(out, "%s%s:\n", indent, diff.keyPath[i]) + indent += " " + } + fmt.Fprintf(out, "%s%s: %v -> %v\n", indent, diff.keyPath[len(diff.keyPath)-1], diff.OldValue, diff.NewValue) + previousKeyPath = diff.keyPath + } +} + +func (d *ObjectDiff) PrintStructuredTo(out io.Writer) { + diffs := d.sortFieldPaths() + + for _, diff := range diffs { + fmt.Fprintf(out, "%s: %v -> %v\n", strings.Join(diff.keyPath, "."), diff.OldValue, diff.NewValue) + } +} + +func (d *ObjectDiff) sortFieldPaths() []prettyPrintFieldPath { + var diffs []prettyPrintFieldPath + for _, diff := range d.fieldDiffs { + diffs = append(diffs, prettyPrintFieldPath{ + fieldDiff: diff, + keyPath: diff.Path.asSlice(), + }) + } + + sort.Slice(diffs, func(i, j int) bool { + return compareStringSlices(diffs[i].keyPath, diffs[j].keyPath) < 0 + }) + + return diffs +} diff --git a/pkg/cli/powertools/diffs/utils.go b/pkg/cli/powertools/diffs/utils.go new file mode 100644 index 0000000000..903ba90655 --- /dev/null +++ b/pkg/cli/powertools/diffs/utils.go @@ -0,0 +1,47 @@ +// Copyright 2024 Google LLC +// +// 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 diffs + +import ( + "cmp" + "strings" +) + +func compareStringSlices(l []string, r []string) int { + lLen := len(l) + rLen := len(r) + minLen := min(lLen, rLen) + for i := 0; i < minLen; i++ { + lv := l[i] + rv := r[i] + if x := strings.Compare(lv, rv); x != 0 { + return x + } + } + if lLen < rLen { + return -1 + } + if rLen > lLen { + return 1 + } + return 0 +} + +func min[T cmp.Ordered](l, r T) T { + if l < r { + return l + } + return r +} diff --git a/pkg/cli/powertools/forcesetfield/cmd.go b/pkg/cli/powertools/forcesetfield/cmd.go new file mode 100644 index 0000000000..3b63866072 --- /dev/null +++ b/pkg/cli/powertools/forcesetfield/cmd.go @@ -0,0 +1,173 @@ +// Copyright 2024 Google LLC +// +// 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 forcesetfield + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/cli/powertools/diffs" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/cli/powertools/kubecli" + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Options configures the behaviour of the ChangeStateIntoSpec operation. +type Options struct { + kubecli.ClusterOptions + kubecli.ObjectOptions + + // FieldManager is the field-manager owner value to use when making changes + FieldManager string + + // DryRun is true if we should not actually make changes, just print the changes we would make + DryRun bool +} + +func (o *Options) PopulateDefaults() { + o.ClusterOptions.PopulateDefaults() + o.ObjectOptions.PopulateDefaults() + + o.FieldManager = "change-state-into-spec" + o.DryRun = false +} + +func AddCommand(parent *cobra.Command) { + var options Options + options.PopulateDefaults() + + cmd := &cobra.Command{ + Use: "force-set-field KIND NAME FIELD.PATH=VALUE", + Short: "Sets a field on a KCC object, even immutable fields (experimental)", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if len(args) >= 1 { + options.Kind = args[0] + } + if len(args) >= 2 { + options.Name = args[1] + } + + setFields := map[string]string{} + if len(args) >= 3 { + for i := 2; i < len(args); i++ { + tokens := strings.SplitN(args[i], "=", 2) + if len(tokens) < 2 { + return fmt.Errorf("expected spec.path=value, got %q", args[i]) + } + k := tokens[0] + v := tokens[1] + setFields[k] = v + } + } + + return Run(ctx, cmd.OutOrStdout(), options, setFields) + }, + Args: cobra.ArbitraryArgs, + } + + options.ObjectOptions.AddFlags(cmd) + options.ClusterOptions.AddFlags(cmd) + + cmd.Flags().BoolVar(&options.DryRun, "dry-run", options.DryRun, "dry-run mode will not make changes, but only print the changes it would make") + + parent.AddCommand(cmd) +} + +func Run(ctx context.Context, out io.Writer, options Options, setFields map[string]string) error { + // log := klog.FromContext(ctx) + + // Impersonate the KCC service account, which is allowed to make changes + // TODO: Make this configurable - maybe it only works in namespaced mode? + options.ClusterOptions.Impersonate = &rest.ImpersonationConfig{ + UserName: "system:serviceaccount:cnrm-system:cnrm-controller-manager-" + options.Namespace, + Groups: []string{"system:serviceaccounts", "system:serviceaccounts:cnrm-system"}, + } + + kubeClient, err := kubecli.NewClient(ctx, options.ClusterOptions) + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + u, err := kubeClient.GetObject(ctx, options.ObjectOptions) + if err != nil { + return fmt.Errorf("getting object: %w", err) + } + + originalObject := u.DeepCopy() + + for k, v := range setFields { + if err := setField(ctx, u, k, v); err != nil { + return fmt.Errorf("setting field %q to %q: %w", k, v, err) + } + + } + diff, err := diffs.BuildObjectDiff(originalObject, u) + if err != nil { + return fmt.Errorf("building object diff: %w", err) + } + + fmt.Fprintf(out, "\n\n") + printOpts := diffs.PrettyPrintOptions{PrintObjectInfo: true, Indent: " "} + diff.PrettyPrintTo(printOpts, out) + fmt.Fprintf(out, "\n\n") + + if options.DryRun { + fmt.Fprintf(out, "dry-run mode, not making changes\n") + return nil + } + + fmt.Fprintf(out, "applying changes\n") + if err := kubeClient.Update(ctx, u, client.FieldOwner(options.FieldManager)); err != nil { + return fmt.Errorf("updating object: %w", err) + } + + return nil +} + +func setField(ctx context.Context, u *unstructured.Unstructured, fieldPath string, newValue any) error { + // log := klog.FromContext(ctx) + + elements := strings.Split(fieldPath, ".") + + pos := u.Object + n := len(elements) + for i := 0; i < n-1; i++ { + element := elements[i] + + v, found := pos[element] + if !found { + v = make(map[string]any) + pos[element] = v + } + m, ok := v.(map[string]any) + if !ok { + return fmt.Errorf("unexpected type for %q: got %T, expected object", fieldPath, v) + } + pos = m + } + + last := elements[n-1] + + // TODO: What about things that aren't strings? + pos[last] = newValue + + return nil +} diff --git a/pkg/cli/powertools/kubecli/client.go b/pkg/cli/powertools/kubecli/client.go new file mode 100644 index 0000000000..da012663a0 --- /dev/null +++ b/pkg/cli/powertools/kubecli/client.go @@ -0,0 +1,198 @@ +// Copyright 2024 Google LLC +// +// 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 kubecli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/discovery" + diskcached "k8s.io/client-go/discovery/cached/disk" + "k8s.io/client-go/rest" + "k8s.io/client-go/util/homedir" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +type Client struct { + client.Client + DiscoveryClient discovery.DiscoveryInterface +} + +func NewClient(ctx context.Context, options ClusterOptions) (*Client, error) { + restConfig, err := config.GetConfig() + if err != nil { + return nil, fmt.Errorf("getting kubernetes configuration: %w", err) + } + + if options.Impersonate != nil { + restConfig.Impersonate = *options.Impersonate + } + + httpClient, err := rest.HTTPClientFor(restConfig) + if err != nil { + return nil, fmt.Errorf("building kubernetes http client: %w", err) + } + + kubeClient, err := client.New(restConfig, client.Options{ + HTTPClient: httpClient, + }) + if err != nil { + return nil, fmt.Errorf("building kubernetes client: %w", err) + } + + discoveryClient, err := buildDiscoveryClient(ctx, restConfig) + // discoveryClient, err := discovery.NewDiscoveryClientForConfigAndClient(restConfig, httpClient) + if err != nil { + return nil, fmt.Errorf("building discovery client: %w", err) + } + + return &Client{ + DiscoveryClient: discoveryClient, + Client: kubeClient, + }, nil +} + +func buildDiscoveryClient(ctx context.Context, restConfig *rest.Config) (discovery.DiscoveryInterface, error) { + // Based on toDiscoveryClient in https://github.com/kubernetes/kubernetes/blob/v1.30.0-alpha.0/staging/src/k8s.io/cli-runtime/pkg/genericclioptions/config_flags.go + + config := *restConfig + + // config.Burst = f.discoveryBurst + // config.QPS = f.discoveryQPS + + cacheDir := getDefaultCacheDir() + + // // retrieve a user-provided value for the "cache-dir" + // // override httpCacheDir and discoveryCacheDir if user-value is given. + // // user-provided value has higher precedence than default + // // and KUBECACHEDIR environment variable. + // if f.CacheDir != nil && *f.CacheDir != "" && *f.CacheDir != getDefaultCacheDir() { + // cacheDir = *f.CacheDir + // } + + httpCacheDir := filepath.Join(cacheDir, "http") + discoveryCacheDir := computeDiscoverCacheDir(filepath.Join(cacheDir, "discovery"), config.Host) + + return diskcached.NewCachedDiscoveryClientForConfig(&config, discoveryCacheDir, httpCacheDir, time.Duration(6*time.Hour)) +} + +// overlyCautiousIllegalFileCharacters matches characters that *might* not be supported. Windows is really restrictive, so this is really restrictive +var overlyCautiousIllegalFileCharacters = regexp.MustCompile(`[^(\w/.)]`) + +// computeDiscoverCacheDir takes the parentDir and the host and comes up with a "usually non-colliding" name. +func computeDiscoverCacheDir(parentDir, host string) string { + // strip the optional scheme from host if its there: + schemelessHost := strings.Replace(strings.Replace(host, "https://", "", 1), "http://", "", 1) + // now do a simple collapse of non-AZ09 characters. Collisions are possible but unlikely. Even if we do collide the problem is short lived + safeHost := overlyCautiousIllegalFileCharacters.ReplaceAllString(schemelessHost, "_") + return filepath.Join(parentDir, safeHost) +} + +// getDefaultCacheDir returns default caching directory path. +// it first looks at KUBECACHEDIR env var if it is set, otherwise +// it returns standard kube cache dir. +func getDefaultCacheDir() string { + if kcd := os.Getenv("KUBECACHEDIR"); kcd != "" { + return kcd + } + + return filepath.Join(homedir.HomeDir(), ".kube", "cache") +} + +func (c *Client) GetObject(ctx context.Context, options ObjectOptions) (*unstructured.Unstructured, error) { + if options.Kind == "" { + return nil, fmt.Errorf("must specify object kind to target") + } + + if options.Name == "" { + return nil, fmt.Errorf("must specify object name to target") + } + + if options.Namespace == "" { + return nil, fmt.Errorf("must specify object namespace to target") + } + + resources, err := c.DiscoveryClient.ServerPreferredResources() + if err != nil { + return nil, fmt.Errorf("discovering server resources: %w", err) + } + + var matches []metav1.APIResource + for _, group := range resources { + for _, resource := range group.APIResources { + match := false + if strings.EqualFold(resource.Kind, options.Kind) { + match = true + } + if strings.EqualFold(resource.Name, options.Kind) { + match = true + } + if strings.EqualFold(resource.SingularName, options.Kind) { + match = true + } + for _, shortName := range resource.ShortNames { + if strings.EqualFold(shortName, options.Kind) { + match = true + } + } + if match { + gv, err := schema.ParseGroupVersion(group.GroupVersion) + if err != nil { + return nil, fmt.Errorf("parsing group version %q: %w", group.GroupVersion, err) + } + + // populate the group and version + r := resource + r.Group = gv.Group + r.Version = gv.Version + + matches = append(matches, r) + } + } + } + if len(matches) == 0 { + return nil, fmt.Errorf("did not find any kubernetes kinds for %q", options.Kind) + } + if len(matches) > 1 { + // TODO: Print fully-qualified names + return nil, fmt.Errorf("found multiple kubernetes kind for %q", options.Kind) + } + resource := matches[0] + + gvk := schema.GroupVersionKind{Group: resource.Group, Version: resource.Version, Kind: resource.Kind} + + key := types.NamespacedName{ + Name: options.Name, + Namespace: options.Namespace, + } + + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(gvk) + + if err := c.Client.Get(ctx, key, u); err != nil { + return nil, fmt.Errorf("getting object %v: %w", key, err) + } + return u, nil +} diff --git a/pkg/cli/powertools/kubecli/options.go b/pkg/cli/powertools/kubecli/options.go new file mode 100644 index 0000000000..3fbb535f27 --- /dev/null +++ b/pkg/cli/powertools/kubecli/options.go @@ -0,0 +1,50 @@ +// Copyright 2024 Google LLC +// +// 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 kubecli + +import ( + "github.com/spf13/cobra" + "k8s.io/client-go/rest" +) + +type ClusterOptions struct { + // Impersonate is the configuration that RESTClient will use for impersonation. + Impersonate *rest.ImpersonationConfig +} + +func (o *ClusterOptions) PopulateDefaults() { + +} + +func (o *ClusterOptions) AddFlags(cmd *cobra.Command) { +} + +type ObjectOptions struct { + // Kind specifies the kind we want to change. It will be matched against kind, resource-name, aliases etc. + Kind string + // Name is the name of the object we want to change + Name string + // Namespace is the namespace of the object we want to change + Namespace string +} + +func (o *ObjectOptions) PopulateDefaults() { + +} + +func (o *ObjectOptions) AddFlags(cmd *cobra.Command) { + // cmd.Flags().StringVar(&o.Name, "name", o.Name, "Name of the object to change") + cmd.Flags().StringVarP(&o.Namespace, "namespace", "n", o.Namespace, "Namespace of the object") +}