K8s/Snapshots: Add dashboardsnapshot api group (#77667)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user