Admin: Add support bundles (#60536)

* Add support bundles

Co-authored-by: ievaVasiljeva <ieva.vasiljeva@grafana.com>
Co-authored-by: Kalle Persson <kalle.persson@grafana.com>

* 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 <ieva.vasiljeva@grafana.com>
Co-authored-by: Kalle Persson <kalle.persson@grafana.com>
This commit is contained in:
Jo 2022-12-20 10:13:37 +00:00 committed by GitHub
parent 6e2b148745
commit 2c7410c87d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1174 additions and 4 deletions

4
.github/CODEOWNERS vendored
View File

@ -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

View File

@ -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 |

View File

@ -43,6 +43,7 @@ export interface FeatureToggles {
migrationLocking?: boolean;
storage?: boolean;
k8s?: boolean;
supportBundles?: boolean;
dashboardsFromStorage?: boolean;
export?: boolean;
azureMonitorResourcePickerForMetrics?: boolean;

View File

@ -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)

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
},
}
}

View File

@ -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,
}
}

View File

@ -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)
}

View File

@ -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)
}
}
}
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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,
)
}

View File

@ -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(

View File

@ -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",

View File

@ -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"

View File

@ -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},
})
}
}

View File

@ -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(

View File

@ -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<unkn
if (link.id === 'help') {
link.children = [
...getFooterLinks(),
...getSupportBundleFooterLinks(),
{
id: 'keyboard-shortcuts',
text: t('nav.help/keyboard-shortcuts', 'Keyboard shortcuts'),
@ -77,6 +78,22 @@ export const enrichConfigItems = (items: NavModelItem[], location: Location<unkn
return items;
};
export let getSupportBundleFooterLinks = (cfg = config): FooterLink[] => {
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 = () => {

View File

@ -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 = (
<span>
Support bundles allow you to easily collect and share Grafana logs, configuration, and data with the Grafana Labs
team.
</span>
);
type SupportBundleState = 'complete' | 'error' | 'timeout' | 'pending';
interface SupportBundle {
uid: string;
state: SupportBundleState;
creator: string;
createdAt: number;
expiresAt: number;
}
const getBundles = () => {
return getBackendSrv().get<SupportBundle[]>('/api/support-bundles');
};
function SupportBundles() {
const [bundlesState, fetchBundles] = useAsyncFn(getBundles, []);
useEffect(() => {
fetchBundles();
}, [fetchBundles]);
return (
<Page navId="support-bundles" subTitle={subTitle}>
<Page.Contents isLoading={bundlesState.loading}>
<LinkButton href="admin/support-bundles/create" variant="primary">
Create New Support Bundle
</LinkButton>
<table className="filter-table form-inline">
<thead>
<tr>
<th>Date</th>
<th>Requested by</th>
<th>Expires</th>
<th style={{ width: '1%' }} />
</tr>
</thead>
<tbody>
{bundlesState?.value?.map((b) => (
<tr key={b.uid}>
<th>{dateTimeFormat(b.createdAt * 1000)}</th>
<th>{b.creator}</th>
<th>{dateTimeFormat(b.expiresAt * 1000)}</th>
<th>
<LinkButton disabled={b.state !== 'complete'} target={'_self'} href={'/api/support-bundles/' + b.uid}>
Download
</LinkButton>
</th>
</tr>
))}
</tbody>
</table>
</Page.Contents>
</Page>
);
}
export default SupportBundles;

View File

@ -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<SupportBundleCollector[]>([]);
// 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<string, boolean> = components.reduce((acc, curr) => {
return { ...acc, [curr.uid]: curr.default };
}, {});
return (
<Page navId="support-bundles" pageNav={{ text: 'Create support bundle' }}>
<Page.Contents>
<Page.OldNavOnly>
<h3 className="page-sub-heading">Create support bundle</h3>
</Page.OldNavOnly>
{state.error && <p>{state.error}</p>}
{!!components.length && (
<Form defaultValues={values} onSubmit={onSubmit} validateOn="onSubmit">
{({ register, errors }) => {
return (
<>
{components.map((component) => {
return (
<Field key={component.uid}>
<Checkbox
{...register(component.uid)}
label={component.displayName}
id={component.uid}
description={component.description}
defaultChecked={component.default}
disabled={component.includedByDefault}
/>
</Field>
);
})}
<Button type="submit">Create</Button>
</>
);
}}
</Form>
)}
</Page.Contents>
</Page>
);
};
export default SupportBundlesCreate;

View File

@ -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 [];

View File

@ -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',