K8s/Snapshots: Add dashboardsnapshot api group (#77667)

This commit is contained in:
Ryan McKinley
2024-02-01 22:40:11 -08:00
committed by GitHub
parent 810d14d88f
commit 795eb4a8d8
32 changed files with 2543 additions and 267 deletions
@@ -16,26 +16,36 @@ import (
type DashboardSnapshotStore struct {
store db.DB
log log.Logger
cfg *setting.Cfg
// deprecated behavior
skipDeleteExpired bool
}
// DashboardStore implements the Store interface
var _ dashboardsnapshots.Store = (*DashboardSnapshotStore)(nil)
func ProvideStore(db db.DB, cfg *setting.Cfg) *DashboardSnapshotStore {
return &DashboardSnapshotStore{store: db, log: log.New("dashboardsnapshot.store"), cfg: cfg}
// nolint:staticcheck
return NewStore(db, !cfg.SnapShotRemoveExpired)
}
func NewStore(db db.DB, skipDeleteExpired bool) *DashboardSnapshotStore {
log := log.New("dashboardsnapshot.store")
if skipDeleteExpired {
log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.")
}
return &DashboardSnapshotStore{store: db, skipDeleteExpired: skipDeleteExpired}
}
// DeleteExpiredSnapshots removes snapshots with old expiry dates.
// SnapShotRemoveExpired is deprecated and should be removed in the future.
// Snapshot expiry is decided by the user when they share the snapshot.
func (d *DashboardSnapshotStore) DeleteExpiredSnapshots(ctx context.Context, cmd *dashboardsnapshots.DeleteExpiredSnapshotsCommand) error {
if d.skipDeleteExpired {
d.log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.")
return nil
}
return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
if !d.cfg.SnapShotRemoveExpired {
d.log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.")
return nil
}
deleteExpiredSQL := "DELETE FROM dashboard_snapshot WHERE expires < ?"
expiredResponse, err := sess.Exec(deleteExpiredSQL, time.Now())
if err != nil {
@@ -8,6 +8,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
@@ -116,9 +118,11 @@ func TestIntegrationDashboardSnapshotDBAccess(t *testing.T) {
cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{
Key: "strangesnapshotwithuserid0",
DeleteKey: "adeletekey",
Dashboard: simplejson.NewFromAny(map[string]any{
"hello": "mupp",
}),
DashboardCreateCommand: dashboardsnapshot.DashboardCreateCommand{
Dashboard: &common.Unstructured{Object: map[string]any{
"hello": "mupp",
}},
},
UserID: 0,
OrgID: 1,
}
@@ -155,11 +159,9 @@ func TestIntegrationDeleteExpiredSnapshots(t *testing.T) {
t.Skip("skipping integration test")
}
sqlstore := db.InitTestDB(t)
dashStore := ProvideStore(sqlstore, setting.NewCfg())
dashStore := NewStore(sqlstore, false)
t.Run("Testing dashboard snapshots clean up", func(t *testing.T) {
dashStore.cfg.SnapShotRemoveExpired = true
nonExpiredSnapshot := createTestSnapshot(t, dashStore, "key1", 48000)
createTestSnapshot(t, dashStore, "key2", -1200)
createTestSnapshot(t, dashStore, "key3", -1200)
@@ -196,12 +198,14 @@ func createTestSnapshot(t *testing.T, dashStore *DashboardSnapshotStore, key str
cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{
Key: key,
DeleteKey: "delete" + key,
Dashboard: simplejson.NewFromAny(map[string]any{
"hello": "mupp",
}),
UserID: 1000,
OrgID: 1,
Expires: expires,
DashboardCreateCommand: dashboardsnapshot.DashboardCreateCommand{
Expires: expires,
Dashboard: &common.Unstructured{Object: map[string]any{
"hello": "mupp",
}},
},
UserID: 1000,
OrgID: 1,
}
result, err := dashStore.CreateDashboardSnapshot(context.Background(), &cmd)
require.NoError(t, err)
+12 -15
View File
@@ -3,6 +3,7 @@ package dashboardsnapshots
import (
"time"
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/services/auth/identity"
)
@@ -47,28 +48,17 @@ type DashboardSnapshotDTO struct {
// swagger:model
type CreateDashboardSnapshotCommand struct {
// The complete dashboard model.
// required:true
Dashboard *simplejson.Json `json:"dashboard" binding:"Required"`
// Snapshot name
// required:false
Name string `json:"name"`
// When the snapshot should expire in seconds in seconds. Default is never to expire.
// required:false
// default:0
Expires int64 `json:"expires"`
// The "public" fields are defined in this struct while the private/SQL/response params are
// defied in the rest of this command
dashboardsnapshot.DashboardCreateCommand
// these are passed when storing an external snapshot ref
// Save the snapshot on an external server rather than locally.
// required:false
// default: false
External bool `json:"external"`
ExternalURL string `json:"-"`
ExternalDeleteURL string `json:"-"`
// Define the unique key. Required if `external` is `true`.
// required:false
Key string `json:"key"`
// Unique key used to delete the snapshot. It is different from the `key` so that only the creator can delete the snapshot. Required if `external` is `true`.
// required:false
DeleteKey string `json:"deleteKey"`
@@ -100,3 +90,10 @@ type GetDashboardSnapshotsQuery struct {
OrgID int64
SignedInUser identity.Requester
}
type CreateExternalSnapshotResponse struct {
Key string `json:"key"`
DeleteKey string `json:"deleteKey"`
Url string `json:"url"`
DeleteUrl string `json:"deleteUrl"`
}
+211
View File
@@ -1,7 +1,23 @@
package dashboardsnapshots
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/services/auth/identity"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
//go:generate mockery --name Service --structname MockService --inpackage --filename service_mock.go
@@ -12,3 +28,198 @@ type Service interface {
GetDashboardSnapshot(context.Context, *GetDashboardSnapshotQuery) (*DashboardSnapshot, error)
SearchDashboardSnapshots(context.Context, *GetDashboardSnapshotsQuery) (DashboardSnapshotsList, error)
}
var client = &http.Client{
Timeout: time.Second * 5,
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
}
func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg dashboardsnapshot.SnapshotSharingOptions, svc Service) {
if !cfg.SnapshotsEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return
}
cmd := CreateDashboardSnapshotCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
c.JsonApiErr(http.StatusBadRequest, "bad request data", err)
return
}
if cmd.Name == "" {
cmd.Name = "Unnamed snapshot"
}
userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID())
if err != nil {
c.JsonApiErr(http.StatusInternalServerError,
"Failed to create external snapshot", err)
return
}
var snapshotUrl string
cmd.ExternalURL = ""
cmd.OrgID = c.SignedInUser.GetOrgID()
cmd.UserID = userID
originalDashboardURL, err := createOriginalDashboardURL(&cmd)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err)
return
}
if cmd.External {
if !cfg.ExternalEnabled {
c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
return
}
resp, err := createExternalDashboardSnapshot(cmd, cfg.ExternalSnapshotURL)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err)
return
}
snapshotUrl = resp.Url
cmd.Key = resp.Key
cmd.DeleteKey = resp.DeleteKey
cmd.ExternalURL = resp.Url
cmd.ExternalDeleteURL = resp.DeleteUrl
cmd.Dashboard = &common.Unstructured{}
metrics.MApiDashboardSnapshotExternal.Inc()
} else {
cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl")
if cmd.Key == "" {
var err error
cmd.Key, err = util.GetRandomString(32)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
return
}
}
if cmd.DeleteKey == "" {
var err error
cmd.DeleteKey, err = util.GetRandomString(32)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
return
}
}
snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
metrics.MApiDashboardSnapshotCreate.Inc()
}
result, err := svc.CreateDashboardSnapshot(c.Req.Context(), &cmd)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err)
return
}
c.JSON(http.StatusOK, dashboardsnapshot.DashboardCreateResponse{
Key: result.Key,
DeleteKey: result.DeleteKey,
URL: snapshotUrl,
DeleteURL: setting.ToAbsUrl("api/snapshots-delete/" + result.DeleteKey),
})
}
var plog = log.New("external-snapshot")
func DeleteExternalDashboardSnapshot(externalUrl string) error {
resp, err := client.Get(externalUrl)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
plog.Warn("Failed to close response body", "err", err)
}
}()
if resp.StatusCode == 200 {
return nil
}
// Gracefully ignore "snapshot not found" errors as they could have already
// been removed either via the cleanup script or by request.
if resp.StatusCode == 500 {
var respJson map[string]any
if err := json.NewDecoder(resp.Body).Decode(&respJson); err != nil {
return err
}
if respJson["message"] == "Failed to get dashboard snapshot" {
return nil
}
}
return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", resp.StatusCode)
}
func createExternalDashboardSnapshot(cmd CreateDashboardSnapshotCommand, externalSnapshotUrl string) (*CreateExternalSnapshotResponse, error) {
var createSnapshotResponse CreateExternalSnapshotResponse
message := map[string]any{
"name": cmd.Name,
"expires": cmd.Expires,
"dashboard": cmd.Dashboard,
"key": cmd.Key,
"deleteKey": cmd.DeleteKey,
}
messageBytes, err := simplejson.NewFromAny(message).Encode()
if err != nil {
return nil, err
}
resp, err := client.Post(externalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes))
if err != nil {
return nil, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
plog.Warn("Failed to close response body", "err", err)
}
}()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("create external snapshot response status code %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&createSnapshotResponse); err != nil {
return nil, err
}
return &createSnapshotResponse, nil
}
func createOriginalDashboardURL(cmd *CreateDashboardSnapshotCommand) (string, error) {
dashUID := cmd.Dashboard.GetNestedString("uid")
if ok := util.IsValidShortUID(dashUID); !ok {
return "", fmt.Errorf("invalid dashboard UID")
}
return fmt.Sprintf("/d/%v", dashUID), nil
}
func DeleteWithKey(ctx context.Context, key string, svc Service) error {
query := &GetDashboardSnapshotQuery{DeleteKey: key}
queryResult, err := svc.GetDashboardSnapshot(ctx, query)
if err != nil {
return err
}
if queryResult.External {
err := DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
if err != nil {
return err
}
}
cmd := &DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey}
return svc.DeleteDashboardSnapshot(ctx, cmd)
}
@@ -26,7 +26,7 @@ func ProvideService(store dashboardsnapshots.Store, secretsService secrets.Servi
}
func (s *ServiceImpl) CreateDashboardSnapshot(ctx context.Context, cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (*dashboardsnapshots.DashboardSnapshot, error) {
marshalledData, err := cmd.Dashboard.Encode()
marshalledData, err := cmd.Dashboard.MarshalJSON()
if err != nil {
return nil, err
}
@@ -2,11 +2,13 @@ package service
import (
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/components/simplejson"
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
dashsnapdb "github.com/grafana/grafana/pkg/services/dashboardsnapshots/database"
@@ -30,8 +32,9 @@ func TestDashboardSnapshotsService(t *testing.T) {
dashboardKey := "12345"
dashboard := &common.Unstructured{}
rawDashboard := []byte(`{"id":123}`)
dashboard, err := simplejson.NewJson(rawDashboard)
err := json.Unmarshal(rawDashboard, dashboard)
require.NoError(t, err)
t.Run("create dashboard snapshot should encrypt the dashboard", func(t *testing.T) {
@@ -40,7 +43,9 @@ func TestDashboardSnapshotsService(t *testing.T) {
cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{
Key: dashboardKey,
DeleteKey: dashboardKey,
Dashboard: dashboard,
DashboardCreateCommand: dashboardsnapshot.DashboardCreateCommand{
Dashboard: dashboard,
},
}
result, err := s.CreateDashboardSnapshot(ctx, &cmd)