From fa6dad2b96ed2752e3d3018262e705b4d41cb168 Mon Sep 17 00:00:00 2001 From: Mateusz Szostok Date: Tue, 1 Aug 2023 22:10:51 +0200 Subject: [PATCH] Add flux executor plugin with GitHub support --- .goreleaser.plugin.yaml | 32 ++ Makefile | 2 +- cmd/botkube-agent/main.go | 14 +- cmd/executor/flux/main.go | 27 ++ go.mod | 24 +- go.sum | 59 ++- .../cli/cmd => internal/cli}/config/config.go | 0 internal/cli/login/login.go | 2 +- internal/cli/migrate/migrate.go | 2 +- internal/executor/doctor/executor.go | 2 +- internal/executor/flux/commands.go | 43 ++ .../executor/flux/commands/store/tables.yaml | 35 ++ .../flux/commands/store/tutorial.yaml | 116 +++++ internal/executor/flux/commands/template.go | 45 ++ internal/executor/flux/config.go | 20 + internal/executor/flux/diff_cmd.go | 423 ++++++++++++++++++ internal/executor/flux/diff_ks_cmd.go | 129 ++++++ internal/executor/flux/diff_ks_cmd_test.go | 116 +++++ internal/executor/flux/executor.go | 211 +++++++++ internal/executor/flux/forbidden.go | 67 +++ internal/executor/flux/gh_cmd.go | 96 ++++ internal/executor/x/cmd_parse.go | 3 + internal/executor/x/output/message_parser.go | 7 +- .../executor/x/output/message_tutorial.go | 5 +- internal/executor/x/run.go | 10 +- internal/httpx/http_client.go | 4 +- {pkg/httpsrv => internal/httpx}/server.go | 6 +- internal/lifecycle/server.go | 6 +- internal/plugin/kubeconfig.go | 18 +- internal/source/dispatcher.go | 6 +- pkg/bot/teams.go | 4 +- pkg/controller/upgrade.go | 2 +- pkg/execute/plugin_executor.go | 2 +- pkg/pluginx/command.go | 16 + pkg/pluginx/command_opts.go | 18 + 35 files changed, 1506 insertions(+), 66 deletions(-) create mode 100644 cmd/executor/flux/main.go rename {cmd/cli/cmd => internal/cli}/config/config.go (100%) create mode 100644 internal/executor/flux/commands.go create mode 100644 internal/executor/flux/commands/store/tables.yaml create mode 100644 internal/executor/flux/commands/store/tutorial.yaml create mode 100644 internal/executor/flux/commands/template.go create mode 100644 internal/executor/flux/config.go create mode 100644 internal/executor/flux/diff_cmd.go create mode 100644 internal/executor/flux/diff_ks_cmd.go create mode 100644 internal/executor/flux/diff_ks_cmd_test.go create mode 100644 internal/executor/flux/executor.go create mode 100644 internal/executor/flux/forbidden.go create mode 100644 internal/executor/flux/gh_cmd.go rename {pkg/httpsrv => internal/httpx}/server.go (87%) diff --git a/.goreleaser.plugin.yaml b/.goreleaser.plugin.yaml index b6059342ee..284c7d118d 100644 --- a/.goreleaser.plugin.yaml +++ b/.goreleaser.plugin.yaml @@ -58,6 +58,22 @@ builds: goarm: - 7 + - id: flux + main: cmd/executor/flux/main.go + binary: executor_flux_{{ .Os }}_{{ .Arch }} + + no_unique_dist_dir: true + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + goarm: + - 7 + - id: gh main: cmd/executor/gh/main.go binary: executor_gh_{{ .Os }}_{{ .Arch }} @@ -122,6 +138,22 @@ builds: goarm: - 7 + - id: github-events + main: cmd/source/github-events/main.go + binary: source_github-events_{{ .Os }}_{{ .Arch }} + + no_unique_dist_dir: true + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + goarm: + - 7 + - id: keptn main: cmd/source/keptn/main.go binary: source_keptn_{{ .Os }}_{{ .Arch }} diff --git a/Makefile b/Makefile index fbee91daaf..dccecd09f9 100644 --- a/Makefile +++ b/Makefile @@ -89,7 +89,7 @@ gen-grpc-resources: # Generate plugins YAML index files for both all plugins and end-user ones. gen-plugins-index: build-plugins go run ./hack/gen-plugin-index.go -output-path ./plugins-dev-index.yaml - go run ./hack/gen-plugin-index.go -output-path ./plugins-index.yaml -plugin-name-filter 'kubectl|helm|kubernetes|prometheus|x|doctor|keptn' + go run ./hack/gen-plugin-index.go -output-path ./plugins-index.yaml -plugin-name-filter 'kubectl|helm|kubernetes|prometheus|exec|doctor|keptn|github-events' gen-docs-cli: rm -f ./cmd/cli/docs/* diff --git a/cmd/botkube-agent/main.go b/cmd/botkube-agent/main.go index 6851b0da79..cd06e322b9 100644 --- a/cmd/botkube-agent/main.go +++ b/cmd/botkube-agent/main.go @@ -7,7 +7,7 @@ import ( "net/http" "time" - "github.com/google/go-github/v44/github" + "github.com/google/go-github/v53/github" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus/promhttp" segment "github.com/segmentio/analytics-go" @@ -29,6 +29,7 @@ import ( "github.com/kubeshop/botkube/internal/config/reloader" "github.com/kubeshop/botkube/internal/config/remote" "github.com/kubeshop/botkube/internal/heartbeat" + "github.com/kubeshop/botkube/internal/httpx" "github.com/kubeshop/botkube/internal/insights" "github.com/kubeshop/botkube/internal/kubex" "github.com/kubeshop/botkube/internal/lifecycle" @@ -43,7 +44,6 @@ import ( "github.com/kubeshop/botkube/pkg/config" "github.com/kubeshop/botkube/pkg/controller" "github.com/kubeshop/botkube/pkg/execute" - "github.com/kubeshop/botkube/pkg/httpsrv" "github.com/kubeshop/botkube/pkg/multierror" "github.com/kubeshop/botkube/pkg/notifier" "github.com/kubeshop/botkube/pkg/sink" @@ -390,7 +390,7 @@ func run(ctx context.Context) (err error) { actionProvider := action.NewProvider(logger.WithField(componentLogFieldKey, "Action Provider"), conf.Actions, executorFactory) - sourcePluginDispatcher := source.NewDispatcher(logger, bots, sinkNotifiers, pluginManager, actionProvider, reporter, auditReporter, kubeConfig) + sourcePluginDispatcher := source.NewDispatcher(logger, conf.Settings.ClusterName, bots, sinkNotifiers, pluginManager, actionProvider, reporter, auditReporter, kubeConfig) scheduler := source.NewScheduler(logger, conf, sourcePluginDispatcher) err = scheduler.Start(ctx) if err != nil { @@ -444,18 +444,18 @@ func run(ctx context.Context) (err error) { return nil } -func newMetricsServer(log logrus.FieldLogger, metricsPort string) *httpsrv.Server { +func newMetricsServer(log logrus.FieldLogger, metricsPort string) *httpx.Server { addr := fmt.Sprintf(":%s", metricsPort) router := mux.NewRouter() router.Handle("/metrics", promhttp.Handler()) - return httpsrv.New(log, addr, router) + return httpx.NewServer(log, addr, router) } -func newHealthServer(log logrus.FieldLogger, port string, healthChecker *healthChecker) *httpsrv.Server { +func newHealthServer(log logrus.FieldLogger, port string, healthChecker *healthChecker) *httpx.Server { addr := fmt.Sprintf(":%s", port) router := mux.NewRouter() router.Handle(healthEndpointName, healthChecker) - return httpsrv.New(log, addr, router) + return httpx.NewServer(log, addr, router) } type healthChecker struct { diff --git a/cmd/executor/flux/main.go b/cmd/executor/flux/main.go new file mode 100644 index 0000000000..711c22665e --- /dev/null +++ b/cmd/executor/flux/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "context" + "time" + + "github.com/allegro/bigcache/v3" + "github.com/hashicorp/go-plugin" + + "github.com/kubeshop/botkube/internal/executor/flux" + "github.com/kubeshop/botkube/internal/loggerx" + "github.com/kubeshop/botkube/pkg/api/executor" +) + +// version is set via ldflags by GoReleaser. +var version = "dev" + +func main() { + cache, err := bigcache.New(context.Background(), bigcache.DefaultConfig(30*time.Minute)) + loggerx.ExitOnError(err, "while creating big cache") + + executor.Serve(map[string]plugin.Plugin{ + flux.PluginName: &executor.Plugin{ + Executor: flux.NewExecutor(cache, version), + }, + }) +} diff --git a/go.mod b/go.mod index bfe2d96a41..e75d9869b2 100644 --- a/go.mod +++ b/go.mod @@ -6,19 +6,23 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/PullRequestInc/go-gpt3 v1.1.15 github.com/alexflint/go-arg v1.4.3 + github.com/allegro/bigcache/v3 v3.1.0 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/avast/retry-go/v4 v4.3.3 github.com/aws/aws-sdk-go v1.44.122 + github.com/bombsimon/logrusr/v4 v4.0.0 github.com/briandowns/spinner v1.23.0 github.com/bwmarrin/discordgo v0.25.0 github.com/charmbracelet/log v0.2.2 github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.15.0 + github.com/fluxcd/kustomize-controller/api v1.0.1 + github.com/fluxcd/source-controller/api v1.0.1 github.com/go-playground/locales v0.14.0 github.com/go-playground/universal-translator v0.18.0 github.com/go-playground/validator/v10 v10.11.0 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 - github.com/google/go-github/v44 v44.1.0 + github.com/google/go-github/v53 v53.2.0 github.com/google/uuid v1.3.0 github.com/gookit/color v1.5.2 github.com/gorilla/mux v1.8.0 @@ -64,14 +68,14 @@ require ( gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.4.0 helm.sh/helm/v3 v3.12.1 - k8s.io/api v0.27.2 - k8s.io/apimachinery v0.27.2 + k8s.io/api v0.27.3 + k8s.io/apimachinery v0.27.3 k8s.io/cli-runtime v0.27.2 - k8s.io/client-go v0.27.2 + k8s.io/client-go v0.27.3 k8s.io/klog/v2 v2.90.1 k8s.io/kubectl v0.27.2 k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5 - sigs.k8s.io/controller-runtime v0.14.1 + sigs.k8s.io/controller-runtime v0.15.0 sigs.k8s.io/yaml v1.3.0 ) @@ -88,6 +92,7 @@ require ( github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect github.com/alexflint/go-scalar v1.1.0 // indirect github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -99,6 +104,7 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect github.com/charmbracelet/lipgloss v0.7.1 // indirect + github.com/cloudflare/circl v1.3.3 // indirect github.com/containerd/containerd v1.7.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/cyphar/filepath-securejoin v0.2.3 // indirect @@ -118,6 +124,8 @@ require ( github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/fluxcd/pkg/apis/kustomize v1.1.1 // indirect + github.com/fluxcd/pkg/apis/meta v1.1.1 // indirect github.com/fortytw2/leaktest v1.3.0 // indirect github.com/francoispqt/gojay v1.2.13 // indirect github.com/fvbommel/sortorder v1.0.1 // indirect @@ -245,9 +253,9 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/apiextensions-apiserver v0.27.2 // indirect - k8s.io/apiserver v0.27.2 // indirect - k8s.io/component-base v0.27.2 // indirect + k8s.io/apiextensions-apiserver v0.27.3 // indirect + k8s.io/apiserver v0.27.3 // indirect + k8s.io/component-base v0.27.3 // indirect k8s.io/kube-openapi v0.0.0-20230501164219-8b0f38b5fd1f // indirect nhooyr.io/websocket v1.8.7 // indirect oras.land/oras-go v1.2.2 // indirect diff --git a/go.sum b/go.sum index 579d309c98..ee027aaf62 100644 --- a/go.sum +++ b/go.sum @@ -224,6 +224,8 @@ github.com/Microsoft/hcsshim v0.10.0-rc.7 h1:HBytQPxcv8Oy4244zbQbe6hnOnx544eL5QP github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= github.com/PullRequestInc/go-gpt3 v1.1.15 h1:pidXZbpqZVW0bp8NBNKDb+/++6PFdYfht9vw2CVpaUs= github.com/PullRequestInc/go-gpt3 v1.1.15/go.mod h1:F9yzAy070LhkqHS2154/IH0HVj5xq5g83gLTj7xzyfw= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= @@ -237,6 +239,8 @@ github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/u github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA= github.com/alexflint/go-scalar v1.1.0 h1:aaAouLLzI9TChcPXotr6gUhq+Scr8rl0P9P4PnltbhM= github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/allegro/bigcache/v3 v3.1.0 h1:H2Vp8VOvxcrB91o86fUSVJFqeuz8kpyyB02eH3bSzwk= +github.com/allegro/bigcache/v3 v3.1.0/go.mod h1:aPyh7jEvrog9zAwx5N7+JUQX5dZTSGpxF1LAR4dr35I= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= @@ -279,6 +283,8 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bombsimon/logrusr/v4 v4.0.0 h1:Pm0InGphX0wMhPqC02t31onlq9OVyJ98eP/Vh63t1Oo= +github.com/bombsimon/logrusr/v4 v4.0.0/go.mod h1:pjfHC5e59CvjTBIU3V3sGhFWFAnsnhOR03TRc6im0l8= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE= @@ -287,6 +293,7 @@ github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7 github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd h1:rFt+Y/IK1aEZkEHchZRSq9OQbsSzIT/OrI8YFFmRIng= github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembjv71DPz3uX/V/6MMlSyD9JBQ6kQ= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/bwmarrin/discordgo v0.25.0 h1:NXhdfHRNxtwso6FPdzW2i3uBvvU7UIQTghmV2T4nqAs= github.com/bwmarrin/discordgo v0.25.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -307,6 +314,9 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudevents/sdk-go/observability/opentelemetry/v2 v2.13.0 h1:m8PqY7+MlFla618wQQ21PFWke0+KD5vnAYhfQb1/NK8= github.com/cloudevents/sdk-go/v2 v2.13.0 h1:2zxDS8RyY1/wVPULGGbdgniGXSzLaRJVl136fLXGsYw= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -399,6 +409,14 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluxcd/kustomize-controller/api v1.0.1 h1:zz9zx4Mc7rw9gqdgdhMWX1uDM2uR1x7WBUujKs4mdx8= +github.com/fluxcd/kustomize-controller/api v1.0.1/go.mod h1:rYUovoofr3bVPgQowWj/CSGw73qoH0tOCopJ3oNh7lM= +github.com/fluxcd/pkg/apis/kustomize v1.1.1 h1:MSGn4z0R9PptmoPFHnx2nEZ8Jtl1sKfw0cuDQY2HYwM= +github.com/fluxcd/pkg/apis/kustomize v1.1.1/go.mod h1:0pCu0ecIY+ZM0iE/hOHYwCMZ3b0SpBrjJ1SH3FFyYdE= +github.com/fluxcd/pkg/apis/meta v1.1.1 h1:sLAKLbEu7rRzJ+Mytffu3NcpfdbOBTa6hcpOQzFWm+M= +github.com/fluxcd/pkg/apis/meta v1.1.1/go.mod h1:soCfzjFWbm1mqybDcOywWKTCEYlH3skpoNGTboVk234= +github.com/fluxcd/source-controller/api v1.0.1 h1:nycylbNBnQd+EO4UHpqXqAQJ1cGAPxgBbrfERCQ1pp8= +github.com/fluxcd/source-controller/api v1.0.1/go.mod h1:rAY5FRFGZUKpIFNyYANHIgPgJPvbALBHWsq/zHw/cXQ= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= @@ -444,7 +462,7 @@ github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-logr/zapr v1.2.3 h1:a9vnzlIBPQBBkeaR9IuMUfmVOrQlkoC4YfPoFkX3T7A= +github.com/go-logr/zapr v1.2.4 h1:QHVo+6stLbfJmYGkQ7uGHUCu5hnAFAj6mDe6Ea0SeOo= github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonreference v0.20.1 h1:FBLnyygC4/IZZr893oiomc9XaghoveYTrLC1F86HID8= @@ -561,8 +579,8 @@ github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= -github.com/google/go-github/v44 v44.1.0 h1:shWPaufgdhr+Ad4eo/pZv9ORTxFpsxPEPEuuXAKIQGA= -github.com/google/go-github/v44 v44.1.0/go.mod h1:iWn00mWcP6PRWHhXm0zuFJ8wbEjE5AGO5D5HXYM4zgw= +github.com/google/go-github/v53 v53.2.0 h1:wvz3FyF53v4BK+AsnvCmeNhf8AkTaeh2SoYu/XUvTtI= +github.com/google/go-github/v53 v53.2.0/go.mod h1:XhFRObz+m/l+UCm9b7KSIC3lT3NWSXGt7mOsAWEloao= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= @@ -955,9 +973,9 @@ github.com/olivere/elastic v6.2.37+incompatible/go.mod h1:J+q1zQJTgAz9woqsbVRqGe github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo/v2 v2.9.1 h1:zie5Ly042PD3bsCvsSOPvRnFwyo3rKe64TJlD6nu0mk= +github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= -github.com/onsi/gomega v1.27.4 h1:Z2AnStgsdSayCMDiCU42qIz+HLqEPcgiOCXjAU/w+8E= +github.com/onsi/gomega v1.27.7 h1:fVih9JD6ogIiHUN6ePK7HJidyEDpWGVB5mzM7cWNXoU= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b h1:YWuSjZCQAPM8UUBLkYUk1e+rZcvWHJmFb6i6rM44Xs8= @@ -1498,6 +1516,7 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1638,7 +1657,7 @@ golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNq golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -gomodules.xyz/jsonpatch/v2 v2.2.0 h1:4pT439QV83L+G9FkcCriY6EkpcK6r6bK+A5FBUMI7qY= +gomodules.xyz/jsonpatch/v2 v2.3.0 h1:8NFhfS6gzxNqjLIYnZxg319wZ5Qjnx4m/CcX+Klzazc= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= @@ -1924,20 +1943,20 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -k8s.io/api v0.27.2 h1:+H17AJpUMvl+clT+BPnKf0E3ksMAzoBBg7CntpSuADo= -k8s.io/api v0.27.2/go.mod h1:ENmbocXfBT2ADujUXcBhHV55RIT31IIEvkntP6vZKS4= -k8s.io/apiextensions-apiserver v0.27.2 h1:iwhyoeS4xj9Y7v8YExhUwbVuBhMr3Q4bd/laClBV6Bo= -k8s.io/apiextensions-apiserver v0.27.2/go.mod h1:Oz9UdvGguL3ULgRdY9QMUzL2RZImotgxvGjdWRq6ZXQ= -k8s.io/apimachinery v0.27.2 h1:vBjGaKKieaIreI+oQwELalVG4d8f3YAMNpWLzDXkxeg= -k8s.io/apimachinery v0.27.2/go.mod h1:XNfZ6xklnMCOGGFNqXG7bUrQCoR04dh/E7FprV6pb+E= -k8s.io/apiserver v0.27.2 h1:p+tjwrcQEZDrEorCZV2/qE8osGTINPuS5ZNqWAvKm5E= -k8s.io/apiserver v0.27.2/go.mod h1:EsOf39d75rMivgvvwjJ3OW/u9n1/BmUMK5otEOJrb1Y= +k8s.io/api v0.27.3 h1:yR6oQXXnUEBWEWcvPWS0jQL575KoAboQPfJAuKNrw5Y= +k8s.io/api v0.27.3/go.mod h1:C4BNvZnQOF7JA/0Xed2S+aUyJSfTGkGFxLXz9MnpIpg= +k8s.io/apiextensions-apiserver v0.27.3 h1:xAwC1iYabi+TDfpRhxh4Eapl14Hs2OftM2DN5MpgKX4= +k8s.io/apiextensions-apiserver v0.27.3/go.mod h1:BH3wJ5NsB9XE1w+R6SSVpKmYNyIiyIz9xAmBl8Mb+84= +k8s.io/apimachinery v0.27.3 h1:Ubye8oBufD04l9QnNtW05idcOe9Z3GQN8+7PqmuVcUM= +k8s.io/apimachinery v0.27.3/go.mod h1:XNfZ6xklnMCOGGFNqXG7bUrQCoR04dh/E7FprV6pb+E= +k8s.io/apiserver v0.27.3 h1:AxLvq9JYtveYWK+D/Dz/uoPCfz8JC9asR5z7+I/bbQ4= +k8s.io/apiserver v0.27.3/go.mod h1:Y61+EaBMVWUBJtxD5//cZ48cHZbQD+yIyV/4iEBhhNA= k8s.io/cli-runtime v0.27.2 h1:9HI8gfReNujKXt16tGOAnb8b4NZ5E+e0mQQHKhFGwYw= k8s.io/cli-runtime v0.27.2/go.mod h1:9UecpyPDTkhiYY4d9htzRqN+rKomJgyb4wi0OfrmCjw= -k8s.io/client-go v0.27.2 h1:vDLSeuYvCHKeoQRhCXjxXO45nHVv2Ip4Fe0MfioMrhE= -k8s.io/client-go v0.27.2/go.mod h1:tY0gVmUsHrAmjzHX9zs7eCjxcBsf8IiNe7KQ52biTcQ= -k8s.io/component-base v0.27.2 h1:neju+7s/r5O4x4/txeUONNTS9r1HsPbyoPBAtHsDCpo= -k8s.io/component-base v0.27.2/go.mod h1:5UPk7EjfgrfgRIuDBFtsEFAe4DAvP3U+M8RTzoSJkpo= +k8s.io/client-go v0.27.3 h1:7dnEGHZEJld3lYwxvLl7WoehK6lAq7GvgjxpA3nv1E8= +k8s.io/client-go v0.27.3/go.mod h1:2MBEKuTo6V1lbKy3z1euEGnhPfGZLKTS9tiJ2xodM48= +k8s.io/component-base v0.27.3 h1:g078YmdcdTfrCE4fFobt7qmVXwS8J/3cI1XxRi/2+6k= +k8s.io/component-base v0.27.3/go.mod h1:JNiKYcGImpQ44iwSYs6dysxzR9SxIIgQalk4HaCNVUY= k8s.io/klog/v2 v2.90.1 h1:m4bYOKall2MmOiRaR1J+We67Do7vm9KiQVlT96lnHUw= k8s.io/klog/v2 v2.90.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/kube-openapi v0.0.0-20230501164219-8b0f38b5fd1f h1:2kWPakN3i/k81b0gvD5C5FJ2kxm1WrQFanWchyKuqGg= @@ -1953,8 +1972,8 @@ oras.land/oras-go v1.2.2/go.mod h1:Apa81sKoZPpP7CDciE006tSZ0x3Q3+dOoBcMZ/aNxvw= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/controller-runtime v0.14.1 h1:vThDes9pzg0Y+UbCPY3Wj34CGIYPgdmspPm2GIpxpzM= -sigs.k8s.io/controller-runtime v0.14.1/go.mod h1:GaRkrY8a7UZF0kqFFbUKG7n9ICiTY5T55P1RiE3UZlU= +sigs.k8s.io/controller-runtime v0.15.0 h1:ML+5Adt3qZnMSYxZ7gAverBLNPSMQEibtzAgp0UPojU= +sigs.k8s.io/controller-runtime v0.15.0/go.mod h1:7ngYvp1MLT+9GeZ+6lH3LOlcHkp/+tzA/fmHa4iq9kk= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/kustomize/api v0.13.2 h1:kejWfLeJhUsTGioDoFNJET5LQe/ajzXhJGYoU+pJsiA= diff --git a/cmd/cli/cmd/config/config.go b/internal/cli/config/config.go similarity index 100% rename from cmd/cli/cmd/config/config.go rename to internal/cli/config/config.go diff --git a/internal/cli/login/login.go b/internal/cli/login/login.go index 2b8357dea9..042fa95e8c 100644 --- a/internal/cli/login/login.go +++ b/internal/cli/login/login.go @@ -11,7 +11,7 @@ import ( "github.com/fatih/color" "github.com/pkg/browser" - "github.com/kubeshop/botkube/cmd/cli/cmd/config" + "github.com/kubeshop/botkube/internal/cli/config" "github.com/kubeshop/botkube/internal/cli/heredoc" ) diff --git a/internal/cli/migrate/migrate.go b/internal/cli/migrate/migrate.go index 581e199807..8767b09e9b 100644 --- a/internal/cli/migrate/migrate.go +++ b/internal/cli/migrate/migrate.go @@ -19,8 +19,8 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd" - cliconfig "github.com/kubeshop/botkube/cmd/cli/cmd/config" "github.com/kubeshop/botkube/internal/cli" + cliconfig "github.com/kubeshop/botkube/internal/cli/config" "github.com/kubeshop/botkube/internal/cli/printer" "github.com/kubeshop/botkube/internal/ptr" gqlModel "github.com/kubeshop/botkube/internal/remote/graphql" diff --git a/internal/executor/doctor/executor.go b/internal/executor/doctor/executor.go index b90494d739..6f834cbdae 100644 --- a/internal/executor/doctor/executor.go +++ b/internal/executor/doctor/executor.go @@ -46,7 +46,7 @@ func NewExecutor(ver string) *Executor { // Metadata returns details about the Doctor plugin. func (d *Executor) Metadata(context.Context) (api.MetadataOutput, error) { return api.MetadataOutput{ - Version: "1.0.0", + Version: d.pluginVersion, Description: "Doctor helps in finding the root cause of a k8s problem.", JSONSchema: api.JSONSchema{ Value: heredoc.Doc(`{ diff --git a/internal/executor/flux/commands.go b/internal/executor/flux/commands.go new file mode 100644 index 0000000000..f79226202a --- /dev/null +++ b/internal/executor/flux/commands.go @@ -0,0 +1,43 @@ +package flux + +import ( + "context" + "fmt" + "strings" + + "github.com/gookit/color" + + "github.com/kubeshop/botkube/pkg/formatx" + "github.com/kubeshop/botkube/pkg/pluginx" +) + +// escapePositionals add '--' after known keyword. Example: +// +// old: `flux gh pr view 2` +// new: `flux gh -- pr view 2` +// +// As a result, we can parse it without fully defining what 'gh' command is specified. +func escapePositionals(in, name string) string { + if strings.Contains(in, name) { + return strings.Replace(in, name, fmt.Sprintf("%s -- ", name), 1) + } + return in +} + +func normalize(in string) string { + out := formatx.RemoveHyperlinks(in) + out = strings.NewReplacer(`“`, `"`, `”`, `"`, `‘`, `"`, `’`, `"`).Replace(out) + + out = strings.TrimSpace(out) + + return out +} + +// ExecuteCommand is a syntax sugar for running CLI commands. +func ExecuteCommand(ctx context.Context, in string, opts ...pluginx.ExecuteCommandMutation) (string, error) { + out, err := pluginx.ExecuteCommand(ctx, in, opts...) + if err != nil { + return "", err + } + return color.ClearCode(out.CombinedOutput()), nil +} diff --git a/internal/executor/flux/commands/store/tables.yaml b/internal/executor/flux/commands/store/tables.yaml new file mode 100644 index 0000000000..b052837682 --- /dev/null +++ b/internal/executor/flux/commands/store/tables.yaml @@ -0,0 +1,35 @@ +templates: + - trigger: + command: + prefix: "flux get sources git" + type: "parser:table:space" + message: + selects: + - name: "Source" + keyTpl: "{{ .Name }}" + actions: + export: "flux export source git {{ .Name }}" + preview: | + Name: {{ .Name }} + Revision: {{ .Revision }} + Suspended: {{ .Suspended }} + Ready: {{ .Ready }} + Message: {{ .Message}} + + - trigger: + command: + regex: "flux get sources (bucket|chart|helm|oci)" + type: "parser:table:space" + message: + selects: + - name: "Item" + keyTpl: "{{ .Name }}" + + - trigger: + command: + regex: "flux get (receivers|helmreleases|kustomizations|ks)" + type: "parser:table:space" + message: + selects: + - name: "Item" + keyTpl: "{{ .Name }}" diff --git a/internal/executor/flux/commands/store/tutorial.yaml b/internal/executor/flux/commands/store/tutorial.yaml new file mode 100644 index 0000000000..0457dd28fb --- /dev/null +++ b/internal/executor/flux/commands/store/tutorial.yaml @@ -0,0 +1,116 @@ +templates: + - trigger: + command: + regex: '^flux get\s?$' + type: "tutorial" + message: + paginate: + page: 5 + header: "Available commands" + buttons: + - name: "Get Provider statuses" + command: "{{BotName}} flux get alert-providers" + description: "{{BotName}} flux get alert-providers" + - name: "Get Alert statuses" + command: "{{BotName}} flux get alerts" + description: "{{BotName}} flux get alerts" + - name: "Get all resources and statuses" + command: "{{BotName}} flux get all" + description: "{{BotName}} flux get all" + - name: "Get HelmRelease statuses" + command: "{{BotName}} flux get helmreleases" + description: "{{BotName}} flux get helmreleases" + - name: "Get image automation object status" + command: "{{BotName}} flux get images" + description: "{{BotName}} flux get images" + - name: "Get Kustomization statuses" + command: "{{BotName}} flux get kustomizations" + description: "{{BotName}} flux get kustomizations" + - name: "Get Receiver statuses" + command: "{{BotName}} flux get receivers" + description: "{{BotName}} flux get receivers" + - name: "Get source statuses" + command: "{{BotName}} flux get sources" + description: "{{BotName}} flux get sources" + + - trigger: + command: + prefix: "flux tutorial" + type: "tutorial" + message: + paginate: + page: 5 + header: "Flux Quick Start tutorial" + buttons: + - name: "Check prerequisites" + command: "{{BotName}} flux check --pre" + description: "{{BotName}} flux check --pre" + - name: "Install Flux" + command: "{{BotName}} flux install" + description: "{{BotName}} flux install" + - name: "Create Git source" + command: | + {{BotName}} flux create source git webapp-latest + --url=https://github.com/stefanprodan/podinfo + --branch=master + --interval=3m + description: | + {{BotName}} flux create source git webapp-latest + --url=https://github.com/stefanprodan/podinfo + --branch=master + --interval=3m + - name: "List Git sources" + command: "{{BotName}} flux get sources git" + description: "{{BotName}} flux get sources git" + - name: "Reconcile Git source" + command: "{{BotName}} flux reconcile source git flux-system" + description: "{{BotName}} flux reconcile source git flux-system" + - name: "Export Git sources" + command: "{{BotName}} flux export source git --all" + description: "{{BotName}} flux export source git --all" + - name: "Create Kustomization" + command: | + {{BotName}} flux create kustomization webapp-dev + --source=webapp-latest + --path='./deploy/webapp/' + --prune=true + --interval=5m + --health-check='Deployment/backend.webapp' + --health-check='Deployment/frontend.webapp' + --health-check-timeout=2m + description: | + {{BotName}} flux create kustomization webapp-dev + --source=webapp-latest + --path='./deploy/webapp/' + --prune=true + --interval=5m + --health-check='Deployment/backend.webapp' + --health-check='Deployment/frontend.webapp' + --health-check-timeout=2m + - name: "Reconcile Kustomization" + command: "{{BotName}} flux reconcile kustomization webapp-dev --with-source" + description: "{{BotName}} flux reconcile kustomization webapp-dev --with-source" + - name: "Suspend Kustomization" + command: "{{BotName}} flux suspend kustomization webapp-dev" + description: "{{BotName}} flux suspend kustomization webapp-dev" + - name: "Export Kustomizations" + command: "{{BotName}} flux export kustomization --all" + description: "{{BotName}} flux export kustomization --all" + - name: "Resume Kustomization" + command: "{{BotName}} flux resume kustomization webapp-dev" + description: "{{BotName}} flux resume kustomization webapp-dev" + - name: "Delete Kustomization" + command: "{{BotName}} flux delete kustomization webapp-dev" + description: "{{BotName}} flux delete kustomization webapp-dev" + - name: "Delete Git source" + command: "{{BotName}} flux delete source git webapp-latest" + description: "{{BotName}} flux delete source" + - name: "Delete Kustomization" + command: "{{BotName}} flux delete kustomization webapp-dev" + description: "{{BotName}} flux delete kustomization webapp-dev" + - name: "Delete Git source" + command: "{{BotName}} flux delete source git webapp-latest --silent" + description: "{{BotName}} flux delete source git webapp-latest --silent" + - name: "Uninstall Flux" + command: "{{BotName}} flux uninstall" + description: "{{BotName}} flux uninstall" diff --git a/internal/executor/flux/commands/template.go b/internal/executor/flux/commands/template.go new file mode 100644 index 0000000000..f07edca579 --- /dev/null +++ b/internal/executor/flux/commands/template.go @@ -0,0 +1,45 @@ +package commands + +import ( + "embed" + "fmt" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/kubeshop/botkube/internal/executor/x/template" +) + +//go:embed store +var f embed.FS + +func LoadTemplates() ([]template.Template, error) { + dirs, err := f.ReadDir("store") + if err != nil { + return nil, err + } + + var templates []template.Template + for _, d := range dirs { + fmt.Println(d.Name()) + if d.IsDir() { + continue + } + file, err := f.ReadFile(filepath.Join("store", d.Name())) + if err != nil { + return nil, err + } + + var cfg struct { + Templates []template.Template `yaml:"templates"` + } + err = yaml.Unmarshal(file, &cfg) + if err != nil { + return nil, fmt.Errorf("while unmarshaling file %q: %v", d.Name(), err) + } + + templates = append(templates, cfg.Templates...) + } + + return templates, nil +} diff --git a/internal/executor/flux/config.go b/internal/executor/flux/config.go new file mode 100644 index 0000000000..cb333fa30d --- /dev/null +++ b/internal/executor/flux/config.go @@ -0,0 +1,20 @@ +package flux + +import ( + "github.com/kubeshop/botkube/internal/plugin" + "github.com/kubeshop/botkube/pkg/config" +) + +// Config holds Flux executor configuration. +type Config struct { + Logger config.Logger `yaml:"logger"` + TmpDir plugin.TmpDir `yaml:"tmpDir"` + GitHub struct { + Auth struct { + // The GitHub access token. + // Instruction for creating a token can be found here: https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/#creating-a-token. + // When not provided some functionality may not work. For example, adding a comment under pull request. + AccessToken string `yaml:"accessToken"` + } `yaml:"auth"` + } `yaml:"github"` +} diff --git a/internal/executor/flux/diff_cmd.go b/internal/executor/flux/diff_cmd.go new file mode 100644 index 0000000000..172cd68a6f --- /dev/null +++ b/internal/executor/flux/diff_cmd.go @@ -0,0 +1,423 @@ +package flux + +import ( + "context" + //nolint:gosec + "crypto/md5" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/allegro/bigcache/v3" + "github.com/bombsimon/logrusr/v4" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/gookit/color" + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/yaml" + + "github.com/kubeshop/botkube/pkg/api" + "github.com/kubeshop/botkube/pkg/api/executor" + "github.com/kubeshop/botkube/pkg/formatx" + "github.com/kubeshop/botkube/pkg/pluginx" +) + +const ( + defaultNamespace = "flux-system" + defaultGHDiffCommentHeader = "Merging this pull request will introduce the following changes:" + ghHDiffCommentHeaderWithClusterNameTpl = "Merging this pull request will trigger updates to the %s cluster, introducing the following changes:" + + requiredGHRefErrMsg = "The --github-ref flag is required to perform diff operation. It can be one of: pr number, full pr url, or branch name." +) + +var gitHubDiffCommentTpl = heredoc.Doc(` + #### Flux Kustomization changes + + %s + +
Output +

+ + + %s + +

+
+ + + _Comment created via [Botkube Flux](https://docs.botkube.io/configuration/executor/flux) integration._`) + +// DiffCommand holds diff sub-commands. We use it to customize the execution process. +type DiffCommand struct { + KustomizationCommandAliases + GitHub *struct { + Comment *struct { + URL string `arg:"--url"` + ArtifactID string `arg:"--cache-id"` + } `arg:"subcommand:comment"` + } `arg:"subcommand:gh"` + Artifact *struct { + Tool []string `arg:"positional"` + } `arg:"subcommand:artifact"` +} + +// KustomizeDiffCmdService provides functionality to run the flux diff kustomization process with Botkube related enhancements such as GitHub integration. +type KustomizeDiffCmdService struct { + log logrus.FieldLogger + cache *bigcache.BigCache +} + +// NewKustomizeDiffCmdService returns a new KustomizeDiffCmdService instance. +func NewKustomizeDiffCmdService(cache *bigcache.BigCache, log logrus.FieldLogger) *KustomizeDiffCmdService { + return &KustomizeDiffCmdService{ + log: log, + cache: cache, + } +} + +// ShouldHandle returns true if commands should be handled by this service. +func (k *KustomizeDiffCmdService) ShouldHandle(command string) (*DiffCommand, bool) { + if !strings.Contains(command, "diff") { + return nil, false + } + + var diffCmd struct { + Diff *DiffCommand `arg:"subcommand:diff"` + } + + err := pluginx.ParseCommand(PluginName, command, &diffCmd) + if err != nil { + // if we cannot parse, it means that unknown command was specified + k.log.WithError(err).Debug("Cannot parse input command into diff ones.") + return nil, false + } + + if diffCmd.Diff == nil { + return nil, false + } + return diffCmd.Diff, true +} + +// Run consumes the output of ShouldHandle method and runs specified command. +func (k *KustomizeDiffCmdService) Run(ctx context.Context, diffCmd *DiffCommand, kubeConfigPath string, kubeConfigBytes []byte, cfg Config) (executor.ExecuteOutput, error) { + switch { + case diffCmd.Artifact != nil: + return executor.ExecuteOutput{}, errors.New("artifact diffing is not supported") + case diffCmd.KustomizationCommandAliases.Get() != nil: + return k.runKustomizeDiff(ctx, diffCmd, kubeConfigPath, kubeConfigBytes, cfg) + case diffCmd.GitHub != nil && diffCmd.GitHub.Comment != nil: + return k.postGitHubComment(ctx, diffCmd, cfg) + default: + return executor.ExecuteOutput{}, errors.New("unknown command") + } +} + +func (k *KustomizeDiffCmdService) postGitHubComment(ctx context.Context, diffCmd *DiffCommand, cfg Config) (executor.ExecuteOutput, error) { + data, err := k.cache.Get(diffCmd.GitHub.Comment.ArtifactID) + switch { + case err == nil: + case errors.Is(err, bigcache.ErrEntryNotFound): + return executor.ExecuteOutput{ + Message: api.Message{ + Sections: []api.Section{ + { + Base: api.Base{ + Header: "❗ Missing report", + Description: "The Kustomize diff report is missing from the cache. Please re-run the `flux diff ks` command to get a fresh report.", + }, + }, + }, + }, + }, nil + default: + return executor.ExecuteOutput{}, fmt.Errorf("while getting diff data from cache: %w", err) + } + gh := NewGitHubCmdService(k.log) + + postPRCommentCmd := fmt.Sprintf("flux gh pr comment '%s' --body-file -", diffCmd.GitHub.Comment.URL) + cmd, can := gh.ShouldHandle(postPRCommentCmd) + if !can { + return executor.ExecuteOutput{}, fmt.Errorf("command %q was not recognized by gh command executor", postPRCommentCmd) + } + + header := defaultGHDiffCommentHeader + clusterName := k.tryToResolveClusterName() + if clusterName != "" { + header = fmt.Sprintf(ghHDiffCommentHeaderWithClusterNameTpl, clusterName) + } + + commentBody := fmt.Sprintf(gitHubDiffCommentTpl, header, formatx.CodeBlock(string(data))) + return gh.Run(ctx, cmd, cfg, pluginx.ExecuteCommandStdin(strings.NewReader(commentBody))) +} + +func (k *KustomizeDiffCmdService) runKustomizeDiff(ctx context.Context, diffCmd *DiffCommand, kubeConfigPath string, kubeConfigBytes []byte, cfg Config) (executor.ExecuteOutput, error) { + kustomizeDiff := diffCmd.KustomizationCommandAliases.Get() + + if kustomizeDiff.GitHubRef == "" { + return executor.ExecuteOutput{}, errors.New(requiredGHRefErrMsg) + } + + workdir, err := k.cloneResources(ctx, kustomizeDiff, kubeConfigBytes, cfg) + if err != nil { + return executor.ExecuteOutput{}, err + } + defer os.RemoveAll(workdir) + + out, changesDetected, err := k.runDiffCmd(ctx, kustomizeDiff.ToCmdString(), + pluginx.ExecuteCommandEnvs(map[string]string{ + "KUBECONFIG": kubeConfigPath, + }), + pluginx.ExecuteCommandWorkingDir(workdir), + ) + if err != nil { + k.log.WithError(err).WithField("command", kustomizeDiff.ToCmdString()).Error("failed to run command") + return executor.ExecuteOutput{}, fmt.Errorf("while running command: %v", err) + } + + textFields, buttons := k.tryToGetPRDetails(ctx, out, kustomizeDiff, workdir, cfg) + + if !changesDetected && out == "" { + return executor.ExecuteOutput{ + Message: api.Message{ + Sections: []api.Section{ + { + Base: api.Base{ + Header: "No changes detected", + Body: api.Body{ + Plaintext: "Running flux diff has not detected any changes that will be made by this pull request.", + }, + }, + TextFields: textFields, + }, + { + Buttons: buttons, + }, + }, + }, + }, nil + } + + return executor.ExecuteOutput{ + Message: api.Message{ + Sections: []api.Section{ + { + Base: api.Base{ + Header: "⚠️ Changes detected", + Description: "GitHub pull request highlights", + Body: api.Body{CodeBlock: out}, + }, + TextFields: textFields, + Buttons: buttons, + }, + }, + }, + }, nil +} + +func (k *KustomizeDiffCmdService) tryToGetPRDetails(ctx context.Context, out string, diff *KustomizationDiffCommand, workdir string, cfg Config) ([]api.TextField, []api.Button) { + resolvePRDetailsCmd := fmt.Sprintf("gh pr view %s --json author,state,url", diff.GitHubRef) + rawDetails, err := ExecuteCommand(ctx, resolvePRDetailsCmd, + pluginx.ExecuteCommandEnvs(map[string]string{ + "GH_TOKEN": cfg.GitHub.Auth.AccessToken, + }), + pluginx.ExecuteCommandWorkingDir(workdir), + ) + if err != nil { + k.log.WithError(err).Debug("while getting pull request details") + return nil, nil + } + + var prDetails PRDetails + err = json.Unmarshal([]byte(rawDetails), &prDetails) + if err != nil { + k.log.WithError(err).Debug("while unmarshalling pull request details") + return nil, nil + } + + textFields := api.TextFields{ + {Key: "Author", Value: formatx.AdaptiveCodeBlock(prDetails.Author.Login)}, + {Key: "State", Value: formatx.AdaptiveCodeBlock(prDetails.State)}, + } + + btnBuilder := api.NewMessageButtonBuilder() + + var btns api.Buttons + + if cfg.GitHub.Auth.AccessToken != "" { // if we don't have access token then we won't be able to create a comment or approve PR + btns = k.appendPostDiffBtn(out, btns, btnBuilder, prDetails) + btns = append(btns, btnBuilder.ForCommandWithoutDesc("Approve pull request", fmt.Sprintf("flux gh pr review %s --approve", prDetails.URL))) + } + + btns = append(btns, btnBuilder.ForURL("View pull request", prDetails.URL)) + + return textFields, btns +} + +func (k *KustomizeDiffCmdService) appendPostDiffBtn(out string, btns api.Buttons, btnBuilder *api.ButtonBuilder, prDetails PRDetails) api.Buttons { + if out == "" { // no diff + return btns + } + cacheID, err := k.storeDiff(out) + if err != nil { + k.log.WithError(err).Info("Cannot store diff, skipping post as comment button") + return btns + } + + cmd := fmt.Sprintf("flux diff gh comment --url %s --cache-id %s --bk-cmd-header='Post diff as GitHub comment'", prDetails.URL, cacheID) + + return append(btns, btnBuilder.ForCommandWithoutDesc("Post diff under pull request", cmd, api.ButtonStylePrimary)) +} + +func (k *KustomizeDiffCmdService) storeDiff(out string) (string, error) { + //nolint:gosec // we use that only for checksum + h := md5.New() + h.Write([]byte(out)) + cacheID := fmt.Sprintf("%x", h.Sum(nil)) + return cacheID, k.cache.Set(cacheID, []byte(out)) +} + +func (*KustomizeDiffCmdService) runDiffCmd(ctx context.Context, in string, opts ...pluginx.ExecuteCommandMutation) (string, bool, error) { + out, err := pluginx.ExecuteCommand(ctx, in, opts...) + if err != nil { + if out.ExitCode == 1 { // the diff commands returns 1 if changes are detected + return out.Stdout, true, nil + } + return "", false, err + } + + message := strings.TrimSpace(color.ClearCode(out.CombinedOutput())) + if message == "" { + return "", false, nil + } + + return message, false, nil +} + +func resolveGitHubRepoURL(ctx context.Context, logger logrus.FieldLogger, kubeConfigBytes []byte, ns string, name string) (string, error) { + scheme := runtime.NewScheme() + if err := kustomizev1.AddToScheme(scheme); err != nil { + return "", fmt.Errorf("failed to add Kustomize scheme: %w", err) + } + + if err := sourcev1.AddToScheme(scheme); err != nil { + return "", fmt.Errorf("failed to add Source scheme: %w", err) + } + + kubeConfig, err := clientcmd.RESTConfigFromKubeConfig(kubeConfigBytes) + if err != nil { + return "", fmt.Errorf("while reading kube config. %v", err) + } + + log.SetLogger(logrusr.New(logger)) + + cl, err := client.New(kubeConfig, client.Options{ + Scheme: scheme, + }) + if err != nil { + return "", fmt.Errorf("failed to create client: %w", err) + } + + // Resolve Kustomize + ks := kustomizev1.Kustomization{} + err = cl.Get(ctx, client.ObjectKey{ + Namespace: ns, + Name: name, + }, &ks) + + if err != nil { + return "", fmt.Errorf("failed to get Kustomization: %w", err) + } + + if ks.Spec.SourceRef.Kind != "GitRepository" { + return "", nil // skip + } + + // Get Kustomization GitHub repository + git := sourcev1.GitRepository{} + namespace := ks.Spec.SourceRef.Namespace + if namespace == "" { + namespace = ks.Namespace + } + + err = cl.Get(ctx, client.ObjectKey{ + Namespace: namespace, + Name: ks.Spec.SourceRef.Name, + }, &git) + + if err != nil { + return "", fmt.Errorf("failed to get GitRepository: %w", err) + } + + return git.Spec.URL, nil +} + +func (k *KustomizeDiffCmdService) cloneResources(ctx context.Context, diff *KustomizationDiffCommand, kubeConfigBytes []byte, cfg Config) (string, error) { + if diff.Namespace == "" { + diff.Namespace = defaultNamespace + } + + url, err := resolveGitHubRepoURL(ctx, k.log, kubeConfigBytes, diff.Namespace, diff.AppName) + if err != nil { + return "", err + } + + k.log.Infof("resolved GitHub URL %s", url) + + // it may occur that it won't be a GitHub repository, but we proceed anyway. + opts := []pluginx.ExecuteCommandMutation{ + pluginx.ExecuteCommandEnvs(map[string]string{ + "GH_TOKEN": cfg.GitHub.Auth.AccessToken, + }), + } + + dir, err := os.MkdirTemp(cfg.TmpDir.GetDirectory(), "gh-repo-") + if err != nil { + return "", fmt.Errorf("while writing creating tmp dir for repository: %w", err) + } + + cloneCmd := fmt.Sprintf("gh repo clone %s %s -- --depth 1", url, dir) + _, err = pluginx.ExecuteCommand(ctx, cloneCmd, opts...) + if err != nil { + return "", err + } + + opts = append(opts, pluginx.ExecuteCommandWorkingDir(dir)) + opts2 := append(opts, pluginx.ExecuteCommandDependencyDir("")) + // because we clone with --depth 1 we have issues as described here: https://github.com/cli/cli/issues/4287 + _, err = pluginx.ExecuteCommand(ctx, `git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"`, opts2...) + if err != nil { + return "", err + } + + k.log.Infof("Going to checkout %s", diff.GitHubRef) + checkoutCmd := fmt.Sprintf("gh pr checkout %s", diff.GitHubRef) + _, err = pluginx.ExecuteCommand(ctx, checkoutCmd, opts...) + if err != nil { + return "", err + } + + return dir, nil +} + +func (k *KustomizeDiffCmdService) tryToResolveClusterName() string { + var apiCfg clientcmdapi.Config + + err := yaml.Unmarshal(nil, &apiCfg) + if err != nil { + k.log.WithError(err).Debug("Cannot unmarshal kubeconfig. Skipping obtaining cluster name") + return "" + } + + if apiCfg.CurrentContext == "default" { + return "" // default was specified in the previous botkube version, for our use-case it's not useful so we skip it too. + } + return apiCfg.CurrentContext +} diff --git a/internal/executor/flux/diff_ks_cmd.go b/internal/executor/flux/diff_ks_cmd.go new file mode 100644 index 0000000000..e101ca6a92 --- /dev/null +++ b/internal/executor/flux/diff_ks_cmd.go @@ -0,0 +1,129 @@ +package flux + +import ( + "strconv" + "strings" + "time" +) + +type ( + // KustomizationCommandAliases holds different names for kustomization subcommand. + // Unfortunately, it's a go-arg limitation that we cannot on a single entry have subcommand aliases. + KustomizationCommandAliases struct { + Kustomization *KustomizationDiffCommand `arg:"subcommand:kustomization"` + Ks *KustomizationDiffCommand `arg:"subcommand:ks"` + } + + KustomizationDiffCommand struct { + AppName string `arg:"positional"` + KustomizationDiffCommandFlags + GlobalCommandFlags + } +) + +func (k KustomizationDiffCommand) ToCmdString() string { + return "flux diff ks " + k.AppName + k.GlobalCommandFlags.ToString() + k.KustomizationDiffCommandFlags.ToString() +} + +type KustomizationDiffCommandFlags struct { + IgnorePaths []string `arg:"--ignore-paths,separate"` + KustomizationFile string `arg:"--kustomization-file"` + Path string `arg:"--path"` + ProgressBar bool `arg:"--progress-bar"` + GitHubRef string `arg:"--github-ref"` +} + +type GlobalCommandFlags struct { + CacheDir string `arg:"--cache-dir"` + DisableCompression bool `arg:"--disable-compression"` + InsecureSkipTLSVerify bool `arg:"--insecure-skip-tls-verify"` + KubeAPIBurst int `arg:"--kube-api-burst"` + KubeAPIQPS float32 `arg:"--kube-api-qps"` + Namespace string `arg:"-n,--namespace"` + Timeout time.Duration `arg:"--timeout"` + Token string `arg:"--token"` + Verbose bool `arg:"--verbose"` +} + +// Get returns HistoryCommand that were unpacked based on the alias used by user. +func (u KustomizationCommandAliases) Get() *KustomizationDiffCommand { + if u.Kustomization != nil { + return u.Kustomization + } + if u.Ks != nil { + return u.Ks + } + + return nil +} + +func (k KustomizationDiffCommandFlags) ToString() string { + var out strings.Builder + + if k.KustomizationFile != "" { + out.WriteString(" --kustomization-file ") + out.WriteString(k.KustomizationFile) + } + + if k.Path != "" { + out.WriteString(" --path ") + out.WriteString(k.Path) + } + + if len(k.IgnorePaths) != 0 { + out.WriteString(" --ignore-paths ") + out.WriteString(strings.Join(k.IgnorePaths, ",")) + } + + out.WriteString(" --progress-bar=false") // we don't want to have it + + return out.String() +} + +func (g GlobalCommandFlags) ToString() string { + var out strings.Builder + + if g.CacheDir != "" { + out.WriteString(" --cache-dir ") + out.WriteString(g.CacheDir) + } + + if g.DisableCompression { + out.WriteString(" --disable-compression ") + } + + if g.InsecureSkipTLSVerify { + out.WriteString(" --insecure-skip-tls-verify ") + } + + if g.KubeAPIBurst != 0 { + out.WriteString(" --kube-api-burst ") + out.WriteString(strconv.Itoa(g.KubeAPIBurst)) + } + + if g.KubeAPIQPS != 0 { + out.WriteString(" --kube-api-qps ") + out.WriteString(strconv.FormatFloat(float64(g.KubeAPIQPS), 'f', -1, 32)) + } + + if g.Namespace != "" { + out.WriteString(" -n ") + out.WriteString(g.Namespace) + } + + if g.Timeout != 0 { + out.WriteString(" --timeout ") + out.WriteString(g.Timeout.String()) + } + + if g.Token != "" { + out.WriteString(" --token ") + out.WriteString(g.Token) + } + + if g.Verbose { + out.WriteString(" --verbose ") + } + + return out.String() +} diff --git a/internal/executor/flux/diff_ks_cmd_test.go b/internal/executor/flux/diff_ks_cmd_test.go new file mode 100644 index 0000000000..041ebb80a5 --- /dev/null +++ b/internal/executor/flux/diff_ks_cmd_test.go @@ -0,0 +1,116 @@ +package flux + +import ( + "fmt" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/mattn/go-shellwords" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kubeshop/botkube/internal/loggerx" +) + +func TestNewKustomizeDiffCmd(t *testing.T) { + log := loggerx.NewNoop() + + t.Run("The command should be handled", func(t *testing.T) { + cmd := "flux diff ks podinfo --github-ref https://github.com/mszostok/podinfo/pull/2 --path ./kustomize --ignore-paths abc --ignore-paths dd" + diffCmd, can := NewKustomizeDiffCmdService(nil, log).ShouldHandle(cmd) + + assert.True(t, can) + assert.NotNil(t, diffCmd) + + diff := diffCmd.Get() + assert.Equal(t, "podinfo", diff.AppName) + assert.Equal(t, "https://github.com/mszostok/podinfo/pull/2", diff.GitHubRef) + assert.Equal(t, "./kustomize", diff.Path) + assert.Equal(t, []string{"abc", "dd"}, diff.IgnorePaths) + + assert.Equal(t, "flux diff ks podinfo --path ./kustomize --ignore-paths abc,dd", diff.ToCmdString()) + }) + + t.Run("The command should not be handled", func(t *testing.T) { + unsupportedCmd := "flux diff unsupported --some-flag value" + unsupportedDiffCmd, can := NewKustomizeDiffCmdService(nil, log).ShouldHandle(unsupportedCmd) + + assert.False(t, can) + assert.Empty(t, unsupportedDiffCmd) + }) +} +func TestNewKustomizeGitHubCommentCmd(t *testing.T) { + log := loggerx.NewNoop() + cmd := "flux diff gh comment --url https://github.com/mszostok/podinfo/pull/2 --cache-id d720520fc1bc3c07657130a0fa270d33" + diffCmd, can := NewKustomizeDiffCmdService(nil, log).ShouldHandle(cmd) + + assert.True(t, can) + assert.NotNil(t, diffCmd) + + comment := diffCmd.GitHub.Comment + assert.Equal(t, "d720520fc1bc3c07657130a0fa270d33", comment.ArtifactID) + assert.Equal(t, "https://github.com/mszostok/podinfo/pull/2", comment.URL) +} + +func Test(t *testing.T) { + parser := shellwords.NewParser() + parser.ParseEnv = false + parser.ParseBacktick = false + postPRCommentCmd := fmt.Sprintf("flux gh pr review '%s' --comment -b '%s'", "https;?/asdf", heredoc.Doc(` + +some +long +lines +... +`)) + args, err := parser.Parse(postPRCommentCmd) + require.NoError(t, err) + + for _, a := range args { + fmt.Println(a) + fmt.Println("----") + } +} + +func TestKustomizationDiffCommandFlags_ToString(t *testing.T) { + // given + flags := KustomizationDiffCommandFlags{ + IgnorePaths: []string{"abc", "dd"}, + KustomizationFile: "ks", + Path: "./kustomize", + ProgressBar: false, + GitHubRef: "https://github.com/mszostok/podinfo/pull/2", + } + + expected := "--ignore-paths abc,dd --kustomization-file ks --path ./kustomize --github-ref https://github.com/mszostok/podinfo/pull/2" + + // when + gotStringFlags := flags.ToString() + + // then + assert.Equal(t, expected, gotStringFlags) +} + +func TestGlobalCommandFlags_ToString(t *testing.T) { + // given + flags := GlobalCommandFlags{ + CacheDir: "/Users/mszostok/.kube/cache", + DisableCompression: true, + InsecureSkipTLSVerify: true, + KubeAPIBurst: 300, + KubeAPIQPS: 50, + Namespace: "flux-system", + Timeout: 5 * time.Minute, + Token: "YOUR_BEARER_TOKEN", + Verbose: true, + } + + expected := "--cache-dir /Users/mszostok/.kube/cache --disable-compression --insecure-skip-tls-verify --kube-api-burst 300 --kube-api-qps 50 -n flux-system --timeout 5m0s --token YOUR_BEARER_TOKEN --verbose" + + // when + gotStringFlags := flags.ToString() + + // then + assert.Equal(t, expected, gotStringFlags) +} diff --git a/internal/executor/flux/executor.go b/internal/executor/flux/executor.go new file mode 100644 index 0000000000..b6e144197e --- /dev/null +++ b/internal/executor/flux/executor.go @@ -0,0 +1,211 @@ +package flux + +import ( + "context" + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/allegro/bigcache/v3" + + "github.com/kubeshop/botkube/internal/executor/flux/commands" + "github.com/kubeshop/botkube/internal/executor/x" + "github.com/kubeshop/botkube/internal/executor/x/output" + "github.com/kubeshop/botkube/internal/executor/x/state" + "github.com/kubeshop/botkube/internal/loggerx" + "github.com/kubeshop/botkube/pkg/api" + "github.com/kubeshop/botkube/pkg/api/executor" + "github.com/kubeshop/botkube/pkg/pluginx" +) + +const ( + PluginName = "flux" + description = "Run the Flux CLI commands directly from your favorite communication platform." +) + +// Executor provides functionality for running Flux. +type Executor struct { + pluginVersion string + cache *bigcache.BigCache +} + +// NewExecutor returns a new Executor instance. +func NewExecutor(cache *bigcache.BigCache, ver string) *Executor { + x.BuiltinCmdPrefix = "" // we don't need them + return &Executor{ + pluginVersion: ver, + cache: cache, + } +} + +// Metadata returns details about the Flux plugin. +func (d *Executor) Metadata(context.Context) (api.MetadataOutput, error) { + return api.MetadataOutput{ + Version: d.pluginVersion, + Description: description, + Dependencies: getPluginDependencies(), + JSONSchema: jsonSchema(), + }, nil +} + +func getPluginDependencies() map[string]api.Dependency { + return map[string]api.Dependency{ + "flux": { + URLs: map[string]string{ + "windows/amd64": "https://github.com/fluxcd/flux2/releases/download/v2.0.1/flux_2.0.1_windows_amd64.zip", + "windows/arm64": "https://github.com/fluxcd/flux2/releases/download/v2.0.1/flux_2.0.1_windows_arm64.zip", + "darwin/amd64": "https://github.com/fluxcd/flux2/releases/download/v2.0.1/flux_2.0.1_darwin_amd64.tar.gz", + "darwin/arm64": "https://github.com/fluxcd/flux2/releases/download/v2.0.1/flux_2.0.1_darwin_arm64.tar.gz", + "linux/amd64": "https://github.com/fluxcd/flux2/releases/download/v2.0.1/flux_2.0.1_linux_amd64.tar.gz", + "linux/arm64": "https://github.com/fluxcd/flux2/releases/download/v2.0.1/flux_2.0.1_linux_arm64.tar.gz", + }, + }, + "gh": { + URLs: map[string]string{ + "windows/amd64": "https://github.com/cli/cli/releases/download/v2.32.1/gh_2.32.1_windows_amd64.zip//gh_2.32.1_windows_amd64/bin", + "windows/arm64": "https://github.com/cli/cli/releases/download/v2.32.1/gh_2.32.1_windows_arm64.zip//gh_2.32.1_windows_arm64/bin", + "darwin/amd64": "https://github.com/cli/cli/releases/download/v2.32.1/gh_2.32.1_macOS_amd64.zip//gh_2.32.1_macOS_amd64/bin", + "darwin/arm64": "https://github.com/cli/cli/releases/download/v2.32.1/gh_2.32.1_macOS_arm64.zip//gh_2.32.1_macOS_arm64/bin", + "linux/amd64": "https://github.com/cli/cli/releases/download/v2.32.1/gh_2.32.1_linux_amd64.zip//gh_2.32.1_linux_amd64/bin", + "linux/arm64": "https://github.com/cli/cli/releases/download/v2.32.1/gh_2.32.1_linux_arm64.zip//gh_2.32.1_linux_arm64/bin", + }, + }, + } +} + +// Execute returns a given command as a response. +func (d *Executor) Execute(ctx context.Context, in executor.ExecuteInput) (executor.ExecuteOutput, error) { + cmd := normalize(in.Command) + + if err := detectNotSupportedGlobalFlags(cmd); err != nil { + return executor.ExecuteOutput{}, err + } + + if err := pluginx.ValidateKubeConfigProvided(PluginName, in.Context.KubeConfig); err != nil { + return executor.ExecuteOutput{}, err + } + + var cfg Config + err := pluginx.MergeExecutorConfigs(in.Configs, &cfg) + if err != nil { + return executor.ExecuteOutput{}, fmt.Errorf("while merging input configuration: %w", err) + } + + log := loggerx.New(cfg.Logger) + + kubeConfigPath, deleteFn, err := pluginx.PersistKubeConfig(ctx, in.Context.KubeConfig) + if err != nil { + return executor.ExecuteOutput{}, fmt.Errorf("while writing kubeconfig file: %w", err) + } + defer func() { + if deleteErr := deleteFn(ctx); deleteErr != nil { + log.Errorf("failed to delete kubeconfig file %s: %w", kubeConfigPath, deleteErr) + } + }() + + log.WithField("rawCommand", cmd).Info("Processing command...") + + diffHandler := NewKustomizeDiffCmdService(d.cache, log) + if diffCmd, shouldHandle := diffHandler.ShouldHandle(in.Command); shouldHandle { + return diffHandler.Run(ctx, diffCmd, kubeConfigPath, in.Context.KubeConfig, cfg) + } + + ghHandler := NewGitHubCmdService(log) + if ghCmd, shouldHandle := ghHandler.ShouldHandle(in.Command); shouldHandle { + return ghHandler.Run(ctx, ghCmd, cfg, nil) + } + + renderer := x.NewRenderer() + err = renderer.RegisterAll(map[string]x.Render{ + "parser:table:.*": output.NewTableCommandParser(log), + "wrapper": output.NewCommandWrapper(), + "tutorial": output.NewTutorialWrapper(), + }) + if err != nil { + return executor.ExecuteOutput{}, fmt.Errorf("while registering message renderers: %v", err) + } + + command := x.Parse(cmd) + + templates, err := commands.LoadTemplates() + if err != nil { + return executor.ExecuteOutput{}, err + } + + return x.NewRunner(log, renderer).RunWithTemplates(templates, state.ExtractSlackState(in.Context.SlackState), command, func() (string, error) { + out, err := ExecuteCommand(ctx, command.ToExecute, pluginx.ExecuteCommandEnvs(map[string]string{ + "KUBECONFIG": kubeConfigPath, + })) + if err != nil { + log.WithError(err).WithField("command", command.ToExecute).Error("failed to run command") + return "", fmt.Errorf("while running command: %v", err) + } + return out, nil + }) +} + +// Help returns help message +func (d *Executor) Help(context.Context) (api.Message, error) { + renderer := x.NewRenderer() + err := renderer.Register("tutorial", output.NewTutorialWrapper()) + if err != nil { + return api.Message{}, fmt.Errorf("while registering message renderers: %v", err) + } + + runner := x.NewRunner(loggerx.NewNoop(), renderer) + + templates, err := commands.LoadTemplates() + if err != nil { + return api.Message{}, err + } + + out, err := runner.RunWithTemplates(templates, nil, x.Parse("flux tutorial"), func() (string, error) { + return "", nil + }) + + if err != nil { + return api.Message{}, err + } + + return out.Message, nil +} + +// jsonSchema returns JSON schema for the executor. +func jsonSchema() api.JSONSchema { + return api.JSONSchema{ + Value: heredoc.Docf(`{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Flux", + "description": "%s", + "type": "object", + "properties": { + "defaultNamespace": { + "title": "Default Kubernetes Namespace", + "description": "Namespace used if not explicitly specified during command execution.", + "type": "string", + "default": "default" + }, + "helmDriver": { + "title": "Storage driver", + "description": "Storage driver for Helm.", + "type": "string", + "default": "secret", + "oneOf": [ + { + "const": "configmap", + "title": "ConfigMap" + }, + { + "const": "secret", + "title": "Secret" + }, + { + "const": "memory", + "title": "Memory" + } + ] + } + }, + "required": [] + }`, description), + } +} diff --git a/internal/executor/flux/forbidden.go b/internal/executor/flux/forbidden.go new file mode 100644 index 0000000000..d332145d01 --- /dev/null +++ b/internal/executor/flux/forbidden.go @@ -0,0 +1,67 @@ +package flux + +import ( + "fmt" + "strings" + + "github.com/spf13/pflag" + + "github.com/kubeshop/botkube/pkg/multierror" +) + +// notSupportedGlobalFlags holds explicitly not supported flags in the format "[,= len(options) { idx = len(options) - 1 diff --git a/internal/executor/x/output/message_tutorial.go b/internal/executor/x/output/message_tutorial.go index fe561d852b..4a8a0af90f 100644 --- a/internal/executor/x/output/message_tutorial.go +++ b/internal/executor/x/output/message_tutorial.go @@ -3,6 +3,7 @@ package output import ( "fmt" + "github.com/kubeshop/botkube/internal/executor/x" "github.com/kubeshop/botkube/internal/executor/x/mathx" "github.com/kubeshop/botkube/internal/executor/x/state" "github.com/kubeshop/botkube/internal/executor/x/template" @@ -54,11 +55,11 @@ func (p *TutorialWrapper) getPaginationButtons(msg template.TutorialMessage, pag var out []api.Button if pageIndex > 0 { - out = append(out, btnsBuilder.ForCommandWithoutDesc("Prev", fmt.Sprintf("exec run %s @page:%d", cmd, mathx.DecreaseWithMin(pageIndex, 0)))) + out = append(out, btnsBuilder.ForCommandWithoutDesc("Prev", fmt.Sprintf("%s %s @page:%d", x.BuiltinCmdPrefix, cmd, mathx.DecreaseWithMin(pageIndex, 0)))) } if pageIndex*msg.Paginate.Page < allItems-1 { - out = append(out, btnsBuilder.ForCommandWithoutDesc("Next", fmt.Sprintf("exec run %s @page:%d", cmd, mathx.IncreaseWithMax(pageIndex, allItems-1)), api.ButtonStylePrimary)) + out = append(out, btnsBuilder.ForCommandWithoutDesc("Next", fmt.Sprintf("%s %s @page:%d", x.BuiltinCmdPrefix, cmd, mathx.IncreaseWithMax(pageIndex, allItems-1)), api.ButtonStylePrimary)) } return out } diff --git a/internal/executor/x/run.go b/internal/executor/x/run.go index 1d2c0f874c..41f5ad057b 100644 --- a/internal/executor/x/run.go +++ b/internal/executor/x/run.go @@ -36,6 +36,12 @@ func (i *Runner) Run(ctx context.Context, cfg Config, state *state.Container, cm if err != nil { return executor.ExecuteOutput{}, err } + + return i.RunWithTemplates(templates, state, cmd, runFn) +} + +// RunWithTemplates runs a given command and parse its output if needed. It uses specified templates instead of downloading them. +func (i *Runner) RunWithTemplates(templates []template.Template, state *state.Container, cmd Command, runFn func() (string, error)) (executor.ExecuteOutput, error) { cmdTemplate, tplFound := template.FindTemplate(templates, cmd.ToExecute) log := i.log.WithFields(logrus.Fields{ @@ -45,8 +51,10 @@ func (i *Runner) Run(ctx context.Context, cfg Config, state *state.Container, cm }) var cmdOutput string + var err error + if !cmdTemplate.SkipCommandExecution { - log.WithField("command", cmd.ToExecute).Error("Running command") + log.WithField("command", cmd.ToExecute).Info("Running command") cmdOutput, err = runFn() if err != nil { return executor.ExecuteOutput{}, err diff --git a/internal/httpx/http_client.go b/internal/httpx/http_client.go index d123f7bb09..ff8311295c 100644 --- a/internal/httpx/http_client.go +++ b/internal/httpx/http_client.go @@ -5,12 +5,12 @@ import ( "time" ) -const defaultTimeout = 30 * time.Second +const DefaultTimeout = 30 * time.Second // NewHTTPClient creates a new http client with timeout. func NewHTTPClient() *http.Client { client := &http.Client{ - Timeout: defaultTimeout, + Timeout: DefaultTimeout, } return client } diff --git a/pkg/httpsrv/server.go b/internal/httpx/server.go similarity index 87% rename from pkg/httpsrv/server.go rename to internal/httpx/server.go index b684eaa4e2..e9449aaca3 100644 --- a/pkg/httpsrv/server.go +++ b/internal/httpx/server.go @@ -1,4 +1,4 @@ -package httpsrv +package httpx import ( "context" @@ -17,8 +17,8 @@ type Server struct { log logrus.FieldLogger } -// New creates a new HTTP server. -func New(log logrus.FieldLogger, addr string, handler http.Handler) *Server { +// NewServer creates a new HTTP server. +func NewServer(log logrus.FieldLogger, addr string, handler http.Handler) *Server { return &Server{ srv: &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: readHeaderTimeout}, log: log, diff --git a/internal/lifecycle/server.go b/internal/lifecycle/server.go index ccbd26a95b..854cf7b2bc 100644 --- a/internal/lifecycle/server.go +++ b/internal/lifecycle/server.go @@ -8,8 +8,8 @@ import ( "github.com/gorilla/mux" "github.com/sirupsen/logrus" + "github.com/kubeshop/botkube/internal/httpx" "github.com/kubeshop/botkube/pkg/config" - "github.com/kubeshop/botkube/pkg/httpsrv" ) type Restarter interface { @@ -20,12 +20,12 @@ type Restarter interface { type SendMessageFn func(msg string) error // NewServer creates a new httpsrv.Server that exposes lifecycle methods as HTTP endpoints. -func NewServer(log logrus.FieldLogger, cfg config.LifecycleServer, restarter Restarter) *httpsrv.Server { +func NewServer(log logrus.FieldLogger, cfg config.LifecycleServer, restarter Restarter) *httpx.Server { addr := fmt.Sprintf(":%d", cfg.Port) router := mux.NewRouter() reloadHandler := newReloadHandler(log, restarter) router.HandleFunc("/reload", reloadHandler) - return httpsrv.New(log, addr, router) + return httpx.NewServer(log, addr, router) } func newReloadHandler(log logrus.FieldLogger, restarter Restarter) http.HandlerFunc { diff --git a/internal/plugin/kubeconfig.go b/internal/plugin/kubeconfig.go index eb1713243b..0c40b65f6d 100644 --- a/internal/plugin/kubeconfig.go +++ b/internal/plugin/kubeconfig.go @@ -17,7 +17,11 @@ type KubeConfigInput struct { Channel string } -func GenerateKubeConfig(restCfg *rest.Config, pluginCtx config.PluginContext, input KubeConfigInput) ([]byte, error) { +func GenerateKubeConfig(restCfg *rest.Config, clusterName string, pluginCtx config.PluginContext, input KubeConfigInput) ([]byte, error) { + if clusterName == "" { + clusterName = kubeconfigDefaultValue + } + rbac := pluginCtx.RBAC if rbac == nil { return nil, nil @@ -27,7 +31,7 @@ func GenerateKubeConfig(restCfg *rest.Config, pluginCtx config.PluginContext, in APIVersion: "v1", Clusters: []clientcmdapi.NamedCluster{ { - Name: kubeconfigDefaultValue, + Name: clusterName, Cluster: clientcmdapi.Cluster{ Server: restCfg.Host, CertificateAuthority: restCfg.CAFile, @@ -37,18 +41,18 @@ func GenerateKubeConfig(restCfg *rest.Config, pluginCtx config.PluginContext, in }, Contexts: []clientcmdapi.NamedContext{ { - Name: kubeconfigDefaultValue, + Name: clusterName, Context: clientcmdapi.Context{ - Cluster: kubeconfigDefaultValue, + Cluster: clusterName, Namespace: kubeconfigDefaultNamespace, - AuthInfo: kubeconfigDefaultValue, + AuthInfo: clusterName, }, }, }, - CurrentContext: kubeconfigDefaultValue, + CurrentContext: clusterName, AuthInfos: []clientcmdapi.NamedAuthInfo{ { - Name: kubeconfigDefaultValue, + Name: clusterName, AuthInfo: clientcmdapi.AuthInfo{ Token: restCfg.BearerToken, TokenFile: restCfg.BearerTokenFile, diff --git a/internal/source/dispatcher.go b/internal/source/dispatcher.go index 478c87d9dc..b384436c2b 100644 --- a/internal/source/dispatcher.go +++ b/internal/source/dispatcher.go @@ -33,6 +33,7 @@ type Dispatcher struct { interactiveNotifiers []notifier.Bot sinkNotifiers []notifier.Sink restCfg *rest.Config + clusterName string } // ActionProvider defines a provider that is responsible for automated actions. @@ -57,7 +58,7 @@ type AnalyticsReporter interface { } // NewDispatcher create a new Dispatcher instance. -func NewDispatcher(log logrus.FieldLogger, notifiers map[string]bot.Bot, sinkNotifiers []notifier.Sink, manager *plugin.Manager, actionProvider ActionProvider, reporter AnalyticsReporter, auditReporter audit.AuditReporter, restCfg *rest.Config) *Dispatcher { +func NewDispatcher(log logrus.FieldLogger, clusterName string, notifiers map[string]bot.Bot, sinkNotifiers []notifier.Sink, manager *plugin.Manager, actionProvider ActionProvider, reporter AnalyticsReporter, auditReporter audit.AuditReporter, restCfg *rest.Config) *Dispatcher { var ( interactiveNotifiers []notifier.Bot markdownNotifiers []notifier.Bot @@ -81,6 +82,7 @@ func NewDispatcher(log logrus.FieldLogger, notifiers map[string]bot.Bot, sinkNot markdownNotifiers: markdownNotifiers, sinkNotifiers: sinkNotifiers, restCfg: restCfg, + clusterName: clusterName, } } @@ -100,7 +102,7 @@ func (d *Dispatcher) Dispatch(dispatch PluginDispatch) error { return fmt.Errorf("while getting source client for %s: %w", dispatch.pluginName, err) } - kubeconfig, err := plugin.GenerateKubeConfig(d.restCfg, dispatch.pluginContext, plugin.KubeConfigInput{}) + kubeconfig, err := plugin.GenerateKubeConfig(d.restCfg, d.clusterName, dispatch.pluginContext, plugin.KubeConfigInput{}) if err != nil { return fmt.Errorf("while generating kube config for %s: %w", dispatch.pluginName, err) } diff --git a/pkg/bot/teams.go b/pkg/bot/teams.go index 1a810da2fb..e95b644560 100644 --- a/pkg/bot/teams.go +++ b/pkg/bot/teams.go @@ -16,12 +16,12 @@ import ( "github.com/infracloudio/msbotbuilder-go/schema" "github.com/sirupsen/logrus" + "github.com/kubeshop/botkube/internal/httpx" "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/bot/interactive" "github.com/kubeshop/botkube/pkg/config" "github.com/kubeshop/botkube/pkg/execute" "github.com/kubeshop/botkube/pkg/execute/command" - "github.com/kubeshop/botkube/pkg/httpsrv" "github.com/kubeshop/botkube/pkg/multierror" "github.com/kubeshop/botkube/pkg/sliceutil" ) @@ -144,7 +144,7 @@ func (b *Teams) Start(ctx context.Context) error { return fmt.Errorf("while reporting analytics: %w", err) } - srv := httpsrv.New(b.log, addr, router) + srv := httpx.NewServer(b.log, addr, router) err = srv.Serve(ctx) if err != nil { return fmt.Errorf("while running MS Teams server: %w", err) diff --git a/pkg/controller/upgrade.go b/pkg/controller/upgrade.go index dd6897c27b..95e35ac34b 100644 --- a/pkg/controller/upgrade.go +++ b/pkg/controller/upgrade.go @@ -6,7 +6,7 @@ import ( "fmt" "time" - "github.com/google/go-github/v44/github" + "github.com/google/go-github/v53/github" "github.com/sirupsen/logrus" "github.com/kubeshop/botkube/pkg/bot" diff --git a/pkg/execute/plugin_executor.go b/pkg/execute/plugin_executor.go index 3394892ca0..154e05e6e7 100644 --- a/pkg/execute/plugin_executor.go +++ b/pkg/execute/plugin_executor.go @@ -78,7 +78,7 @@ func (e *PluginExecutor) Execute(ctx context.Context, bindings []string, slackSt input := plugin.KubeConfigInput{ Channel: cmdCtx.Conversation.DisplayName, } - kubeconfig, err := plugin.GenerateKubeConfig(e.restCfg, plugins[0].Context, input) + kubeconfig, err := plugin.GenerateKubeConfig(e.restCfg, e.cfg.Settings.ClusterName, plugins[0].Context, input) if err != nil { return interactive.CoreMessage{}, fmt.Errorf("while generating kube config: %w", err) } diff --git a/pkg/pluginx/command.go b/pkg/pluginx/command.go index 7cf538e1a3..6e1400f97e 100644 --- a/pkg/pluginx/command.go +++ b/pkg/pluginx/command.go @@ -53,6 +53,17 @@ type ExecuteCommandOutput struct { ExitCode int } +// CombinedOutput return combined stdout and stderr. +func (out ExecuteCommandOutput) CombinedOutput() string { + var str strings.Builder + str.WriteString(out.Stdout) + if out.Stderr != "" { + str.WriteString("\n") + str.WriteString(out.Stderr) + } + return strings.TrimSpace(str.String()) +} + // ExecuteCommandWithEnvs is a simple wrapper around exec.CommandContext to simplify running a given // command. // @@ -71,6 +82,9 @@ func ExecuteCommand(ctx context.Context, rawCmd string, mutators ...ExecuteComma DependencyDir: os.Getenv(plugin.DependencyDirEnvName), } for _, mutate := range mutators { + if mutate == nil { + continue + } mutate(&opts) } @@ -98,6 +112,8 @@ func ExecuteCommand(ctx context.Context, rawCmd string, mutators ...ExecuteComma cmd := exec.CommandContext(ctx, bin, binArgs...) cmd.Stdout = &stdout cmd.Stderr = &stderr + cmd.Dir = opts.WorkDir + cmd.Stdin = opts.Stdin cmd.Env = append(cmd.Env, os.Environ()...) diff --git a/pkg/pluginx/command_opts.go b/pkg/pluginx/command_opts.go index d5fef3a855..03868d3053 100644 --- a/pkg/pluginx/command_opts.go +++ b/pkg/pluginx/command_opts.go @@ -1,9 +1,13 @@ package pluginx +import "io" + // ExecuteCommandOptions represents the options for executing a command. type ExecuteCommandOptions struct { Envs map[string]string DependencyDir string + WorkDir string + Stdin io.Reader } // ExecuteCommandMutation is a function type that can be used to modify ExecuteCommandOptions. @@ -22,3 +26,17 @@ func ExecuteCommandDependencyDir(dir string) ExecuteCommandMutation { options.DependencyDir = dir } } + +// ExecuteCommandWorkingDir is a functions that sets the working directory of the command. +func ExecuteCommandWorkingDir(dir string) ExecuteCommandMutation { + return func(options *ExecuteCommandOptions) { + options.WorkDir = dir + } +} + +// ExecuteCommandStdin is a functions that sets the stdin of the command. +func ExecuteCommandStdin(in io.Reader) ExecuteCommandMutation { + return func(options *ExecuteCommandOptions) { + options.Stdin = in + } +}