K8s/Snapshots: Add dashboardsnapshot api group (#77667)
This commit is contained in:
@@ -31,7 +31,6 @@ package api
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
"github.com/grafana/grafana/pkg/middleware/requestmeta"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -49,8 +48,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
var plog = log.New("api")
|
||||
|
||||
// registerRoutes registers all API HTTP routes.
|
||||
func (hs *HTTPServer) registerRoutes() {
|
||||
reqNoAuth := middleware.NoAuth()
|
||||
|
||||
+17
-200
@@ -1,32 +1,22 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
|
||||
"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/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
var client = &http.Client{
|
||||
Timeout: time.Second * 5,
|
||||
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
|
||||
}
|
||||
|
||||
// swagger:route GET /snapshot/shared-options snapshots getSharingOptions
|
||||
//
|
||||
// Get snapshot sharing settings.
|
||||
@@ -43,58 +33,6 @@ func (hs *HTTPServer) GetSharingOptions(c *contextmodel.ReqContext) {
|
||||
})
|
||||
}
|
||||
|
||||
type CreateExternalSnapshotResponse struct {
|
||||
Key string `json:"key"`
|
||||
DeleteKey string `json:"deleteKey"`
|
||||
Url string `json:"url"`
|
||||
DeleteUrl string `json:"deleteUrl"`
|
||||
}
|
||||
|
||||
func createExternalDashboardSnapshot(cmd dashboardsnapshots.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 *dashboardsnapshots.CreateDashboardSnapshotCommand) (string, error) {
|
||||
dashUID := cmd.Dashboard.Get("uid").MustString("")
|
||||
if ok := util.IsValidShortUID(dashUID); !ok {
|
||||
return "", fmt.Errorf("invalid dashboard UID")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/d/%v", dashUID), nil
|
||||
}
|
||||
|
||||
// swagger:route POST /snapshots snapshots createDashboardSnapshot
|
||||
//
|
||||
// When creating a snapshot using the API, you have to provide the full dashboard payload including the snapshot data. This endpoint is designed for the Grafana UI.
|
||||
@@ -106,95 +44,13 @@ func createOriginalDashboardURL(cmd *dashboardsnapshots.CreateDashboardSnapshotC
|
||||
// 401: unauthorisedError
|
||||
// 403: forbiddenError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) response.Response {
|
||||
if !hs.Cfg.SnapshotEnabled {
|
||||
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{}
|
||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||
}
|
||||
if cmd.Name == "" {
|
||||
cmd.Name = "Unnamed snapshot"
|
||||
}
|
||||
|
||||
userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID())
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError,
|
||||
"Failed to create external snapshot", err)
|
||||
}
|
||||
|
||||
var snapshotUrl string
|
||||
cmd.ExternalURL = ""
|
||||
cmd.OrgID = c.SignedInUser.GetOrgID()
|
||||
cmd.UserID = userID
|
||||
originalDashboardURL, err := createOriginalDashboardURL(&cmd)
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError, "Invalid app URL", err)
|
||||
}
|
||||
|
||||
if cmd.External {
|
||||
if !hs.Cfg.ExternalEnabled {
|
||||
c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := createExternalDashboardSnapshot(cmd, hs.Cfg.ExternalSnapshotUrl)
|
||||
if err != nil {
|
||||
c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
snapshotUrl = resp.Url
|
||||
cmd.Key = resp.Key
|
||||
cmd.DeleteKey = resp.DeleteKey
|
||||
cmd.ExternalURL = resp.Url
|
||||
cmd.ExternalDeleteURL = resp.DeleteUrl
|
||||
cmd.Dashboard = simplejson.New()
|
||||
|
||||
metrics.MApiDashboardSnapshotExternal.Inc()
|
||||
} else {
|
||||
cmd.Dashboard.SetPath([]string{"snapshot", "originalUrl"}, originalDashboardURL)
|
||||
|
||||
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 nil
|
||||
}
|
||||
}
|
||||
|
||||
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 nil
|
||||
}
|
||||
}
|
||||
|
||||
snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
|
||||
|
||||
metrics.MApiDashboardSnapshotCreate.Inc()
|
||||
}
|
||||
|
||||
result, err := hs.dashboardsnapshotsService.CreateDashboardSnapshot(c.Req.Context(), &cmd)
|
||||
if err != nil {
|
||||
c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.DynMap{
|
||||
"key": cmd.Key,
|
||||
"deleteKey": cmd.DeleteKey,
|
||||
"url": snapshotUrl,
|
||||
"deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey),
|
||||
"id": result.ID,
|
||||
})
|
||||
return nil
|
||||
func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) {
|
||||
dashboardsnapshots.CreateDashboardSnapshot(c, dashboardsnapshot.SnapshotSharingOptions{
|
||||
SnapshotsEnabled: hs.Cfg.SnapshotEnabled,
|
||||
ExternalEnabled: hs.Cfg.ExternalEnabled,
|
||||
ExternalSnapshotName: hs.Cfg.ExternalSnapshotName,
|
||||
ExternalSnapshotURL: hs.Cfg.ExternalSnapshotUrl,
|
||||
}, hs.dashboardsnapshotsService)
|
||||
}
|
||||
|
||||
// GET /api/snapshots/:key
|
||||
@@ -247,38 +103,6 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *contextmodel.ReqContext) response.
|
||||
return response.JSON(http.StatusOK, dto).SetHeader("Cache-Control", "public, max-age=3600")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// swagger:route GET /snapshots-delete/{deleteKey} snapshots deleteDashboardSnapshotByDeleteKey
|
||||
//
|
||||
// Delete Snapshot by deleteKey.
|
||||
@@ -302,28 +126,16 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *contextmodel.ReqCont
|
||||
return response.Error(404, "Snapshot not found", nil)
|
||||
}
|
||||
|
||||
query := &dashboardsnapshots.GetDashboardSnapshotQuery{DeleteKey: key}
|
||||
queryResult, err := hs.dashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
|
||||
err := dashboardsnapshots.DeleteWithKey(c.Req.Context(), key, hs.dashboardsnapshotsService)
|
||||
if err != nil {
|
||||
return response.Err(err)
|
||||
}
|
||||
|
||||
if queryResult.External {
|
||||
err := deleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
|
||||
if err != nil {
|
||||
return response.Error(500, "Failed to delete external dashboard", err)
|
||||
if errors.Is(err, dashboardsnapshots.ErrBaseNotFound) {
|
||||
return response.Error(404, "Snapshot not found", err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey}
|
||||
|
||||
if err := hs.dashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
|
||||
return response.Error(500, "Failed to delete dashboard snapshot", err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, util.DynMap{
|
||||
"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.",
|
||||
"id": queryResult.ID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -357,8 +169,13 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon
|
||||
return response.Error(http.StatusNotFound, "Failed to get dashboard snapshot", nil)
|
||||
}
|
||||
|
||||
// TODO: enforce org ID same
|
||||
// if queryResult.OrgID != c.OrgID {
|
||||
// return response.Error(http.StatusUnauthorized, "OrgID mismatch", nil)
|
||||
// }
|
||||
|
||||
if queryResult.External {
|
||||
err := deleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
|
||||
err := dashboardsnapshots.DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err)
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/web/webtest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db/dbtest"
|
||||
@@ -148,12 +149,11 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
|
||||
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec()
|
||||
|
||||
require.Equal(t, 200, sc.resp.Code)
|
||||
require.Equal(t, 200, sc.resp.Code, "BODY: "+sc.resp.Body.String())
|
||||
respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted"))
|
||||
assert.Equal(t, 1, respJSON.Get("id").MustInt())
|
||||
|
||||
assert.Equal(t, http.MethodGet, externalRequest.Method)
|
||||
assert.Equal(t, ts.URL, fmt.Sprintf("http://%s", externalRequest.Host))
|
||||
@@ -271,7 +271,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) {
|
||||
sc.handlerFunc = hs.DeleteDashboardSnapshot
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, sc.resp.Code)
|
||||
assert.Equal(t, http.StatusNotFound, sc.resp.Code, "BODY: "+sc.resp.Body.String())
|
||||
}, sqlmock)
|
||||
|
||||
loggedInUserScenarioWithRole(t,
|
||||
@@ -282,7 +282,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) {
|
||||
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, sc.resp.Code)
|
||||
assert.Equal(t, http.StatusNotFound, sc.resp.Code, "BODY: "+sc.resp.Body.String())
|
||||
}, sqlmock)
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ func TestGetDashboardSnapshotFailure(t *testing.T) {
|
||||
sc.handlerFunc = hs.DeleteDashboardSnapshot
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, sc.resp.Code)
|
||||
assert.Equal(t, http.StatusForbidden, sc.resp.Code, "BODY: "+sc.resp.Body.String())
|
||||
}, sqlmock)
|
||||
|
||||
loggedInUserScenarioWithRole(t,
|
||||
@@ -356,7 +356,7 @@ func TestGetDashboardSnapshotFailure(t *testing.T) {
|
||||
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec()
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, sc.resp.Code)
|
||||
assert.Equal(t, http.StatusInternalServerError, sc.resp.Code, "BODY: "+sc.resp.Body.String())
|
||||
}, sqlmock)
|
||||
|
||||
loggedInUserScenarioWithRole(t,
|
||||
@@ -367,7 +367,7 @@ func TestGetDashboardSnapshotFailure(t *testing.T) {
|
||||
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec()
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, sc.resp.Code)
|
||||
assert.Equal(t, http.StatusForbidden, sc.resp.Code, "BODY: "+sc.resp.Body.String())
|
||||
}, sqlmock)
|
||||
}
|
||||
|
||||
@@ -391,6 +391,7 @@ func setUpSnapshotTest(t *testing.T, userId int64, deleteUrl string) dashboardsn
|
||||
|
||||
res := &dashboardsnapshots.DashboardSnapshot{
|
||||
ID: 1,
|
||||
OrgID: 1,
|
||||
Key: "12345",
|
||||
DeleteKey: "54321",
|
||||
Dashboard: jsonModel,
|
||||
|
||||
Reference in New Issue
Block a user