From 2c7410c87d81b8a644e738fee2bb6f34355111c6 Mon Sep 17 00:00:00 2001 From: Jo Date: Tue, 20 Dec 2022 10:13:37 +0000 Subject: [PATCH] Admin: Add support bundles (#60536) * Add support bundles Co-authored-by: ievaVasiljeva Co-authored-by: Kalle Persson * tweak code owners * rename and lint frontend * lint * fix backend lint * register feature flag * add feature toggle. fix small backend issues Co-authored-by: ievaVasiljeva Co-authored-by: Kalle Persson --- .github/CODEOWNERS | 4 + .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + pkg/api/api.go | 3 + pkg/infra/supportbundles/interface.go | 45 +++++ .../supportbundles/supportbundlesimpl/api.go | 115 ++++++++++++ .../supportbundlesimpl/collectors.go | 162 +++++++++++++++++ .../supportbundlesimpl/db_collector.go | 75 ++++++++ .../supportbundlesimpl/models.go | 49 ++++++ .../supportbundlesimpl/service.go | 164 ++++++++++++++++++ .../supportbundlesimpl/service_bundle.go | 153 ++++++++++++++++ .../supportbundlesimpl/store.go | 108 ++++++++++++ .../supportbundlesimpl/user_collector.go | 47 +++++ .../backgroundsvcs/background_services.go | 6 +- pkg/server/wire.go | 4 + pkg/services/featuremgmt/registry.go | 5 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/navtree/navtreeimpl/navtree.go | 11 +- .../NavBar/navBarItem-translations.ts | 4 + public/app/core/components/NavBar/utils.ts | 19 +- .../support-bundles/SupportBundles.tsx | 73 ++++++++ .../support-bundles/SupportBundlesCreate.tsx | 96 ++++++++++ public/app/routes/routes.tsx | 23 +++ public/test/mocks/navModel.ts | 6 + 24 files changed, 1174 insertions(+), 4 deletions(-) create mode 100644 pkg/infra/supportbundles/interface.go create mode 100644 pkg/infra/supportbundles/supportbundlesimpl/api.go create mode 100644 pkg/infra/supportbundles/supportbundlesimpl/collectors.go create mode 100644 pkg/infra/supportbundles/supportbundlesimpl/db_collector.go create mode 100644 pkg/infra/supportbundles/supportbundlesimpl/models.go create mode 100644 pkg/infra/supportbundles/supportbundlesimpl/service.go create mode 100644 pkg/infra/supportbundles/supportbundlesimpl/service_bundle.go create mode 100644 pkg/infra/supportbundles/supportbundlesimpl/store.go create mode 100644 pkg/infra/supportbundles/supportbundlesimpl/user_collector.go create mode 100644 public/app/features/support-bundles/SupportBundles.tsx create mode 100644 public/app/features/support-bundles/SupportBundlesCreate.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bd8bab5fa4a..0940451dcef 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -229,6 +229,10 @@ lerna.json @grafana/frontend-ops /pkg/services/loginattempt @grafana/grafana-authnz-team /pkg/services/authn @grafana/grafana-authnz-team +# Support bundles +/public/app/features/support-bundles @grafana/grafana-authnz-team +/pkg/infra/supportbundles @grafana/grafana-authnz-team + # Grafana Partnerships Team /pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go @grafana/grafana-partnerships-team /pkg/infra/httpclient/httpclientprovider/sigv4_middleware_test.go @grafana/grafana-partnerships-team diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 139ce6cd4d6..b4b5cb665cb 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -67,6 +67,7 @@ Alpha features might be changed or removed without prior notice. | `dashboardComments` | Enable dashboard-wide comments | | `annotationComments` | Enable annotation comments | | `storage` | Configurable storage for dashboards, datasources, and resources | +| `supportBundles` | Support bundles for troubleshooting | | `exploreMixedDatasource` | Enable mixed datasource in Explore | | `tracing` | Adds trace ID to error notifications | | `correlations` | Correlations page | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index b40b2ec766b..50b7f2118b2 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -43,6 +43,7 @@ export interface FeatureToggles { migrationLocking?: boolean; storage?: boolean; k8s?: boolean; + supportBundles?: boolean; dashboardsFromStorage?: boolean; export?: boolean; azureMonitorResourcePickerForMetrics?: boolean; diff --git a/pkg/api/api.go b/pkg/api/api.go index 4f57e1815e9..2036b617b2d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -121,6 +121,9 @@ func (hs *HTTPServer) registerRoutes() { } r.Get("/styleguide", reqSignedIn, hs.Index) + r.Get("/admin/support-bundles", reqGrafanaAdmin, hs.Index) + r.Get("/admin/support-bundles/create", reqGrafanaAdmin, hs.Index) + r.Get("/live", reqGrafanaAdmin, hs.Index) r.Get("/live/pipeline", reqGrafanaAdmin, hs.Index) r.Get("/live/cloud", reqGrafanaAdmin, hs.Index) diff --git a/pkg/infra/supportbundles/interface.go b/pkg/infra/supportbundles/interface.go new file mode 100644 index 00000000000..3a46ab3e1ea --- /dev/null +++ b/pkg/infra/supportbundles/interface.go @@ -0,0 +1,45 @@ +package supportbundles + +import "context" + +type SupportItem struct { + Filename string + FileBytes []byte +} + +type State string + +const ( + StatePending State = "pending" + StateComplete State = "complete" + StateError State = "error" + StateTimeout State = "timeout" +) + +func (s State) String() string { + return string(s) +} + +type Bundle struct { + UID string `json:"uid"` + State State `json:"state"` + FilePath string `json:"filePath"` + Creator string `json:"creator"` + CreatedAt int64 `json:"createdAt"` + ExpiresAt int64 `json:"expiresAt"` +} + +type CollectorFunc func(context.Context) (*SupportItem, error) + +type Collector struct { + UID string `json:"uid"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + IncludedByDefault bool `json:"includedByDefault"` + Default bool `json:"default"` + Fn CollectorFunc `json:"-"` +} + +type Service interface { + RegisterSupportItemCollector(collector Collector) +} diff --git a/pkg/infra/supportbundles/supportbundlesimpl/api.go b/pkg/infra/supportbundles/supportbundlesimpl/api.go new file mode 100644 index 00000000000..61b54f740c9 --- /dev/null +++ b/pkg/infra/supportbundles/supportbundlesimpl/api.go @@ -0,0 +1,115 @@ +package supportbundlesimpl + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/supportbundles" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/models" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/web" +) + +const rootUrl = "/api/support-bundles" + +func (s *Service) registerAPIEndpoints(routeRegister routing.RouteRegister) { + authorize := ac.Middleware(s.accessControl) + + routeRegister.Group(rootUrl, func(subrouter routing.RouteRegister) { + subrouter.Get("/", authorize(middleware.ReqGrafanaAdmin, + ac.EvalPermission(ActionRead)), routing.Wrap(s.handleList)) + subrouter.Post("/", authorize(middleware.ReqGrafanaAdmin, + ac.EvalPermission(ActionCreate)), routing.Wrap(s.handleCreate)) + subrouter.Get("/:uid", authorize(middleware.ReqGrafanaAdmin, + ac.EvalPermission(ActionRead)), s.handleDownload) + subrouter.Delete("/:uid", authorize(middleware.ReqGrafanaAdmin, + ac.EvalPermission(ActionDelete)), s.handleRemove) + subrouter.Get("/collectors", authorize(middleware.ReqGrafanaAdmin, + ac.EvalPermission(ActionCreate)), routing.Wrap(s.handleGetCollectors)) + }) +} + +func (s *Service) handleList(ctx *models.ReqContext) response.Response { + bundles, err := s.List(ctx.Req.Context()) + if err != nil { + return response.Error(http.StatusInternalServerError, "failed to list bundles", err) + } + + data, err := json.Marshal(bundles) + if err != nil { + return response.Error(http.StatusInternalServerError, "failed to encode bundle", err) + } + + return response.JSON(http.StatusOK, data) +} + +func (s *Service) handleCreate(ctx *models.ReqContext) response.Response { + type command struct { + Collectors []string `json:"collectors"` + } + + var c command + if err := web.Bind(ctx.Req, &c); err != nil { + return response.Error(http.StatusBadRequest, "failed to parse request", err) + } + + bundle, err := s.Create(context.Background(), c.Collectors, ctx.SignedInUser) + if err != nil { + return response.Error(http.StatusInternalServerError, "failed to create support bundle", err) + } + + data, err := json.Marshal(bundle) + if err != nil { + return response.Error(http.StatusInternalServerError, "failed to encode bundle", err) + } + + return response.JSON(http.StatusCreated, data) +} + +func (s *Service) handleDownload(ctx *models.ReqContext) { + uid := web.Params(ctx.Req)[":uid"] + bundle, err := s.Get(ctx.Req.Context(), uid) + if err != nil { + ctx.Redirect("/admin/support-bundles") + return + } + + if bundle.State != supportbundles.StateComplete { + ctx.Redirect("/admin/support-bundles") + return + } + + if bundle.FilePath == "" { + ctx.Redirect("/admin/support-bundles") + return + } + + if _, err := os.Stat(bundle.FilePath); err != nil { + ctx.Redirect("/admin/support-bundles") + return + } + + ctx.Resp.Header().Set("Content-Type", "application/tar+gzip") + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%d.tar.gz", bundle.CreatedAt)) + http.ServeFile(ctx.Resp, ctx.Req, bundle.FilePath) +} + +func (s *Service) handleRemove(ctx *models.ReqContext) response.Response { + uid := web.Params(ctx.Req)[":uid"] + err := s.Remove(ctx.Req.Context(), uid) + if err != nil { + return response.Error(http.StatusInternalServerError, "failed to remove bundle", err) + } + + return response.Respond(http.StatusOK, "successfully removed the support bundle") +} + +func (s *Service) handleGetCollectors(ctx *models.ReqContext) response.Response { + return response.JSON(http.StatusOK, s.collectors) +} diff --git a/pkg/infra/supportbundles/supportbundlesimpl/collectors.go b/pkg/infra/supportbundles/supportbundlesimpl/collectors.go new file mode 100644 index 00000000000..73f05255b55 --- /dev/null +++ b/pkg/infra/supportbundles/supportbundlesimpl/collectors.go @@ -0,0 +1,162 @@ +package supportbundlesimpl + +import ( + "context" + "encoding/json" + "time" + + "github.com/grafana/grafana/pkg/infra/supportbundles" + "github.com/grafana/grafana/pkg/infra/usagestats" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/pluginsettings" + "github.com/grafana/grafana/pkg/setting" +) + +func basicCollector(cfg *setting.Cfg) supportbundles.Collector { + return supportbundles.Collector{ + UID: "basic", + DisplayName: "Basic information", + Description: "Basic information about the Grafana instance", + IncludedByDefault: true, + Default: true, + Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) { + type basicInfo struct { + Version string `json:"version"` + Commit string `json:"commit"` + } + + data, err := json.Marshal(basicInfo{ + Version: cfg.BuildVersion, + Commit: cfg.BuildCommit, + }) + if err != nil { + return nil, err + } + + return &supportbundles.SupportItem{ + Filename: "basic.json", + FileBytes: data, + }, nil + }, + } +} + +func settingsCollector(settings setting.Provider) supportbundles.Collector { + return supportbundles.Collector{ + UID: "settings", + DisplayName: "Settings", + Description: "Settings for grafana instance", + IncludedByDefault: false, + Default: true, + Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) { + current := settings.Current() + data, err := json.Marshal(current) + if err != nil { + return nil, err + } + + return &supportbundles.SupportItem{ + Filename: "settings.json", + FileBytes: data, + }, nil + }, + } +} + +func usageStatesCollector(stats usagestats.Service) supportbundles.Collector { + return supportbundles.Collector{ + UID: "usage-stats", + DisplayName: "Usage statistics", + Description: "Usage statistic for grafana instance", + IncludedByDefault: false, + Default: true, + Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) { + report, err := stats.GetUsageReport(context.Background()) + if err != nil { + return nil, err + } + + data, err := json.Marshal(report) + if err != nil { + return nil, err + } + return &supportbundles.SupportItem{ + Filename: "usage-stats.json", + FileBytes: data, + }, nil + }, + } +} + +func pluginInfoCollector(pluginStore plugins.Store, pluginSettings pluginsettings.Service) supportbundles.Collector { + return supportbundles.Collector{ + UID: "plugins", + DisplayName: "Plugin information", + Description: "Plugin information for grafana instance", + IncludedByDefault: false, + Default: true, + Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) { + type pluginInfo struct { + data plugins.JSONData + Class plugins.Class + + // App fields + IncludedInAppID string + DefaultNavURL string + Pinned bool + + // Signature fields + Signature plugins.SignatureStatus + + // SystemJS fields + Module string + BaseURL string + + PluginVersion string + Enabled bool + Updated time.Time + } + + plugins := pluginStore.Plugins(context.Background()) + + var pluginInfoList []pluginInfo + for _, plugin := range plugins { + // skip builtin plugins + if plugin.BuiltIn { + continue + } + + pInfo := pluginInfo{ + data: plugin.JSONData, + Class: plugin.Class, + IncludedInAppID: plugin.IncludedInAppID, + DefaultNavURL: plugin.DefaultNavURL, + Pinned: plugin.Pinned, + Signature: plugin.Signature, + Module: plugin.Module, + BaseURL: plugin.BaseURL, + } + + // TODO need to loop through all the orgs + // TODO ignore the error for now, not all plugins have settings + settings, err := pluginSettings.GetPluginSettingByPluginID(context.Background(), &pluginsettings.GetByPluginIDArgs{PluginID: plugin.ID, OrgID: 1}) + if err == nil { + pInfo.PluginVersion = settings.PluginVersion + pInfo.Enabled = settings.Enabled + pInfo.Updated = settings.Updated + } + + pluginInfoList = append(pluginInfoList, pInfo) + } + + data, err := json.Marshal(pluginInfoList) + if err != nil { + return nil, err + } + return &supportbundles.SupportItem{ + Filename: "plugins.json", + FileBytes: data, + }, nil + }, + } +} diff --git a/pkg/infra/supportbundles/supportbundlesimpl/db_collector.go b/pkg/infra/supportbundles/supportbundlesimpl/db_collector.go new file mode 100644 index 00000000000..de1d593c89b --- /dev/null +++ b/pkg/infra/supportbundles/supportbundlesimpl/db_collector.go @@ -0,0 +1,75 @@ +package supportbundlesimpl + +import ( + "bytes" + "context" + "fmt" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/supportbundles" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +func dbCollector(sql db.DB) supportbundles.Collector { + collectorFn := func(ctx context.Context) (*supportbundles.SupportItem, error) { + dbType := string(sql.GetDBType()) + + // buffer writer + bWriter := bytes.NewBuffer(nil) + + bWriter.WriteString("# Database information\n\n") + bWriter.WriteString("dbType: " + dbType + " \n") + + logItems := make([]migrator.MigrationLog, 0) + version := []string{} + err := sql.WithDbSession(ctx, func(sess *db.Session) error { + rawSQL := "" + if dbType == migrator.MySQL { + rawSQL = "SELECT @@VERSION" + } else if dbType == migrator.Postgres { + rawSQL = "SELECT version()" + } else if dbType == migrator.SQLite { + rawSQL = "SELECT sqlite_version()" + } else { + return fmt.Errorf("unsupported dbType: %s", dbType) + } + + return sess.Table("migration_log").SQL(rawSQL).Find(&version) + }) + if err != nil { + return nil, err + } + + for _, v := range version { + bWriter.WriteString("version: " + v + " \n") + } + + errD := sql.WithDbSession(ctx, func(sess *db.Session) error { + return sess.Table("migration_log").Find(&logItems) + }) + if errD != nil { + return nil, err + } + + bWriter.WriteString("\n## Migration Log\n\n") + + for _, logItem := range logItems { + bWriter.WriteString(fmt.Sprintf("**migrationId**: %s \nsuccess: %t \nerror: %s \ntimestamp: %s\n\n", + logItem.MigrationID, logItem.Success, logItem.Error, logItem.Timestamp.UTC())) + } + + return &supportbundles.SupportItem{ + Filename: "db.md", + FileBytes: bWriter.Bytes(), + }, nil + } + + return supportbundles.Collector{ + UID: "db", + Description: "Database information and migration log", + DisplayName: "Database and Migration information", + IncludedByDefault: false, + Default: true, + Fn: collectorFn, + } +} diff --git a/pkg/infra/supportbundles/supportbundlesimpl/models.go b/pkg/infra/supportbundles/supportbundlesimpl/models.go new file mode 100644 index 00000000000..5f0964bafcd --- /dev/null +++ b/pkg/infra/supportbundles/supportbundlesimpl/models.go @@ -0,0 +1,49 @@ +package supportbundlesimpl + +import ( + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/org" +) + +const ( + ActionRead = "support.bundles:read" + ActionCreate = "support.bundles:create" + ActionDelete = "support.bundles:delete" +) + +var ( + bundleReaderRole = accesscontrol.RoleDTO{ + Name: "fixed:support.bundles:reader", + DisplayName: "Support bundle reader", + Description: "List and download support bundles", + Group: "Support bundles", + Permissions: []accesscontrol.Permission{ + {Action: ActionRead}, + }, + } + + bundleWriterRole = accesscontrol.RoleDTO{ + Name: "fixed:support.bundles:writer", + DisplayName: "Support bundle writer", + Description: "Create, delete, list and download support bundles", + Group: "Support bundles", + Permissions: []accesscontrol.Permission{ + {Action: ActionRead}, + {Action: ActionCreate}, + {Action: ActionDelete}, + }, + } +) + +func DeclareFixedRoles(ac accesscontrol.Service) error { + bundleReader := accesscontrol.RoleRegistration{ + Role: bundleReaderRole, + Grants: []string{string(org.RoleAdmin)}, + } + bundleWriter := accesscontrol.RoleRegistration{ + Role: bundleWriterRole, + Grants: []string{string(org.RoleAdmin)}, + } + + return ac.DeclareFixedRoles(bundleWriter, bundleReader) +} diff --git a/pkg/infra/supportbundles/supportbundlesimpl/service.go b/pkg/infra/supportbundles/supportbundlesimpl/service.go new file mode 100644 index 00000000000..288e6f98ecd --- /dev/null +++ b/pkg/infra/supportbundles/supportbundlesimpl/service.go @@ -0,0 +1,164 @@ +package supportbundlesimpl + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/kvstore" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/supportbundles" + "github.com/grafana/grafana/pkg/infra/usagestats" + "github.com/grafana/grafana/pkg/plugins" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsettings" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" +) + +type Service struct { + cfg *setting.Cfg + store *store + pluginStore plugins.Store + pluginSettings pluginsettings.Service + accessControl ac.AccessControl + features *featuremgmt.FeatureManager + + log log.Logger + + collectors []supportbundles.Collector +} + +func ProvideService(cfg *setting.Cfg, + sql db.DB, + kvStore kvstore.KVStore, + accessControl ac.AccessControl, + accesscontrolService ac.Service, + routeRegister routing.RouteRegister, + userService user.Service, + settings setting.Provider, + pluginStore plugins.Store, + pluginSettings pluginsettings.Service, + features *featuremgmt.FeatureManager, + usageStats usagestats.Service) (*Service, error) { + s := &Service{ + cfg: cfg, + store: newStore(kvStore), + pluginStore: pluginStore, + pluginSettings: pluginSettings, + accessControl: accessControl, + features: features, + log: log.New("supportbundle.service"), + } + + if !features.IsEnabled(featuremgmt.FlagSupportBundles) { + return s, nil + } + + if !accessControl.IsDisabled() { + if err := DeclareFixedRoles(accesscontrolService); err != nil { + return nil, err + } + } + + s.registerAPIEndpoints(routeRegister) + + // TODO: move to relevant services + s.RegisterSupportItemCollector(basicCollector(cfg)) + s.RegisterSupportItemCollector(settingsCollector(settings)) + s.RegisterSupportItemCollector(usageStatesCollector(usageStats)) + s.RegisterSupportItemCollector(userCollector(userService)) + s.RegisterSupportItemCollector(dbCollector(sql)) + s.RegisterSupportItemCollector(pluginInfoCollector(pluginStore, pluginSettings)) + + return s, nil +} + +func (s *Service) Create(ctx context.Context, collectors []string, usr *user.SignedInUser) (*supportbundles.Bundle, error) { + bundle, err := s.store.Create(ctx, usr) + if err != nil { + return nil, err + } + + go func(uid string, collectors []string) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + s.startBundleWork(ctx, collectors, uid) + }(bundle.UID, collectors) + + return bundle, nil +} + +func (s *Service) Get(ctx context.Context, uid string) (*supportbundles.Bundle, error) { + return s.store.Get(ctx, uid) +} + +func (s *Service) List(ctx context.Context) ([]supportbundles.Bundle, error) { + return s.store.List() +} + +func (s *Service) Remove(ctx context.Context, uid string) error { + // Remove the data + bundle, err := s.store.Get(ctx, uid) + if err != nil { + return fmt.Errorf("could not retrieve support bundle with uid %s: %w", uid, err) + } + + // TODO handle cases when bundles aren't complete yet + if bundle.State == supportbundles.StatePending { + return fmt.Errorf("could not remove a support bundle with uid %s as it is still beign cteated", uid) + } + + if bundle.FilePath != "" { + if err := os.RemoveAll(filepath.Dir(bundle.FilePath)); err != nil { + return fmt.Errorf("could not remove directory for support bundle %s: %w", uid, err) + } + } + + // Remove the KV store entry + return s.store.Remove(ctx, uid) +} + +func (s *Service) RegisterSupportItemCollector(collector supportbundles.Collector) { + // FIXME: add check for duplicate UIDs + s.collectors = append(s.collectors, collector) +} + +func (s *Service) Run(ctx context.Context) error { + if !s.features.IsEnabled(featuremgmt.FlagSupportBundles) { + return nil + } + + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + s.cleanup(ctx) + select { + case <-ticker.C: + s.cleanup(ctx) + case <-ctx.Done(): + break + } + return ctx.Err() +} + +func (s *Service) cleanup(ctx context.Context) { + bundles, err := s.List(ctx) + if err != nil { + s.log.Error("failed to list bundles to clean up", "error", err) + } + + if err == nil { + for _, b := range bundles { + if time.Now().Unix() >= b.ExpiresAt { + if err := s.Remove(ctx, b.UID); err != nil { + s.log.Error("failed to cleanup bundle", "error", err) + } + } + } + } +} diff --git a/pkg/infra/supportbundles/supportbundlesimpl/service_bundle.go b/pkg/infra/supportbundles/supportbundlesimpl/service_bundle.go new file mode 100644 index 00000000000..86fe79f5ae4 --- /dev/null +++ b/pkg/infra/supportbundles/supportbundlesimpl/service_bundle.go @@ -0,0 +1,153 @@ +package supportbundlesimpl + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/grafana/grafana/pkg/infra/supportbundles" +) + +type bundleResult struct { + path string + err error +} + +func (s *Service) startBundleWork(ctx context.Context, collectors []string, uid string) { + result := make(chan bundleResult) + go func() { + sbFilePath, err := s.bundle(ctx, collectors, uid) + if err != nil { + result <- bundleResult{err: err} + } + result <- bundleResult{ + path: sbFilePath, + } + close(result) + }() + + select { + case <-ctx.Done(): + s.log.Warn("Context cancelled while collecting support bundle") + if err := s.store.Update(ctx, uid, supportbundles.StateTimeout, ""); err != nil { + s.log.Error("failed to update bundle after timeout") + } + return + case r := <-result: + if r.err != nil { + if err := s.store.Update(ctx, uid, supportbundles.StateError, ""); err != nil { + s.log.Error("failed to update bundle after error") + } + return + } + if err := s.store.Update(ctx, uid, supportbundles.StateComplete, r.path); err != nil { + s.log.Error("failed to update bundle after completion") + } + return + } +} + +func (s *Service) bundle(ctx context.Context, collectors []string, uid string) (string, error) { + lookup := make(map[string]bool, len(collectors)) + for _, c := range collectors { + lookup[c] = true + } + + sbDir, err := os.MkdirTemp("", "") + if err != nil { + return "", err + } + + for _, collector := range s.collectors { + if !lookup[collector.UID] && !collector.IncludedByDefault { + continue + } + item, err := collector.Fn(ctx) + if err != nil { + s.log.Warn("Failed to collect support bundle item", "error", err) + } + + // write item to file + if item != nil { + if err := os.WriteFile(filepath.Join(sbDir, item.Filename), item.FileBytes, 0600); err != nil { + s.log.Warn("Failed to collect support bundle item", "error", err) + } + } + } + + // create tar.gz file + var buf bytes.Buffer + errCompress := compress(sbDir, &buf) + if errCompress != nil { + return "", errCompress + } + + finalFilePath := filepath.Join(sbDir, fmt.Sprintf("%s.tar.gz", uid)) + + // Ignore gosec G304 as this function is only used internally. + //nolint:gosec + fileToWrite, err := os.OpenFile(finalFilePath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return "", err + } + if _, err := io.Copy(fileToWrite, &buf); err != nil { + return "", err + } + + return finalFilePath, nil +} + +func compress(src string, buf io.Writer) error { + // tar > gzip > buf + zr := gzip.NewWriter(buf) + tw := tar.NewWriter(zr) + + // walk through every file in the folder + err := filepath.Walk(src, func(file string, fi os.FileInfo, err error) error { + // if not a dir, write file content + if !fi.IsDir() { + // generate tar header + header, err := tar.FileInfoHeader(fi, file) + if err != nil { + return err + } + + header.Name = filepath.ToSlash("/bundle/" + header.Name) + + // write header + if err := tw.WriteHeader(header); err != nil { + return err + } + + // Ignore gosec G304 as this function is only used internally. + //nolint:gosec + data, err := os.Open(file) + if err != nil { + return err + } + if _, err := io.Copy(tw, data); err != nil { + return err + } + } + return nil + }) + if err != nil { + return err + } + + // produce tar + if err := tw.Close(); err != nil { + return err + } + // produce gzip + if err := zr.Close(); err != nil { + return err + } + // + return nil +} diff --git a/pkg/infra/supportbundles/supportbundlesimpl/store.go b/pkg/infra/supportbundles/supportbundlesimpl/store.go new file mode 100644 index 00000000000..f843d463c02 --- /dev/null +++ b/pkg/infra/supportbundles/supportbundlesimpl/store.go @@ -0,0 +1,108 @@ +package supportbundlesimpl + +import ( + "context" + "encoding/json" + "errors" + "sort" + "strings" + "time" + + "github.com/google/uuid" + "github.com/grafana/grafana/pkg/infra/kvstore" + "github.com/grafana/grafana/pkg/infra/supportbundles" + "github.com/grafana/grafana/pkg/services/user" +) + +func newStore(kv kvstore.KVStore) *store { + return &store{kv: kvstore.WithNamespace(kv, 0, "supportbundle")} +} + +type store struct { + kv *kvstore.NamespacedKVStore +} + +func (s *store) Create(ctx context.Context, usr *user.SignedInUser) (*supportbundles.Bundle, error) { + uid, err := uuid.NewRandom() + if err != nil { + return nil, err + } + + bundle := supportbundles.Bundle{ + UID: uid.String(), + State: supportbundles.StatePending, + Creator: usr.Login, + CreatedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + } + + if err := s.set(ctx, &bundle); err != nil { + return nil, err + } + return &bundle, nil +} + +func (s *store) Update(ctx context.Context, uid string, state supportbundles.State, filePath string) error { + bundle, err := s.Get(ctx, uid) + if err != nil { + return err + } + + bundle.State = state + bundle.FilePath = filePath + + return s.set(ctx, bundle) +} + +func (s *store) set(ctx context.Context, bundle *supportbundles.Bundle) error { + data, err := json.Marshal(&bundle) + if err != nil { + return err + } + return s.kv.Set(ctx, bundle.UID, string(data)) +} + +func (s *store) Get(ctx context.Context, uid string) (*supportbundles.Bundle, error) { + data, ok, err := s.kv.Get(ctx, uid) + if err != nil { + return nil, err + } + if !ok { + // FIXME: handle not found + return nil, errors.New("not found") + } + var b supportbundles.Bundle + if err := json.NewDecoder(strings.NewReader(data)).Decode(&b); err != nil { + return nil, err + } + + return &b, nil +} + +func (s *store) Remove(ctx context.Context, uid string) error { + return s.kv.Del(ctx, uid) +} + +func (s *store) List() ([]supportbundles.Bundle, error) { + data, err := s.kv.GetAll(context.Background()) + if err != nil { + return nil, err + } + + var res []supportbundles.Bundle + for _, items := range data { + for _, s := range items { + var b supportbundles.Bundle + if err := json.NewDecoder(strings.NewReader(s)).Decode(&b); err != nil { + return nil, err + } + res = append(res, b) + } + } + + sort.Slice(res, func(i, j int) bool { + return res[i].CreatedAt < res[j].CreatedAt + }) + + return res, nil +} diff --git a/pkg/infra/supportbundles/supportbundlesimpl/user_collector.go b/pkg/infra/supportbundles/supportbundlesimpl/user_collector.go new file mode 100644 index 00000000000..b7b5baeea80 --- /dev/null +++ b/pkg/infra/supportbundles/supportbundlesimpl/user_collector.go @@ -0,0 +1,47 @@ +package supportbundlesimpl + +import ( + "context" + "encoding/json" + + "github.com/grafana/grafana/pkg/infra/supportbundles" + "github.com/grafana/grafana/pkg/services/user" +) + +func userCollector(users user.Service) supportbundles.Collector { + collectorFn := func(ctx context.Context) (*supportbundles.SupportItem, error) { + query := &user.SearchUsersQuery{ + SignedInUser: &user.SignedInUser{}, + OrgID: 0, + Query: "", + Page: 0, + Limit: 0, + AuthModule: "", + Filters: []user.Filter{}, + IsDisabled: new(bool), + } + res, err := users.Search(ctx, query) + if err != nil { + return nil, err + } + + userBytes, err := json.Marshal(res.Users) + if err != nil { + return nil, err + } + + return &supportbundles.SupportItem{ + Filename: "users.json", + FileBytes: userBytes, + }, nil + } + + return supportbundles.Collector{ + UID: "users", + Description: "User information", + DisplayName: "A list of users of the Grafana instance", + IncludedByDefault: false, + Default: true, + Fn: collectorFn, + } +} diff --git a/pkg/server/backgroundsvcs/background_services.go b/pkg/server/backgroundsvcs/background_services.go index 0ae9a20470a..3d9f260d870 100644 --- a/pkg/server/backgroundsvcs/background_services.go +++ b/pkg/server/backgroundsvcs/background_services.go @@ -4,6 +4,7 @@ import ( "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/remotecache" + "github.com/grafana/grafana/pkg/infra/supportbundles/supportbundlesimpl" "github.com/grafana/grafana/pkg/infra/tracing" uss "github.com/grafana/grafana/pkg/infra/usagestats/service" "github.com/grafana/grafana/pkg/infra/usagestats/statscollector" @@ -46,8 +47,8 @@ func ProvideBackgroundServiceRegistry( secretsService *secretsManager.SecretsService, remoteCache *remotecache.RemoteCache, thumbnailsService thumbs.Service, StorageService store.StorageService, searchService searchV2.SearchService, entityEventsService store.EntityEventsService, saService *samanager.ServiceAccountsService, authInfoService *authinfoservice.Implementation, - grpcServerProvider grpcserver.Provider, - secretMigrationProvider secretsMigrations.SecretMigrationProvider, loginAttemptService *loginattemptimpl.Service, + grpcServerProvider grpcserver.Provider, secretMigrationProvider secretsMigrations.SecretMigrationProvider, loginAttemptService *loginattemptimpl.Service, + bundleService *supportbundlesimpl.Service, // Need to make sure these are initialized, is there a better place to put them? _ dashboardsnapshots.Service, _ *alerting.AlertNotificationService, _ serviceaccounts.Service, _ *guardian.Provider, @@ -83,6 +84,7 @@ func ProvideBackgroundServiceRegistry( processManager, secretMigrationProvider, loginAttemptService, + bundleService, ) } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 78a1f689481..7283b60b453 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -20,6 +20,8 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/infra/serverlock" + "github.com/grafana/grafana/pkg/infra/supportbundles" + "github.com/grafana/grafana/pkg/infra/supportbundles/supportbundlesimpl" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/usagestats" uss "github.com/grafana/grafana/pkg/infra/usagestats/service" @@ -354,6 +356,8 @@ var wireBasicSet = wire.NewSet( authnimpl.ProvideService, wire.Bind(new(authn.Service), new(*authnimpl.Service)), k8saccess.ProvideK8SAccess, + supportbundlesimpl.ProvideService, + wire.Bind(new(supportbundles.Service), new(*supportbundlesimpl.Service)), ) var wireSet = wire.NewSet( diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index f2575df33eb..57d14b58c14 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -153,6 +153,11 @@ var ( State: FeatureStateAlpha, RequiresDevMode: true, }, + { + Name: "supportBundles", + Description: "Support bundles for troubleshooting", + State: FeatureStateAlpha, + }, { Name: "dashboardsFromStorage", Description: "Load dashboards from the generic storage interface", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index acd7f81abc6..4c10b89383b 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -115,6 +115,10 @@ const ( // Explore native k8s integrations FlagK8s = "k8s" + // FlagSupportBundles + // Support bundles for troubleshooting + FlagSupportBundles = "supportBundles" + // FlagDashboardsFromStorage // Load dashboards from the generic storage interface FlagDashboardsFromStorage = "dashboardsFromStorage" diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index f82a6f164e0..ea45df6a535 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -243,6 +243,15 @@ func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqC helpVersion = setting.ApplicationName } + supportBundleNode := &navtree.NavLink{ + Text: "Support bundles", + Id: "support-bundles", + Url: "/admin/support-bundles", + Icon: "wrench", + Section: navtree.NavSectionConfig, + SortWeight: navtree.WeightHelp, + } + treeRoot.AddSection(&navtree.NavLink{ Text: "Help", SubTitle: helpVersion, @@ -251,7 +260,7 @@ func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqC Icon: "question-circle", SortWeight: navtree.WeightHelp, Section: navtree.NavSectionConfig, - Children: []*navtree.NavLink{}, + Children: []*navtree.NavLink{supportBundleNode}, }) } } diff --git a/public/app/core/components/NavBar/navBarItem-translations.ts b/public/app/core/components/NavBar/navBarItem-translations.ts index 9fc0820c0e0..4cbebe51972 100644 --- a/public/app/core/components/NavBar/navBarItem-translations.ts +++ b/public/app/core/components/NavBar/navBarItem-translations.ts @@ -95,6 +95,8 @@ export function getNavTitle(navId: string | undefined) { return t('nav.service-accounts.title', 'Service accounts'); case 'admin': return t('nav.admin.title', 'Server admin'); + case 'support-bundles': + return t('nav.support-bundles.title', 'Support Bundles'); case 'global-users': return config.featureToggles.topnav ? t('nav.global-users.title', 'Users') @@ -191,6 +193,8 @@ export function getNavSubTitle(navId: string | undefined) { return t('nav.server-settings.subtitle', 'View the settings defined in your Grafana config'); case 'storage': return t('nav.storage.subtitle', 'Manage file storage'); + case 'support-bundles': + return t('nav.support-bundles.subtitle', 'Download support bundles'); case 'admin': return config.featureToggles.topnav ? t( diff --git a/public/app/core/components/NavBar/utils.ts b/public/app/core/components/NavBar/utils.ts index 43e9ac77633..69b0a425009 100644 --- a/public/app/core/components/NavBar/utils.ts +++ b/public/app/core/components/NavBar/utils.ts @@ -7,7 +7,7 @@ import { contextSrv } from 'app/core/services/context_srv'; import { ShowModalReactEvent } from '../../../types/events'; import appEvents from '../../app_events'; -import { getFooterLinks } from '../Footer/Footer'; +import { FooterLink, getFooterLinks } from '../Footer/Footer'; import { OrgSwitcher } from '../OrgSwitcher'; import { HelpModal } from '../help/HelpModal'; @@ -53,6 +53,7 @@ export const enrichConfigItems = (items: NavModelItem[], location: Location { + if (!cfg.featureToggles.supportBundles) { + return []; + } + + return [ + { + target: '_self', + id: 'support-bundle', + text: t('nav.help/support-bundle', 'Support Bundles'), + icon: 'question-circle', + url: '/admin/support-bundles', + }, + ]; +}; + export const enrichWithInteractionTracking = (item: NavModelItem, expandedState: boolean) => { const onClick = item.onClick; item.onClick = () => { diff --git a/public/app/features/support-bundles/SupportBundles.tsx b/public/app/features/support-bundles/SupportBundles.tsx new file mode 100644 index 00000000000..7352c940c15 --- /dev/null +++ b/public/app/features/support-bundles/SupportBundles.tsx @@ -0,0 +1,73 @@ +import React, { useEffect } from 'react'; +import { useAsyncFn } from 'react-use'; + +import { dateTimeFormat } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; +import { LinkButton } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; + +const subTitle = ( + + Support bundles allow you to easily collect and share Grafana logs, configuration, and data with the Grafana Labs + team. + +); + +type SupportBundleState = 'complete' | 'error' | 'timeout' | 'pending'; + +interface SupportBundle { + uid: string; + state: SupportBundleState; + creator: string; + createdAt: number; + expiresAt: number; +} + +const getBundles = () => { + return getBackendSrv().get('/api/support-bundles'); +}; + +function SupportBundles() { + const [bundlesState, fetchBundles] = useAsyncFn(getBundles, []); + + useEffect(() => { + fetchBundles(); + }, [fetchBundles]); + + return ( + + + + Create New Support Bundle + + + + + + + + + + + + {bundlesState?.value?.map((b) => ( + + + + + + + ))} + +
DateRequested byExpires +
{dateTimeFormat(b.createdAt * 1000)}{b.creator}{dateTimeFormat(b.expiresAt * 1000)} + + Download + +
+
+
+ ); +} + +export default SupportBundles; diff --git a/public/app/features/support-bundles/SupportBundlesCreate.tsx b/public/app/features/support-bundles/SupportBundlesCreate.tsx new file mode 100644 index 00000000000..8a07f28d399 --- /dev/null +++ b/public/app/features/support-bundles/SupportBundlesCreate.tsx @@ -0,0 +1,96 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useAsyncFn } from 'react-use'; + +import { getBackendSrv, locationService } from '@grafana/runtime'; +import { Form, Button, Field, Checkbox } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; + +// move to types +export interface SupportBundleCreateRequest { + collectors: string[]; +} + +export interface SupportBundleCollector { + uid: string; + displayName: string; + description: string; + includedByDefault: boolean; + default: boolean; +} + +export interface Props {} + +const createSupportBundle = async (data: SupportBundleCreateRequest) => { + const result = await getBackendSrv().post('/api/support-bundles', data); + return result; +}; + +export const SupportBundlesCreate = ({}: Props): JSX.Element => { + const onSubmit = useCallback(async (data) => { + try { + const selectedLabelsArray = Object.keys(data).filter((key) => data[key]); + const response = await createSupportBundle({ collectors: selectedLabelsArray }); + console.info(response); + } catch (e) { + console.error(e); + } + + locationService.push('/admin/support-bundles'); + }, []); + + const [components, setComponents] = useState([]); + // populate components from the backend + const populateComponents = async () => { + return await getBackendSrv().get('/api/support-bundles/collectors'); + }; + + const [state, fetchComponents] = useAsyncFn(populateComponents); + useEffect(() => { + fetchComponents().then((res) => { + setComponents(res); + }); + }, [fetchComponents]); + + // turn components into a uuid -> enabled map + const values: Record = components.reduce((acc, curr) => { + return { ...acc, [curr.uid]: curr.default }; + }, {}); + + return ( + + + +

Create support bundle

+
+ {state.error &&

{state.error}

} + {!!components.length && ( +
+ {({ register, errors }) => { + return ( + <> + {components.map((component) => { + return ( + + + + ); + })} + + + ); + }} +
+ )} +
+
+ ); +}; + +export default SupportBundlesCreate; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 8cb51f92928..69a35ab937e 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -514,6 +514,7 @@ export function getAppRoutes(): RouteDescriptor[] { ...getBrowseStorageRoutes(), ...getDynamicDashboardRoutes(), ...getPluginCatalogRoutes(), + ...getSupportBundleRoutes(), ...getLiveRoutes(), ...getAlertingRoutes(), ...getProfileRoutes(), @@ -552,6 +553,28 @@ export function getBrowseStorageRoutes(cfg = config): RouteDescriptor[] { ]; } +export function getSupportBundleRoutes(cfg = config): RouteDescriptor[] { + if (!cfg.featureToggles.supportBundles) { + return []; + } + + return [ + { + path: '/admin/support-bundles', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "SupportBundles" */ 'app/features/support-bundles/SupportBundles') + ), + }, + { + path: '/admin/support-bundles/create', + component: SafeDynamicImport( + () => + import(/* webpackChunkName: "ServiceAccountCreatePage" */ 'app/features/support-bundles/SupportBundlesCreate') + ), + }, + ]; +} + export function getDynamicDashboardRoutes(cfg = config): RouteDescriptor[] { if (!cfg.featureToggles.scenes) { return []; diff --git a/public/test/mocks/navModel.ts b/public/test/mocks/navModel.ts index 3469523b2cd..717a1a039fb 100644 --- a/public/test/mocks/navModel.ts +++ b/public/test/mocks/navModel.ts @@ -1194,6 +1194,12 @@ export const mockNavModel: NavIndex = { ], }, }, + 'support-bundles': { + id: 'support-bundles', + text: 'Support bundles', + icon: 'sliders-v-alt', + url: '/admin/support-bundles', + }, 'server-settings': { id: 'server-settings', text: 'Settings',