diff --git a/.github/workflows/branch-build.yml b/.github/workflows/branch-build.yml index bd67c73bca..c1218226c4 100644 --- a/.github/workflows/branch-build.yml +++ b/.github/workflows/branch-build.yml @@ -4,13 +4,14 @@ on: push: branches: - main + - migration-improvements env: HELM_VERSION: v3.9.0 K3D_VERSION: v5.4.6 IMAGE_REGISTRY: "ghcr.io" IMAGE_REPOSITORY: "kubeshop/botkube" - MIGRATOR_IMAGE_REPOSITORY: "kubeshop/botkube-migration" + CFG_EXPORTER_IMAGE_REPOSITORY: "kubeshop/botkube-config-exporter" IMAGE_TAG: v9.99.9-dev # TODO: Use commit hash tag to make the predictable builds for each commit on branch jobs: @@ -82,7 +83,7 @@ jobs: run: make release-snapshot-cli - name: Add botkube alias run: | - echo BOTKUBE_BIN="$PWD/dist/botkube-cli_linux_amd64_v1/botkube" >> $GITHUB_ENV + echo BOTKUBE_BINARY_PATH="$PWD/dist/botkube-cli_linux_amd64_v1/botkube" >> $GITHUB_ENV - name: Install Helm uses: azure/setup-helm@v1 with: @@ -94,7 +95,7 @@ jobs: - name: Run e2e tests for botkube client env: DISCORD_BOT_ID: ${{ secrets.DISCORD_BOT_ID }} - DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_TESTER_APP_TOKEN }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} DISCORD_GUILD_ID: ${{ secrets.DISCORD_GUILD_ID }} DISCORD_TESTER_APP_TOKEN: ${{ secrets.DISCORD_TESTER_APP_TOKEN }} BOTKUBE_CLOUD_DEV_GQL_ENDPOINT: ${{ secrets.BOTKUBE_CLOUD_DEV_GQL_ENDPOINT }} diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml index 9ab895aff4..c82f733d80 100644 --- a/.github/workflows/pr-build.yaml +++ b/.github/workflows/pr-build.yaml @@ -26,6 +26,7 @@ env: PR_NUMBER: ${{ github.event.pull_request.number }} IMAGE_REGISTRY: "ghcr.io" IMAGE_REPOSITORY: "kubeshop/pr/botkube" + CFG_EXPORTER_IMAGE_REPOSITORY: "kubeshop/pr/botkube-config-exporter" IMAGE_TAG: ${{ github.event.pull_request.number }}-PR IMAGE_SAVE_LOAD_DIR: /tmp/botkube-images @@ -69,7 +70,7 @@ jobs: retention-days: 1 push-image: - name: Push Botkube image + name: Push images runs-on: ubuntu-latest needs: [ save-image ] diff --git a/.goreleaser.yml b/.goreleaser.yml index a0bc131bc0..94b4c08e36 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,7 +3,7 @@ env: - IMAGE_REGISTRY={{ if index .Env "IMAGE_REGISTRY" }}{{ .Env.IMAGE_REGISTRY }}{{ else }}ghcr.io{{ end }} - IMAGE_REPOSITORY={{ if index .Env "IMAGE_REPOSITORY" }}{{ .Env.IMAGE_REPOSITORY }}{{ else }}kubeshop/botkube{{ end }} - IMAGE_TAG={{ if index .Env "IMAGE_TAG" }}{{ .Env.IMAGE_TAG }}{{ else }}{{ .Tag }}{{ end }} - - MIGRATOR_IMAGE_REPOSITORY={{ if index .Env "MIGRATOR_IMAGE_REPOSITORY" }}{{ .Env.MIGRATOR_IMAGE_REPOSITORY }}{{ else }}kubeshop/botkube-migration{{ end }} + - CFG_EXPORTER_IMAGE_REPOSITORY={{ if index .Env "CFG_EXPORTER_IMAGE_REPOSITORY" }}{{ .Env.CFG_EXPORTER_IMAGE_REPOSITORY }}{{ else }}kubeshop/botkube-config-exporter{{ end }} - ANALYTICS_API_KEY={{ if index .Env "ANALYTICS_API_KEY" }}{{ .Env.ANALYTICS_API_KEY }}{{ else }}{{ end }} before: hooks: @@ -33,7 +33,10 @@ builds: main: cmd/cli/main.go ldflags: - -s -w - -X github.com/kubeshop/botkube/internal/cli/migrate.Tag={{ .Env.IMAGE_TAG }} + -X github.com/kubeshop/botkube/cmd/cli/cmd/migrate.DefaultImageTag={{ .Env.IMAGE_TAG }} + -X go.szostok.io/version.version={{.Version}} + -X go.szostok.io/version.buildDate={{.Date}} + -X go.szostok.io/version.name=botkube env: - CGO_ENABLED=0 goos: @@ -44,8 +47,8 @@ builds: - arm64 goarm: - "7" - - id: botkube-cloud-migration - binary: botkube-cloud-migration + - id: botkube-config-exporter + binary: botkube-config-exporter main: cmd/config-exporter/main.go env: - CGO_ENABLED=0 @@ -80,6 +83,7 @@ changelog: skip: false dockers: + # Botkube Agent - image_templates: - "{{.Env.IMAGE_REGISTRY}}/{{.Env.IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}-amd64" use: buildx @@ -104,19 +108,39 @@ dockers: build_flag_templates: - "--platform=linux/arm" - "--build-arg=botkube_version={{ .Env.IMAGE_TAG }}" + # Config Exporter - image_templates: - - "{{.Env.IMAGE_REGISTRY}}/{{.Env.MIGRATOR_IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}" + - "{{.Env.IMAGE_REGISTRY}}/{{.Env.CFG_EXPORTER_IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}-amd64" use: buildx - dockerfile: "build/Dockerfile.migration" + dockerfile: "build/Dockerfile.config_exporter" build_flag_templates: - "--platform=linux/amd64" - - "--build-arg=botkube_cloud_migration_version={{ .Env.IMAGE_TAG }}" + - "--build-arg=botkube_config_exporter_version={{ .Env.IMAGE_TAG }}" + - image_templates: + - "{{.Env.IMAGE_REGISTRY}}/{{.Env.CFG_EXPORTER_IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}-arm64" + use: buildx + goarch: arm64 + dockerfile: "build/Dockerfile.config_exporter" + build_flag_templates: + - "--platform=linux/arm64" + - "--build-arg=botkube_config_exporter_version={{ .Env.IMAGE_TAG }}" + - image_templates: + - "{{.Env.IMAGE_REGISTRY}}/{{.Env.CFG_EXPORTER_IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}-armv7" + use: buildx + goarch: arm + goarm: 7 + dockerfile: "build/Dockerfile.config_exporter" + build_flag_templates: + - "--platform=linux/arm" + - "--build-arg=botkube_config_exporter_version={{ .Env.IMAGE_TAG }}" docker_manifests: - name_template: "{{.Env.IMAGE_REGISTRY}}/{{.Env.IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}" image_templates: - "{{.Env.IMAGE_REGISTRY}}/{{.Env.IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}-amd64" - "{{.Env.IMAGE_REGISTRY}}/{{.Env.IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}-arm64" - "{{.Env.IMAGE_REGISTRY}}/{{.Env.IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}-armv7" - - name_template: "{{.Env.IMAGE_REGISTRY}}/{{.Env.MIGRATOR_IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}" + - name_template: "{{.Env.IMAGE_REGISTRY}}/{{.Env.CFG_EXPORTER_IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}" image_templates: - - "{{.Env.IMAGE_REGISTRY}}/{{.Env.MIGRATOR_IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}" + - "{{.Env.IMAGE_REGISTRY}}/{{.Env.CFG_EXPORTER_IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}-amd64" + - "{{.Env.IMAGE_REGISTRY}}/{{.Env.CFG_EXPORTER_IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}-arm64" + - "{{.Env.IMAGE_REGISTRY}}/{{.Env.CFG_EXPORTER_IMAGE_REPOSITORY}}:{{ .Env.IMAGE_TAG }}-armv7" diff --git a/Makefile b/Makefile index 769dbdcaeb..3731a15c06 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ test-integration-discord: system-check @go test -v -tags=integration -race -count=1 ./test/e2e/... -run "TestDiscord" test-migration-tool: system-check - @go test -v -tags=e2e -race -count=1 ./test/migration/e2e/... + @go test -v -tags=migration -race -count=1 ./test/e2e/... # Build the binary build: pre-build @@ -93,7 +93,7 @@ gen-plugins-index: build-plugins gen-docs-cli: rm -f ./cmd/cli/docs/* - go run cmd/cli/main.go gen-usage-docs + go run -ldflags="-X go.szostok.io/version.name=botkube" cmd/cli/main.go gen-usage-docs .PHONY: gen-docs-cli # Pre-build checks diff --git a/build/Dockerfile.config_exporter b/build/Dockerfile.config_exporter new file mode 100644 index 0000000000..393c471a88 --- /dev/null +++ b/build/Dockerfile.config_exporter @@ -0,0 +1,14 @@ +FROM gcr.io/distroless/static:latest + +ARG botkube_config_exporter_version="dev" + +LABEL org.opencontainers.image.source="git@github.com:kubeshop/botkube.git" \ + org.opencontainers.image.title="Botkube Config Exporter" \ + org.opencontainers.image.version="${botkube_config_exporter_version}" \ + org.opencontainers.image.description="Botkube Config Exporter fetches the Botkube configuration and stores it in a ConfigMap." \ + org.opencontainers.image.documentation="https://docs.botkube.io" \ + org.opencontainers.image.licenses="MIT" + +COPY botkube-config-exporter /usr/local/bin/botkube-config-exporter + +CMD ["botkube-config-exporter"] diff --git a/build/Dockerfile.migration b/build/Dockerfile.migration deleted file mode 100644 index 07a43f7848..0000000000 --- a/build/Dockerfile.migration +++ /dev/null @@ -1,14 +0,0 @@ -FROM gcr.io/distroless/static:latest - -ARG botkube_cloud_migration_version="dev" - -LABEL org.opencontainers.image.source="git@github.com:kubeshop/botkube.git" \ - org.opencontainers.image.title="Botkube Cloud Migration" \ - org.opencontainers.image.version="${botkube_cloud_migration_version}" \ - org.opencontainers.image.description="Botkube is a messaging bot for monitoring and debugging Kubernetes clusters" \ - org.opencontainers.image.documentation="https://docs.botkube.io" \ - org.opencontainers.image.licenses="MIT" - -COPY botkube-cloud-migration /usr/local/bin/botkube-cloud-migration - -CMD ["botkube-cloud-migration"] diff --git a/cmd/cli/README.md b/cmd/cli/README.md index 6a1d3747c6..4abfc15b12 100644 --- a/cmd/cli/README.md +++ b/cmd/cli/README.md @@ -45,9 +45,9 @@ The server is stopped after the callback is received. ### Migration -Once logged in, we create a pod in the same namespace as the Botkube instance that mounts the same -secrets and config maps as the Botkube pod and generates and stores the entire configuration in a -config map `botkube-migration`. +Once user is logged in, Botkube CLI creates a Pod in the same namespace where Botkube resides. Then, it mounts the same +Secrets and ConfigMaps as the Botkube Pod, and pulls entire configuration to a +ConfigMap `botkube-config-exporter`. Once we have the configuration, we can turn it into a API call and create identical resources in Botkube Cloud. diff --git a/cmd/cli/cmd/config/config.go b/cmd/cli/cmd/config/config.go index 2ea63217bf..51b54a04d9 100644 --- a/cmd/cli/cmd/config/config.go +++ b/cmd/cli/cmd/config/config.go @@ -32,11 +32,17 @@ func New() (*Config, error) { return c, nil } +const ( + dirPerms = 0755 + filePerms = 0644 +) + // Save saves Config to local FS func (c *Config) Save() error { - if _, err := os.Stat(filepath.Clean(filepath.Dir(configFilePath))); os.IsNotExist(err) { - // #nosec G301 - err := os.MkdirAll(filepath.Clean(filepath.Dir(configFilePath)), 0755) + cfgFileDir := filepath.Clean(filepath.Dir(configFilePath)) + cfgFilePath := filepath.Clean(configFilePath) + if _, err := os.Stat(cfgFileDir); os.IsNotExist(err) { + err = os.MkdirAll(cfgFileDir, dirPerms) if err != nil { return fmt.Errorf("failed to create config directory: %v", err) } @@ -48,7 +54,7 @@ func (c *Config) Save() error { } // #nosec G306 - err = os.WriteFile(filepath.Clean(configFilePath), data, 0644) + err = os.WriteFile(cfgFilePath, data, filePerms) if err != nil { return fmt.Errorf("failed to write config: %v", err) } diff --git a/cmd/cli/cmd/login.go b/cmd/cli/cmd/login.go index 8de7a677b0..98506b3aee 100644 --- a/cmd/cli/cmd/login.go +++ b/cmd/cli/cmd/login.go @@ -1,30 +1,19 @@ package cmd import ( - "context" - "fmt" - "io" - "net/http" "os" - "time" - "github.com/fatih/color" - "github.com/pkg/browser" "github.com/spf13/cobra" - "github.com/kubeshop/botkube/cmd/cli/cmd/config" "github.com/kubeshop/botkube/internal/cli" "github.com/kubeshop/botkube/internal/cli/heredoc" -) - -const ( - srvAddress = "localhost:8085" - loginURL = "http://localhost:3000/cli/login?cli_server_login=http://localhost:8085/login_redirect" - redirectURLSuccess = "http://localhost:3000/cli/login?success=true" + "github.com/kubeshop/botkube/internal/cli/login" ) // NewLogin returns a cobra.Command for logging into a Botkube Cloud. func NewLogin() *cobra.Command { + var opts login.Options + login := &cobra.Command{ Use: "login [OPTIONS]", Short: "Login to a Botkube Cloud", @@ -33,74 +22,13 @@ func NewLogin() *cobra.Command { login `, cli.Name), RunE: func(cmd *cobra.Command, args []string) error { - return runLogin(cmd.Context(), os.Stdout) + return login.Run(cmd.Context(), os.Stdout, opts) }, } - return login -} - -func runLogin(_ context.Context, w io.Writer) error { - t, err := fetchToken(srvAddress, loginURL) - if err != nil { - return err - } - - c := config.Config{Token: t.Token} - if err := c.Save(); err != nil { - return err - } - - okCheck := color.New(color.FgGreen).FprintlnFunc() - okCheck(w, "Login Succeeded") - - return nil -} - -type tokenResp struct { - Token string `json:"token"` -} - -func fetchToken(addr, authUrl string) (*tokenResp, error) { - ch := make(chan *tokenResp) - errCh := make(chan error) - - mux := http.NewServeMux() - mux.HandleFunc("/login_redirect", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, redirectURLSuccess, http.StatusFound) - - ch <- &tokenResp{ - Token: r.URL.Query().Get("token"), - } - }) - - s := http.Server{ - Addr: addr, - Handler: mux, - ReadHeaderTimeout: 2 * time.Second, - } - - go func() { - err := s.ListenAndServe() - if err != nil && err != http.ErrServerClosed { - errCh <- err - } - }() - - fmt.Println(heredoc.Docf(` - Your browser has been opened to visit: - %s - `, authUrl)) - err := browser.OpenURL(authUrl) - if err != nil { - return nil, fmt.Errorf("failed to open page: %v", err) - } + flags := login.Flags() + flags.StringVar(&opts.CloudDashboardURL, "cloud-dashboard-url", "https://app.botkube.io", "Botkube Cloud URL") + flags.StringVar(&opts.LocalServerAddress, "local-server-addr", "localhost:8085", "Address of a local server which is used for the login flow") - select { - case token := <-ch: - _ = s.Shutdown(context.Background()) - return token, nil - case err = <-errCh: - return nil, err - } + return login } diff --git a/cmd/cli/cmd/migrate.go b/cmd/cli/cmd/migrate.go index 6ecb3839e8..0f3ef9e4d7 100644 --- a/cmd/cli/cmd/migrate.go +++ b/cmd/cli/cmd/migrate.go @@ -3,12 +3,14 @@ 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/spf13/cobra" - "golang.org/x/exp/maps" + "go.szostok.io/version" corev1 "k8s.io/api/core/v1" "github.com/kubeshop/botkube/internal/cli" @@ -17,15 +19,14 @@ import ( "github.com/kubeshop/botkube/internal/cli/printer" ) -var ( - compatibleBotkubeVersions = map[string]bool{ - "v1.0.0": true, - "v1.0.1": true, - "v1.1.0": true, - "v1.2.0": true, - } +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 @@ -34,7 +35,8 @@ func NewMigrate() *cobra.Command { Use: "migrate [OPTIONS]", Short: "Automatically migrates Botkube installation into Botkube Cloud", Long: heredoc.WithCLIName(` - Automatically migrates Botkube installation into Botkube Cloud + Automatically migrates Botkube installation to Botkube Cloud. + This command will create a new Botkube Cloud instance based on your existing Botkube configuration, and upgrade your Botkube installation to use the remote configuration. Supported Botkube bot platforms for migration: - Socket Slack @@ -53,7 +55,7 @@ func NewMigrate() *cobra.Command { `, cli.Name), RunE: func(cmd *cobra.Command, args []string) (err error) { - status := printer.NewStatus(cmd.OutOrStdout(), "Migrating Botkube open source installation...") + status := printer.NewStatus(cmd.OutOrStdout(), "Migrating Botkube installation to Cloud") defer func() { status.End(err == nil) }() @@ -64,16 +66,34 @@ func NewMigrate() *cobra.Command { return err } - version, err := getBotkubeVersion(pod) + botkubeVersionStr, err := getBotkubeVersion(pod) if err != nil { return err } - status.Infof("Checking Botkube version %q compatibility", version) - if !compatibleBotkubeVersions[version] { + status.Infof("Checking if Botkube version %q can be migrated safely", botkubeVersionStr) + + constraint, err := semver.NewConstraint(botkubeVersionConstraints) + if err != nil { + return fmt.Errorf("unable to parse Botkube semver version constraints: %w", err) + } + + botkubeVersion, err := semver.NewVersion(botkubeVersionStr) + if err != nil { + return fmt.Errorf("unable to parse botkube version %s as semver: %w", botkubeVersion, err) + } + + isCompatible := constraint.Check(botkubeVersion) + if !isCompatible { run := false - suportedVersions := strings.Join(maps.Keys(compatibleBotkubeVersions), ", ") + prompt := &survey.Confirm{ - Message: fmt.Sprintf("Your Botkube version %q is not supported, migration might fail. Do you wish to continue?\nSupported versions: %s", version, suportedVersions), + Message: heredoc.Docf(` + + The migration process for the Botkube CLI you're using (version: %q) wasn't tested with your Botkube version on your cluster (%q). + Botkube version constraints for the currently installed CLI: %s + We recommend upgrading your CLI to the latest version. In order to do so, navigate to https://docs.botkube.io/. + + Do you wish to continue?`, version.Get().Version, botkubeVersion, botkubeVersionConstraints), Default: false, } @@ -96,28 +116,47 @@ func NewMigrate() *cobra.Command { okCheck := color.New(color.FgGreen).FprintlnFunc() okCheck(cmd.OutOrStdout(), "\nMigration Succeeded 🎉") - if opts.SkipConnect { + instanceURL := fmt.Sprintf("%s/instances/%s", opts.CloudDashboardURL, instanceID) + + if opts.SkipOpenBrowser { + fmt.Println(heredoc.Docf(` + Visit the URL to see your instance details: + %s + `, instanceURL)) return nil } - return browser.OpenURL(fmt.Sprintf("%s/instances/%s", opts.CloudDashboardURL, instanceID)) + + fmt.Println(heredoc.Docf(` + If your browser didn't open automatically, visit the URL to see your instance details: + %s + `, instanceURL)) + return browser.OpenURL(instanceURL) }, } flags := login.Flags() flags.StringVar(&opts.Token, "token", "", "Botkube Cloud authentication token") 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", "Botkube Cloud API URL") + 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.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.BoolVar(&opts.AutoUpgrade, "auto-upgrade", false, "Automatically upgrades Botkube instance without additional prompt") + 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.BoolVarP(&opts.Debug, "debug", "d", false, "Turn on debug logging") return login } func getBotkubeVersion(p *corev1.Pod) (string, error) { for _, c := range p.Spec.Containers { - if c.Name == "botkube" { + if c.Name == containerName { fqin := strings.Split(c.Image, ":") if len(fqin) > 1 { return fqin[len(fqin)-1], nil diff --git a/cmd/cli/cmd/root.go b/cmd/cli/cmd/root.go index 550829eb67..a897288d9d 100644 --- a/cmd/cli/cmd/root.go +++ b/cmd/cli/cmd/root.go @@ -2,11 +2,17 @@ package cmd import ( "github.com/spf13/cobra" + "go.szostok.io/version/extension" "github.com/kubeshop/botkube/internal/cli" "github.com/kubeshop/botkube/internal/cli/heredoc" ) +const ( + orgName = "kubeshop" + repoName = "botkube" +) + // NewRoot returns a root cobra.Command for the whole Botkube Cloud CLI. func NewRoot() *cobra.Command { rootCmd := &cobra.Command{ @@ -35,6 +41,9 @@ func NewRoot() *cobra.Command { NewLogin(), NewMigrate(), NewDocs(), + extension.NewVersionCobraCmd( + extension.WithUpgradeNotice(orgName, repoName), + ), ) return rootCmd diff --git a/cmd/cli/docs/botkube.md b/cmd/cli/docs/botkube.md index 175b1dda29..adf1c753d7 100644 --- a/cmd/cli/docs/botkube.md +++ b/cmd/cli/docs/botkube.md @@ -35,4 +35,5 @@ botkube [flags] * [botkube login](botkube_login.md) - Login to a Botkube Cloud * [botkube migrate](botkube_migrate.md) - Automatically migrates Botkube installation into Botkube Cloud +* [botkube version](botkube_version.md) - Print the CLI version diff --git a/cmd/cli/docs/botkube_login.md b/cmd/cli/docs/botkube_login.md index 683f7dcc16..30975a0103 100644 --- a/cmd/cli/docs/botkube_login.md +++ b/cmd/cli/docs/botkube_login.md @@ -21,7 +21,9 @@ botkube login ### Options ``` - -h, --help help for login + --cloud-dashboard-url string Botkube Cloud URL (default "https://app.botkube.io") + -h, --help help for login + --local-server-addr string Address of a local server which is used for the login flow (default "localhost:8085") ``` ### SEE ALSO diff --git a/cmd/cli/docs/botkube_migrate.md b/cmd/cli/docs/botkube_migrate.md index be5a0ec4a3..d9a4932d7e 100644 --- a/cmd/cli/docs/botkube_migrate.md +++ b/cmd/cli/docs/botkube_migrate.md @@ -8,7 +8,8 @@ Automatically migrates Botkube installation into Botkube Cloud ### Synopsis -Automatically migrates Botkube installation into Botkube Cloud +Automatically migrates Botkube installation to Botkube Cloud. +This command will create a new Botkube Cloud instance based on your existing Botkube configuration, and upgrade your Botkube installation to use the remote configuration. Supported Botkube bot platforms for migration: - Socket Slack @@ -34,14 +35,22 @@ botkube migrate [OPTIONS] [flags] ### Options ``` - --cloud-api-url string Botkube Cloud API URL (default "https://api.botkube.io") - --cloud-dashboard-url string Botkube Cloud URL (default "https://app.botkube.io") - -h, --help help for migrate - --instance-name string Botkube Cloud Instance name that will be created - -l, --label string Label of Botkube pod (default "app=botkube") - -n, --namespace string Namespace of Botkube pod (default "botkube") - -q, --skip-connect Skips connecting to Botkube Cloud after migration - --token string Botkube Cloud authentication token + --auto-upgrade Automatically upgrades Botkube instance without additional prompt + --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) + --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") + -d, --debug Turn on debug logging + -h, --help help for migrate + --instance-name string Botkube Cloud Instance name that will be created + -l, --label string Label of 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 + --token string Botkube Cloud authentication token ``` ### SEE ALSO diff --git a/cmd/cli/docs/botkube_version.md b/cmd/cli/docs/botkube_version.md new file mode 100644 index 0000000000..03e8c94634 --- /dev/null +++ b/cmd/cli/docs/botkube_version.md @@ -0,0 +1,34 @@ +--- +title: botkube version +--- + +## botkube version + +Print the CLI version + +``` +botkube version [flags] +``` + +### Examples + +``` + +botkube version +botkube version -o=json +botkube version -o=yaml +botkube version -o=short + +``` + +### Options + +``` + -h, --help help for version + -o, --output string Output format. One of: json | pretty | short | yaml (default "pretty") +``` + +### SEE ALSO + +* [botkube](botkube.md) - Botkube Cloud CLI + diff --git a/cmd/config-exporter/main.go b/cmd/config-exporter/main.go index c2c190e9a6..f902319556 100644 --- a/cmd/config-exporter/main.go +++ b/cmd/config-exporter/main.go @@ -15,6 +15,11 @@ import ( "github.com/kubeshop/botkube/pkg/config" ) +const ( + configMapName = "botkube-config-exporter" + configMapNamespace = "botkube" +) + func main() { files, _, err := cfginternal.NewEnvProvider().Configs(context.Background()) if err != nil { @@ -61,10 +66,10 @@ func newK8sClient() (client.Client, error) { func newCM() *corev1.ConfigMap { return &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Name: "botkube-migration", - Namespace: "botkube", + Name: configMapName, + Namespace: configMapNamespace, Labels: map[string]string{ - "app": "botkube-migration", + "app": configMapName, }, }, } diff --git a/go.mod b/go.mod index 3b257975e0..bac80d072b 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/aws/aws-sdk-go v1.44.122 github.com/briandowns/spinner v1.23.0 github.com/bwmarrin/discordgo v0.25.0 - github.com/dustin/go-humanize v1.0.0 + github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.13.0 github.com/go-playground/locales v0.14.0 github.com/go-playground/universal-translator v0.18.0 @@ -46,12 +46,13 @@ require ( github.com/sha1sum/aws_signing_client v0.0.0-20200229211254-f7815c59d5c1 github.com/sirupsen/logrus v1.9.0 github.com/slack-go/slack v0.12.2 - github.com/spf13/cobra v1.6.1 + github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 github.com/spiffe/spire v1.5.3 github.com/stretchr/testify v1.8.4 github.com/vrischmann/envconfig v1.3.0 github.com/xeipuuv/gojsonschema v1.2.0 + go.szostok.io/version v1.2.0 golang.org/x/exp v0.0.0-20230307190834-24139beb5833 golang.org/x/oauth2 v0.5.0 golang.org/x/sync v0.2.0 @@ -78,7 +79,11 @@ require ( cloud.google.com/go/storage v1.28.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/BurntSushi/toml v1.2.1 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.2.0 // indirect + github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/alexflint/go-scalar v1.1.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/blang/semver v3.5.1+incompatible // indirect @@ -102,6 +107,7 @@ require ( github.com/go-openapi/jsonreference v0.20.1 // indirect github.com/go-openapi/swag v0.22.3 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.11.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.4.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect @@ -121,8 +127,9 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // 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.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -137,13 +144,14 @@ require ( github.com/lestrrat-go/jwx v1.2.26 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect github.com/mattermost/logr v1.0.13 // indirect github.com/mattermost/logr/v2 v2.0.15 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/minio/md5-simd v1.1.2 // indirect @@ -160,6 +168,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/muesli/termenv v0.15.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/run v1.1.0 // indirect github.com/pborman/uuid v1.2.1 // indirect @@ -174,6 +183,8 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect github.com/sergi/go-diff v1.3.1 // indirect + github.com/shopspring/decimal v1.2.0 // indirect + github.com/spf13/cast v1.3.1 // indirect github.com/tinylib/msgp v1.1.6 // indirect github.com/ulikunitz/xz v0.5.10 // indirect github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect diff --git a/go.sum b/go.sum index 6556f2e22f..f670cb41a2 100644 --- a/go.sum +++ b/go.sum @@ -248,8 +248,14 @@ github.com/Julusian/godocdown v0.0.0-20170816220326-6d19f8ff2df8/go.mod h1:INZr5 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/glide v0.13.2/go.mod h1:STyF5vcenH/rUqTEv+/hBXlSTo7KYwg2oc2f4tzPWic= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/Masterminds/squirrel v1.5.0/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Masterminds/squirrel v1.5.2/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Masterminds/vcs v1.13.0/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA= @@ -372,6 +378,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.6.1/go.mod h1:hLZ/AnkIKHLuPGjEiyghNE github.com/aws/aws-sdk-go-v2/service/sts v1.7.2/go.mod h1:8EzeIqfWt2wWT4rJVu3f21TfrhJ8AEMzVybRNSb/b4g= github.com/aws/smithy-go v1.7.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= @@ -648,8 +656,9 @@ github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5Jflh github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dvyukov/go-fuzz v0.0.0-20210429054444-fca39067bc72/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09 h1:AQLr//nh20BzN3hIWj2+/Gt3FwSs8Nwo/nz4hMIcLPg= github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09/go.mod h1:nYia/MIs9OyvXXYboPmNOj0gVWo97Wx0sde+ZuKkoM4= @@ -846,6 +855,8 @@ github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/E github.com/goccy/go-json v0.4.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.11.0 h1:n7Z+zx8S9f9KgzG6KtQKf+kwqXZlLNR2F6018Dgau54= +github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng= github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4/go.mod h1:4Fw1eo5iaEhDUs8XyuhSVCVy52Jq3L+/3GJgYkwc+/0= github.com/gocql/gocql v0.0.0-20210515062232-b7ef815b4556/go.mod h1:DL0ekTmBSTdlNF25Orwt/JMzqIq3EJ4MVa/J/uK64OY= github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= @@ -1133,7 +1144,10 @@ github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs= github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E= +github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8= +github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -1149,8 +1163,8 @@ github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= -github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/infracloudio/msbotbuilder-go v0.2.5 h1:/FmQPW387kUAiLn7lngcCbBgvuYMUtZhA6Ry0t3R/HY= github.com/infracloudio/msbotbuilder-go v0.2.5/go.mod h1:kdhU1DN2E6cvo7we1EBOAHjU0kaIJPNYzzvdlHh+yhI= github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI= @@ -1356,6 +1370,8 @@ github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -1422,8 +1438,9 @@ github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= @@ -1517,6 +1534,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/smartcrop v0.2.1-0.20181030220600-548bbf0c0965/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI= github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI= +github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= +github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= @@ -1786,6 +1805,7 @@ github.com/sha1sum/aws_signing_client v0.0.0-20200229211254-f7815c59d5c1 h1:k3oI github.com/sha1sum/aws_signing_client v0.0.0-20200229211254-f7815c59d5c1/go.mod h1:hPj3jKAamv0ryZvssbqkCeOWYFmy9itWMSOD7tDsE3E= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= @@ -1842,6 +1862,7 @@ github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= @@ -1850,8 +1871,8 @@ github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHN github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= -github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= -github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= @@ -2057,6 +2078,8 @@ go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5f go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 h1:+FNtrFTmVw0YZGpBGX56XDee331t6JAXeK2bcyhLOOc= go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5/go.mod h1:nmDLcffg48OtT/PSW0Hg7FvpRQsQh5OSqIylirxKC7o= +go.szostok.io/version v1.2.0 h1:8eMMdfsonjbibwZRLJ8TnrErY8bThFTQsZYV16mcXms= +go.szostok.io/version v1.2.0/go.mod h1:EiU0gPxaXb6MZ+apSN0WgDO6F4JXyC99k9PIXf2k2E8= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= @@ -2118,6 +2141,7 @@ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -2272,6 +2296,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= @@ -2473,6 +2498,7 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= @@ -2481,6 +2507,7 @@ golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXR golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= diff --git a/hack/goreleaser.sh b/hack/goreleaser.sh index 0d5db5bb52..c1f1bd9ae2 100755 --- a/hack/goreleaser.sh +++ b/hack/goreleaser.sh @@ -5,6 +5,7 @@ set -o pipefail IMAGE_REGISTRY="${IMAGE_REGISTRY:-ghcr.io}" IMAGE_REPOSITORY="${IMAGE_REPOSITORY:-kubeshop/botkube}" +CFG_EXPORTER_IMAGE_REPOSITORY="${CFG_EXPORTER_IMAGE_REPOSITORY:-kubeshop/botkube-config-exporter}" IMAGE_SAVE_LOAD_DIR="${IMAGE_SAVE_LOAD_DIR:-/tmp/botkube-images}" IMAGE_PLATFORM="${IMAGE_PLATFORM:-linux/amd64}" @@ -22,7 +23,10 @@ release_snapshot() { docker push ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-amd64 docker push ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-arm64 docker push ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 - docker push ${IMAGE_REGISTRY}/${MIGRATOR_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG} + + docker push ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-amd64 + docker push ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-arm64 + docker push ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 # Create manifest docker manifest create ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG} \ @@ -31,10 +35,12 @@ release_snapshot() { --amend ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 docker manifest push ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG} - # Create migrator manifest - docker manifest create ${IMAGE_REGISTRY}/${MIGRATOR_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG} \ - --amend ${IMAGE_REGISTRY}/${MIGRATOR_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG} - docker manifest push ${IMAGE_REGISTRY}/${MIGRATOR_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG} + # Create Config Exporter manifest + docker manifest create ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG} \ + --amend ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-amd64 \ + --amend ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-arm64 \ + --amend ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 + docker manifest push ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG} } release_snapshot_cli() { @@ -61,6 +67,11 @@ save_images() { docker save ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-amd64 >${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-amd64.tar docker save ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-arm64 >${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-arm64.tar docker save ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 >${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-armv7.tar + + CFG_EXPORTER_IMAGE_FILE_NAME_PREFIX=$(echo "${CFG_EXPORTER_IMAGE_REPOSITORY}" | tr "/" "-") + docker save ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-amd64 >${IMAGE_SAVE_LOAD_DIR}/${CFG_EXPORTER_IMAGE_FILE_NAME_PREFIX}-amd64.tar + docker save ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-arm64 >${IMAGE_SAVE_LOAD_DIR}/${CFG_EXPORTER_IMAGE_FILE_NAME_PREFIX}-arm64.tar + docker save ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 >${IMAGE_SAVE_LOAD_DIR}/${CFG_EXPORTER_IMAGE_FILE_NAME_PREFIX}-armv7.tar } load_and_push_images() { @@ -78,17 +89,32 @@ load_and_push_images() { docker load --input ${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-arm64.tar docker load --input ${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-armv7.tar + CFG_EXPORTER_IMAGE_FILE_NAME_PREFIX=$(echo "${CFG_EXPORTER_IMAGE_REPOSITORY}" | tr "/" "-") + docker load --input ${IMAGE_SAVE_LOAD_DIR}/${CFG_EXPORTER_IMAGE_FILE_NAME_PREFIX}-amd64.tar + docker load --input ${IMAGE_SAVE_LOAD_DIR}/${CFG_EXPORTER_IMAGE_FILE_NAME_PREFIX}-arm64.tar + docker load --input ${IMAGE_SAVE_LOAD_DIR}/${CFG_EXPORTER_IMAGE_FILE_NAME_PREFIX}-armv7.tar + # Push images docker push ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-amd64 docker push ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-arm64 docker push ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 + docker push ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-amd64 + docker push ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-arm64 + docker push ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 + # Create manifest docker manifest create ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG} \ --amend ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-amd64 \ --amend ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-arm64 \ --amend ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 docker manifest push ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG} + + docker manifest create ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG} \ + --amend ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-amd64 \ + --amend ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-arm64 \ + --amend ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 + docker manifest push ${IMAGE_REGISTRY}/${CFG_EXPORTER_IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG} } build() { diff --git a/internal/cli/login/login.go b/internal/cli/login/login.go new file mode 100644 index 0000000000..2b8357dea9 --- /dev/null +++ b/internal/cli/login/login.go @@ -0,0 +1,96 @@ +package login + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/fatih/color" + "github.com/pkg/browser" + + "github.com/kubeshop/botkube/cmd/cli/cmd/config" + "github.com/kubeshop/botkube/internal/cli/heredoc" +) + +const ( + loginURLFmt = "%s/cli/login?redirect_url=http://%s/login_redirect" + redirectURLSuccessFmt = "%s/cli/login?success=true" +) + +func Run(ctx context.Context, w io.Writer, opts Options) error { + loginURL := fmt.Sprintf(loginURLFmt, opts.CloudDashboardURL, opts.LocalServerAddress) + successURL := fmt.Sprintf(redirectURLSuccessFmt, opts.CloudDashboardURL) + + t, err := runServer(ctx, opts.LocalServerAddress, loginURL, successURL) + if err != nil { + return err + } + + c := config.Config{Token: t.Token} + if err := c.Save(); err != nil { + return err + } + + okCheck := color.New(color.FgGreen).FprintlnFunc() + okCheck(w, "Login Succeeded") + + return nil +} + +type tokenResp struct { + Token string `json:"token"` +} + +func runServer(ctx context.Context, srvAddr, authURL, successURL string) (*tokenResp, error) { + ch := make(chan *tokenResp) + errCh := make(chan error, 2) + + mux := http.NewServeMux() + mux.HandleFunc("/login_redirect", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, successURL, http.StatusFound) + + ch <- &tokenResp{ + Token: r.URL.Query().Get("token"), + } + }) + + s := http.Server{ + Addr: srvAddr, + Handler: mux, + ReadHeaderTimeout: 2 * time.Second, + } + + go func() { + err := s.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + go func(ctx context.Context) { + <-ctx.Done() + fmt.Println("Shutting down server...") + _ = s.Shutdown(context.Background()) + errCh <- errors.New("login process has been cancelled") + }(ctx) + + fmt.Println(heredoc.Docf(` + If your browser didn't open automatically, visit the URL to finish the login process: + %s + `, authURL)) + err := browser.OpenURL(authURL) + if err != nil { + return nil, fmt.Errorf("failed to open page: %v", err) + } + + select { + case token := <-ch: + _ = s.Shutdown(context.Background()) + return token, nil + case err = <-errCh: + return nil, err + } +} diff --git a/internal/cli/login/opts.go b/internal/cli/login/opts.go new file mode 100644 index 0000000000..2affa43c03 --- /dev/null +++ b/internal/cli/login/opts.go @@ -0,0 +1,7 @@ +package login + +// Options holds migrate possible configuration options. +type Options struct { + CloudDashboardURL string + LocalServerAddress string +} diff --git a/internal/cli/migrate/migrate.go b/internal/cli/migrate/migrate.go index 8f62e5857c..8bd144ce63 100644 --- a/internal/cli/migrate/migrate.go +++ b/internal/cli/migrate/migrate.go @@ -18,27 +18,25 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd" - "sigs.k8s.io/yaml" cliconfig "github.com/kubeshop/botkube/cmd/cli/cmd/config" "github.com/kubeshop/botkube/internal/cli/printer" "github.com/kubeshop/botkube/internal/ptr" gqlModel "github.com/kubeshop/botkube/internal/remote/graphql" bkconfig "github.com/kubeshop/botkube/pkg/config" + "github.com/kubeshop/botkube/pkg/multierror" ) const ( - migrationName = "botkube-migration" - jobImage = "ghcr.io/kubeshop/botkube-migration" -) + migrationJobName = "botkube-migration" + configMapName = "botkube-config-exporter" -var ( - Tag = "latest" + instanceDetailsURLFmt = "%s/instances/%s" ) // Run runs the migration process. func Run(ctx context.Context, status *printer.StatusPrinter, config []byte, opts Options) (string, error) { - var authToken string = opts.Token + authToken := opts.Token if authToken == "" { cfg, err := cliconfig.New() if err != nil { @@ -107,6 +105,7 @@ func migrate(ctx context.Context, status *printer.StatusPrinter, opts Options, b aliases := converter.ConvertAliases(botkubeClusterConfig.Aliases, mutation.CreateDeployment.ID) status.Step("Converted %d aliases", len(aliases)) + errs := multierror.New() for _, alias := range aliases { status.Step("Migrating Alias %q", alias.Name) var aliasMutation struct { @@ -118,10 +117,15 @@ func migrate(ctx context.Context, status *printer.StatusPrinter, opts Options, b "input": *alias, }) if err != nil { - return "", errors.Wrap(err, "while creating alias") + errs = multierror.Append(errs, fmt.Errorf("while creating Alias %q: %w", alias.Name, err)) + continue } } + if errs.ErrorOrNil() != nil { + return "", fmt.Errorf("while migrating aliases: %w%s", errs.ErrorOrNil(), errStateMessage(opts.CloudDashboardURL, mutation.CreateDeployment.ID)) + } + if opts.SkipConnect { status.End(true) return mutation.CreateDeployment.ID, nil @@ -140,17 +144,11 @@ func migrate(ctx context.Context, status *printer.StatusPrinter, opts Options, b } status.InfoWithBody("Connect Botkube instance", bldr.String()) - run := false - prompt := &survey.Confirm{ - Message: "Would you like to continue?", - Default: true, - } - - err = survey.AskOne(prompt, &run) + shouldUpgrade, err := shouldUpgradeInstallation(opts) if err != nil { - return "", errors.Wrap(err, "while asking for confirmation") + return "", err } - if !run { + if !shouldUpgrade { status.Infof("Skipping command execution. Remember to run it manually to finish the migration process.") return mutation.CreateDeployment.ID, nil } @@ -209,7 +207,7 @@ func GetConfigFromCluster(ctx context.Context, opts Options) ([]byte, *corev1.Po return nil, nil, err } - if err = createMigrationJob(ctx, k8sCli, botkubePod); err != nil { + if err = createMigrationJob(ctx, k8sCli, botkubePod, opts.ConfigExporter); err != nil { return nil, nil, err } @@ -242,7 +240,7 @@ func getBotkubePod(ctx context.Context, k8sCli *kubernetes.Clientset, opts Optio return &pods.Items[0], nil } -func createMigrationJob(ctx context.Context, k8sCli *kubernetes.Clientset, botkubePod *corev1.Pod) error { +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" { @@ -253,10 +251,10 @@ func createMigrationJob(ctx context.Context, k8sCli *kubernetes.Clientset, botku job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ - Name: migrationName, + Name: migrationJobName, Namespace: botkubePod.Namespace, Labels: map[string]string{ - "app": migrationName, + "app": migrationJobName, "botkube.io/migration": "true", }, }, @@ -265,8 +263,8 @@ func createMigrationJob(ctx context.Context, k8sCli *kubernetes.Clientset, botku Spec: corev1.PodSpec{ Containers: []corev1.Container{ { - Name: migrationName, - Image: fmt.Sprintf("%s:%s", jobImage, Tag), + Name: migrationJobName, + Image: fmt.Sprintf("%s/%s:%s", cfg.Registry, cfg.Repository, cfg.Tag), ImagePullPolicy: corev1.PullIfNotPresent, Env: container.Env, VolumeMounts: container.VolumeMounts, @@ -286,29 +284,42 @@ func createMigrationJob(ctx context.Context, k8sCli *kubernetes.Clientset, botku } 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 - var err error - for i := 0; i < 30; i++ { - job, err = k8sCli.BatchV1().Jobs(opts.Namespace).Get(ctx, migrationName, metav1.GetOptions{}) - if err != nil { - return err + for { + select { + case <-ctxWithTimeout.Done(): + + errMsg := fmt.Sprintf("migration job failed: %s", context.Canceled.Error()) + + if opts.Debug && 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 + } } - if job.Status.Succeeded > 0 { - return nil - } - time.Sleep(2 * time.Second) - } - - dat, err := yaml.Marshal(job.Status) - if err != nil { - return err } - - return fmt.Errorf("migration job failed: %s", string(dat)) } func readConfigFromCM(ctx context.Context, k8sCli *kubernetes.Clientset, opts Options) ([]byte, error) { - configMap, err := k8sCli.CoreV1().ConfigMaps(opts.Namespace).Get(ctx, migrationName, metav1.GetOptions{}) + configMap, err := k8sCli.CoreV1().ConfigMaps(opts.Namespace).Get(ctx, configMapName, metav1.GetOptions{}) if err != nil { return nil, err } @@ -317,6 +328,30 @@ func readConfigFromCM(ctx context.Context, k8sCli *kubernetes.Clientset, opts Op func cleanup(ctx context.Context, k8sCli *kubernetes.Clientset, opts Options) { foreground := metav1.DeletePropagationForeground - _ = k8sCli.BatchV1().Jobs(opts.Namespace).Delete(ctx, migrationName, metav1.DeleteOptions{PropagationPolicy: &foreground}) - _ = k8sCli.CoreV1().ConfigMaps(opts.Namespace).Delete(ctx, migrationName, metav1.DeleteOptions{}) + _ = 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)) +} + +func shouldUpgradeInstallation(opts Options) (bool, error) { + if opts.AutoUpgrade { + return true, nil + } + + run := false + prompt := &survey.Confirm{ + Message: "Would you like to continue?", + Default: true, + } + + err := survey.AskOne(prompt, &run) + if err != nil { + return false, errors.Wrap(err, "while asking for confirmation") + } + + return run, nil } diff --git a/internal/cli/migrate/opts.go b/internal/cli/migrate/opts.go index e10909d756..efe3aebb44 100644 --- a/internal/cli/migrate/opts.go +++ b/internal/cli/migrate/opts.go @@ -1,7 +1,10 @@ package migrate +import "time" + // Options holds migrate possible configuration options. type Options struct { + Debug bool Token string InstanceName string `survey:"instanceName"` CloudDashboardURL string @@ -9,4 +12,17 @@ type Options struct { Namespace string Label string SkipConnect bool + SkipOpenBrowser bool + AutoUpgrade 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 } diff --git a/test/e2e/discord_driver_test.go b/test/commplatform/discord_tester.go similarity index 76% rename from test/e2e/discord_driver_test.go rename to test/commplatform/discord_tester.go index be669464da..d67e85be2c 100644 --- a/test/e2e/discord_driver_test.go +++ b/test/commplatform/discord_tester.go @@ -1,6 +1,4 @@ -//go:build integration - -package e2e +package commplatform import ( "context" @@ -10,6 +8,8 @@ import ( "testing" "time" + "github.com/kubeshop/botkube/test/diff" + "github.com/araddon/dateparse" "github.com/bwmarrin/discordgo" "github.com/google/uuid" @@ -35,7 +35,19 @@ func (s *DiscordChannel) Identifier() string { return s.Channel.ID } -type discordTester struct { +type DiscordConfig struct { + BotName string `envconfig:"optional"` + BotID string `envconfig:"default=983294404108378154"` + TesterName string `envconfig:"optional"` + TesterID string `envconfig:"default=1020384322114572381"` + AdditionalContextMessage string `envconfig:"optional"` + GuildID string + TesterAppToken string + RecentMessagesLimit int `envconfig:"default=6"` + MessageWaitTimeout time.Duration `envconfig:"default=30s"` +} + +type DiscordTester struct { cli *discordgo.Session cfg DiscordConfig botUserID string @@ -46,43 +58,43 @@ type discordTester struct { mdFormatter interactive.MDFormatter } -func newDiscordDriver(discordCfg DiscordConfig) (BotDriver, error) { +func NewDiscordTester(discordCfg DiscordConfig) (BotDriver, error) { discordCli, err := discordgo.New("Bot " + discordCfg.TesterAppToken) if err != nil { return nil, fmt.Errorf("while creating Discord session: %w", err) } - return &discordTester{cli: discordCli, cfg: discordCfg, mdFormatter: interactive.DefaultMDFormatter()}, nil + return &DiscordTester{cli: discordCli, cfg: discordCfg, mdFormatter: interactive.DefaultMDFormatter()}, nil } -func (d *discordTester) Type() DriverType { +func (d *DiscordTester) Type() DriverType { return DiscordBot } -func (d *discordTester) BotName() string { +func (d *DiscordTester) BotName() string { return "@Botkube" } -func (d *discordTester) BotUserID() string { +func (d *DiscordTester) BotUserID() string { return d.botUserID } -func (d *discordTester) TesterUserID() string { +func (d *DiscordTester) TesterUserID() string { return d.testerUserID } -func (d *discordTester) Channel() Channel { +func (d *DiscordTester) Channel() Channel { return d.channel } -func (d *discordTester) SecondChannel() Channel { +func (d *DiscordTester) SecondChannel() Channel { return d.secondChannel } -func (d *discordTester) ThirdChannel() Channel { +func (d *DiscordTester) ThirdChannel() Channel { return d.thirdChannel } -func (d *discordTester) InitUsers(t *testing.T) { +func (d *DiscordTester) InitUsers(t *testing.T) { t.Helper() d.botUserID = d.cfg.BotID @@ -100,15 +112,15 @@ func (d *discordTester) InitUsers(t *testing.T) { } } -func (d *discordTester) InitChannels(t *testing.T) []func() { - channel, cleanupChannelFn := d.createChannel(t, "first") - d.channel = &DiscordChannel{Channel: channel} +func (d *DiscordTester) InitChannels(t *testing.T) []func() { + channel, cleanupChannelFn := d.CreateChannel(t, "first") + d.channel = channel - secondChannel, cleanupSecondChannelFn := d.createChannel(t, "second") - d.secondChannel = &DiscordChannel{Channel: secondChannel} + secondChannel, cleanupSecondChannelFn := d.CreateChannel(t, "second") + d.secondChannel = secondChannel - thirdChannel, cleanupThirdChannelFn := d.createChannel(t, "rbac") - d.thirdChannel = &DiscordChannel{Channel: thirdChannel} + thirdChannel, cleanupThirdChannelFn := d.CreateChannel(t, "rbac") + d.thirdChannel = thirdChannel return []func(){ func() { cleanupChannelFn(t) }, @@ -117,7 +129,30 @@ func (d *discordTester) InitChannels(t *testing.T) []func() { } } -func (d *discordTester) PostInitialMessage(t *testing.T, channelID string) { +// CreateChannel creates Discord channel. +func (d *DiscordTester) CreateChannel(t *testing.T, prefix string) (Channel, func(t *testing.T)) { + t.Helper() + randomID := uuid.New() + channelName := fmt.Sprintf("%s-%s-%s", channelNamePrefix, prefix, randomID.String()) + + t.Logf("Creating channel %q...", channelName) + channel, err := d.cli.GuildChannelCreate(d.cfg.GuildID, channelName, discordgo.ChannelTypeGuildText) + require.NoError(t, err) + + t.Logf("Channel %q (ID: %q) created", channelName, channel.ID) + + cleanupFn := func(t *testing.T) { + t.Helper() + t.Logf("Deleting channel %q...", channel.Name) + // We cannot archive a channel: https://support.discord.com/hc/en-us/community/posts/360042842012-Archive-old-chat-channels + _, err := d.cli.ChannelDelete(channel.ID) + assert.NoError(t, err) + } + + return &DiscordChannel{channel}, cleanupFn +} + +func (d *DiscordTester) PostInitialMessage(t *testing.T, channelID string) { t.Helper() t.Logf("Posting welcome message for channel: %s...", channelID) @@ -130,47 +165,47 @@ func (d *discordTester) PostInitialMessage(t *testing.T, channelID string) { require.NoError(t, err) } -func (d *discordTester) PostMessageToBot(t *testing.T, channel, command string) { +func (d *DiscordTester) PostMessageToBot(t *testing.T, channel, command string) { message := fmt.Sprintf("<@%s> %s", d.botUserID, command) _, err := d.cli.ChannelMessageSend(channel, message) require.NoError(t, err) } -func (d *discordTester) InviteBotToChannel(_ *testing.T, _ string) { +func (d *DiscordTester) InviteBotToChannel(_ *testing.T, _ string) { // This is not required in Discord. // Bots can't "join" text channels because when you join a server you're already in every text channel. // See: https://stackoverflow.com/questions/60990748/making-discord-bot-join-leave-a-channel } -func (d *discordTester) WaitForMessagePostedRecentlyEqual(userID, channelID, expectedMsg string) error { - return d.WaitForMessagePosted(userID, channelID, recentMessagesLimit, func(msg string) (bool, int, string) { +func (d *DiscordTester) WaitForMessagePostedRecentlyEqual(userID, channelID, expectedMsg string) error { + return d.WaitForMessagePosted(userID, channelID, d.cfg.RecentMessagesLimit, func(msg string) (bool, int, string) { if !strings.EqualFold(expectedMsg, msg) { - count := countMatchBlock(expectedMsg, msg) - msgDiff := diff(expectedMsg, msg) + count := diff.CountMatchBlock(expectedMsg, msg) + msgDiff := diff.Diff(expectedMsg, msg) return false, count, msgDiff } return true, 0, "" }) } -func (d *discordTester) WaitForLastMessageContains(userID, channelID, expectedMsgSubstring string) error { +func (d *DiscordTester) WaitForLastMessageContains(userID, channelID, expectedMsgSubstring string) error { return d.WaitForMessagePosted(userID, channelID, 1, func(msg string) (bool, int, string) { return strings.Contains(msg, expectedMsgSubstring), 0, "" }) } -func (d *discordTester) WaitForLastMessageEqual(userID, channelID, expectedMsg string) error { +func (d *DiscordTester) WaitForLastMessageEqual(userID, channelID, expectedMsg string) error { return d.WaitForMessagePosted(userID, channelID, 1, func(msg string) (bool, int, string) { if msg != expectedMsg { - count := countMatchBlock(expectedMsg, msg) - msgDiff := diff(expectedMsg, msg) + count := diff.CountMatchBlock(expectedMsg, msg) + msgDiff := diff.Diff(expectedMsg, msg) return false, count, msgDiff } return true, 0, "" }) } -func (d *discordTester) WaitForMessagePosted(userID, channelID string, limitMessages int, assertFn MessageAssertion) error { +func (d *DiscordTester) WaitForMessagePosted(userID, channelID string, limitMessages int, assertFn MessageAssertion) error { // To always receive message content: // ensure you enable the MESSAGE CONTENT INTENT for the tester bot on the developer portal. // Applications ↦ Settings ↦ Bot ↦ Privileged Gateway Intents @@ -225,11 +260,11 @@ func (d *discordTester) WaitForMessagePosted(userID, channelID string, limitMess return nil } -func (d *discordTester) WaitForInteractiveMessagePosted(userID, channelID string, limitMessages int, assertFn MessageAssertion) error { +func (d *DiscordTester) WaitForInteractiveMessagePosted(userID, channelID string, limitMessages int, assertFn MessageAssertion) error { return d.WaitForMessagePosted(userID, channelID, limitMessages, assertFn) } -func (d *discordTester) WaitForMessagePostedWithFileUpload(userID, channelID string, assertFn FileUploadAssertion) error { +func (d *DiscordTester) WaitForMessagePostedWithFileUpload(userID, channelID string, assertFn FileUploadAssertion) error { // To always receive message content: // ensure you enable the MESSAGE CONTENT INTENT for the tester bot on the developer portal. // Applications ↦ Settings ↦ Bot ↦ Privileged Gateway Intents @@ -280,7 +315,7 @@ func (d *discordTester) WaitForMessagePostedWithFileUpload(userID, channelID str return nil } -func (d *discordTester) WaitForMessagePostedWithAttachment(userID, channelID string, limitMessages int, assertFn ExpAttachmentInput) error { +func (d *DiscordTester) WaitForMessagePostedWithAttachment(userID, channelID string, limitMessages int, assertFn ExpAttachmentInput) error { // To always receive message content: // ensure you enable the MESSAGE CONTENT INTENT for the tester bot on the developer portal. // Applications ↦ Settings ↦ Bot ↦ Privileged Gateway Intents @@ -332,7 +367,7 @@ func (d *discordTester) WaitForMessagePostedWithAttachment(userID, channelID str return false, err } - if err = timeWithinDuration(expTime, gotEventTime, time.Minute); err != nil { + if err = diff.TimeWithinDuration(expTime, gotEventTime, time.Minute); err != nil { return false, err } gotEmbed.Timestamp = "" // reset so it doesn't impact static content assertion @@ -369,31 +404,31 @@ func (f fakeT) Errorf(format string, args ...interface{}) { fmt.Printf("%s: %s", f.Context, msg) } -func (d *discordTester) WaitForInteractiveMessagePostedRecentlyEqual(userID, channelID string, msg interactive.CoreMessage) error { +func (d *DiscordTester) WaitForInteractiveMessagePostedRecentlyEqual(userID, channelID string, msg interactive.CoreMessage) error { markdown := strings.TrimSpace(interactive.RenderMessage(d.mdFormatter, msg)) - return d.WaitForMessagePosted(userID, channelID, recentMessagesLimit, func(msg string) (bool, int, string) { + return d.WaitForMessagePosted(userID, channelID, d.cfg.RecentMessagesLimit, func(msg string) (bool, int, string) { if !strings.EqualFold(markdown, msg) { - count := countMatchBlock(markdown, msg) - msgDiff := diff(markdown, msg) + count := diff.CountMatchBlock(markdown, msg) + msgDiff := diff.Diff(markdown, msg) return false, count, msgDiff } return true, 0, "" }) } -func (d *discordTester) WaitForLastInteractiveMessagePostedEqual(userID, channelID string, msg interactive.CoreMessage) error { +func (d *DiscordTester) WaitForLastInteractiveMessagePostedEqual(userID, channelID string, msg interactive.CoreMessage) error { markdown := strings.TrimSpace(interactive.RenderMessage(d.mdFormatter, msg)) return d.WaitForMessagePosted(userID, channelID, 1, func(msg string) (bool, int, string) { if !strings.EqualFold(markdown, msg) { - count := countMatchBlock(markdown, msg) - msgDiff := diff(markdown, msg) + count := diff.CountMatchBlock(markdown, msg) + msgDiff := diff.Diff(markdown, msg) return false, count, msgDiff } return true, 0, "" }) } -func (d *discordTester) findUserID(t *testing.T, name string) string { +func (d *DiscordTester) findUserID(t *testing.T, name string) string { t.Logf("Getting user %q...", name) res, err := d.cli.GuildMembersSearch(d.cfg.GuildID, name, 50) require.NoError(t, err) @@ -408,25 +443,3 @@ func (d *discordTester) findUserID(t *testing.T, name string) string { return "" } - -func (d *discordTester) createChannel(t *testing.T, prefix string) (*discordgo.Channel, func(t *testing.T)) { - t.Helper() - randomID := uuid.New() - channelName := fmt.Sprintf("%s-%s-%s", channelNamePrefix, prefix, randomID.String()) - - t.Logf("Creating channel %q...", channelName) - channel, err := d.cli.GuildChannelCreate(d.cfg.GuildID, channelName, discordgo.ChannelTypeGuildText) - require.NoError(t, err) - - t.Logf("Channel %q (ID: %q) created", channelName, channel.ID) - - cleanupFn := func(t *testing.T) { - t.Helper() - t.Logf("Deleting channel %q...", channel.Name) - // We cannot archive a channel: https://support.discord.com/hc/en-us/community/posts/360042842012-Archive-old-chat-channels - _, err := d.cli.ChannelDelete(channel.ID) - assert.NoError(t, err) - } - - return channel, cleanupFn -} diff --git a/test/e2e/bots_tester_test.go b/test/commplatform/generic.go similarity index 76% rename from test/e2e/bots_tester_test.go rename to test/commplatform/generic.go index 70e4d4ec4f..9e74e23ff6 100644 --- a/test/e2e/bots_tester_test.go +++ b/test/commplatform/generic.go @@ -1,36 +1,19 @@ -//go:build integration - -package e2e +package commplatform import ( - "regexp" "testing" "time" - "github.com/sanity-io/litter" - "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/bot/interactive" ) -const recentMessagesLimit = 6 - -// structDumper provides an option to print the struct in more readable way. -var structDumper = litter.Options{ - HidePrivateFields: true, - HideZeroValues: true, - StripPackageNames: false, - FieldExclusions: regexp.MustCompile(`^(XXX_.*)$`), // XXX_ is a prefix of fields generated by protoc-gen-go - Separator: " ", -} - -type MessageAssertion func(content string) (bool, int, string) -type FileUploadAssertion func(title, mimetype string) bool +const ( + channelNamePrefix = "test" + welcomeText = "Let the tests begin 🤞" -type ExpAttachmentInput struct { - Message api.Message - AllowedTimestampDelta time.Duration -} + pollInterval = time.Second +) type Channel interface { ID() string @@ -38,19 +21,11 @@ type Channel interface { Identifier() string } -// DriverType to instrument -type DriverType string - -const ( - // CreateEvent when resource is created - SlackBot DriverType = "slack" - // UpdateEvent when resource is updated - DiscordBot DriverType = "discord" -) - type BotDriver interface { Type() DriverType InitUsers(t *testing.T) + + CreateChannel(t *testing.T, prefix string) (Channel, func(t *testing.T)) InitChannels(t *testing.T) []func() PostInitialMessage(t *testing.T, channel string) PostMessageToBot(t *testing.T, channel, command string) @@ -71,3 +46,20 @@ type BotDriver interface { WaitForInteractiveMessagePostedRecentlyEqual(userID string, channelID string, message interactive.CoreMessage) error WaitForLastInteractiveMessagePostedEqual(userID string, channelID string, message interactive.CoreMessage) error } + +type MessageAssertion func(content string) (bool, int, string) + +type FileUploadAssertion func(title, mimetype string) bool + +type ExpAttachmentInput struct { + Message api.Message + AllowedTimestampDelta time.Duration +} + +// DriverType to instrument +type DriverType string + +const ( + SlackBot DriverType = "slack" + DiscordBot DriverType = "discord" +) diff --git a/test/e2e/slack_driver_test.go b/test/commplatform/slack_tester.go similarity index 75% rename from test/e2e/slack_driver_test.go rename to test/commplatform/slack_tester.go index de8027e321..9420905e3f 100644 --- a/test/e2e/slack_driver_test.go +++ b/test/commplatform/slack_tester.go @@ -1,6 +1,4 @@ -//go:build integration - -package e2e +package commplatform import ( "context" @@ -10,6 +8,8 @@ import ( "testing" "time" + "github.com/kubeshop/botkube/test/diff" + "github.com/araddon/dateparse" "github.com/google/uuid" "github.com/slack-go/slack" @@ -22,6 +22,15 @@ import ( "github.com/kubeshop/botkube/pkg/formatx" ) +type SlackConfig struct { + BotName string `envconfig:"default=botkube"` + TesterName string `envconfig:"default=tester"` + AdditionalContextMessage string `envconfig:"optional"` + TesterAppToken string + RecentMessagesLimit int `envconfig:"default=6"` + MessageWaitTimeout time.Duration `envconfig:"default=30s"` +} + type SlackChannel struct { *slack.Channel } @@ -36,7 +45,7 @@ func (s *SlackChannel) Identifier() string { return s.Channel.Name } -type slackTester struct { +type SlackTester struct { cli *slack.Client cfg SlackConfig botUserID string @@ -47,7 +56,7 @@ type slackTester struct { mdFormatter interactive.MDFormatter } -func newSlackDriver(slackCfg SlackConfig) (BotDriver, error) { +func NewSlackTester(slackCfg SlackConfig) (BotDriver, error) { slackCli := slack.New(slackCfg.TesterAppToken) _, err := slackCli.AuthTest() if err != nil { @@ -56,10 +65,10 @@ func newSlackDriver(slackCfg SlackConfig) (BotDriver, error) { mdFormatter := interactive.NewMDFormatter(interactive.NewlineFormatter, func(msg string) string { return fmt.Sprintf("*%s*", msg) }) - return &slackTester{cli: slackCli, cfg: slackCfg, mdFormatter: mdFormatter}, nil + return &SlackTester{cli: slackCli, cfg: slackCfg, mdFormatter: mdFormatter}, nil } -func (s *slackTester) InitUsers(t *testing.T) { +func (s *SlackTester) InitUsers(t *testing.T) { t.Helper() s.botUserID = s.findUserID(t, s.cfg.BotName) assert.NotEmpty(t, s.botUserID, "could not find slack botUserID with name: %s", s.cfg.BotName) @@ -68,15 +77,15 @@ func (s *slackTester) InitUsers(t *testing.T) { assert.NotEmpty(t, s.testerUserID, "could not find slack testerUserID with name: %s", s.cfg.TesterName) } -func (s *slackTester) InitChannels(t *testing.T) []func() { - channel, cleanupChannelFn := s.createChannel(t, "first") - s.channel = &SlackChannel{Channel: channel} +func (s *SlackTester) InitChannels(t *testing.T) []func() { + channel, cleanupChannelFn := s.CreateChannel(t, "first") + s.channel = channel - secondChannel, cleanupSecondChannelFn := s.createChannel(t, "second") - s.secondChannel = &SlackChannel{Channel: secondChannel} + secondChannel, cleanupSecondChannelFn := s.CreateChannel(t, "second") + s.secondChannel = secondChannel - thirdChannel, cleanupThirdChannelFn := s.createChannel(t, "rbac") - s.thirdChannel = &SlackChannel{Channel: thirdChannel} + thirdChannel, cleanupThirdChannelFn := s.CreateChannel(t, "rbac") + s.thirdChannel = thirdChannel return []func(){ func() { cleanupChannelFn(t) }, @@ -85,35 +94,35 @@ func (s *slackTester) InitChannels(t *testing.T) []func() { } } -func (s *slackTester) Type() DriverType { +func (s *SlackTester) Type() DriverType { return SlackBot } -func (s *slackTester) BotName() string { +func (s *SlackTester) BotName() string { return fmt.Sprintf("<@%s>", s.BotUserID()) } -func (s *slackTester) BotUserID() string { +func (s *SlackTester) BotUserID() string { return s.botUserID } -func (s *slackTester) TesterUserID() string { +func (s *SlackTester) TesterUserID() string { return s.testerUserID } -func (s *slackTester) Channel() Channel { +func (s *SlackTester) Channel() Channel { return s.channel } -func (s *slackTester) SecondChannel() Channel { +func (s *SlackTester) SecondChannel() Channel { return s.secondChannel } -func (s *slackTester) ThirdChannel() Channel { +func (s *SlackTester) ThirdChannel() Channel { return s.thirdChannel } -func (s *slackTester) PostInitialMessage(t *testing.T, channelName string) { +func (s *SlackTester) PostInitialMessage(t *testing.T, channelName string) { t.Helper() t.Log("Posting welcome message...") @@ -126,50 +135,50 @@ func (s *slackTester) PostInitialMessage(t *testing.T, channelName string) { require.NoError(t, err) } -func (s *slackTester) PostMessageToBot(t *testing.T, channel, command string) { +func (s *SlackTester) PostMessageToBot(t *testing.T, channel, command string) { message := fmt.Sprintf("<@%s> %s", s.cfg.BotName, command) _, _, err := s.cli.PostMessage(channel, slack.MsgOptionText(message, false)) require.NoError(t, err) } -func (s *slackTester) InviteBotToChannel(t *testing.T, channelID string) { +func (s *SlackTester) InviteBotToChannel(t *testing.T, channelID string) { t.Logf("Inviting bot with ID %q to the channel with ID %q", s.botUserID, channelID) _, err := s.cli.InviteUsersToConversation(channelID, s.botUserID) require.NoError(t, err) } -func (s *slackTester) WaitForMessagePostedRecentlyEqual(userID, channelID, expectedMsg string) error { - return s.WaitForMessagePosted(userID, channelID, recentMessagesLimit, func(msg string) (bool, int, string) { - msg = trimTrailingLine(msg) +func (s *SlackTester) WaitForMessagePostedRecentlyEqual(userID, channelID, expectedMsg string) error { + return s.WaitForMessagePosted(userID, channelID, s.cfg.RecentMessagesLimit, func(msg string) (bool, int, string) { + msg = TrimSlackMsgTrailingLine(msg) if !strings.EqualFold(expectedMsg, msg) { - count := countMatchBlock(expectedMsg, msg) - msgDiff := diff(expectedMsg, msg) + count := diff.CountMatchBlock(expectedMsg, msg) + msgDiff := diff.Diff(expectedMsg, msg) return false, count, msgDiff } return true, 0, "" }) } -func (s *slackTester) WaitForLastMessageContains(userID, channelID, expectedMsgSubstring string) error { +func (s *SlackTester) WaitForLastMessageContains(userID, channelID, expectedMsgSubstring string) error { return s.WaitForMessagePosted(userID, channelID, 1, func(msg string) (bool, int, string) { - return strings.Contains(trimTrailingLine(msg), expectedMsgSubstring), 0, "" + return strings.Contains(TrimSlackMsgTrailingLine(msg), expectedMsgSubstring), 0, "" }) } -func (s *slackTester) WaitForLastMessageEqual(userID, channelID, expectedMsg string) error { +func (s *SlackTester) WaitForLastMessageEqual(userID, channelID, expectedMsg string) error { return s.WaitForMessagePosted(userID, channelID, 1, func(msg string) (bool, int, string) { - msg = trimTrailingLine(msg) + msg = TrimSlackMsgTrailingLine(msg) msg = formatx.RemoveHyperlinks(msg) // normalize the message URLs if msg != expectedMsg { - count := countMatchBlock(expectedMsg, msg) - msgDiff := diff(expectedMsg, msg) + count := diff.CountMatchBlock(expectedMsg, msg) + msgDiff := diff.Diff(expectedMsg, msg) return false, count, msgDiff } return true, 0, "" }) } -func (s *slackTester) WaitForMessagePosted(userID, channelID string, limitMessages int, assertFn MessageAssertion) error { +func (s *SlackTester) WaitForMessagePosted(userID, channelID string, limitMessages int, assertFn MessageAssertion) error { var fetchedMessages []slack.Message var lastErr error var diffMessage string @@ -220,11 +229,11 @@ func (s *slackTester) WaitForMessagePosted(userID, channelID string, limitMessag return nil } -func (s *slackTester) WaitForInteractiveMessagePosted(userID, channelID string, limitMessages int, assertFn MessageAssertion) error { +func (s *SlackTester) WaitForInteractiveMessagePosted(userID, channelID string, limitMessages int, assertFn MessageAssertion) error { return s.WaitForMessagePosted(userID, channelID, limitMessages, assertFn) } -func (s *slackTester) WaitForMessagePostedWithFileUpload(userID, channelID string, assertFn FileUploadAssertion) error { +func (s *SlackTester) WaitForMessagePostedWithFileUpload(userID, channelID string, assertFn FileUploadAssertion) error { var fetchedMessages []slack.Message var lastErr error err := wait.PollUntilContextTimeout(context.Background(), pollInterval, s.cfg.MessageWaitTimeout, false, func(ctx context.Context) (done bool, err error) { @@ -270,7 +279,7 @@ func (s *slackTester) WaitForMessagePostedWithFileUpload(userID, channelID strin return nil } -func (s *slackTester) WaitForMessagePostedWithAttachment(userID, channelID string, limitMessages int, assertFn ExpAttachmentInput) error { +func (s *SlackTester) WaitForMessagePostedWithAttachment(userID, channelID string, limitMessages int, assertFn ExpAttachmentInput) error { renderer := bot.NewSlackRenderer() var expTime time.Time @@ -295,15 +304,15 @@ func (s *slackTester) WaitForMessagePostedWithAttachment(userID, channelID strin return false, 0, err.Error() } - if err = timeWithinDuration(expTime, gotEventTime, time.Minute); err != nil { + if err = diff.TimeWithinDuration(expTime, gotEventTime, time.Minute); err != nil { return false, 0, err.Error() } } expMsg = replaceEmojiWithTags(expMsg) if !strings.EqualFold(expMsg, content) { - count := countMatchBlock(expMsg, content) - msgDiff := diff(expMsg, content) + count := diff.CountMatchBlock(expMsg, content) + msgDiff := diff.Diff(expMsg, content) return false, count, msgDiff } @@ -313,34 +322,34 @@ func (s *slackTester) WaitForMessagePostedWithAttachment(userID, channelID strin // TODO: This contains an implementation for socket mode slack apps. Once needed, you can see the already implemented // functions here https://github.com/kubeshop/botkube/blob/abfeb95fa5f84ceb9b25a30159cdc3d17e130711/test/e2e/slack_driver_test.go#L289 -func (s *slackTester) WaitForInteractiveMessagePostedRecentlyEqual(userID, channelID string, msg interactive.CoreMessage) error { +func (s *SlackTester) WaitForInteractiveMessagePostedRecentlyEqual(userID, channelID string, msg interactive.CoreMessage) error { renderedMsg := interactive.RenderMessage(s.mdFormatter, msg) - return s.WaitForMessagePosted(userID, channelID, recentMessagesLimit, func(msg string) (bool, int, string) { + return s.WaitForMessagePosted(userID, channelID, s.cfg.RecentMessagesLimit, func(msg string) (bool, int, string) { // Slack encloses URLs with `<` and `>`, since we need to remove them before assertion msg = strings.NewReplacer("\n", "\n").Replace(msg) if !strings.EqualFold(renderedMsg, msg) { - count := countMatchBlock(renderedMsg, msg) - msgDiff := diff(renderedMsg, msg) + count := diff.CountMatchBlock(renderedMsg, msg) + msgDiff := diff.Diff(renderedMsg, msg) return false, count, msgDiff } return true, 0, "" }) } -func (s *slackTester) WaitForLastInteractiveMessagePostedEqual(userID, channelID string, msg interactive.CoreMessage) error { +func (s *SlackTester) WaitForLastInteractiveMessagePostedEqual(userID, channelID string, msg interactive.CoreMessage) error { renderedMsg := interactive.RenderMessage(s.mdFormatter, msg) return s.WaitForMessagePosted(userID, channelID, 1, func(msg string) (bool, int, string) { msg = strings.NewReplacer("\n", "\n").Replace(msg) if !strings.EqualFold(renderedMsg, msg) { - count := countMatchBlock(renderedMsg, msg) - msgDiff := diff(renderedMsg, msg) + count := diff.CountMatchBlock(renderedMsg, msg) + msgDiff := diff.Diff(renderedMsg, msg) return false, count, msgDiff } return true, 0, "" }) } -func (s *slackTester) findUserID(t *testing.T, name string) string { +func (s *SlackTester) findUserID(t *testing.T, name string) string { t.Log("Getting users...") res, err := s.cli.GetUsers() require.NoError(t, err) @@ -356,7 +365,7 @@ func (s *slackTester) findUserID(t *testing.T, name string) string { return "" } -func (s *slackTester) createChannel(t *testing.T, prefix string) (*slack.Channel, func(t *testing.T)) { +func (s *SlackTester) CreateChannel(t *testing.T, prefix string) (Channel, func(t *testing.T)) { t.Helper() randomID := uuid.New() channelName := fmt.Sprintf("%s-%s-%s", channelNamePrefix, prefix, randomID.String()) @@ -378,16 +387,16 @@ func (s *slackTester) createChannel(t *testing.T, prefix string) (*slack.Channel assert.NoError(t, err) } - return channel, cleanupFn + return &SlackChannel{channel}, cleanupFn } -func trimTrailingLine(msg string) string { +func TrimSlackMsgTrailingLine(msg string) string { // There is always a `\n` on Slack messages due to Markdown formatting. // That should be replaced for RTM return strings.TrimSuffix(msg, "\n") } -func (s *slackTester) cutLastLine(in string) (before string, after string) { +func (s *SlackTester) cutLastLine(in string) (before string, after string) { in = strings.TrimSpace(in) if in == "" { return "", "" diff --git a/test/commplatform/struct_dumper.go b/test/commplatform/struct_dumper.go new file mode 100644 index 0000000000..3c1925693a --- /dev/null +++ b/test/commplatform/struct_dumper.go @@ -0,0 +1,16 @@ +package commplatform + +import ( + "regexp" + + "github.com/sanity-io/litter" +) + +// structDumper provides an option to print the struct in more readable way. +var structDumper = litter.Options{ + HidePrivateFields: true, + HideZeroValues: true, + StripPackageNames: false, + FieldExclusions: regexp.MustCompile(`^(XXX_.*)$`), // XXX_ is a prefix of fields generated by protoc-gen-go + Separator: " ", +} diff --git a/test/e2e/diff_helpers_test.go b/test/diff/diff.go similarity index 70% rename from test/e2e/diff_helpers_test.go rename to test/diff/diff.go index 0d0931cb85..e21faa84f6 100644 --- a/test/e2e/diff_helpers_test.go +++ b/test/diff/diff.go @@ -1,6 +1,4 @@ -//go:build integration - -package e2e +package diff import ( "fmt" @@ -11,8 +9,9 @@ import ( // Original source: https://github.com/stretchr/testify/blob/181cea6eab8b2de7071383eca4be32a424db38dd/assert/assertions.go#L1685-L1695 // Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors. Licensed under MIT License. -// return diff string is expect and actual are different, otherwise return empty string -func diff(expect string, actual string) string { + +// Diff returns diff string is expect and actual are different, otherwise returns empty string. +func Diff(expect string, actual string) string { if expect == actual { return "" } @@ -29,8 +28,8 @@ func diff(expect string, actual string) string { return "\n\nDiff:\n" + diff } -// countMatchBlock count the number of lines matched between two strings -func countMatchBlock(expect string, actual string) int { +// CountMatchBlock count the number of lines matched between two strings. +func CountMatchBlock(expect string, actual string) int { matcher := difflib.NewMatcher(difflib.SplitLines(expect), difflib.SplitLines(actual)) matches := matcher.GetMatchingBlocks() count := 0 @@ -40,7 +39,8 @@ func countMatchBlock(expect string, actual string) int { return count } -func timeWithinDuration(expected, actual time.Time, delta time.Duration) error { +// TimeWithinDuration checks if the difference between two times is within a given duration. +func TimeWithinDuration(expected, actual time.Time, delta time.Duration) error { dt := expected.Sub(actual) if dt < -delta || dt > delta { return fmt.Errorf("max difference between %v and %v allowed is %v, but difference was %v", expected, actual, delta, dt) diff --git a/test/discordx/discord_helpers.go b/test/discordx/discord_helpers.go deleted file mode 100644 index a0f067e0eb..0000000000 --- a/test/discordx/discord_helpers.go +++ /dev/null @@ -1,192 +0,0 @@ -package discordx - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "k8s.io/apimachinery/pkg/util/wait" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - - "github.com/bwmarrin/discordgo" - "github.com/kubeshop/botkube/pkg/bot/interactive" - "github.com/kubeshop/botkube/pkg/formatx" - "github.com/stretchr/testify/require" -) - -const ( - channelNamePrefix = "test" - pollInterval = time.Second - recentMessagesLimit = 2 -) - -// DiscordChannel represents Discord channel -type DiscordChannel struct { - *discordgo.Channel -} - -// ID returns channel ID -func (s *DiscordChannel) ID() string { - return s.Channel.ID -} - -// Name returns channel name -func (s *DiscordChannel) Name() string { - return s.Channel.Name -} - -// Identifier returns channel identifier -func (s *DiscordChannel) Identifier() string { - return s.Channel.ID -} - -// DiscordConfig represents Discord configuration -type DiscordConfig struct { - BotName string `envconfig:"optional"` - BotID string `envconfig:"default=983294404108378154"` - TesterName string `envconfig:"optional"` - TesterID string `envconfig:"default=1020384322114572381"` - AdditionalContextMessage string `envconfig:"optional"` - GuildID string - TesterAppToken string - BotToken string - MessageWaitTimeout time.Duration `envconfig:"default=1m"` -} - -// DiscordTester represents Discord tester -type DiscordTester struct { - cli *discordgo.Session - cfg DiscordConfig - botUserID string - testerUserID string - mdFormatter interactive.MDFormatter -} - -// Channel describes channel behavior -type Channel interface { - ID() string - Name() string - Identifier() string -} - -// MessageAssertion represents message assertion function -type MessageAssertion func(content string) bool - -// New creates new Discord tester -func New(discordCfg DiscordConfig) (*DiscordTester, error) { - discordCli, err := discordgo.New("Bot " + discordCfg.TesterAppToken) - if err != nil { - return nil, fmt.Errorf("while creating Discord session: %w", err) - } - return &DiscordTester{cli: discordCli, cfg: discordCfg, mdFormatter: interactive.DefaultMDFormatter()}, nil -} - -// InitUsers initializes Discord users -func (d *DiscordTester) InitUsers(t *testing.T) { - t.Helper() - - d.botUserID = d.cfg.BotID - if d.cfg.BotName != "" || d.botUserID == "" { - t.Log("Bot user ID not set, looking for ID based on Bot name...") - d.botUserID = d.findUserID(t, d.cfg.BotName) - require.NotEmpty(t, d.botUserID, "could not find discord botUserID with name: %s", d.cfg.BotName) - } - - d.testerUserID = d.cfg.TesterID - if d.cfg.TesterName != "" || d.testerUserID == "" { - t.Log("Tester user ID not set, looking for ID based on Tester name...") - d.testerUserID = d.findUserID(t, d.cfg.TesterName) - require.NotEmpty(t, d.testerUserID, "could not find discord testerUserID with name: %s", d.cfg.TesterName) - } -} - -// CreateChannel creates Discord channel -func (d *DiscordTester) CreateChannel(t *testing.T, prefix string) (*discordgo.Channel, func(t *testing.T)) { - t.Helper() - randomID := uuid.New() - channelName := fmt.Sprintf("%s-%s-%s", channelNamePrefix, prefix, randomID.String()) - - t.Logf("Creating channel %q...", channelName) - channel, err := d.cli.GuildChannelCreate(d.cfg.GuildID, channelName, discordgo.ChannelTypeGuildText) - require.NoError(t, err) - - t.Logf("Channel %q (ID: %q) created", channelName, channel.ID) - - cleanupFn := func(t *testing.T) { - t.Helper() - t.Logf("Deleting channel %q...", channel.Name) - // We cannot archive a channel: https://support.discord.com/hc/en-us/community/posts/360042842012-Archive-old-chat-channels - _, err := d.cli.ChannelDelete(channel.ID) - assert.NoError(t, err) - } - - return channel, cleanupFn -} - -// PostMessageToBot posts message to bot -func (d *DiscordTester) PostMessageToBot(t *testing.T, channel, command string) { - message := fmt.Sprintf("<@%s> %s", d.botUserID, command) - _, err := d.cli.ChannelMessageSend(channel, message) - require.NoError(t, err) -} - -// WaitForMessagePosted waits for message posted -func (d *DiscordTester) WaitForMessagePosted(userID, channelID string, assertFn MessageAssertion) error { - var fetchedMessages []*discordgo.Message - var lastErr error - - err := wait.PollUntilContextTimeout(context.Background(), pollInterval, d.cfg.MessageWaitTimeout, false, func(ctx context.Context) (done bool, err error) { - messages, err := d.cli.ChannelMessages(channelID, recentMessagesLimit, "", "", "") - if err != nil { - lastErr = err - return false, nil - } - - fetchedMessages = messages - for _, msg := range messages { - if msg.Author.ID != userID { - continue - } - - expectedResult := assertFn(msg.Content) - if !expectedResult { - continue - } - - return true, nil - } - - return false, nil - }) - if lastErr == nil { - lastErr = fmt.Errorf("message assertion function returned false with %s", lastErr) - } - if err != nil { - if wait.Interrupted(err) { - return fmt.Errorf("while waiting for condition: last error: %w; fetched messages: %s", lastErr, formatx.StructDumper().Sdump(fetchedMessages)) - } - return err - } - - return nil -} - -func (d *DiscordTester) findUserID(t *testing.T, name string) string { - t.Logf("Getting user %q...", name) - res, err := d.cli.GuildMembersSearch(d.cfg.GuildID, name, 50) - require.NoError(t, err) - - t.Logf("Finding user ID in %v...", res) - for _, m := range res { - if !strings.EqualFold(name, m.User.Username) { - continue - } - return m.User.ID - } - - return "" -} diff --git a/test/e2e/bots_test.go b/test/e2e/bots_test.go index 4c25e1ba5b..8477d1f86a 100644 --- a/test/e2e/bots_test.go +++ b/test/e2e/bots_test.go @@ -10,6 +10,9 @@ import ( "testing" "time" + "github.com/kubeshop/botkube/test/commplatform" + "github.com/kubeshop/botkube/test/diff" + netapiv1 "k8s.io/api/networking/v1" rbacapiv1 "k8s.io/api/rbac/v1" netv1 "k8s.io/client-go/kubernetes/typed/networking/v1" @@ -59,33 +62,11 @@ type Config struct { Namespace string `envconfig:"default=botkube"` } ClusterName string `envconfig:"default=sample"` - Slack SlackConfig - Discord DiscordConfig -} - -type SlackConfig struct { - BotName string `envconfig:"default=botkube"` - TesterName string `envconfig:"default=tester"` - AdditionalContextMessage string `envconfig:"optional"` - TesterAppToken string - MessageWaitTimeout time.Duration `envconfig:"default=30s"` -} - -type DiscordConfig struct { - BotName string `envconfig:"optional"` - BotID string `envconfig:"default=983294404108378154"` - TesterName string `envconfig:"optional"` - TesterID string `envconfig:"default=1020384322114572381"` - AdditionalContextMessage string `envconfig:"optional"` - GuildID string - TesterAppToken string - MessageWaitTimeout time.Duration `envconfig:"default=30s"` + Slack commplatform.SlackConfig + Discord commplatform.DiscordConfig } const ( - channelNamePrefix = "test" - welcomeText = "Let the tests begin 🤞" - pollInterval = time.Second globalConfigMapName = "botkube-global-config" ) @@ -112,7 +93,7 @@ func TestSlack(t *testing.T) { runBotTest(t, appCfg, - SlackBot, + commplatform.SlackBot, slackInvalidCmd, appCfg.Deployment.Envs.DefaultSlackChannelIDName, appCfg.Deployment.Envs.SecondarySlackChannelIDName, @@ -128,7 +109,7 @@ func TestDiscord(t *testing.T) { runBotTest(t, appCfg, - DiscordBot, + commplatform.DiscordBot, discordInvalidCmd, appCfg.Deployment.Envs.DefaultDiscordChannelIDName, appCfg.Deployment.Envs.SecondaryDiscordChannelIDName, @@ -136,19 +117,19 @@ func TestDiscord(t *testing.T) { ) } -func newBotDriver(cfg Config, driverType DriverType) (BotDriver, error) { +func newBotDriver(cfg Config, driverType commplatform.DriverType) (commplatform.BotDriver, error) { switch driverType { - case SlackBot: - return newSlackDriver(cfg.Slack) - case DiscordBot: - return newDiscordDriver(cfg.Discord) + case commplatform.SlackBot: + return commplatform.NewSlackTester(cfg.Slack) + case commplatform.DiscordBot: + return commplatform.NewDiscordTester(cfg.Discord) } return nil, nil } func runBotTest(t *testing.T, appCfg Config, - driverType DriverType, + driverType commplatform.DriverType, invalidCmdTemplate, deployEnvChannelIDName, deployEnvSecondaryChannelIDName, @@ -178,7 +159,7 @@ func runBotTest(t *testing.T, t.Cleanup(fn) } - channels := map[string]Channel{ + channels := map[string]commplatform.Channel{ deployEnvChannelIDName: botDriver.Channel(), deployEnvSecondaryChannelIDName: botDriver.SecondChannel(), deployEnvRbacChannelIDName: botDriver.ThirdChannel(), @@ -279,7 +260,7 @@ func runBotTest(t *testing.T, t.Run("Echo Executor help", func(t *testing.T) { command := "echo help" expectedBody := ".... empty response _*<cricket sounds>*_ :cricket: :cricket: :cricket:" - if botDriver.Type() == DiscordBot { + if botDriver.Type() == commplatform.DiscordBot { expectedBody = ".... empty response _**_ :cricket: :cricket: :cricket:" } expectedMessage := fmt.Sprintf("%s\n%s", cmdHeader(command), expectedBody) @@ -566,10 +547,10 @@ func runBotTest(t *testing.T, assertionFn := func(msg string) (bool, int, string) { msg = podName.ReplaceAllString(msg, `"botkube-pod" is`) - msg = trimTrailingLine(msg) + msg = commplatform.TrimSlackMsgTrailingLine(msg) if !strings.EqualFold(expectedMessage, msg) { - count := countMatchBlock(expectedMessage, msg) - msgDiff := diff(expectedMessage, msg) + count := diff.CountMatchBlock(expectedMessage, msg) + msgDiff := diff.Diff(expectedMessage, msg) return false, count, msgDiff } return true, 0, "" @@ -633,7 +614,7 @@ func runBotTest(t *testing.T, } }) - var firstCMUpdate ExpAttachmentInput + var firstCMUpdate commplatform.ExpAttachmentInput t.Run("Multi-channel notifications", func(t *testing.T) { t.Log("Getting notifier status from second channel...") command := "status notifications" @@ -674,7 +655,7 @@ func runBotTest(t *testing.T, t.Cleanup(func() { cleanupCreatedCfgMapIfShould(t, cfgMapCli, cfgMap.Name, &cfgMapAlreadyDeleted) }) t.Log("Expecting bot message in first channel...") - expAttachmentIn := ExpAttachmentInput{ + expAttachmentIn := commplatform.ExpAttachmentInput{ AllowedTimestampDelta: time.Minute, Message: api.Message{ Type: api.NonInteractiveSingleSection, @@ -727,7 +708,7 @@ func runBotTest(t *testing.T, t.Log("Expecting bot message in all channels...") for _, channelID := range channelIDs { - err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), channelID, 2, ExpAttachmentInput{ + err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), channelID, 2, commplatform.ExpAttachmentInput{ AllowedTimestampDelta: time.Minute, Message: api.Message{ Type: api.NonInteractiveSingleSection, @@ -791,7 +772,7 @@ func runBotTest(t *testing.T, err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) require.NoError(t, err) - secondCMUpdate := ExpAttachmentInput{ + secondCMUpdate := commplatform.ExpAttachmentInput{ AllowedTimestampDelta: time.Minute, Message: api.Message{ Type: api.NonInteractiveSingleSection, @@ -849,7 +830,7 @@ func runBotTest(t *testing.T, require.NoError(t, err) cfgMapAlreadyDeleted = true - firstCMUpdate = ExpAttachmentInput{ + firstCMUpdate = commplatform.ExpAttachmentInput{ AllowedTimestampDelta: time.Minute, Message: api.Message{ Type: api.NonInteractiveSingleSection, @@ -928,7 +909,7 @@ func runBotTest(t *testing.T, // - message with recommendations from 'k8s-events' // - massage with pod create event from 'k8s-pod-create-events' // - message with kc execution via 'get-created-resource' automation - err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.Channel().ID(), 3, ExpAttachmentInput{ + err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.Channel().ID(), 3, commplatform.ExpAttachmentInput{ AllowedTimestampDelta: time.Minute, Message: api.Message{ Type: api.NonInteractiveSingleSection, @@ -1066,7 +1047,7 @@ func runBotTest(t *testing.T, command := "list sources" expectedBody := codeBlock(heredoc.Doc(` SOURCE ENABLED`)) - if botDriver.Type() == DiscordBot { + if botDriver.Type() == commplatform.DiscordBot { expectedBody = codeBlock(heredoc.Doc(` SOURCE ENABLED botkube/cm-watcher true @@ -1140,7 +1121,7 @@ func runBotTest(t *testing.T, require.NoError(t, err) t.Log("Expecting bot event message...") - err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.Channel().ID(), 2, ExpAttachmentInput{ + err = botDriver.WaitForMessagePostedWithAttachment(botDriver.BotUserID(), botDriver.Channel().ID(), 2, commplatform.ExpAttachmentInput{ AllowedTimestampDelta: time.Minute, Message: api.Message{ Type: api.NonInteractiveSingleSection, diff --git a/test/e2e/k8s_helpers_test.go b/test/e2e/k8s_helpers_test.go index 85bfc95178..b276a52505 100644 --- a/test/e2e/k8s_helpers_test.go +++ b/test/e2e/k8s_helpers_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/kubeshop/botkube/test/commplatform" + "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" @@ -19,7 +21,11 @@ import ( deploymentutil "k8s.io/kubectl/pkg/util/deployment" ) -func setTestEnvsForDeploy(t *testing.T, appCfg Config, deployNsCli appsv1cli.DeploymentInterface, driverType DriverType, channels map[string]Channel, pluginRepoURL string) func(t *testing.T) { +const ( + pollInterval = 1 * time.Second +) + +func setTestEnvsForDeploy(t *testing.T, appCfg Config, deployNsCli appsv1cli.DeploymentInterface, driverType commplatform.DriverType, channels map[string]commplatform.Channel, pluginRepoURL string) func(t *testing.T) { t.Helper() deployment, err := deployNsCli.Get(context.Background(), appCfg.Deployment.Name, metav1.GetOptions{}) @@ -65,7 +71,7 @@ func setTestEnvsForDeploy(t *testing.T, appCfg Config, deployNsCli appsv1cli.Dep }, } - if len(channels) > 0 && driverType == SlackBot { + if len(channels) > 0 && driverType == commplatform.SlackBot { slackEnabledEnvName := appCfg.Deployment.Envs.SlackEnabledName newEnvs = append(newEnvs, v1.EnvVar{Name: slackEnabledEnvName, Value: enabled}) @@ -74,7 +80,7 @@ func setTestEnvsForDeploy(t *testing.T, appCfg Config, deployNsCli appsv1cli.Dep } } - if len(channels) > 0 && driverType == DiscordBot { + if len(channels) > 0 && driverType == commplatform.DiscordBot { discordEnabledEnvName := appCfg.Deployment.Envs.DiscordEnabledName newEnvs = append(newEnvs, v1.EnvVar{Name: discordEnabledEnvName, Value: enabled}) diff --git a/test/e2e/migration_test.go b/test/e2e/migration_test.go new file mode 100644 index 0000000000..e5620a2997 --- /dev/null +++ b/test/e2e/migration_test.go @@ -0,0 +1,358 @@ +//go:build migration + +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "github.com/kubeshop/botkube/internal/ptr" + gqlModel "github.com/kubeshop/botkube/internal/remote/graphql" + "github.com/kubeshop/botkube/test/commplatform" + "github.com/stretchr/testify/assert" + "io" + "net/http" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/hasura/go-graphql-client" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + "github.com/vrischmann/envconfig" + "golang.org/x/oauth2" + + "github.com/kubeshop/botkube/test/helmx" +) + +const ( + // using latest version (without `--version` flag) + helmCmdFmt = `helm upgrade botkube --install --namespace botkube --create-namespace --wait \ + --set communications.default-group.discord.enabled=true \ + --set communications.default-group.discord.channels.default.id=%s \ + --set communications.default-group.discord.botID=%s \ + --set communications.default-group.discord.token=%s \ + --set settings.clusterName=%s \ + --set executors.k8s-default-tools.botkube/kubectl.enabled=true \ + --set analytics.disable=true \ + botkube/botkube` + oauth2TokenURL = "https://botkube-dev.eu.auth0.com/oauth/token" +) + +type MigrationConfig struct { + BotkubeCloudDevGQLEndpoint string + BotkubeCloudDevRefreshToken string + BotkubeCloudDevAuth0ClientID string + BotkubeBinaryPath string + DeploymentName string `envconfig:"default=test-migration"` + Discord commplatform.DiscordConfig + DiscordBotToken string + + Timeout time.Duration `envconfig:"default=15s"` +} + +func TestBotkubeMigration(t *testing.T) { + t.Log("Loading configuration...") + var appCfg MigrationConfig + err := envconfig.Init(&appCfg) + require.NoError(t, err) + appCfg.Discord.RecentMessagesLimit = 2 // fix the value for this specific test + + token, err := refreshAccessToken(t, appCfg) + require.NoError(t, err) + + src := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + httpClient := oauth2.NewClient(context.Background(), src) + gqlCli := graphql.NewClient(appCfg.BotkubeCloudDevGQLEndpoint, httpClient) + + t.Log("Pruning old instances...") + err = pruneInstances(t, gqlCli) + require.NoError(t, err) + + t.Log("Initializing Discord...") + tester, err := commplatform.NewDiscordTester(appCfg.Discord) + require.NoError(t, err) + + t.Log("Initializing users...") + tester.InitUsers(t) + + t.Log("Creating channel...") + channel, createChannelCallback := tester.CreateChannel(t, "migration") + t.Cleanup(func() { createChannelCallback(t) }) + + t.Log("Inviting Bot to the channel...") + tester.InviteBotToChannel(t, channel.ID()) + + t.Logf("Channel %s", channel.Name()) + + cmd := fmt.Sprintf(helmCmdFmt, channel.ID(), appCfg.Discord.BotID, appCfg.DiscordBotToken, channel.Name()) + params := helmx.InstallChartParams{ + RepoName: "botkube", + RepoURL: "https://charts.botkube.io", + Name: "botkube", + Namespace: "botkube", + Command: cmd, + } + helmInstallCallback := helmx.InstallChart(t, params) // TODO: Fix - do not install static Botkube version + t.Cleanup(func() { helmInstallCallback(t) }) + + t.Run("Check if Botkube is running before migration", func(t *testing.T) { + clusterName := channel.Name() + + // Discord bot needs a bit more time to connect to Discord API. + time.Sleep(appCfg.Discord.MessageWaitTimeout) + + t.Log("Waiting for Bot message in channel...") + err = tester.WaitForMessagePostedRecentlyEqual(tester.BotUserID(), channel.ID(), fmt.Sprintf("My watch begins for cluster '%s'! :crossed_swords:", clusterName)) + require.NoError(t, err) + + t.Log("Testing ping...") + command := fmt.Sprintf("ping") + expectedMessage := fmt.Sprintf("`%s` on `%s`\n```\npong", command, clusterName) + tester.PostMessageToBot(t, channel.ID(), command) + err = tester.WaitForLastMessageContains(tester.BotUserID(), channel.ID(), expectedMessage) + require.NoError(t, err) + }) + + t.Run("Migrate Discord Botkube to Botkube Cloud", func(t *testing.T) { + cmd := exec.Command(appCfg.BotkubeBinaryPath, "migrate", + "--auto-upgrade", + "--skip-open-browser", + "--debug", + fmt.Sprintf("--token=%s", token), + fmt.Sprintf("--cloud-api-url=%s", appCfg.BotkubeCloudDevGQLEndpoint), + fmt.Sprintf("--instance-name=%s", appCfg.DeploymentName)) + cmd.Env = os.Environ() + + o, err := cmd.CombinedOutput() + t.Logf("CLI output:\n%s", string(o)) + require.NoError(t, err) + }) + + t.Run("Check if the instance is created on Botkube Cloud side", func(t *testing.T) { + deployPage := queryInstances(t, gqlCli) + require.Len(t, deployPage.Data, 1) + + deploy := deployPage.Data[0] + assert.Equal(t, appCfg.DeploymentName, deploy.Name) + + assertAliases(t, deploy.Aliases) + assertPlatforms(t, deploy.Platforms, appCfg, channel.ID()) + assertPlugins(t, deploy.Plugins) + }) + + t.Run("Check if Botkube Cloud is running after migration", func(t *testing.T) { + // Discord bot needs a bit more time to connect to Discord API. + time.Sleep(appCfg.Discord.MessageWaitTimeout) + + clusterName := appCfg.DeploymentName // it is different after migration + + t.Log("Waiting for Bot message in channel...") + err = tester.WaitForMessagePostedRecentlyEqual(tester.BotUserID(), channel.ID(), fmt.Sprintf("My watch begins for cluster '%s'! :crossed_swords:", clusterName)) + assert.NoError(t, err) + + t.Log("Testing ping...") + command := fmt.Sprintf("ping") + expectedMessage := fmt.Sprintf("`%s` on `%s`\n```\npong", command, clusterName) + tester.PostMessageToBot(t, channel.ID(), command) + err = tester.WaitForLastMessageContains(tester.BotUserID(), channel.ID(), expectedMessage) + assert.NoError(t, err) + }) +} + +func queryInstances(t *testing.T, client *graphql.Client) gqlModel.DeploymentPage { + t.Helper() + var query struct { + Deployments gqlModel.DeploymentPage `graphql:"deployments"` + } + err := client.Query(context.Background(), &query, nil) + require.NoError(t, err) + + return query.Deployments +} + +func pruneInstances(t *testing.T, client *graphql.Client) error { + t.Helper() + + deployPage := queryInstances(t, client) + for _, deployment := range deployPage.Data { + var mutation struct { + Success bool `graphql:"deleteDeployment(id: $id)"` + } + err := client.Mutate(context.Background(), &mutation, map[string]interface{}{ + "id": graphql.ID(deployment.ID), + }) + require.NoError(t, err) + } + return nil +} + +func refreshAccessToken(t *testing.T, cfg MigrationConfig) (string, error) { + t.Helper() + + type TokenMsg struct { + Token string `json:"access_token"` + } + + payloadRequest := fmt.Sprintf("grant_type=refresh_token&client_id=%s&refresh_token=%s", cfg.BotkubeCloudDevAuth0ClientID, cfg.BotkubeCloudDevRefreshToken) + payload := strings.NewReader(payloadRequest) + req, err := http.NewRequest("POST", oauth2TokenURL, payload) + if err != nil { + return "", errors.Wrap(err, "failed to create request") + } + + req.Header.Add("content-type", "application/x-www-form-urlencoded") + + var client = &http.Client{ + Timeout: cfg.Timeout, + } + + res, err := client.Do(req) + if err != nil { + return "", errors.Wrap(err, "failed to get response") + } + + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return "", errors.Wrap(err, "failed to read response body") + } + + var tokenMsg TokenMsg + if err := json.Unmarshal(body, &tokenMsg); err != nil { + return "", errors.Wrap(err, "failed to unmarshal response body") + } + return tokenMsg.Token, nil +} + +func assertAliases(t *testing.T, actual []*gqlModel.Alias) { + t.Helper() + + expected := []*gqlModel.Alias{ + { + ID: "", + Name: "k", + DisplayName: "Kubectl alias", + Command: "kubectl", + Deployments: nil, + }, + { + ID: "", + Name: "kc", + DisplayName: "Kubectl alias", + Command: "kubectl", + Deployments: nil, + }, + } + + assert.Len(t, actual, 2) + + // trim ID and deployments + for i := range actual { + actual[i].ID = "" + actual[i].Deployments = nil + } + + assert.ElementsMatchf(t, expected, actual, "Aliases are not equal") +} + +func assertPlatforms(t *testing.T, actual *gqlModel.Platforms, appCfg MigrationConfig, channelID string) { + t.Helper() + + expectedDiscords := []*gqlModel.Discord{ + { + ID: "", // trim + Name: "", // trim + Token: appCfg.DiscordBotToken, + BotID: appCfg.Discord.BotID, + Channels: []*gqlModel.ChannelBindingsByID{ + { + ID: channelID, + Bindings: &gqlModel.BotBindings{ + Sources: []string{ + "k8s-err-events", + "k8s-recommendation-events", + }, + Executors: []string{ + "k8s-default-tools", + }, + }, + NotificationsDisabled: ptr.FromType(false), + }, + }, + }, + } + + assert.NotNil(t, actual, 1) + assert.Len(t, actual.Discords, 1) + + // trim ignored fields + for i := range actual.Discords { + actual.Discords[i].ID = "" + actual.Discords[i].Name = "" + } + + assert.ElementsMatchf(t, expectedDiscords, actual.Discords, "Platforms are not equal") +} + +func assertPlugins(t *testing.T, actual []*gqlModel.Plugin) { + t.Helper() + + expectedPlugins := []*gqlModel.Plugin{ + { + Name: "botkube/kubernetes", + DisplayName: "Kubernetes Resource Created Events", + Type: "SOURCE", + ConfigurationName: "k8s-create-events", + Configuration: "{\"event\":{\"types\":[\"create\"]},\"namespaces\":{\"include\":[\".*\"]},\"resources\":[{\"type\":\"v1/pods\"},{\"type\":\"v1/services\"},{\"type\":\"networking.k8s.io/v1/ingresses\"},{\"type\":\"v1/nodes\"},{\"type\":\"v1/namespaces\"},{\"type\":\"v1/configmaps\"},{\"type\":\"apps/v1/deployments\"},{\"type\":\"apps/v1/statefulsets\"},{\"type\":\"apps/v1/daemonsets\"},{\"type\":\"batch/v1/jobs\"}]}", + }, + { + Name: "botkube/kubernetes", + DisplayName: "Kubernetes Errors for resources with logs", + Type: "SOURCE", + ConfigurationName: "k8s-err-with-logs-events", + Configuration: "{\"event\":{\"types\":[\"error\"]},\"namespaces\":{\"include\":[\".*\"]},\"resources\":[{\"type\":\"v1/pods\"},{\"type\":\"apps/v1/deployments\"},{\"type\":\"apps/v1/statefulsets\"},{\"type\":\"apps/v1/daemonsets\"},{\"type\":\"batch/v1/jobs\"}]}", + }, + { + Name: "botkube/kubernetes", + DisplayName: "Kubernetes Recommendations", + Type: "SOURCE", + ConfigurationName: "k8s-recommendation-events", + Configuration: "{\"namespaces\":{\"include\":[\".*\"]},\"recommendations\":{\"ingress\":{\"backendServiceValid\":true,\"tlsSecretValid\":true},\"pod\":{\"labelsSet\":true,\"noLatestImageTag\":true}}}", + }, + { + Name: "botkube/kubernetes", + DisplayName: "Kubernetes Info", + Type: "SOURCE", + ConfigurationName: "k8s-all-events", + Configuration: "{\"annotations\":{},\"event\":{\"message\":{\"exclude\":[],\"include\":[]},\"reason\":{\"exclude\":[],\"include\":[]},\"types\":[\"create\",\"delete\",\"error\"]},\"filters\":{\"nodeEventsChecker\":true,\"objectAnnotationChecker\":true},\"labels\":{},\"namespaces\":{\"include\":[\".*\"]},\"resources\":[{\"type\":\"v1/pods\"},{\"type\":\"v1/services\"},{\"type\":\"networking.k8s.io/v1/ingresses\"},{\"type\":\"v1/nodes\"},{\"type\":\"v1/namespaces\"},{\"type\":\"v1/persistentvolumes\"},{\"type\":\"v1/persistentvolumeclaims\"},{\"type\":\"v1/configmaps\"},{\"type\":\"rbac.authorization.k8s.io/v1/roles\"},{\"type\":\"rbac.authorization.k8s.io/v1/rolebindings\"},{\"type\":\"rbac.authorization.k8s.io/v1/clusterrolebindings\"},{\"type\":\"rbac.authorization.k8s.io/v1/clusterroles\"},{\"event\":{\"types\":[\"create\",\"update\",\"delete\",\"error\"]},\"type\":\"apps/v1/daemonsets\",\"updateSetting\":{\"fields\":[\"spec.template.spec.containers[*].image\",\"status.numberReady\"],\"includeDiff\":true}},{\"event\":{\"types\":[\"create\",\"update\",\"delete\",\"error\"]},\"type\":\"batch/v1/jobs\",\"updateSetting\":{\"fields\":[\"spec.template.spec.containers[*].image\",\"status.conditions[*].type\"],\"includeDiff\":true}},{\"event\":{\"types\":[\"create\",\"update\",\"delete\",\"error\"]},\"type\":\"apps/v1/deployments\",\"updateSetting\":{\"fields\":[\"spec.template.spec.containers[*].image\",\"status.availableReplicas\"],\"includeDiff\":true}},{\"event\":{\"types\":[\"create\",\"update\",\"delete\",\"error\"]},\"type\":\"apps/v1/statefulsets\",\"updateSetting\":{\"fields\":[\"spec.template.spec.containers[*].image\",\"status.readyReplicas\"],\"includeDiff\":true}}]}", + }, + { + Name: "botkube/kubernetes", + DisplayName: "Kubernetes Errors", + Type: "SOURCE", + ConfigurationName: "k8s-err-events", + Configuration: "{\"event\":{\"types\":[\"error\"]},\"namespaces\":{\"include\":[\".*\"]},\"resources\":[{\"type\":\"v1/pods\"},{\"type\":\"v1/services\"},{\"type\":\"networking.k8s.io/v1/ingresses\"},{\"type\":\"v1/nodes\"},{\"type\":\"v1/namespaces\"},{\"type\":\"v1/persistentvolumes\"},{\"type\":\"v1/persistentvolumeclaims\"},{\"type\":\"v1/configmaps\"},{\"type\":\"rbac.authorization.k8s.io/v1/roles\"},{\"type\":\"rbac.authorization.k8s.io/v1/rolebindings\"},{\"type\":\"rbac.authorization.k8s.io/v1/clusterrolebindings\"},{\"type\":\"rbac.authorization.k8s.io/v1/clusterroles\"},{\"type\":\"apps/v1/deployments\"},{\"type\":\"apps/v1/statefulsets\"},{\"type\":\"apps/v1/daemonsets\"},{\"type\":\"batch/v1/jobs\"}]}", + }, + { + Name: "botkube/kubectl", + DisplayName: "botkube/kubectl", + Type: "EXECUTOR", + ConfigurationName: "k8s-default-tools", + Configuration: "{\"defaultNamespace\":\"default\"}", + }, + } + + assert.NotEmpty(t, actual) + + // trim ignored fields + for i := range actual { + actual[i].ID = "" + } + + assert.ElementsMatchf(t, expectedPlugins, actual, "Plugins are not equal") +} diff --git a/test/helmx/helm_helpers.go b/test/helmx/helm_helpers.go index 29a09000bb..f0b073f25d 100644 --- a/test/helmx/helm_helpers.go +++ b/test/helmx/helm_helpers.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" ) -// InstallChartParams are parameters for InstallChart +// InstallChartParams are parameters for InstallChart. type InstallChartParams struct { RepoName string RepoURL string @@ -17,14 +17,14 @@ type InstallChartParams struct { Command string } -// ToOptions converts Command to helm install options +// ToOptions converts Command to helm install options. func (p *InstallChartParams) ToOptions() []string { cmd := strings.Replace(p.Command, "\n", "", -1) cmd = strings.Replace(cmd, "\\", " ", -1) return strings.Fields(cmd)[1:] } -// InstallChart installs helm chart +// InstallChart installs helm chart. func InstallChart(t *testing.T, params InstallChartParams) func(t *testing.T) { t.Helper() diff --git a/test/migration/e2e/migration_test.go b/test/migration/e2e/migration_test.go deleted file mode 100644 index 47143862c9..0000000000 --- a/test/migration/e2e/migration_test.go +++ /dev/null @@ -1,166 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "strings" - "testing" - "time" - - "github.com/hasura/go-graphql-client" - "github.com/pkg/errors" - "github.com/stretchr/testify/require" - "github.com/vrischmann/envconfig" - "golang.org/x/oauth2" - - "github.com/kubeshop/botkube/test/discordx" - "github.com/kubeshop/botkube/test/helmx" -) - -const ( - helmCmd = `helm upgrade botkube --install --version v1.1.0 --namespace botkube --create-namespace --wait \ - --set communications.default-group.discord.enabled=true \ - --set communications.default-group.discord.channels.default.id=%s \ - --set communications.default-group.discord.botID=%s \ - --set communications.default-group.discord.token=%s \ - --set settings.clusterName=%s \ - --set executors.k8s-default-tools.botkube/kubectl.enabled=true \ - --set analytics.disable=true \ - botkube/botkube` - oauthURL = "https://botkube-dev.eu.auth0.com/oauth/token" -) - -type Config struct { - BotkubeCloudDevGQLEndpoint string - BotkubeCloudDevRefreshToken string - BotkubeCloudDevAuth0ClientID string - Discord discordx.DiscordConfig -} - -func TestBotkubeMigration(t *testing.T) { - t.Log("Loading configuration...") - var appCfg Config - err := envconfig.Init(&appCfg) - require.NoError(t, err) - - token, err := refreshAccessToken(t, appCfg) - require.NoError(t, err) - - t.Log("Pruning old instances...") - err = pruneInstances(t, appCfg.BotkubeCloudDevGQLEndpoint, token) - require.NoError(t, err) - - t.Log("Initializing Discord...") - tester, err := discordx.New(appCfg.Discord) - require.NoError(t, err) - - t.Log("Initializing users...") - tester.InitUsers(t) - - t.Log("Creating channel...") - channel, createChannelCallback := tester.CreateChannel(t, "test-migration") - - t.Cleanup(func() { createChannelCallback(t) }) - - t.Logf("Channel %s", channel.Name) - - cmd := fmt.Sprintf(helmCmd, channel.ID, appCfg.Discord.BotID, appCfg.Discord.BotToken, "TestMigration") - params := helmx.InstallChartParams{ - RepoURL: "https://charts.botkube.io", - RepoName: "botkube", - Name: "botkube", - Namespace: "botkube", - Command: cmd, - } - helmInstallCallback := helmx.InstallChart(t, params) - t.Cleanup(func() { helmInstallCallback(t) }) - - t.Run("Migrate Discord Botkube to Botkube Cloud", func(t *testing.T) { - cmd := exec.Command(os.Getenv("BOTKUBE_BIN"), "migrate", - fmt.Sprintf("--token=%s", token), - fmt.Sprintf("--cloud-api-url=%s", appCfg.BotkubeCloudDevGQLEndpoint), - "--instance-name=test-migration", - "-q") - cmd.Env = os.Environ() - - o, err := cmd.CombinedOutput() - require.NoError(t, err, string(o)) - }) - -} - -func pruneInstances(t *testing.T, url, token string) error { - t.Helper() - - src := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: token}, - ) - httpClient := oauth2.NewClient(context.Background(), src) - client := graphql.NewClient(url, httpClient) - - var query struct { - Deployments struct { - Data []struct { - ID string `graphql:"id"` - } - } `graphql:"deployments()"` - } - err := client.Query(context.Background(), &query, map[string]interface{}{}) - require.NoError(t, err) - - for _, deployment := range query.Deployments.Data { - var mutation struct { - Success bool `graphql:"deleteDeployment(id: $id)"` - } - err := client.Mutate(context.Background(), &mutation, map[string]interface{}{ - "id": graphql.ID(deployment.ID), - }) - require.NoError(t, err) - } - return nil -} - -func refreshAccessToken(t *testing.T, cfg Config) (string, error) { - t.Helper() - - type TokenMsg struct { - Token string `json:"access_token"` - } - - payloadRequest := fmt.Sprintf("grant_type=refresh_token&client_id=%s&refresh_token=%s", cfg.BotkubeCloudDevAuth0ClientID, cfg.BotkubeCloudDevRefreshToken) - payload := strings.NewReader(payloadRequest) - req, err := http.NewRequest("POST", oauthURL, payload) - if err != nil { - return "", errors.Wrap(err, "failed to create request") - } - - req.Header.Add("content-type", "application/x-www-form-urlencoded") - - var client = &http.Client{ - Timeout: 10 * time.Second, - } - - res, err := client.Do(req) - if err != nil { - return "", errors.Wrap(err, "failed to get response") - } - - defer res.Body.Close() - body, err := io.ReadAll(res.Body) - if err != nil { - return "", errors.Wrap(err, "failed to read response body") - } - - var tokenMsg TokenMsg - if err := json.Unmarshal(body, &tokenMsg); err != nil { - return "", errors.Wrap(err, "failed to unmarshal response body") - } - return tokenMsg.Token, nil -}