mirror of https://github.com/grafana/grafana
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>pull/60557/head
parent
6e2b148745
commit
2c7410c87d
@ -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) |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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 |
||||
}, |
||||
} |
||||
} |
||||
@ -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, |
||||
} |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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, |
||||
} |
||||
} |
||||
@ -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; |
||||
@ -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; |
||||
Loading…
Reference in new issue