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,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// +k8s:deepcopy-gen=package
|
||||
// +k8s:openapi-gen=true
|
||||
// +k8s:defaulter-gen=TypeMeta
|
||||
// +groupName=dashboardsnapshot.grafana.app
|
||||
|
||||
package v0alpha1
|
||||
@@ -0,0 +1,25 @@
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
|
||||
)
|
||||
|
||||
const (
|
||||
GROUP = "dashboardsnapshot.grafana.app"
|
||||
VERSION = "v0alpha1"
|
||||
APIVERSION = GROUP + "/" + VERSION
|
||||
)
|
||||
|
||||
var DashboardSnapshotResourceInfo = common.NewResourceInfo(GROUP, VERSION,
|
||||
"dashboardsnapshots", "dashboardsnapshot", "DashboardSnapshot",
|
||||
func() runtime.Object { return &DashboardSnapshot{} },
|
||||
func() runtime.Object { return &DashboardSnapshotList{} },
|
||||
)
|
||||
|
||||
var (
|
||||
// SchemeGroupVersion is group version used to register these objects
|
||||
SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION}
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
|
||||
)
|
||||
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type DashboardSnapshot struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
// +optional
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
// Snapshot summary info
|
||||
Spec SnapshotInfo `json:"spec"`
|
||||
}
|
||||
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type DashboardSnapshotList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
// +optional
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
|
||||
Items []DashboardSnapshot `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type SnapshotInfo struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
// Optionally auto-remove the snapshot at a future date
|
||||
Expires int64 `json:"expires,omitempty"`
|
||||
// When set to true, the snapshot exists in a remote server
|
||||
External bool `json:"external,omitempty"`
|
||||
// The external URL where the snapshot can be seen
|
||||
ExternalURL string `json:"externalUrl,omitempty"`
|
||||
// The URL that created the dashboard originally
|
||||
OriginalUrl string `json:"originalUrl,omitempty"`
|
||||
// Snapshot creation timestamp
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
// This is returned from the POST command
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type DashboardSnapshotWithDeleteKey struct {
|
||||
DashboardSnapshot `json:",inline"`
|
||||
|
||||
// The delete key is only returned when the item is created. It is not returned from a get request
|
||||
DeleteKey string `json:"deleteKey,omitempty"`
|
||||
}
|
||||
|
||||
// This is the snapshot returned from the subresource
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type FullDashboardSnapshot struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
// +optional
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
// Snapshot summary info
|
||||
Info SnapshotInfo `json:"info"`
|
||||
|
||||
// The raw dashboard (unstructured for now)
|
||||
Dashboard common.Unstructured `json:"dashboard"`
|
||||
}
|
||||
|
||||
// Each tenant, may have different sharing options
|
||||
// This is currently set using custom.ini, but multi-tenant support will need
|
||||
// to be managed differently
|
||||
type SnapshotSharingOptions struct {
|
||||
SnapshotsEnabled bool `json:"snapshotEnabled"`
|
||||
ExternalSnapshotURL string `json:"externalSnapshotURL,omitempty"`
|
||||
ExternalSnapshotName string `json:"externalSnapshotName,omitempty"`
|
||||
ExternalEnabled bool `json:"externalEnabled,omitempty"`
|
||||
}
|
||||
|
||||
// These are the values expected to be sent from an end user
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type DashboardCreateCommand struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
|
||||
// Snapshot name
|
||||
// required:false
|
||||
Name string `json:"name"`
|
||||
|
||||
// The complete dashboard model.
|
||||
// required:true
|
||||
Dashboard *common.Unstructured `json:"dashboard" binding:"Required"`
|
||||
|
||||
// When the snapshot should expire in seconds in seconds. Default is never to expire.
|
||||
// required:false
|
||||
// default:0
|
||||
Expires int64 `json:"expires"`
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// The create response
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type DashboardCreateResponse struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
|
||||
// The unique key
|
||||
Key string `json:"key"`
|
||||
|
||||
// A unique key that will allow delete
|
||||
DeleteKey string `json:"deleteKey"`
|
||||
|
||||
// Absolute URL to show the dashboard
|
||||
URL string `json:"url"`
|
||||
|
||||
// URL that will delete the response
|
||||
DeleteURL string `json:"deleteUrl"`
|
||||
}
|
||||
|
||||
// Represents an options object that must be named for each namespace/team/user
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type SharingOptions struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
// Show the options inline
|
||||
Spec SnapshotSharingOptions `json:"spec"`
|
||||
}
|
||||
|
||||
// Represents a list of namespaced options
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type SharingOptionsList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
// +optional
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
|
||||
Items []SharingOptions `json:"items,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//go:build !ignore_autogenerated
|
||||
// +build !ignore_autogenerated
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Code generated by deepcopy-gen. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
runtime "k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DashboardCreateCommand) DeepCopyInto(out *DashboardCreateCommand) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
if in.Dashboard != nil {
|
||||
in, out := &in.Dashboard, &out.Dashboard
|
||||
*out = (*in).DeepCopy()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardCreateCommand.
|
||||
func (in *DashboardCreateCommand) DeepCopy() *DashboardCreateCommand {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DashboardCreateCommand)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *DashboardCreateCommand) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DashboardCreateResponse) DeepCopyInto(out *DashboardCreateResponse) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardCreateResponse.
|
||||
func (in *DashboardCreateResponse) DeepCopy() *DashboardCreateResponse {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DashboardCreateResponse)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *DashboardCreateResponse) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DashboardSnapshot) DeepCopyInto(out *DashboardSnapshot) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
out.Spec = in.Spec
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSnapshot.
|
||||
func (in *DashboardSnapshot) DeepCopy() *DashboardSnapshot {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DashboardSnapshot)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *DashboardSnapshot) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DashboardSnapshotList) DeepCopyInto(out *DashboardSnapshotList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]DashboardSnapshot, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSnapshotList.
|
||||
func (in *DashboardSnapshotList) DeepCopy() *DashboardSnapshotList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DashboardSnapshotList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *DashboardSnapshotList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DashboardSnapshotWithDeleteKey) DeepCopyInto(out *DashboardSnapshotWithDeleteKey) {
|
||||
*out = *in
|
||||
in.DashboardSnapshot.DeepCopyInto(&out.DashboardSnapshot)
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DashboardSnapshotWithDeleteKey.
|
||||
func (in *DashboardSnapshotWithDeleteKey) DeepCopy() *DashboardSnapshotWithDeleteKey {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DashboardSnapshotWithDeleteKey)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *DashboardSnapshotWithDeleteKey) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *FullDashboardSnapshot) DeepCopyInto(out *FullDashboardSnapshot) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
out.Info = in.Info
|
||||
in.Dashboard.DeepCopyInto(&out.Dashboard)
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FullDashboardSnapshot.
|
||||
func (in *FullDashboardSnapshot) DeepCopy() *FullDashboardSnapshot {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(FullDashboardSnapshot)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *FullDashboardSnapshot) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *SharingOptions) DeepCopyInto(out *SharingOptions) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
out.Spec = in.Spec
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SharingOptions.
|
||||
func (in *SharingOptions) DeepCopy() *SharingOptions {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(SharingOptions)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *SharingOptions) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *SharingOptionsList) DeepCopyInto(out *SharingOptionsList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]SharingOptions, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SharingOptionsList.
|
||||
func (in *SharingOptionsList) DeepCopy() *SharingOptionsList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(SharingOptionsList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *SharingOptionsList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *SnapshotInfo) DeepCopyInto(out *SnapshotInfo) {
|
||||
*out = *in
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshotInfo.
|
||||
func (in *SnapshotInfo) DeepCopy() *SnapshotInfo {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(SnapshotInfo)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *SnapshotSharingOptions) DeepCopyInto(out *SnapshotSharingOptions) {
|
||||
*out = *in
|
||||
return
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshotSharingOptions.
|
||||
func (in *SnapshotSharingOptions) DeepCopy() *SnapshotSharingOptions {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(SnapshotSharingOptions)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !ignore_autogenerated
|
||||
// +build !ignore_autogenerated
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Code generated by defaulter-gen. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
runtime "k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// RegisterDefaults adds defaulters functions to the given scheme.
|
||||
// Public to allow building arbitrary schemes.
|
||||
// All generated defaulters are covering - they call all nested defaulters.
|
||||
func RegisterDefaults(scheme *runtime.Scheme) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
//go:build !ignore_autogenerated
|
||||
// +build !ignore_autogenerated
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Code generated by openapi-gen. DO NOT EDIT.
|
||||
|
||||
// This file was autogenerated by openapi-gen. Do not edit it manually!
|
||||
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
common "k8s.io/kube-openapi/pkg/common"
|
||||
spec "k8s.io/kube-openapi/pkg/validation/spec"
|
||||
)
|
||||
|
||||
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
|
||||
return map[string]common.OpenAPIDefinition{
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateCommand": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardCreateCommand(ref),
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateResponse": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardCreateResponse(ref),
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshot": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshot(ref),
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshotList": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshotList(ref),
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshotWithDeleteKey": schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshotWithDeleteKey(ref),
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.FullDashboardSnapshot": schema_pkg_apis_dashboardsnapshot_v0alpha1_FullDashboardSnapshot(ref),
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SharingOptions": schema_pkg_apis_dashboardsnapshot_v0alpha1_SharingOptions(ref),
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SharingOptionsList": schema_pkg_apis_dashboardsnapshot_v0alpha1_SharingOptionsList(ref),
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo": schema_pkg_apis_dashboardsnapshot_v0alpha1_SnapshotInfo(ref),
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotSharingOptions": schema_pkg_apis_dashboardsnapshot_v0alpha1_SnapshotSharingOptions(ref),
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardCreateCommand(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "These are the values expected to be sent from an end user",
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"name": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Snapshot name required:false",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"dashboard": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The complete dashboard model. required:true",
|
||||
Ref: ref("github.com/grafana/grafana/pkg/apis/common/v0alpha1.Unstructured"),
|
||||
},
|
||||
},
|
||||
"expires": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "When the snapshot should expire in seconds in seconds. Default is never to expire. required:false default:0",
|
||||
Default: 0,
|
||||
Type: []string{"integer"},
|
||||
Format: "int64",
|
||||
},
|
||||
},
|
||||
"external": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "these are passed when storing an external snapshot ref Save the snapshot on an external server rather than locally. required:false default: false",
|
||||
Default: false,
|
||||
Type: []string{"boolean"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "dashboard", "expires", "external"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/pkg/apis/common/v0alpha1.Unstructured"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardCreateResponse(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The create response",
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"key": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The unique key",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"deleteKey": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "A unique key that will allow delete",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"url": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Absolute URL to show the dashboard",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"deleteUrl": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "URL that will delete the response",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"key", "deleteKey", "url", "deleteUrl"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshot(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Snapshot summary info",
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"spec"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshotList(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshot"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardSnapshot", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_dashboardsnapshot_v0alpha1_DashboardSnapshotWithDeleteKey(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "This is returned from the POST command",
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Snapshot summary info",
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo"),
|
||||
},
|
||||
},
|
||||
"deleteKey": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The delete key is only returned when the item is created. It is not returned from a get request",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"spec"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_dashboardsnapshot_v0alpha1_FullDashboardSnapshot(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "This is the snapshot returned from the subresource",
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
|
||||
},
|
||||
},
|
||||
"info": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Snapshot summary info",
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo"),
|
||||
},
|
||||
},
|
||||
"dashboard": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The raw dashboard (unstructured for now)",
|
||||
Ref: ref("github.com/grafana/grafana/pkg/apis/common/v0alpha1.Unstructured"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"info", "dashboard"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/pkg/apis/common/v0alpha1.Unstructured", "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_dashboardsnapshot_v0alpha1_SharingOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Represents an options object that must be named for each namespace/team/user",
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Show the options inline",
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotSharingOptions"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"spec"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SnapshotSharingOptions", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_dashboardsnapshot_v0alpha1_SharingOptionsList(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Represents a list of namespaced options",
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SharingOptions"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.SharingOptions", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_dashboardsnapshot_v0alpha1_SnapshotInfo(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"title": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"expires": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Optionally auto-remove the snapshot at a future date",
|
||||
Type: []string{"integer"},
|
||||
Format: "int64",
|
||||
},
|
||||
},
|
||||
"external": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "When set to true, the snapshot exists in a remote server",
|
||||
Type: []string{"boolean"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"externalUrl": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The external URL where the snapshot can be seen",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"originalUrl": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The URL that created the dashboard originally",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"timestamp": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Snapshot creation timestamp",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_dashboardsnapshot_v0alpha1_SnapshotSharingOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Each tenant, may have different sharing options This is currently set using custom.ini, but multi-tenant support will need to be managed differently",
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"snapshotEnabled": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: false,
|
||||
Type: []string{"boolean"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"externalSnapshotURL": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"externalSnapshotName": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"externalEnabled": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"boolean"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"snapshotEnabled"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1,DashboardCreateResponse,DeleteURL
|
||||
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1,SnapshotInfo,ExternalURL
|
||||
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1,SnapshotSharingOptions,SnapshotsEnabled
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/datasource"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/example"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
|
||||
@@ -26,6 +27,7 @@ func ProvideRegistryServiceSink(
|
||||
_ *dashboard.DashboardsAPIBuilder,
|
||||
_ *playlist.PlaylistAPIBuilder,
|
||||
_ *example.TestingAPIBuilder,
|
||||
_ *dashboardsnapshot.SnapshotsAPIBuilder,
|
||||
_ *featuretoggle.FeatureFlagAPIBuilder,
|
||||
_ *datasource.DataSourceAPIBuilder,
|
||||
_ *folders.FolderAPIBuilder,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package dashboardsnapshot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
)
|
||||
|
||||
func convertDTOToSnapshot(v *dashboardsnapshots.DashboardSnapshotDTO, namespacer request.NamespaceMapper) *dashboardsnapshot.DashboardSnapshot {
|
||||
expires := v.Expires.UnixMilli()
|
||||
if v.Expires.After(time.Date(2070, time.January, 0, 0, 0, 0, 0, time.UTC)) {
|
||||
expires = 0 // ignore things expiring long into the future
|
||||
}
|
||||
snap := &dashboardsnapshot.DashboardSnapshot{
|
||||
TypeMeta: resourceInfo.TypeMeta(),
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: v.Key,
|
||||
ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()),
|
||||
CreationTimestamp: metav1.NewTime(v.Created),
|
||||
Namespace: namespacer(v.OrgID),
|
||||
},
|
||||
Spec: dashboardsnapshot.SnapshotInfo{
|
||||
Title: v.Name,
|
||||
ExternalURL: v.ExternalURL,
|
||||
Expires: expires,
|
||||
},
|
||||
}
|
||||
if v.Updated != v.Created {
|
||||
meta, _ := utils.MetaAccessor(snap)
|
||||
meta.SetUpdatedTimestamp(&v.Updated)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func convertSnapshotToK8sResource(v *dashboardsnapshots.DashboardSnapshot, namespacer request.NamespaceMapper) *dashboardsnapshot.DashboardSnapshot {
|
||||
expires := v.Expires.UnixMilli()
|
||||
if v.Expires.After(time.Date(2070, time.January, 0, 0, 0, 0, 0, time.UTC)) {
|
||||
expires = 0 // ignore things expiring long into the future
|
||||
}
|
||||
|
||||
info := dashboardsnapshot.SnapshotInfo{
|
||||
Title: v.Name,
|
||||
ExternalURL: v.ExternalURL,
|
||||
Expires: expires,
|
||||
}
|
||||
s := v.Dashboard.Get("snapshot")
|
||||
if s != nil {
|
||||
info.OriginalUrl, _ = s.Get("originalUrl").String()
|
||||
info.Timestamp, _ = s.Get("timestamp").String()
|
||||
}
|
||||
snap := &dashboardsnapshot.DashboardSnapshot{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: v.Key,
|
||||
ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()),
|
||||
CreationTimestamp: metav1.NewTime(v.Created),
|
||||
Namespace: namespacer(v.OrgID),
|
||||
},
|
||||
Spec: info,
|
||||
}
|
||||
if v.Updated != v.Created {
|
||||
meta, _ := utils.MetaAccessor(snap)
|
||||
meta.SetUpdatedTimestamp(&v.Updated)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package dashboardsnapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gocloud.dev/blob"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
)
|
||||
|
||||
type dashExportStatus struct {
|
||||
Count int
|
||||
Index int
|
||||
Started int64
|
||||
Updated int64
|
||||
Finished int64
|
||||
Error string
|
||||
}
|
||||
|
||||
type dashExporter struct {
|
||||
status dashExportStatus
|
||||
|
||||
service dashboardsnapshots.Service
|
||||
sql db.DB
|
||||
}
|
||||
|
||||
func (d *dashExporter) getAPIRouteHandler() builder.APIRouteHandler {
|
||||
return builder.APIRouteHandler{
|
||||
Path: "admin/export",
|
||||
Spec: &spec3.PathProps{
|
||||
Summary: "an example at the root level",
|
||||
Description: "longer description here?",
|
||||
Post: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: []string{"export"},
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
StatusCodeResponses: map[int]*spec3.Response{
|
||||
200: {
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only let it start once
|
||||
if d.status.Started == 0 {
|
||||
go d.doExport()
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
_ = json.NewEncoder(w).Encode(d.status)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NO way to stop!!!!!!
|
||||
func (d *dashExporter) doExport() {
|
||||
defer func() {
|
||||
d.status.Finished = time.Now().UnixMilli()
|
||||
}()
|
||||
d.status = dashExportStatus{
|
||||
Started: time.Now().UnixMilli(),
|
||||
}
|
||||
if d.sql == nil {
|
||||
d.status.Error = "missing dependencies"
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
keys := []string{}
|
||||
err := d.sql.GetSqlxSession().Select(ctx,
|
||||
&keys, "SELECT key FROM dashboard_snapshot ORDER BY id asc")
|
||||
if err != nil {
|
||||
d.status.Error = err.Error()
|
||||
return
|
||||
}
|
||||
d.status.Count = len(keys)
|
||||
|
||||
bucket, err := blob.OpenBucket(ctx, "mem://?key=foo.txt&prefix=a/subfolder/")
|
||||
if err != nil {
|
||||
d.status.Error = err.Error()
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = bucket.Close()
|
||||
}()
|
||||
|
||||
for idx, key := range keys {
|
||||
d.status.Index = idx
|
||||
snap, err := d.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{
|
||||
Key: key,
|
||||
})
|
||||
if err != nil {
|
||||
d.status.Error = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
dash, err := snap.Dashboard.ToDB()
|
||||
if err != nil {
|
||||
d.status.Error = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("TODO, export: %s (len: %d)\n", snap.Key, len(dash))
|
||||
|
||||
// w, err := bucket.NewWriter(ctx, "foo.txt", nil)
|
||||
// if err != nil {
|
||||
// d.status.Error = err.Error()
|
||||
// return
|
||||
// }
|
||||
|
||||
time.Sleep(time.Second * 1)
|
||||
d.status.Updated = time.Now().UnixMilli()
|
||||
}
|
||||
fmt.Printf("done!\n")
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package dashboardsnapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
var (
|
||||
_ rest.Scoper = (*optionsStorage)(nil)
|
||||
_ rest.SingularNameProvider = (*optionsStorage)(nil)
|
||||
_ rest.Getter = (*optionsStorage)(nil)
|
||||
_ rest.Lister = (*optionsStorage)(nil)
|
||||
_ rest.Storage = (*optionsStorage)(nil)
|
||||
)
|
||||
|
||||
type sharingOptionsGetter = func(namespace string) (*dashboardsnapshot.SharingOptions, error)
|
||||
|
||||
func newSharingOptionsGetter(cfg *setting.Cfg) sharingOptionsGetter {
|
||||
s := &dashboardsnapshot.SharingOptions{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
CreationTimestamp: metav1.Now(),
|
||||
},
|
||||
Spec: dashboardsnapshot.SnapshotSharingOptions{
|
||||
SnapshotsEnabled: cfg.SnapshotEnabled,
|
||||
ExternalSnapshotURL: cfg.ExternalSnapshotUrl,
|
||||
ExternalSnapshotName: cfg.ExternalSnapshotName,
|
||||
ExternalEnabled: cfg.ExternalEnabled,
|
||||
},
|
||||
}
|
||||
return func(namespace string) (*dashboardsnapshot.SharingOptions, error) {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
type optionsStorage struct {
|
||||
getter sharingOptionsGetter
|
||||
tableConverter rest.TableConvertor
|
||||
}
|
||||
|
||||
func (s *optionsStorage) New() runtime.Object {
|
||||
return &dashboardsnapshot.SharingOptions{}
|
||||
}
|
||||
|
||||
func (s *optionsStorage) Destroy() {}
|
||||
|
||||
func (s *optionsStorage) NamespaceScoped() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *optionsStorage) GetSingularName() string {
|
||||
return "options"
|
||||
}
|
||||
|
||||
func (s *optionsStorage) NewList() runtime.Object {
|
||||
return &dashboardsnapshot.SharingOptionsList{}
|
||||
}
|
||||
|
||||
func (s *optionsStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *optionsStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.OrgID < 0 {
|
||||
return nil, fmt.Errorf("missing namespace")
|
||||
}
|
||||
v, err := s.getter(info.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := &dashboardsnapshot.SharingOptionsList{
|
||||
Items: []dashboardsnapshot.SharingOptions{*v},
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *optionsStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
return s.getter(name)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package dashboardsnapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
common "k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/grafana/grafana/pkg/util/errutil/errhttp"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupBuilder = (*SnapshotsAPIBuilder)(nil)
|
||||
|
||||
var resourceInfo = dashboardsnapshot.DashboardSnapshotResourceInfo
|
||||
|
||||
// This is used just so wire has something unique to return
|
||||
type SnapshotsAPIBuilder struct {
|
||||
service dashboardsnapshots.Service
|
||||
namespacer request.NamespaceMapper
|
||||
options sharingOptionsGetter
|
||||
exporter *dashExporter
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func NewSnapshotsAPIBuilder(
|
||||
p dashboardsnapshots.Service,
|
||||
cfg *setting.Cfg,
|
||||
exporter *dashExporter,
|
||||
) *SnapshotsAPIBuilder {
|
||||
return &SnapshotsAPIBuilder{
|
||||
service: p,
|
||||
options: newSharingOptionsGetter(cfg),
|
||||
namespacer: request.GetNamespaceMapper(cfg),
|
||||
exporter: exporter,
|
||||
logger: log.New("snapshots::RawHandlers"),
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
service dashboardsnapshots.Service,
|
||||
apiregistration builder.APIRegistrar,
|
||||
cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles,
|
||||
sql db.DB,
|
||||
) *SnapshotsAPIBuilder {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
return nil // skip registration unless opting into experimental apis
|
||||
}
|
||||
builder := NewSnapshotsAPIBuilder(service, cfg, &dashExporter{
|
||||
service: service,
|
||||
sql: sql,
|
||||
})
|
||||
apiregistration.RegisterAPI(builder)
|
||||
return builder
|
||||
}
|
||||
|
||||
func (b *SnapshotsAPIBuilder) GetGroupVersion() schema.GroupVersion {
|
||||
return resourceInfo.GroupVersion()
|
||||
}
|
||||
|
||||
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
|
||||
scheme.AddKnownTypes(gv,
|
||||
&dashboardsnapshot.DashboardSnapshot{},
|
||||
&dashboardsnapshot.DashboardSnapshotList{},
|
||||
&dashboardsnapshot.SharingOptions{},
|
||||
&dashboardsnapshot.SharingOptionsList{},
|
||||
&dashboardsnapshot.FullDashboardSnapshot{},
|
||||
&dashboardsnapshot.DashboardSnapshotWithDeleteKey{},
|
||||
&metav1.Status{},
|
||||
)
|
||||
}
|
||||
|
||||
func (b *SnapshotsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
gv := resourceInfo.GroupVersion()
|
||||
addKnownTypes(scheme, gv)
|
||||
|
||||
// Link this version to the internal representation.
|
||||
// This is used for server-side-apply (PATCH), and avoids the error:
|
||||
// "no kind is registered for the type"
|
||||
addKnownTypes(scheme, schema.GroupVersion{
|
||||
Group: gv.Group,
|
||||
Version: runtime.APIVersionInternal,
|
||||
})
|
||||
|
||||
// If multiple versions exist, then register conversions from zz_generated.conversion.go
|
||||
// if err := playlist.RegisterConversions(scheme); err != nil {
|
||||
// return err
|
||||
// }
|
||||
metav1.AddToGroupVersion(scheme, gv)
|
||||
return scheme.SetVersionPriority(gv)
|
||||
}
|
||||
|
||||
func (b *SnapshotsAPIBuilder) GetAPIGroupInfo(
|
||||
scheme *runtime.Scheme,
|
||||
codecs serializer.CodecFactory, // pointer?
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
dualWrite bool,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(dashboardsnapshot.GROUP, scheme, metav1.ParameterCodec, codecs)
|
||||
storage := map[string]rest.Storage{}
|
||||
|
||||
legacyStore := &legacyStorage{
|
||||
service: b.service,
|
||||
namespacer: b.namespacer,
|
||||
options: b.options,
|
||||
}
|
||||
legacyStore.tableConverter = utils.NewTableConverter(
|
||||
resourceInfo.GroupResource(),
|
||||
[]metav1.TableColumnDefinition{
|
||||
{Name: "Name", Type: "string", Format: "name"},
|
||||
{Name: "Title", Type: "string", Format: "string", Description: "The snapshot name"},
|
||||
{Name: "Created At", Type: "date"},
|
||||
},
|
||||
func(obj any) ([]interface{}, error) {
|
||||
m, ok := obj.(*dashboardsnapshot.DashboardSnapshot)
|
||||
if ok {
|
||||
return []interface{}{
|
||||
m.Name,
|
||||
m.Spec.Title,
|
||||
m.CreationTimestamp.UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("expected snapshot")
|
||||
},
|
||||
)
|
||||
storage[resourceInfo.StoragePath()] = legacyStore
|
||||
storage[resourceInfo.StoragePath("body")] = &subBodyREST{
|
||||
service: b.service,
|
||||
namespacer: b.namespacer,
|
||||
}
|
||||
|
||||
storage["options"] = &optionsStorage{
|
||||
getter: b.options,
|
||||
tableConverter: legacyStore.tableConverter,
|
||||
}
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[dashboardsnapshot.VERSION] = storage
|
||||
return &apiGroupInfo, nil
|
||||
}
|
||||
|
||||
func (b *SnapshotsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||
return dashboardsnapshot.GetOpenAPIDefinitions
|
||||
}
|
||||
|
||||
// Register additional routes with the server
|
||||
func (b *SnapshotsAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
|
||||
prefix := dashboardsnapshot.DashboardSnapshotResourceInfo.GroupResource().Resource
|
||||
defs := dashboardsnapshot.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} })
|
||||
createCmd := defs["github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateCommand"].Schema
|
||||
createExample := `{"dashboard":{"annotations":{"list":[{"name":"Annotations & Alerts","enable":true,"iconColor":"rgba(0, 211, 255, 1)","snapshotData":[],"type":"dashboard","builtIn":1,"hide":true}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":203,"links":[],"liveNow":false,"panels":[{"datasource":null,"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":0},"id":1,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"10.4.0-pre","snapshotData":[{"fields":[{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"showPoints":"auto","thresholdsStyle":{"mode":"off"}},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"time","type":"time","values":[1706030536378,1706034856378,1706039176378,1706043496378,1706047816378,1706052136378]},{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"A-series","type":"number","values":[1,20,90,30,50,0]}],"refId":"A"}],"targets":[],"title":"Simple example","type":"timeseries","links":[]}],"refresh":"","schemaVersion":39,"snapshot":{"timestamp":"2024-01-23T23:22:16.377Z"},"tags":[],"templating":{"list":[]},"time":{"from":"2024-01-23T17:22:20.380Z","to":"2024-01-23T23:22:20.380Z","raw":{"from":"now-6h","to":"now"}},"timepicker":{},"timezone":"","title":"simple and small","uid":"b22ec8db-399b-403b-b6c7-b0fb30ccb2a5","version":1,"weekStart":""},"name":"simple and small","expires":86400}`
|
||||
createRsp := defs["github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateResponse"].Schema
|
||||
|
||||
tags := []string{dashboardsnapshot.DashboardSnapshotResourceInfo.GroupVersionKind().Kind}
|
||||
routes := &builder.APIRoutes{
|
||||
Namespace: []builder.APIRouteHandler{
|
||||
{
|
||||
Path: prefix + "/create",
|
||||
Spec: &spec3.PathProps{
|
||||
Summary: "an example at the root level",
|
||||
Description: "longer description here?",
|
||||
Post: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: tags,
|
||||
Parameters: []*spec3.Parameter{
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "namespace",
|
||||
In: "path",
|
||||
Required: true,
|
||||
Example: "default",
|
||||
Description: "workspace",
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
},
|
||||
RequestBody: &spec3.RequestBody{
|
||||
RequestBodyProps: spec3.RequestBodyProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &createCmd,
|
||||
Example: createExample, // raw JSON body
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
StatusCodeResponses: map[int]*spec3.Response{
|
||||
200: {
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &createRsp,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := appcontext.User(r.Context())
|
||||
if err != nil {
|
||||
errhttp.Write(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
wrap := &contextmodel.ReqContext{
|
||||
Logger: b.logger,
|
||||
Context: &web.Context{
|
||||
Req: r,
|
||||
Resp: web.NewResponseWriter(r.Method, w),
|
||||
},
|
||||
SignedInUser: user,
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
info, err := request.ParseNamespace(vars["namespace"])
|
||||
if err != nil {
|
||||
wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil)
|
||||
return
|
||||
}
|
||||
if info.OrgID != user.OrgID {
|
||||
wrap.JsonApiErr(http.StatusBadRequest,
|
||||
fmt.Sprintf("user orgId does not match namespace (%d != %d)", info.OrgID, user.OrgID), nil)
|
||||
return
|
||||
}
|
||||
opts, err := b.options(info.Value)
|
||||
if err != nil {
|
||||
wrap.JsonApiErr(http.StatusBadRequest, "error getting options", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Use the existing snapshot service
|
||||
dashboardsnapshots.CreateDashboardSnapshot(wrap, opts.Spec, b.service)
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: prefix + "/delete/{deleteKey}",
|
||||
Spec: &spec3.PathProps{
|
||||
Summary: "an example at the root level",
|
||||
Description: "longer description here?",
|
||||
Delete: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: tags,
|
||||
Parameters: []*spec3.Parameter{
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "deleteKey",
|
||||
In: "path",
|
||||
Required: true,
|
||||
Description: "unique key returned in create",
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
vars := mux.Vars(r)
|
||||
key := vars["deleteKey"]
|
||||
|
||||
err := dashboardsnapshots.DeleteWithKey(ctx, key, b.service)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, fmt.Errorf("failed to delete external dashboard (%w)", err), w)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(&util.DynMap{
|
||||
"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.",
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// dev environment to export all snapshots to a blob store
|
||||
if b.exporter != nil && false {
|
||||
routes.Root = append(routes.Root, b.exporter.getAPIRouteHandler())
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
func (b *SnapshotsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
// TODO: this behavior must match the existing logic (it is currently more restrictive)
|
||||
//
|
||||
// https://github.com/grafana/grafana/blob/f63e43c113ac0cf8f78ed96ee2953874139bd2dc/pkg/middleware/auth.go#L203
|
||||
// func SnapshotPublicModeOrSignedIn(cfg *setting.Cfg) web.Handler {
|
||||
// return func(c *contextmodel.ReqContext) {
|
||||
// if cfg.SnapshotPublicMode {
|
||||
// return
|
||||
// }
|
||||
|
||||
// if !c.IsSignedIn {
|
||||
// notAuthorized(c)
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
return authorizer.AuthorizerFunc(
|
||||
func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
|
||||
// Everyone can view dashsnaps
|
||||
if attr.GetVerb() == "get" && attr.GetResource() == dashboardsnapshot.DashboardSnapshotResourceInfo.GroupResource().Resource {
|
||||
return authorizer.DecisionAllow, "", err
|
||||
}
|
||||
|
||||
// Fallback to the default behaviors (namespace matches org)
|
||||
return authorizer.DecisionNoOpinion, "", err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package dashboardsnapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
)
|
||||
|
||||
var (
|
||||
_ rest.Scoper = (*legacyStorage)(nil)
|
||||
_ rest.SingularNameProvider = (*legacyStorage)(nil)
|
||||
_ rest.Getter = (*legacyStorage)(nil)
|
||||
_ rest.Lister = (*legacyStorage)(nil)
|
||||
_ rest.Storage = (*legacyStorage)(nil)
|
||||
_ rest.GracefulDeleter = (*legacyStorage)(nil)
|
||||
)
|
||||
|
||||
type legacyStorage struct {
|
||||
service dashboardsnapshots.Service
|
||||
namespacer request.NamespaceMapper
|
||||
tableConverter rest.TableConvertor
|
||||
options sharingOptionsGetter
|
||||
}
|
||||
|
||||
func (s *legacyStorage) New() runtime.Object {
|
||||
return resourceInfo.NewFunc()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Destroy() {}
|
||||
|
||||
func (s *legacyStorage) NamespaceScoped() bool {
|
||||
return true // namespace == org
|
||||
}
|
||||
|
||||
func (s *legacyStorage) GetSingularName() string {
|
||||
return resourceInfo.GetSingularName()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) NewList() runtime.Object {
|
||||
return resourceInfo.NewListFunc()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) checkEnabled(ns string) error {
|
||||
opts, err := s.options(ns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !opts.Spec.SnapshotsEnabled {
|
||||
return fmt.Errorf("snapshots not enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err == nil {
|
||||
err = s.checkEnabled(info.Value)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := appcontext.User(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limit := 5000
|
||||
if options.Limit > 0 {
|
||||
limit = int(options.Limit)
|
||||
}
|
||||
res, err := s.service.SearchDashboardSnapshots(ctx, &dashboardsnapshots.GetDashboardSnapshotsQuery{
|
||||
OrgID: info.OrgID,
|
||||
SignedInUser: user,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := &dashboardsnapshot.DashboardSnapshotList{}
|
||||
for _, v := range res {
|
||||
list.Items = append(list.Items, *convertDTOToSnapshot(v, s.namespacer))
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err == nil {
|
||||
err = s.checkEnabled(info.Value)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v, err := s.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{
|
||||
Key: name,
|
||||
})
|
||||
if err != nil || v == nil {
|
||||
// if errors.Is(err, playlistsvc.ErrPlaylistNotFound) || err == nil {
|
||||
// err = k8serrors.NewNotFound(s.SingularQualifiedResource, name)
|
||||
// }
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return convertSnapshotToK8sResource(v, s.namespacer), nil
|
||||
}
|
||||
|
||||
// GracefulDeleter
|
||||
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
snap, err := s.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{
|
||||
Key: name,
|
||||
})
|
||||
if err != nil || snap == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// Delete the external one first
|
||||
if snap.ExternalDeleteURL != "" {
|
||||
err := dashboardsnapshots.DeleteExternalDashboardSnapshot(snap.ExternalDeleteURL)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
err = s.service.DeleteDashboardSnapshot(ctx, &dashboardsnapshots.DeleteDashboardSnapshotCommand{
|
||||
DeleteKey: snap.DeleteKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, true, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dashboardsnapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
|
||||
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
)
|
||||
|
||||
type subBodyREST struct {
|
||||
service dashboardsnapshots.Service
|
||||
namespacer request.NamespaceMapper
|
||||
}
|
||||
|
||||
var _ = rest.Connecter(&subBodyREST{})
|
||||
|
||||
func (r *subBodyREST) New() runtime.Object {
|
||||
return &dashboardsnapshot.FullDashboardSnapshot{}
|
||||
}
|
||||
|
||||
func (r *subBodyREST) Destroy() {}
|
||||
|
||||
func (r *subBodyREST) ConnectMethods() []string {
|
||||
return []string{"GET"}
|
||||
}
|
||||
|
||||
func (r *subBodyREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||
return nil, false, ""
|
||||
}
|
||||
|
||||
func (r *subBodyREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
snap, err := r.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{
|
||||
Key: name,
|
||||
})
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := snap.Dashboard.Map()
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
r := convertSnapshotToK8sResource(snap, r.namespacer)
|
||||
responder.Object(200, &dashboardsnapshot.FullDashboardSnapshot{
|
||||
ObjectMeta: r.ObjectMeta,
|
||||
Info: r.Spec,
|
||||
Dashboard: common.Unstructured{Object: data},
|
||||
})
|
||||
}), nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/google/wire"
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/datasource"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/example"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
|
||||
@@ -26,6 +27,7 @@ var WireSet = wire.NewSet(
|
||||
playlist.RegisterAPIService,
|
||||
dashboard.RegisterAPIService,
|
||||
example.RegisterAPIService,
|
||||
dashboardsnapshot.RegisterAPIService,
|
||||
featuretoggle.RegisterAPIService,
|
||||
datasource.RegisterAPIService,
|
||||
folders.RegisterAPIService,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -359,12 +359,14 @@ type Cfg struct {
|
||||
SqlDatasourceMaxConnLifetimeDefault int
|
||||
|
||||
// Snapshots
|
||||
SnapshotEnabled bool
|
||||
ExternalSnapshotUrl string
|
||||
ExternalSnapshotName string
|
||||
ExternalEnabled bool
|
||||
SnapshotEnabled bool
|
||||
ExternalSnapshotUrl string
|
||||
ExternalSnapshotName string
|
||||
ExternalEnabled bool
|
||||
// Deprecated: setting this to false adds deprecation warnings at runtime
|
||||
SnapShotRemoveExpired bool
|
||||
|
||||
// Only used in https://snapshots.raintank.io/
|
||||
SnapshotPublicMode bool
|
||||
|
||||
ErrTemplateName string
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package dashboardsnapshots
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
)
|
||||
|
||||
func TestDashboardSnapshots(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false, // required for experimental apis
|
||||
DisableAnonymous: true,
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // required to register dashboardsnapshot.grafana.app
|
||||
},
|
||||
})
|
||||
|
||||
t.Run("Check discovery client", func(t *testing.T) {
|
||||
disco := helper.GetGroupVersionInfoJSON("dashboardsnapshot.grafana.app")
|
||||
|
||||
// fmt.Printf("%s", disco)
|
||||
require.JSONEq(t, `[
|
||||
{
|
||||
"freshness": "Current",
|
||||
"resources": [
|
||||
{
|
||||
"resource": "dashboardsnapshot",
|
||||
"responseKind": {
|
||||
"group": "",
|
||||
"kind": "DashboardSnapshot",
|
||||
"version": ""
|
||||
},
|
||||
"scope": "Namespaced",
|
||||
"singularResource": "dashsnap",
|
||||
"subresources": [
|
||||
{
|
||||
"responseKind": {
|
||||
"group": "",
|
||||
"kind": "FullDashboardSnapshot",
|
||||
"version": ""
|
||||
},
|
||||
"subresource": "body",
|
||||
"verbs": [
|
||||
"get"
|
||||
]
|
||||
}
|
||||
],
|
||||
"verbs": [
|
||||
"delete",
|
||||
"get",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"resource": "options",
|
||||
"responseKind": {
|
||||
"group": "",
|
||||
"kind": "SharingOptions",
|
||||
"version": ""
|
||||
},
|
||||
"scope": "Namespaced",
|
||||
"singularResource": "options",
|
||||
"verbs": [
|
||||
"get",
|
||||
"list"
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": "v0alpha1"
|
||||
}
|
||||
]`, disco)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user