From 0da68dd7dcba223711bf967d1d6d396196d8c779 Mon Sep 17 00:00:00 2001 From: Mateusz Szostok Date: Thu, 31 Aug 2023 00:15:05 +0200 Subject: [PATCH] Add config get CLI command (#1208) --- cmd/cli/cmd/config/config.go | 17 ++ cmd/cli/cmd/config/get.go | 129 +++++++++++ cmd/cli/cmd/install.go | 3 +- cmd/cli/cmd/migrate.go | 45 +--- cmd/cli/cmd/root.go | 2 + cmd/cli/cmd/uninstall.go | 4 +- cmd/cli/docs/botkube.md | 1 + cmd/cli/docs/botkube_config.md | 25 +++ cmd/cli/docs/botkube_config_get.md | 54 +++++ cmd/cli/docs/botkube_migrate.md | 15 +- cmd/config-exporter/main.go | 5 +- go.mod | 2 +- internal/cli/{config => }/config.go | 9 +- internal/cli/config/get.go | 327 ++++++++++++++++++++++++++++ internal/cli/install/install.go | 2 +- internal/cli/login/login.go | 4 +- internal/cli/migrate/migrate.go | 155 +------------ internal/cli/migrate/opts.go | 16 +- internal/cli/printer/format.go | 47 ++++ internal/cli/printer/json.go | 23 ++ internal/cli/printer/resource.go | 96 ++++++++ internal/cli/printer/yaml.go | 22 ++ 22 files changed, 786 insertions(+), 217 deletions(-) create mode 100644 cmd/cli/cmd/config/config.go create mode 100644 cmd/cli/cmd/config/get.go create mode 100644 cmd/cli/docs/botkube_config.md create mode 100644 cmd/cli/docs/botkube_config_get.md rename internal/cli/{config => }/config.go (91%) create mode 100644 internal/cli/config/get.go create mode 100644 internal/cli/printer/format.go create mode 100644 internal/cli/printer/json.go create mode 100644 internal/cli/printer/resource.go create mode 100644 internal/cli/printer/yaml.go diff --git a/cmd/cli/cmd/config/config.go b/cmd/cli/cmd/config/config.go new file mode 100644 index 000000000..3c4f99f8c --- /dev/null +++ b/cmd/cli/cmd/config/config.go @@ -0,0 +1,17 @@ +package config + +import "github.com/spf13/cobra" + +// NewCmd returns a new cobra.Command subcommand for config-related operations. +func NewCmd() *cobra.Command { + root := &cobra.Command{ + Use: "config", + Aliases: []string{"cfg"}, + Short: "This command consists of multiple subcommands for working with Botkube configuration", + } + + root.AddCommand( + NewGet(), + ) + return root +} diff --git a/cmd/cli/cmd/config/get.go b/cmd/cli/cmd/config/get.go new file mode 100644 index 000000000..8ec4c2297 --- /dev/null +++ b/cmd/cli/cmd/config/get.go @@ -0,0 +1,129 @@ +package config + +import ( + "fmt" + "os" + "reflect" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/kubeshop/botkube/internal/cli" + "github.com/kubeshop/botkube/internal/cli/config" + "github.com/kubeshop/botkube/internal/cli/heredoc" + "github.com/kubeshop/botkube/internal/cli/printer" + "github.com/kubeshop/botkube/internal/kubex" +) + +type GetOptions struct { + OmitEmpty bool + Exporter config.ExporterOptions +} + +// NewGet returns a cobra.Command for getting Botkube configuration. +func NewGet() *cobra.Command { + var opts GetOptions + + resourcePrinter := printer.NewForResource(os.Stdout, printer.WithJSON(), printer.WithYAML()) + + cmd := &cobra.Command{ + Use: "get", + Short: "Displays Botkube configuration", + Example: heredoc.WithCLIName(` + # Show configuration for currently installed Botkube + config get + + # Show configuration in JSON format + config get -ojson + + # Save configuration in file + config get > config.yaml + `, cli.Name), + RunE: func(cmd *cobra.Command, args []string) (err error) { + status := printer.NewStatus(cmd.ErrOrStderr(), "Fetching Botkube configuration") + defer func() { + status.End(err == nil) + }() + + k8sCfg, err := kubex.LoadRestConfigWithMetaInformation() + if err != nil { + return fmt.Errorf("while creating k8s config: %w", err) + } + + err = status.InfoStructFields("Export details:", exportDetails{ + ExporterVersion: opts.Exporter.Tag, + K8sCtx: k8sCfg.CurrentContext, + LookupNamespace: opts.Exporter.BotkubePodNamespace, + LookupPodLabel: opts.Exporter.BotkubePodLabel, + }) + if err != nil { + return err + } + + status.Infof("Fetching configuration") + cfg, botkubeVersionStr, err := config.GetFromCluster(cmd.Context(), k8sCfg.K8s, opts.Exporter, false) + if err != nil { + return fmt.Errorf("while getting configuration: %w", err) + } + + var raw interface{} + err = yaml.Unmarshal(cfg, &raw) + if err != nil { + return fmt.Errorf("while loading configuration: %w", err) + } + + if opts.OmitEmpty { + status.Step("Removing empty keys from configuration") + raw = removeEmptyValues(raw) + status.End(true) + } + + status.Step("Exported Botkube configuration installed in version %s", botkubeVersionStr) + status.End(true) + + return resourcePrinter.Print(raw) + }, + } + + flags := cmd.Flags() + + flags.BoolVar(&opts.OmitEmpty, "omit-empty-values", true, "Omits empty keys from printed configuration") + + opts.Exporter.RegisterFlags(flags) + + resourcePrinter.RegisterFlags(flags) + + return cmd +} + +type exportDetails struct { + K8sCtx string `pretty:"Kubernetes Context"` + ExporterVersion string `pretty:"Exporter Version"` + LookupNamespace string `pretty:"Lookup Namespace"` + LookupPodLabel string `pretty:"Lookup Pod Label"` +} + +func removeEmptyValues(obj any) any { + switch v := obj.(type) { + case map[string]any: + newObj := make(map[string]any) + for key, value := range v { + if value != nil { + newValue := removeEmptyValues(value) + if newValue != nil { + newObj[key] = newValue + } + } + } + if len(newObj) == 0 { + return nil + } + return newObj + default: + val := reflect.ValueOf(v) + if val.IsZero() { + return nil + } + return obj + } +} diff --git a/cmd/cli/cmd/install.go b/cmd/cli/cmd/install.go index 4e1d307fb..807e5494e 100644 --- a/cmd/cli/cmd/install.go +++ b/cmd/cli/cmd/install.go @@ -5,7 +5,6 @@ import ( "os" "time" - "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/kubeshop/botkube/internal/cli" @@ -36,7 +35,7 @@ func NewInstall() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { config, err := kubex.LoadRestConfigWithMetaInformation() if err != nil { - return errors.Wrap(err, "while creating k8s config") + return fmt.Errorf("while creating k8s config: %w", err) } return install.Install(cmd.Context(), os.Stdout, config, opts) diff --git a/cmd/cli/cmd/migrate.go b/cmd/cli/cmd/migrate.go index b0431bf02..54b89eb68 100644 --- a/cmd/cli/cmd/migrate.go +++ b/cmd/cli/cmd/migrate.go @@ -2,19 +2,17 @@ package cmd import ( "fmt" - "strings" "time" "github.com/AlecAivazis/survey/v2" "github.com/fatih/color" semver "github.com/hashicorp/go-version" "github.com/pkg/browser" - "github.com/pkg/errors" "github.com/spf13/cobra" "go.szostok.io/version" - corev1 "k8s.io/api/core/v1" "github.com/kubeshop/botkube/internal/cli" + "github.com/kubeshop/botkube/internal/cli/config" "github.com/kubeshop/botkube/internal/cli/heredoc" "github.com/kubeshop/botkube/internal/cli/migrate" "github.com/kubeshop/botkube/internal/cli/printer" @@ -23,12 +21,8 @@ import ( const ( botkubeVersionConstraints = ">= 1.0, < 1.3" - - containerName = "botkube" ) -var DefaultImageTag = "v9.99.9-dev" - // NewMigrate returns a cobra.Command for migrate the OS into Cloud. func NewMigrate() *cobra.Command { var opts migrate.Options @@ -58,7 +52,7 @@ func NewMigrate() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) (err error) { k8sConfig, err := kubex.LoadRestConfigWithMetaInformation() if err != nil { - return errors.Wrap(err, "while creating k8s config") + return fmt.Errorf("while creating k8s config: %w", err) } status := printer.NewStatus(cmd.OutOrStdout(), "Migrating Botkube installation to Cloud") @@ -66,16 +60,12 @@ func NewMigrate() *cobra.Command { status.End(err == nil) }() - status.Step("Fetching Botkube configuration") - cfg, pod, err := migrate.GetConfigFromCluster(cmd.Context(), k8sConfig.K8s, opts) + status.Infof("Fetching Botkube configuration") + cfg, botkubeVersionStr, err := config.GetFromCluster(cmd.Context(), k8sConfig.K8s, opts.ConfigExporter, opts.AutoApprove) if err != nil { return err } - botkubeVersionStr, err := getBotkubeVersion(pod) - if err != nil { - return err - } status.Infof("Checking if Botkube version %q can be migrated safely", botkubeVersionStr) constraint, err := semver.NewConstraint(botkubeVersionConstraints) @@ -141,37 +131,20 @@ func NewMigrate() *cobra.Command { } flags := login.Flags() - kubex.RegisterKubeconfigFlag(flags) + flags.DurationVar(&opts.Timeout, "timeout", 10*time.Minute, `Maximum time during which the Botkube installation is being watched, where "0" means "infinite". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".`) flags.StringVar(&opts.Token, "token", "", "Botkube Cloud authentication token") flags.BoolVarP(&opts.Watch, "watch", "w", true, "Watches the status of the Botkube installation until it finish or the defined `--timeout` occurs.") flags.StringVar(&opts.InstanceName, "instance-name", "", "Botkube Cloud Instance name that will be created") flags.StringVar(&opts.CloudAPIURL, "cloud-api-url", "https://api.botkube.io/graphql", "Botkube Cloud API URL") flags.StringVar(&opts.CloudDashboardURL, "cloud-dashboard-url", "https://app.botkube.io", "Botkube Cloud URL") - flags.StringVarP(&opts.Label, "label", "l", "app=botkube", "Label of Botkube pod") - flags.StringVarP(&opts.Namespace, "namespace", "n", "botkube", "Namespace of Botkube pod") - flags.StringVarP(&opts.ImageTag, "image-tag", "", "", "Botkube image tag, possible values latest, v1.2.0, ...") flags.BoolVarP(&opts.SkipConnect, "skip-connect", "q", false, "Skips connecting to Botkube Cloud after migration") flags.BoolVar(&opts.SkipOpenBrowser, "skip-open-browser", false, "Skips opening web browser after migration") flags.BoolVarP(&opts.AutoApprove, "auto-approve", "y", false, "Skips interactive approval for upgrading Botkube installation.") - flags.StringVar(&opts.ConfigExporter.Registry, "cfg-exporter-image-registry", "ghcr.io", "Config Exporter job image registry") - flags.StringVar(&opts.ConfigExporter.Repository, "cfg-exporter-image-repo", "kubeshop/botkube-config-exporter", "Config Exporter job image repository") - flags.StringVar(&opts.ConfigExporter.Tag, "cfg-exporter-image-tag", DefaultImageTag, "Config Exporter job image tag") - flags.DurationVar(&opts.ConfigExporter.PollPeriod, "cfg-exporter-poll-period", 1*time.Second, "Config Exporter job poll period") - flags.DurationVar(&opts.ConfigExporter.Timeout, "cfg-exporter-timeout", 1*time.Minute, "Config Exporter job timeout") + flags.StringVarP(&opts.ImageTag, "image-tag", "", "", "Botkube image tag, possible values latest, v1.2.0, ...") - return login -} + opts.ConfigExporter.RegisterFlags(flags) + kubex.RegisterKubeconfigFlag(flags) -func getBotkubeVersion(p *corev1.Pod) (string, error) { - for _, c := range p.Spec.Containers { - if c.Name == containerName { - fqin := strings.Split(c.Image, ":") - if len(fqin) > 1 { - return fqin[len(fqin)-1], nil - } - break - } - } - return "", fmt.Errorf("unable to get botkube version: pod %q does not have botkube container", p.Name) + return login } diff --git a/cmd/cli/cmd/root.go b/cmd/cli/cmd/root.go index 961dcdec5..f3f1c2d70 100644 --- a/cmd/cli/cmd/root.go +++ b/cmd/cli/cmd/root.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "go.szostok.io/version/extension" + "github.com/kubeshop/botkube/cmd/cli/cmd/config" "github.com/kubeshop/botkube/internal/cli" "github.com/kubeshop/botkube/internal/cli/heredoc" ) @@ -47,6 +48,7 @@ func NewRoot() *cobra.Command { NewDocs(), NewInstall(), NewUninstall(), + config.NewCmd(), extension.NewVersionCobraCmd( extension.WithUpgradeNotice(orgName, repoName), ), diff --git a/cmd/cli/cmd/uninstall.go b/cmd/cli/cmd/uninstall.go index ea67dd24f..fa9d929ee 100644 --- a/cmd/cli/cmd/uninstall.go +++ b/cmd/cli/cmd/uninstall.go @@ -1,10 +1,10 @@ package cmd import ( + "fmt" "os" "time" - "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/kubeshop/botkube/internal/cli" @@ -35,7 +35,7 @@ func NewUninstall() *cobra.Command { return err } if err != nil { - return errors.Wrap(err, "while creating k8s config") + return fmt.Errorf("while creating k8s config: %w", err) } return uninstall.Uninstall(cmd.Context(), os.Stdout, config, opts) diff --git a/cmd/cli/docs/botkube.md b/cmd/cli/docs/botkube.md index 2c628c9b2..fa5747e43 100644 --- a/cmd/cli/docs/botkube.md +++ b/cmd/cli/docs/botkube.md @@ -36,6 +36,7 @@ botkube [flags] ### SEE ALSO +* [botkube config](botkube_config.md) - This command consists of multiple subcommands for working with Botkube configuration * [botkube install](botkube_install.md) - install or upgrade Botkube in k8s cluster * [botkube login](botkube_login.md) - Login to a Botkube Cloud * [botkube migrate](botkube_migrate.md) - Automatically migrates Botkube installation into Botkube Cloud diff --git a/cmd/cli/docs/botkube_config.md b/cmd/cli/docs/botkube_config.md new file mode 100644 index 000000000..5cda8fc96 --- /dev/null +++ b/cmd/cli/docs/botkube_config.md @@ -0,0 +1,25 @@ +--- +title: botkube config +--- + +## botkube config + +This command consists of multiple subcommands for working with Botkube configuration + +### Options + +``` + -h, --help help for config +``` + +### Options inherited from parent commands + +``` + -v, --verbose int/string[=simple] Prints more verbose output. Allowed values: 0 - disable, 1 - simple, 2 - trace (default 0 - disable) +``` + +### SEE ALSO + +* [botkube](botkube.md) - Botkube CLI +* [botkube config get](botkube_config_get.md) - Displays Botkube configuration + diff --git a/cmd/cli/docs/botkube_config_get.md b/cmd/cli/docs/botkube_config_get.md new file mode 100644 index 000000000..82059c9c8 --- /dev/null +++ b/cmd/cli/docs/botkube_config_get.md @@ -0,0 +1,54 @@ +--- +title: botkube config get +--- + +## botkube config get + +Displays Botkube configuration + +``` +botkube config get [flags] +``` + +### Examples + +``` +# Show configuration for currently installed Botkube +botkube config get + +# Show configuration in JSON format +botkube config get -ojson + +# Save configuration in file +botkube config get > config.yaml + +``` + +### Options + +``` + --cfg-exporter-image-registry string Registry for the Config Exporter job image (default "ghcr.io") + --cfg-exporter-image-repo string Repository for the Config Exporter job image (default "kubeshop/botkube-config-exporter") + --cfg-exporter-image-tag string Tag of the Config Exporter job image (default "v9.99.9-dev") + --cfg-exporter-poll-period duration Interval used to check if Config Exporter job was finished (default 1s) + --cfg-exporter-timeout duration Maximum execution time for the Config Exporter job (default 1m0s) + --cloud-env-api-key string API key environment variable name specified under Deployment for cloud installation. (default "CONFIG_PROVIDER_API_KEY") + --cloud-env-endpoint string Endpoint environment variable name specified under Deployment for cloud installation. (default "CONFIG_PROVIDER_ENDPOINT") + --cloud-env-id string Identifier environment variable name specified under Deployment for cloud installation. (default "CONFIG_PROVIDER_IDENTIFIER") + -h, --help help for get + -l, --label string Label used for identifying the Botkube pod (default "app=botkube") + -n, --namespace string Namespace of Botkube pod (default "botkube") + --omit-empty-values Omits empty keys from printed configuration (default true) + -o, --output string Output format. One of: json | yaml (default "yaml") +``` + +### Options inherited from parent commands + +``` + -v, --verbose int/string[=simple] Prints more verbose output. Allowed values: 0 - disable, 1 - simple, 2 - trace (default 0 - disable) +``` + +### SEE ALSO + +* [botkube config](botkube_config.md) - This command consists of multiple subcommands for working with Botkube configuration + diff --git a/cmd/cli/docs/botkube_migrate.md b/cmd/cli/docs/botkube_migrate.md index a621d773f..729dc458b 100644 --- a/cmd/cli/docs/botkube_migrate.md +++ b/cmd/cli/docs/botkube_migrate.md @@ -35,18 +35,21 @@ botkube migrate [OPTIONS] [flags] ``` -y, --auto-approve Skips interactive approval for upgrading Botkube installation. - --cfg-exporter-image-registry string Config Exporter job image registry (default "ghcr.io") - --cfg-exporter-image-repo string Config Exporter job image repository (default "kubeshop/botkube-config-exporter") - --cfg-exporter-image-tag string Config Exporter job image tag (default "v9.99.9-dev") - --cfg-exporter-poll-period duration Config Exporter job poll period (default 1s) - --cfg-exporter-timeout duration Config Exporter job timeout (default 1m0s) + --cfg-exporter-image-registry string Registry for the Config Exporter job image (default "ghcr.io") + --cfg-exporter-image-repo string Repository for the Config Exporter job image (default "kubeshop/botkube-config-exporter") + --cfg-exporter-image-tag string Tag of the Config Exporter job image (default "v9.99.9-dev") + --cfg-exporter-poll-period duration Interval used to check if Config Exporter job was finished (default 1s) + --cfg-exporter-timeout duration Maximum execution time for the Config Exporter job (default 1m0s) --cloud-api-url string Botkube Cloud API URL (default "https://api.botkube.io/graphql") --cloud-dashboard-url string Botkube Cloud URL (default "https://app.botkube.io") + --cloud-env-api-key string API key environment variable name specified under Deployment for cloud installation. (default "CONFIG_PROVIDER_API_KEY") + --cloud-env-endpoint string Endpoint environment variable name specified under Deployment for cloud installation. (default "CONFIG_PROVIDER_ENDPOINT") + --cloud-env-id string Identifier environment variable name specified under Deployment for cloud installation. (default "CONFIG_PROVIDER_IDENTIFIER") -h, --help help for migrate --image-tag string Botkube image tag, possible values latest, v1.2.0, ... --instance-name string Botkube Cloud Instance name that will be created --kubeconfig string Paths to a kubeconfig. Only required if out-of-cluster. - -l, --label string Label of Botkube pod (default "app=botkube") + -l, --label string Label used for identifying the Botkube pod (default "app=botkube") -n, --namespace string Namespace of Botkube pod (default "botkube") -q, --skip-connect Skips connecting to Botkube Cloud after migration --skip-open-browser Skips opening web browser after migration diff --git a/cmd/config-exporter/main.go b/cmd/config-exporter/main.go index f90231955..750517fcd 100644 --- a/cmd/config-exporter/main.go +++ b/cmd/config-exporter/main.go @@ -46,8 +46,9 @@ func createOrUpdateCM(ctx context.Context, config []byte) error { cm := newCM() return retry.RetryOnConflict(retry.DefaultRetry, func() error { _, corErr := ctrlutil.CreateOrUpdate(ctx, k8sClient, cm, func() error { - cm.BinaryData = map[string][]byte{ - "config.yaml": config, + cm.BinaryData = nil // remove data from previous approach, otherwise we may get error: 'Invalid value: "config.yaml": duplicate of key present in binaryData' + cm.Data = map[string]string{ + "config.yaml": string(config), } return nil }) diff --git a/go.mod b/go.mod index 415ad0bae..5c138f616 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/hashicorp/go-plugin v1.4.10 github.com/hashicorp/go-version v1.6.0 github.com/hasura/go-graphql-client v0.8.1 + github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f github.com/huandu/xstrings v1.4.0 github.com/infracloudio/msbotbuilder-go v0.2.5 github.com/keptn/go-utils v0.20.1 @@ -165,7 +166,6 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/hashicorp/yamux v0.1.1 // indirect - github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect github.com/imdario/mergo v0.3.13 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect diff --git a/internal/cli/config/config.go b/internal/cli/config.go similarity index 91% rename from internal/cli/config/config.go rename to internal/cli/config.go index 51b54a04d..f73cbc7b8 100644 --- a/internal/cli/config/config.go +++ b/internal/cli/config.go @@ -1,4 +1,4 @@ -package config +package cli import ( "encoding/json" @@ -8,13 +8,12 @@ import ( "k8s.io/client-go/util/homedir" - "github.com/kubeshop/botkube/internal/cli" "github.com/kubeshop/botkube/internal/cli/heredoc" ) var ( configFilePath = filepath.Join(homedir.HomeDir(), ".botkube", "cloud.json") - loginCmd = heredoc.WithCLIName(`login with: login`, cli.Name) + loginCmd = heredoc.WithCLIName(`login with: login`, Name) ) // Config is botkube cli config @@ -22,8 +21,8 @@ type Config struct { Token string `json:"token"` } -// New creates new Config from local data -func New() (*Config, error) { +// NewConfig creates new Config from local data +func NewConfig() (*Config, error) { c := &Config{} err := c.Read() if err != nil { diff --git a/internal/cli/config/get.go b/internal/cli/config/get.go new file mode 100644 index 000000000..9fa85abfe --- /dev/null +++ b/internal/cli/config/get.go @@ -0,0 +1,327 @@ +package config + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc" + semver "github.com/hashicorp/go-version" + "github.com/spf13/pflag" + "go.szostok.io/version" + "golang.org/x/exp/slices" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/kubeshop/botkube/internal/cli" + "github.com/kubeshop/botkube/internal/cli/install/iox" + "github.com/kubeshop/botkube/internal/config/remote" + "github.com/kubeshop/botkube/internal/ptr" + "github.com/kubeshop/botkube/pkg/formatx" +) + +const ( + exportJobName = "botkube-config-exporter" + configMapName = "botkube-config-exporter" + containerName = "botkube" +) + +// ExporterOptions holds config exporter image configuration options. +type ExporterOptions struct { + Registry string + Repository string + Tag string + + BotkubePodLabel string + BotkubePodNamespace string + + Timeout time.Duration + PollPeriod time.Duration + + CloudEnvs CloudEnvsOptions +} + +type CloudEnvsOptions struct { + EndpointEnvName string + IDEnvName string + APIKeyEnvName string +} + +func (o *ExporterOptions) RegisterFlags(flags *pflag.FlagSet) { + flags.StringVarP(&o.BotkubePodNamespace, "namespace", "n", "botkube", "Namespace of Botkube pod") + flags.StringVarP(&o.BotkubePodLabel, "label", "l", "app=botkube", "Label used for identifying the Botkube pod") + flags.StringVar(&o.Registry, "cfg-exporter-image-registry", "ghcr.io", "Registry for the Config Exporter job image") + flags.StringVar(&o.Repository, "cfg-exporter-image-repo", "kubeshop/botkube-config-exporter", "Repository for the Config Exporter job image") + flags.StringVar(&o.Tag, "cfg-exporter-image-tag", getDefaultImageTag(), "Tag of the Config Exporter job image") + flags.DurationVar(&o.PollPeriod, "cfg-exporter-poll-period", 1*time.Second, "Interval used to check if Config Exporter job was finished") + flags.DurationVar(&o.Timeout, "cfg-exporter-timeout", 1*time.Minute, "Maximum execution time for the Config Exporter job") + + flags.StringVar(&o.CloudEnvs.EndpointEnvName, "cloud-env-endpoint", remote.ProviderEndpointEnvKey, "Endpoint environment variable name specified under Deployment for cloud installation.") + flags.StringVar(&o.CloudEnvs.IDEnvName, "cloud-env-id", remote.ProviderIdentifierEnvKey, "Identifier environment variable name specified under Deployment for cloud installation.") + flags.StringVar(&o.CloudEnvs.APIKeyEnvName, "cloud-env-api-key", remote.ProviderAPIKeyEnvKey, "API key environment variable name specified under Deployment for cloud installation.") +} + +func GetFromCluster(ctx context.Context, k8sCfg *rest.Config, opts ExporterOptions, autoApprove bool) ([]byte, string, error) { + k8sCli, err := kubernetes.NewForConfig(k8sCfg) + if err != nil { + return nil, "", fmt.Errorf("while getting k8s client: %w", err) + } + + botkubePod, err := getBotkubePod(ctx, k8sCli, opts.BotkubePodNamespace, opts.BotkubePodLabel) + if err != nil { + return nil, "", fmt.Errorf("while getting botkube pod: %w", err) + } + + botkubeContainer, found := getBotkubeContainer(botkubePod) + if !found { + return nil, "", fmt.Errorf("cannot find %q container in %q Pod", containerName, botkubePod.Name) + } + ver, err := getBotkubeVersion(botkubeContainer) + if err != nil { + return nil, "", fmt.Errorf("while getting botkube version: %w", err) + } + + envs := getCloudRelatedEnvs(botkubeContainer, opts.CloudEnvs) + if len(envs) > 1 { + cfg, err := fetchCloudConfig(ctx, envs) + if err != nil { + return nil, "", fmt.Errorf("while fetching cloud configuration: %w", err) + } + return cfg, ver, nil + } + + if err = createExportJob(ctx, k8sCli, botkubePod, botkubeContainer, opts, autoApprove); err != nil { + return nil, "", fmt.Errorf("while creating config exporter job: %w", err) + } + + if err = waitForExportJob(ctx, k8sCli, opts); err != nil { + return nil, "", fmt.Errorf("while waiting for config exporter job: %w", err) + } + defer cleanup(ctx, k8sCli, opts.BotkubePodNamespace) + config, err := readConfigFromCM(ctx, k8sCli, opts.BotkubePodNamespace) + if err != nil { + return nil, "", fmt.Errorf("while getting exported config: %w", err) + } + + return config, ver, nil +} + +func fetchCloudConfig(ctx context.Context, envs map[string]string) ([]byte, error) { + cfg := remote.Config{ + Endpoint: envs[remote.ProviderEndpointEnvKey], + Identifier: envs[remote.ProviderIdentifierEnvKey], + APIKey: envs[remote.ProviderAPIKeyEnvKey], + } + + gqlClient := remote.NewDefaultGqlClient(cfg) + deployClient := remote.NewDeploymentClient(gqlClient) + deployConfig, err := deployClient.GetConfigWithResourceVersion(ctx) + if err != nil { + return nil, err + } + return []byte(deployConfig.YAMLConfig), nil +} + +func getCloudRelatedEnvs(c corev1.Container, envs CloudEnvsOptions) map[string]string { + exp := []string{envs.EndpointEnvName, envs.IDEnvName, envs.APIKeyEnvName} + out := map[string]string{} + for _, env := range c.Env { + name := strings.TrimSpace(env.Name) + if name == "" { + continue + } + if slices.Contains(exp, name) { + out[name] = env.Value + } + } + return out +} + +func getBotkubePod(ctx context.Context, k8sCli *kubernetes.Clientset, namespace, label string) (*corev1.Pod, error) { + pods, err := k8sCli.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: label}) + if err != nil { + return nil, err + } + if len(pods.Items) == 0 { + return nil, fmt.Errorf("there are not Pods with label %q in the %q namespace", label, namespace) + } + return &pods.Items[0], nil +} + +func createExportJob(ctx context.Context, k8sCli *kubernetes.Clientset, botkubePod *corev1.Pod, container corev1.Container, cfg ExporterOptions, autoApprove bool) error { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: exportJobName, + Namespace: botkubePod.Namespace, + Labels: map[string]string{ + "app": exportJobName, + "botkube.io/export-cfg": "true", + }, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: exportJobName, + Image: fmt.Sprintf("%s/%s:%s", cfg.Registry, cfg.Repository, cfg.Tag), + ImagePullPolicy: corev1.PullIfNotPresent, + Env: container.Env, + VolumeMounts: container.VolumeMounts, + }, + }, + Volumes: botkubePod.Spec.Volumes, + ServiceAccountName: botkubePod.Spec.ServiceAccountName, + RestartPolicy: corev1.RestartPolicyNever, + }, + }, + }, + } + + _, err := k8sCli.BatchV1().Jobs(job.Namespace).Create(ctx, job, metav1.CreateOptions{}) + if err != nil && apierrors.IsAlreadyExists(err) { + can, err := checkIfCanDelete(autoApprove) + if err != nil { + return fmt.Errorf("while checking if can delete: %w", err) + } + + if can { + _ = k8sCli.BatchV1().Jobs(job.Namespace).Delete(ctx, exportJobName, metav1.DeleteOptions{ + GracePeriodSeconds: ptr.FromType[int64](0), + PropagationPolicy: ptr.FromType(metav1.DeletePropagationOrphan), + }) + err := wait.PollUntilContextTimeout(ctx, time.Second, cfg.Timeout, false, func(ctx context.Context) (done bool, err error) { + _, err = k8sCli.BatchV1().Jobs(job.Namespace).Get(ctx, job.Namespace, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + return true, nil + } + return false, err + }) + if err != nil { + return fmt.Errorf("while waiting for job deletion: %w", err) + } + + _, err = k8sCli.BatchV1().Jobs(job.Namespace).Create(ctx, job, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("while re-creating job: %w", err) + } + return nil + } + } + return err +} + +func checkIfCanDelete(autoApprove bool) (bool, error) { + if autoApprove { + return true, nil + } + + run := false + prompt := &survey.Confirm{ + Message: "The migration job already exist. Do you want to re-create it?", + Default: true, + } + + questionIndent := iox.NewIndentStdoutWriter("?", 1) // we indent questions by 1 space to match the step layout + err := survey.AskOne(prompt, &run, survey.WithStdio(os.Stdin, questionIndent, os.Stderr)) + return run, err +} + +func getBotkubeContainer(botkubePod *corev1.Pod) (corev1.Container, bool) { + for _, c := range botkubePod.Spec.Containers { + if c.Name == containerName { + return c, true + } + } + return corev1.Container{}, false +} + +func waitForExportJob(ctx context.Context, k8sCli *kubernetes.Clientset, opts ExporterOptions) error { + ctxWithTimeout, cancelFn := context.WithTimeout(ctx, opts.Timeout) + defer cancelFn() + + ticker := time.NewTicker(opts.PollPeriod) + defer ticker.Stop() + + var job *batchv1.Job + for { + select { + case <-ctxWithTimeout.Done(): + errMsg := fmt.Sprintf("Timeout (%s) occurred while waiting for export config job.", opts.Timeout.String()) + + if job != nil { + errMsg = heredoc.Docf(` + %s + + To get all Botkube logs, run: + + kubectl logs -n %s jobs/%s + `, errMsg, job.Namespace, job.Name) + } + + if cli.VerboseMode.IsEnabled() && job != nil { + job.ManagedFields = nil + errMsg = fmt.Sprintf("%s\n\nDEBUG:\nJob definition:\n\n%s", errMsg, formatx.StructDumper().Sdump(job)) + } + + return errors.New(errMsg) + case <-ticker.C: + var err error + job, err = k8sCli.BatchV1().Jobs(opts.BotkubePodNamespace).Get(ctx, exportJobName, metav1.GetOptions{}) + if err != nil { + fmt.Println("Error getting export config job: ", err.Error()) + continue + } + + if job.Status.Succeeded > 0 { + return nil + } + } + } +} + +func readConfigFromCM(ctx context.Context, k8sCli *kubernetes.Clientset, namespace string) ([]byte, error) { + configMap, err := k8sCli.CoreV1().ConfigMaps(namespace).Get(ctx, configMapName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + oldApproachStorage := configMap.BinaryData["config.yaml"] + if len(oldApproachStorage) > 0 { + return oldApproachStorage, nil + } + + return []byte(configMap.Data["config.yaml"]), nil +} + +func cleanup(ctx context.Context, k8sCli *kubernetes.Clientset, namespace string) { + foreground := metav1.DeletePropagationForeground + _ = k8sCli.BatchV1().Jobs(namespace).Delete(ctx, exportJobName, metav1.DeleteOptions{PropagationPolicy: &foreground}) + _ = k8sCli.CoreV1().ConfigMaps(namespace).Delete(ctx, configMapName, metav1.DeleteOptions{}) +} + +func getBotkubeVersion(c corev1.Container) (string, error) { + fqin := strings.Split(c.Image, ":") + if len(fqin) > 1 { + return fqin[len(fqin)-1], nil + } + return "", errors.New("unable to get botkube version") +} + +func getDefaultImageTag() string { + imageTag := "v9.99.9-dev" + ver, err := semver.NewSemver(version.Get().Version) + if err == nil { + imageTag = "v" + ver.String() + } + return imageTag +} diff --git a/internal/cli/install/install.go b/internal/cli/install/install.go index b72e769ba..75c20a222 100644 --- a/internal/cli/install/install.go +++ b/internal/cli/install/install.go @@ -221,7 +221,7 @@ func printFailedInstallMessage(version string, namespace string, name string, w props := map[string]string{ "SlackURL": "https://join.botkube.io", - "DocsURL": "https://docs.botkube.io", + "DocsURL": "https://docs.botkube.io/operation/common-problems", "Version": version, "Namespace": namespace, "PodName": name, diff --git a/internal/cli/login/login.go b/internal/cli/login/login.go index 042fa95e8..0395c114a 100644 --- a/internal/cli/login/login.go +++ b/internal/cli/login/login.go @@ -11,7 +11,7 @@ import ( "github.com/fatih/color" "github.com/pkg/browser" - "github.com/kubeshop/botkube/internal/cli/config" + "github.com/kubeshop/botkube/internal/cli" "github.com/kubeshop/botkube/internal/cli/heredoc" ) @@ -29,7 +29,7 @@ func Run(ctx context.Context, w io.Writer, opts Options) error { return err } - c := config.Config{Token: t.Token} + c := cli.Config{Token: t.Token} if err := c.Save(); err != nil { return err } diff --git a/internal/cli/migrate/migrate.go b/internal/cli/migrate/migrate.go index 1e44cf738..11bfdb214 100644 --- a/internal/cli/migrate/migrate.go +++ b/internal/cli/migrate/migrate.go @@ -4,23 +4,15 @@ import ( "context" "fmt" "os" - "time" "github.com/AlecAivazis/survey/v2" "github.com/hasura/go-graphql-client" "github.com/mattn/go-shellwords" - "github.com/pkg/errors" flag "github.com/spf13/pflag" "golang.org/x/oauth2" "helm.sh/helm/v3/pkg/cli/values" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" "github.com/kubeshop/botkube/internal/cli" - cliconfig "github.com/kubeshop/botkube/internal/cli/config" "github.com/kubeshop/botkube/internal/cli/install" "github.com/kubeshop/botkube/internal/cli/install/helm" "github.com/kubeshop/botkube/internal/cli/printer" @@ -31,8 +23,6 @@ import ( ) const ( - migrationJobName = "botkube-migration" - configMapName = "botkube-config-exporter" defaultInstanceName = "Botkube" instanceDetailsURLFmt = "%s/instances/%s" @@ -43,7 +33,7 @@ const ( func Run(ctx context.Context, status *printer.StatusPrinter, config []byte, k8sCfg *kubex.ConfigWithMeta, opts Options) (string, error) { authToken := opts.Token if authToken == "" { - cfg, err := cliconfig.New() + cfg, err := cli.NewConfig() if err != nil { return "", err } @@ -69,7 +59,7 @@ func migrate(ctx context.Context, status *printer.StatusPrinter, opts Options, b plugins, err := converter.ConvertPlugins(botkubeClusterConfig.Executors, botkubeClusterConfig.Sources) if err != nil { - return "", errors.Wrap(err, "while converting plugins") + return "", fmt.Errorf("while converting plugins: %w", err) } pluginsCount := 0 @@ -91,7 +81,7 @@ func migrate(ctx context.Context, status *printer.StatusPrinter, opts Options, b instanceName, err := getInstanceName(opts) if err != nil { - return "", errors.Wrap(err, "while parsing instance name") + return "", fmt.Errorf("while parsing instance name: %w", err) } status.Step("Creating %q Cloud Instance", instanceName) var mutation struct { @@ -109,7 +99,7 @@ func migrate(ctx context.Context, status *printer.StatusPrinter, opts Options, b }, }) if err != nil { - return "", errors.Wrap(err, "while creating deployment") + return "", fmt.Errorf("while creating deployment: %w", err) } aliases := converter.ConvertAliases(botkubeClusterConfig.Aliases, mutation.CreateDeployment.ID) @@ -144,7 +134,7 @@ func migrate(ctx context.Context, status *printer.StatusPrinter, opts Options, b params, err := parseHelmCommand(mutation.CreateDeployment.InstallUpgradeInstructions, opts) if err != nil { - return "", errors.Wrap(err, "while parsing helm command") + return "", fmt.Errorf("while parsing helm command: %w", err) } installConfig := install.Config{ HelmParams: params, @@ -152,7 +142,7 @@ func migrate(ctx context.Context, status *printer.StatusPrinter, opts Options, b Timeout: opts.Timeout, } if err := install.Install(ctx, os.Stdout, k8sCfg, installConfig); err != nil { - return "", errors.Wrap(err, "while installing Botkube") + return "", fmt.Errorf("while installing Botkube: %w", err) } return mutation.CreateDeployment.ID, nil @@ -185,121 +175,6 @@ func getInstanceName(opts Options) (string, error) { return opts.InstanceName, nil } -func GetConfigFromCluster(ctx context.Context, k8sCfg *rest.Config, opts Options) ([]byte, *corev1.Pod, error) { - k8sCli, err := kubernetes.NewForConfig(k8sCfg) - if err != nil { - return nil, nil, err - } - defer cleanup(ctx, k8sCli, opts) - - botkubePod, err := getBotkubePod(ctx, k8sCli, opts) - if err != nil { - return nil, nil, err - } - - if err = createMigrationJob(ctx, k8sCli, botkubePod, opts.ConfigExporter); err != nil { - return nil, nil, err - } - - if err = waitForMigrationJob(ctx, k8sCli, opts); err != nil { - return nil, nil, err - } - config, err := readConfigFromCM(ctx, k8sCli, opts) - if err != nil { - return nil, nil, err - } - return config, botkubePod, nil -} - -func getBotkubePod(ctx context.Context, k8sCli *kubernetes.Clientset, opts Options) (*corev1.Pod, error) { - pods, err := k8sCli.CoreV1().Pods(opts.Namespace).List(ctx, metav1.ListOptions{LabelSelector: opts.Label}) - if err != nil { - return nil, err - } - if len(pods.Items) == 0 { - return nil, fmt.Errorf("no botkube pod found") - } - return &pods.Items[0], nil -} - -func createMigrationJob(ctx context.Context, k8sCli *kubernetes.Clientset, botkubePod *corev1.Pod, cfg ConfigExporterOptions) error { - var container corev1.Container - for _, c := range botkubePod.Spec.Containers { - if c.Name == "botkube" { - container = c - break - } - } - - job := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: migrationJobName, - Namespace: botkubePod.Namespace, - Labels: map[string]string{ - "app": migrationJobName, - "botkube.io/migration": "true", - }, - }, - Spec: batchv1.JobSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: migrationJobName, - Image: fmt.Sprintf("%s/%s:%s", cfg.Registry, cfg.Repository, cfg.Tag), - ImagePullPolicy: corev1.PullIfNotPresent, - Env: container.Env, - VolumeMounts: container.VolumeMounts, - }, - }, - Volumes: botkubePod.Spec.Volumes, - ServiceAccountName: botkubePod.Spec.ServiceAccountName, - RestartPolicy: corev1.RestartPolicyNever, - }, - }, - }, - } - - _, err := k8sCli.BatchV1().Jobs(botkubePod.Namespace).Create(ctx, job, metav1.CreateOptions{}) - - return err -} - -func waitForMigrationJob(ctx context.Context, k8sCli *kubernetes.Clientset, opts Options) error { - ctxWithTimeout, cancelFn := context.WithTimeout(ctx, opts.ConfigExporter.Timeout) - defer cancelFn() - - ticker := time.NewTicker(opts.ConfigExporter.PollPeriod) - defer ticker.Stop() - - var job *batchv1.Job - for { - select { - case <-ctxWithTimeout.Done(): - - errMsg := fmt.Sprintf("migration job failed: %s", context.Canceled.Error()) - - if cli.VerboseMode.IsEnabled() && job != nil { - errMsg = fmt.Sprintf("%s\n\nDEBUG:\njob:\n\n%s", errMsg, job.String()) - } - - // TODO: Add ability to keep the job if it fails and improve the error - return errors.New(errMsg) - case <-ticker.C: - var err error - job, err = k8sCli.BatchV1().Jobs(opts.Namespace).Get(ctx, migrationJobName, metav1.GetOptions{}) - if err != nil { - fmt.Println("Error getting migration job: ", err.Error()) - continue - } - - if job.Status.Succeeded > 0 { - return nil - } - } - } -} - func parseHelmCommand(instructions []*gqlModel.InstallUpgradeInstructionsForPlatform, opts Options) (helm.Config, error) { var raw string for _, i := range instructions { @@ -309,7 +184,7 @@ func parseHelmCommand(instructions []*gqlModel.InstallUpgradeInstructionsForPlat } tokenized, err := shellwords.Parse(raw) if err != nil { - return helm.Config{}, errors.Wrap(err, "could not tokenize helm command") + return helm.Config{}, fmt.Errorf("while tokenizing helm command: %w", err) } var version string @@ -318,7 +193,7 @@ func parseHelmCommand(instructions []*gqlModel.InstallUpgradeInstructionsForPlat flagSet.StringVar(&version, "version", "", "") flagSet.StringArrayVar(&vals, "set", []string{}, "") if err := flagSet.Parse(tokenized); err != nil { - return helm.Config{}, errors.Wrap(err, "could not register flags") + return helm.Config{}, fmt.Errorf("while registering flags: %w", err) } if opts.ImageTag != "" { @@ -338,20 +213,6 @@ func parseHelmCommand(instructions []*gqlModel.InstallUpgradeInstructionsForPlat }, nil } -func readConfigFromCM(ctx context.Context, k8sCli *kubernetes.Clientset, opts Options) ([]byte, error) { - configMap, err := k8sCli.CoreV1().ConfigMaps(opts.Namespace).Get(ctx, configMapName, metav1.GetOptions{}) - if err != nil { - return nil, err - } - return configMap.BinaryData["config.yaml"], nil -} - -func cleanup(ctx context.Context, k8sCli *kubernetes.Clientset, opts Options) { - foreground := metav1.DeletePropagationForeground - _ = k8sCli.BatchV1().Jobs(opts.Namespace).Delete(ctx, migrationJobName, metav1.DeleteOptions{PropagationPolicy: &foreground}) - _ = k8sCli.CoreV1().ConfigMaps(opts.Namespace).Delete(ctx, configMapName, metav1.DeleteOptions{}) -} - func errStateMessage(dashboardURL, instanceID string) string { return fmt.Sprintf("\n\nMigration process failed. Navigate to %s to continue configuring newly created instance.\n"+ "Alternatively, delete the instance from the link above and try again.", fmt.Sprintf(instanceDetailsURLFmt, dashboardURL, instanceID)) diff --git a/internal/cli/migrate/opts.go b/internal/cli/migrate/opts.go index 1e4a30132..6e87f5bed 100644 --- a/internal/cli/migrate/opts.go +++ b/internal/cli/migrate/opts.go @@ -2,6 +2,8 @@ package migrate import ( "time" + + "github.com/kubeshop/botkube/internal/cli/config" ) // Options holds migrate possible configuration options. @@ -12,21 +14,9 @@ type Options struct { InstanceName string `survey:"instanceName"` CloudDashboardURL string CloudAPIURL string - Namespace string ImageTag string - Label string SkipConnect bool SkipOpenBrowser bool AutoApprove bool - ConfigExporter ConfigExporterOptions -} - -// ConfigExporterOptions holds config exporter image configuration options. -type ConfigExporterOptions struct { - Registry string - Repository string - Tag string - - Timeout time.Duration - PollPeriod time.Duration + ConfigExporter config.ExporterOptions } diff --git a/internal/cli/printer/format.go b/internal/cli/printer/format.go new file mode 100644 index 000000000..d80124fb0 --- /dev/null +++ b/internal/cli/printer/format.go @@ -0,0 +1,47 @@ +package printer + +import ( + "fmt" +) + +// PrintFormat is a type for capturing supported output formats. +// Implements pflag.Value interface. +type PrintFormat string + +// ErrInvalidFormatType is returned when an unsupported format type is used +var ErrInvalidFormatType = fmt.Errorf("invalid output format type") + +const ( + // JSONFormat represents JSON data format. + JSONFormat PrintFormat = "json" + // YAMLFormat represents YAML data format. + YAMLFormat PrintFormat = "yaml" +) + +// IsValid returns true if PrintFormat is valid. +func (o PrintFormat) IsValid() bool { + switch o { + case JSONFormat, YAMLFormat: + return true + } + return false +} + +// String returns the string representation of the Format. Required by pflag.Value interface. +func (o PrintFormat) String() string { + return string(o) +} + +// Set format type to a given input. Required by pflag.Value interface. +func (o *PrintFormat) Set(in string) error { + *o = PrintFormat(in) + if !o.IsValid() { + return ErrInvalidFormatType + } + return nil +} + +// Type returns data type. Required by pflag.Value interface. +func (o *PrintFormat) Type() string { + return "string" +} diff --git a/internal/cli/printer/json.go b/internal/cli/printer/json.go new file mode 100644 index 000000000..d672b9199 --- /dev/null +++ b/internal/cli/printer/json.go @@ -0,0 +1,23 @@ +package printer + +import ( + "io" + + "github.com/hokaccha/go-prettyjson" +) + +var _ Printer = &JSON{} + +// JSON prints data in JSON format. +type JSON struct{} + +// Print marshals input data to JSON format and writes it to a given writer. +func (p *JSON) Print(in interface{}, w io.Writer) error { + out, err := prettyjson.Marshal(in) + if err != nil { + return err + } + + _, err = w.Write(out) + return err +} diff --git a/internal/cli/printer/resource.go b/internal/cli/printer/resource.go new file mode 100644 index 000000000..e6588150c --- /dev/null +++ b/internal/cli/printer/resource.go @@ -0,0 +1,96 @@ +package printer + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/spf13/pflag" +) + +// Printer is an interface that knows how to print objects. +type Printer interface { + // Print receives an object, formats it and prints it to a writer. + Print(in interface{}, w io.Writer) error +} + +// ResourcePrinter provides functionality to print a given resource in requested format. +// Can be configured with pflag.FlagSet. +type ResourcePrinter struct { + writer io.Writer + output PrintFormat + + printers map[PrintFormat]Printer +} + +// NewForResource returns a new ResourcePrinter instance. +func NewForResource(w io.Writer, opts ...ResourcePrinterOption) *ResourcePrinter { + p := &ResourcePrinter{ + writer: w, + printers: map[PrintFormat]Printer{}, + output: YAMLFormat, + } + + for _, opt := range opts { + opt(p) + } + + return p +} + +// ResourcePrinterOption allows ResourcePrinter instance customization. +type ResourcePrinterOption func(*ResourcePrinter) + +// WithJSON registers JSON format type. +func WithJSON() ResourcePrinterOption { + return func(r *ResourcePrinter) { + r.printers[JSONFormat] = &JSON{} + } +} + +// WithYAML registers YAML format type. +func WithYAML() ResourcePrinterOption { + return func(r *ResourcePrinter) { + r.printers[YAMLFormat] = &YAML{} + } +} + +// WithDefaultOutputFormat sets a default format type. +func WithDefaultOutputFormat(format PrintFormat) ResourcePrinterOption { + return func(r *ResourcePrinter) { + r.output = format + } +} + +// RegisterFlags registers ResourcePrinter terminal flags. +func (r *ResourcePrinter) RegisterFlags(flags *pflag.FlagSet) { + flags.VarP(&r.output, "output", "o", fmt.Sprintf("Output format. One of: %s", r.availablePrinters())) +} + +// PrintFormat returns default print format type. +func (r *ResourcePrinter) PrintFormat() PrintFormat { + return r.output +} + +// Print prints received object in requested format. +func (r *ResourcePrinter) Print(in interface{}) error { + printer, found := r.printers[r.output] + if !found { + return fmt.Errorf("printer %q is not available", r.output) + } + + return printer.Print(in, r.writer) +} + +func (r *ResourcePrinter) availablePrinters() string { + var out []string + for key := range r.printers { + out = append(out, key.String()) + } + + // We generate doc automatically, so it needs to be deterministic + sort.Strings(out) + + return strings.Join(out, " | ") +} diff --git a/internal/cli/printer/yaml.go b/internal/cli/printer/yaml.go new file mode 100644 index 000000000..29a71d4f4 --- /dev/null +++ b/internal/cli/printer/yaml.go @@ -0,0 +1,22 @@ +package printer + +import ( + "io" + + "sigs.k8s.io/yaml" +) + +var _ Printer = &YAML{} + +// YAML prints data in YAML format. +type YAML struct{} + +// Print marshals input data to YAML format and writes it to a given writer. +func (p *YAML) Print(in interface{}, w io.Writer) error { + out, err := yaml.Marshal(in) + if err != nil { + return err + } + _, err = w.Write(out) + return err +}