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

This commit is contained in:
Ryan McKinley 2024-02-01 22:40:11 -08:00 committed by GitHub
parent 810d14d88f
commit 795eb4a8d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2543 additions and 267 deletions

View File

@ -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()

View File

@ -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)
}

View File

@ -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,

View File

@ -0,0 +1,6 @@
// +k8s:deepcopy-gen=package
// +k8s:openapi-gen=true
// +k8s:defaulter-gen=TypeMeta
// +groupName=dashboardsnapshot.grafana.app
package v0alpha1

View File

@ -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}
)

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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"},
},
},
}
}

View File

@ -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

View File

@ -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,

View File

@ -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
}

View File

@ -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")
}

View File

@ -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)
}

View File

@ -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
})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,

View File

@ -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 {

View File

@ -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)

View File

@ -3,6 +3,7 @@ package dashboardsnapshots
import (
"time"
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/services/auth/identity"
)
@ -47,28 +48,17 @@ type DashboardSnapshotDTO struct {
// swagger:model
type CreateDashboardSnapshotCommand struct {
// The complete dashboard model.
// required:true
Dashboard *simplejson.Json `json:"dashboard" binding:"Required"`
// Snapshot name
// required:false
Name string `json:"name"`
// When the snapshot should expire in seconds in seconds. Default is never to expire.
// required:false
// default:0
Expires int64 `json:"expires"`
// The "public" fields are defined in this struct while the private/SQL/response params are
// defied in the rest of this command
dashboardsnapshot.DashboardCreateCommand
// these are passed when storing an external snapshot ref
// Save the snapshot on an external server rather than locally.
// required:false
// default: false
External bool `json:"external"`
ExternalURL string `json:"-"`
ExternalDeleteURL string `json:"-"`
// Define the unique key. Required if `external` is `true`.
// required:false
Key string `json:"key"`
// Unique key used to delete the snapshot. It is different from the `key` so that only the creator can delete the snapshot. Required if `external` is `true`.
// required:false
DeleteKey string `json:"deleteKey"`
@ -100,3 +90,10 @@ type GetDashboardSnapshotsQuery struct {
OrgID int64
SignedInUser identity.Requester
}
type CreateExternalSnapshotResponse struct {
Key string `json:"key"`
DeleteKey string `json:"deleteKey"`
Url string `json:"url"`
DeleteUrl string `json:"deleteUrl"`
}

View File

@ -1,7 +1,23 @@
package dashboardsnapshots
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/services/auth/identity"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
//go:generate mockery --name Service --structname MockService --inpackage --filename service_mock.go
@ -12,3 +28,198 @@ type Service interface {
GetDashboardSnapshot(context.Context, *GetDashboardSnapshotQuery) (*DashboardSnapshot, error)
SearchDashboardSnapshots(context.Context, *GetDashboardSnapshotsQuery) (DashboardSnapshotsList, error)
}
var client = &http.Client{
Timeout: time.Second * 5,
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
}
func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg dashboardsnapshot.SnapshotSharingOptions, svc Service) {
if !cfg.SnapshotsEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return
}
cmd := CreateDashboardSnapshotCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
c.JsonApiErr(http.StatusBadRequest, "bad request data", err)
return
}
if cmd.Name == "" {
cmd.Name = "Unnamed snapshot"
}
userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID())
if err != nil {
c.JsonApiErr(http.StatusInternalServerError,
"Failed to create external snapshot", err)
return
}
var snapshotUrl string
cmd.ExternalURL = ""
cmd.OrgID = c.SignedInUser.GetOrgID()
cmd.UserID = userID
originalDashboardURL, err := createOriginalDashboardURL(&cmd)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err)
return
}
if cmd.External {
if !cfg.ExternalEnabled {
c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
return
}
resp, err := createExternalDashboardSnapshot(cmd, cfg.ExternalSnapshotURL)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err)
return
}
snapshotUrl = resp.Url
cmd.Key = resp.Key
cmd.DeleteKey = resp.DeleteKey
cmd.ExternalURL = resp.Url
cmd.ExternalDeleteURL = resp.DeleteUrl
cmd.Dashboard = &common.Unstructured{}
metrics.MApiDashboardSnapshotExternal.Inc()
} else {
cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl")
if cmd.Key == "" {
var err error
cmd.Key, err = util.GetRandomString(32)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
return
}
}
if cmd.DeleteKey == "" {
var err error
cmd.DeleteKey, err = util.GetRandomString(32)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
return
}
}
snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
metrics.MApiDashboardSnapshotCreate.Inc()
}
result, err := svc.CreateDashboardSnapshot(c.Req.Context(), &cmd)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err)
return
}
c.JSON(http.StatusOK, dashboardsnapshot.DashboardCreateResponse{
Key: result.Key,
DeleteKey: result.DeleteKey,
URL: snapshotUrl,
DeleteURL: setting.ToAbsUrl("api/snapshots-delete/" + result.DeleteKey),
})
}
var plog = log.New("external-snapshot")
func DeleteExternalDashboardSnapshot(externalUrl string) error {
resp, err := client.Get(externalUrl)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
plog.Warn("Failed to close response body", "err", err)
}
}()
if resp.StatusCode == 200 {
return nil
}
// Gracefully ignore "snapshot not found" errors as they could have already
// been removed either via the cleanup script or by request.
if resp.StatusCode == 500 {
var respJson map[string]any
if err := json.NewDecoder(resp.Body).Decode(&respJson); err != nil {
return err
}
if respJson["message"] == "Failed to get dashboard snapshot" {
return nil
}
}
return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", resp.StatusCode)
}
func createExternalDashboardSnapshot(cmd CreateDashboardSnapshotCommand, externalSnapshotUrl string) (*CreateExternalSnapshotResponse, error) {
var createSnapshotResponse CreateExternalSnapshotResponse
message := map[string]any{
"name": cmd.Name,
"expires": cmd.Expires,
"dashboard": cmd.Dashboard,
"key": cmd.Key,
"deleteKey": cmd.DeleteKey,
}
messageBytes, err := simplejson.NewFromAny(message).Encode()
if err != nil {
return nil, err
}
resp, err := client.Post(externalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes))
if err != nil {
return nil, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
plog.Warn("Failed to close response body", "err", err)
}
}()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("create external snapshot response status code %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&createSnapshotResponse); err != nil {
return nil, err
}
return &createSnapshotResponse, nil
}
func createOriginalDashboardURL(cmd *CreateDashboardSnapshotCommand) (string, error) {
dashUID := cmd.Dashboard.GetNestedString("uid")
if ok := util.IsValidShortUID(dashUID); !ok {
return "", fmt.Errorf("invalid dashboard UID")
}
return fmt.Sprintf("/d/%v", dashUID), nil
}
func DeleteWithKey(ctx context.Context, key string, svc Service) error {
query := &GetDashboardSnapshotQuery{DeleteKey: key}
queryResult, err := svc.GetDashboardSnapshot(ctx, query)
if err != nil {
return err
}
if queryResult.External {
err := DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
if err != nil {
return err
}
}
cmd := &DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey}
return svc.DeleteDashboardSnapshot(ctx, cmd)
}

View File

@ -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
}

View File

@ -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)

View File

@ -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

View File

@ -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)
})
}

View File

@ -3258,8 +3258,12 @@
"dashboard"
],
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
"type": "string"
},
"dashboard": {
"$ref": "#/definitions/Json"
"$ref": "#/definitions/Unstructured"
},
"deleteKey": {
"description": "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`.",
@ -3280,6 +3284,10 @@
"description": "Define the unique key. Required if `external` is `true`.",
"type": "string"
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
"type": "string"
},
"name": {
"description": "Snapshot name",
"type": "string"
@ -3618,6 +3626,41 @@
}
}
},
"DashboardCreateCommand": {
"description": "These are the values expected to be sent from an end user\n+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object",
"type": "object",
"required": [
"dashboard"
],
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
"type": "string"
},
"dashboard": {
"$ref": "#/definitions/Unstructured"
},
"expires": {
"description": "When the snapshot should expire in seconds in seconds. Default is never to expire.",
"type": "integer",
"format": "int64",
"default": 0
},
"external": {
"description": "these are passed when storing an external snapshot ref\nSave the snapshot on an external server rather than locally.",
"type": "boolean",
"default": false
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
"type": "string"
},
"name": {
"description": "Snapshot name",
"type": "string"
}
}
},
"DashboardFullWithMeta": {
"type": "object",
"properties": {
@ -7380,6 +7423,21 @@
"Type": {
"type": "string"
},
"TypeMeta": {
"description": "+k8s:deepcopy-gen=false",
"type": "object",
"title": "TypeMeta describes an individual object in an API response or request\nwith strings representing the type of the object and its API schema version.\nStructures that are versioned or persisted should inline TypeMeta.",
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
"type": "string"
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
"type": "string"
}
}
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"type": "object",
@ -7420,6 +7478,17 @@
}
}
},
"Unstructured": {
"description": "Unstructured allows objects that do not have Golang structs registered to be manipulated\ngenerically.",
"type": "object",
"properties": {
"Object": {
"description": "Object is a JSON compatible map with string, float, int, bool, []interface{},\nor map[string]interface{} children.",
"type": "object",
"additionalProperties": {}
}
}
},
"UpdateAlertNotificationCommand": {
"type": "object",
"properties": {

View File

@ -13578,8 +13578,12 @@
"dashboard"
],
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
"type": "string"
},
"dashboard": {
"$ref": "#/definitions/Json"
"$ref": "#/definitions/Unstructured"
},
"deleteKey": {
"description": "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`.",
@ -13600,6 +13604,10 @@
"description": "Define the unique key. Required if `external` is `true`.",
"type": "string"
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
"type": "string"
},
"name": {
"description": "Snapshot name",
"type": "string"
@ -13938,6 +13946,41 @@
}
}
},
"DashboardCreateCommand": {
"description": "These are the values expected to be sent from an end user\n+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object",
"type": "object",
"required": [
"dashboard"
],
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
"type": "string"
},
"dashboard": {
"$ref": "#/definitions/Unstructured"
},
"expires": {
"description": "When the snapshot should expire in seconds in seconds. Default is never to expire.",
"type": "integer",
"format": "int64",
"default": 0
},
"external": {
"description": "these are passed when storing an external snapshot ref\nSave the snapshot on an external server rather than locally.",
"type": "boolean",
"default": false
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
"type": "string"
},
"name": {
"description": "Snapshot name",
"type": "string"
}
}
},
"DashboardFullWithMeta": {
"type": "object",
"properties": {
@ -20764,6 +20807,21 @@
"Type": {
"type": "string"
},
"TypeMeta": {
"description": "+k8s:deepcopy-gen=false",
"type": "object",
"title": "TypeMeta describes an individual object in an API response or request\nwith strings representing the type of the object and its API schema version.\nStructures that are versioned or persisted should inline TypeMeta.",
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
"type": "string"
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
"type": "string"
}
}
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"type": "object",
@ -20804,6 +20862,17 @@
}
}
},
"Unstructured": {
"description": "Unstructured allows objects that do not have Golang structs registered to be manipulated\ngenerically.",
"type": "object",
"properties": {
"Object": {
"description": "Object is a JSON compatible map with string, float, int, bool, []interface{},\nor map[string]interface{} children.",
"type": "object",
"additionalProperties": false
}
}
},
"UpdateAlertNotificationCommand": {
"type": "object",
"properties": {

View File

@ -15,8 +15,6 @@ import { DashboardInteractions } from '../utils/interactions';
import { SceneShareTabState } from './types';
const SNAPSHOTS_API_ENDPOINT = '/api/snapshots';
const getExpireOptions = () => {
const DEFAULT_EXPIRE_OPTION: SelectableValue<number> = {
label: t('share-modal.snapshot.expire-never', `Never`),
@ -121,8 +119,7 @@ export class ShareSnapshotTab extends SceneObjectBase<ShareSnapshotTabState> {
};
try {
const results: { deleteUrl: string; url: string } = await getBackendSrv().post(SNAPSHOTS_API_ENDPOINT, cmdData);
return results;
return await getDashboardSnapshotSrv().create(cmdData);
} finally {
if (external) {
DashboardInteractions.publishSnapshotClicked({ expires: cmdData.expires });

View File

@ -13,8 +13,6 @@ import { getDashboardSnapshotSrv } from '../../services/SnapshotSrv';
import { ShareModalTabProps } from './types';
const snapshotApiUrl = '/api/snapshots';
interface Props extends ShareModalTabProps {}
interface State {
@ -109,7 +107,7 @@ export class ShareSnapshot extends PureComponent<Props, State> {
};
try {
const results: { deleteUrl: string; url: string } = await getBackendSrv().post(snapshotApiUrl, cmdData);
const results = await getDashboardSnapshotSrv().create(cmdData);
this.setState({
deleteUrl: results.deleteUrl,
snapshotUrl: results.url,

View File

@ -1,5 +1,8 @@
import { getBackendSrv } from '@grafana/runtime';
import { DashboardDTO } from 'app/types';
import { lastValueFrom, map } from 'rxjs';
import { config, getBackendSrv, FetchResponse } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { DashboardDataDTO, DashboardDTO } from 'app/types';
// Used in the snapshot list
export interface Snapshot {
@ -17,7 +20,21 @@ export interface SnapshotSharingOptions {
snapshotEnabled: boolean;
}
export interface SnapshotCreateCommand {
dashboard: object;
name: string;
expires?: number;
external?: boolean;
}
export interface SnapshotCreateResponse {
key: string;
url: string;
deleteUrl: string;
}
export interface DashboardSnapshotSrv {
create: (cmd: SnapshotCreateCommand) => Promise<SnapshotCreateResponse>;
getSnapshots: () => Promise<Snapshot[]>;
getSharingOptions: () => Promise<SnapshotSharingOptions>;
deleteSnapshot: (key: string) => Promise<void>;
@ -25,6 +42,7 @@ export interface DashboardSnapshotSrv {
}
const legacyDashboardSnapshotSrv: DashboardSnapshotSrv = {
create: (cmd: SnapshotCreateCommand) => getBackendSrv().post<SnapshotCreateResponse>('/api/snapshots', cmd),
getSnapshots: () => getBackendSrv().get<Snapshot[]>('/api/dashboard/snapshots'),
getSharingOptions: () => getBackendSrv().get<SnapshotSharingOptions>('/api/snapshot/shared-options'),
deleteSnapshot: (key: string) => getBackendSrv().delete('/api/snapshots/' + key),
@ -35,6 +53,109 @@ const legacyDashboardSnapshotSrv: DashboardSnapshotSrv = {
},
};
interface K8sMetadata {
name: string;
namespace: string;
resourceVersion: string;
creationTimestamp: string;
}
interface K8sSnapshotInfo {
title: string;
externalUrl?: string;
expires?: number;
}
interface K8sSnapshotResource {
metadata: K8sMetadata;
spec: K8sSnapshotInfo;
}
interface DashboardSnapshotList {
items: K8sSnapshotResource[];
}
interface K8sDashboardSnapshot {
apiVersion: string;
kind: 'DashboardSnapshot';
metadata: K8sMetadata;
dashboard: DashboardDataDTO;
}
class K8sAPI implements DashboardSnapshotSrv {
readonly apiVersion = 'dashboardsnapshot.grafana.app/v0alpha1';
readonly url: string;
constructor() {
this.url = `/apis/${this.apiVersion}/namespaces/${config.namespace}/dashboardsnapshots`;
}
async create(cmd: SnapshotCreateCommand) {
return getBackendSrv().post<SnapshotCreateResponse>(this.url + '/create', cmd);
}
async getSnapshots(): Promise<Snapshot[]> {
const result = await getBackendSrv().get<DashboardSnapshotList>(this.url);
return result.items.map((r) => {
return {
key: r.metadata.name,
name: r.spec.title,
external: r.spec.externalUrl != null,
externalUrl: r.spec.externalUrl,
};
});
}
deleteSnapshot(uid: string) {
return getBackendSrv().delete<void>(this.url + '/' + uid);
}
async getSharingOptions() {
// TODO? should this be in a config service, or in the same service?
// we have http://localhost:3000/apis/dashboardsnapshot.grafana.app/v0alpha1/namespaces/default/options
// BUT that has an unclear user mapping story still, so lets stick with the existing shared-options endpoint
return getBackendSrv().get<SnapshotSharingOptions>('/api/snapshot/shared-options');
}
async getSnapshot(uid: string): Promise<DashboardDTO> {
const headers: Record<string, string> = {};
if (!contextSrv.isSignedIn) {
alert('TODO... need a barer token for anonymous use case');
const token = `??? TODO, get anon token for snapshots (${contextSrv.user?.name}) ???`;
headers['Authorization'] = `Bearer ${token}`;
}
return lastValueFrom(
getBackendSrv()
.fetch<K8sDashboardSnapshot>({
url: this.url + '/' + uid + '/body',
method: 'GET',
headers: headers,
})
.pipe(
map((response: FetchResponse<K8sDashboardSnapshot>) => {
return {
dashboard: response.data.dashboard,
meta: {
isSnapshot: true,
canSave: false,
canEdit: false,
canAdmin: false,
canStar: false,
canShare: false,
canDelete: false,
isFolder: false,
provisioned: false,
},
};
})
)
);
}
}
export function getDashboardSnapshotSrv(): DashboardSnapshotSrv {
if (config.featureToggles.kubernetesSnapshots) {
return new K8sAPI();
}
return legacyDashboardSnapshotSrv;
}

View File

@ -4058,8 +4058,12 @@
},
"CreateDashboardSnapshotCommand": {
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
"type": "string"
},
"dashboard": {
"$ref": "#/components/schemas/Json"
"$ref": "#/components/schemas/Unstructured"
},
"deleteKey": {
"description": "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`.",
@ -4080,6 +4084,10 @@
"description": "Define the unique key. Required if `external` is `true`.",
"type": "string"
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
"type": "string"
},
"name": {
"description": "Snapshot name",
"type": "string"
@ -4422,6 +4430,41 @@
},
"type": "object"
},
"DashboardCreateCommand": {
"description": "These are the values expected to be sent from an end user\n+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object",
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
"type": "string"
},
"dashboard": {
"$ref": "#/components/schemas/Unstructured"
},
"expires": {
"default": 0,
"description": "When the snapshot should expire in seconds in seconds. Default is never to expire.",
"format": "int64",
"type": "integer"
},
"external": {
"default": false,
"description": "these are passed when storing an external snapshot ref\nSave the snapshot on an external server rather than locally.",
"type": "boolean"
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
"type": "string"
},
"name": {
"description": "Snapshot name",
"type": "string"
}
},
"required": [
"dashboard"
],
"type": "object"
},
"DashboardFullWithMeta": {
"properties": {
"dashboard": {
@ -11247,6 +11290,21 @@
"Type": {
"type": "string"
},
"TypeMeta": {
"description": "+k8s:deepcopy-gen=false",
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n+optional",
"type": "string"
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n+optional",
"type": "string"
}
},
"title": "TypeMeta describes an individual object in an API response or request\nwith strings representing the type of the object and its API schema version.\nStructures that are versioned or persisted should inline TypeMeta.",
"type": "object"
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"properties": {
@ -11287,6 +11345,17 @@
"title": "A URL represents a parsed URL (technically, a URI reference).",
"type": "object"
},
"Unstructured": {
"description": "Unstructured allows objects that do not have Golang structs registered to be manipulated\ngenerically.",
"properties": {
"Object": {
"additionalProperties": false,
"description": "Object is a JSON compatible map with string, float, int, bool, []interface{},\nor map[string]interface{} children.",
"type": "object"
}
},
"type": "object"
},
"UpdateAlertNotificationCommand": {
"properties": {
"disableResolveMessage": {