From 795eb4a8d8a299543d222ccedec44f8eac38e005 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 1 Feb 2024 22:40:11 -0800 Subject: [PATCH] K8s/Snapshots: Add dashboardsnapshot api group (#77667) --- pkg/api/api.go | 3 - pkg/api/dashboard_snapshot.go | 217 +------- pkg/api/dashboard_snapshot_test.go | 21 +- pkg/apis/dashboardsnapshot/v0alpha1/doc.go | 6 + .../dashboardsnapshot/v0alpha1/register.go | 25 + pkg/apis/dashboardsnapshot/v0alpha1/types.go | 136 +++++ .../v0alpha1/zz_generated.deepcopy.go | 271 +++++++++ .../v0alpha1/zz_generated.defaults.go | 19 + .../v0alpha1/zz_generated.openapi.go | 521 ++++++++++++++++++ ...enerated.openapi_violation_exceptions.list | 3 + pkg/registry/apis/apis.go | 2 + .../apis/dashboardsnapshot/conversions.go | 71 +++ .../apis/dashboardsnapshot/exporter.go | 130 +++++ .../apis/dashboardsnapshot/options_storage.go | 91 +++ .../apis/dashboardsnapshot/register.go | 345 ++++++++++++ .../apis/dashboardsnapshot/sql_storage.go | 147 +++++ .../apis/dashboardsnapshot/sub_body.go | 60 ++ pkg/registry/apis/wireset.go | 2 + .../dashboardsnapshots/database/database.go | 24 +- .../database/database_test.go | 28 +- pkg/services/dashboardsnapshots/models.go | 27 +- pkg/services/dashboardsnapshots/service.go | 211 +++++++ .../dashboardsnapshots/service/service.go | 2 +- .../service/service_test.go | 11 +- pkg/setting/setting.go | 10 +- .../apis/dashboardsnapshot/snapshots_test.go | 80 +++ public/api-enterprise-spec.json | 71 ++- public/api-merged.json | 71 ++- .../sharing/ShareSnapshotTab.tsx | 5 +- .../components/ShareModal/ShareSnapshot.tsx | 4 +- .../dashboard/services/SnapshotSrv.ts | 125 ++++- public/openapi3.json | 71 ++- 32 files changed, 2543 insertions(+), 267 deletions(-) create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/doc.go create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/register.go create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/types.go create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.deepcopy.go create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.defaults.go create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi.go create mode 100644 pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi_violation_exceptions.list create mode 100644 pkg/registry/apis/dashboardsnapshot/conversions.go create mode 100644 pkg/registry/apis/dashboardsnapshot/exporter.go create mode 100644 pkg/registry/apis/dashboardsnapshot/options_storage.go create mode 100644 pkg/registry/apis/dashboardsnapshot/register.go create mode 100644 pkg/registry/apis/dashboardsnapshot/sql_storage.go create mode 100644 pkg/registry/apis/dashboardsnapshot/sub_body.go create mode 100644 pkg/tests/apis/dashboardsnapshot/snapshots_test.go diff --git a/pkg/api/api.go b/pkg/api/api.go index e8886baf0bc..2a857fbf65c 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -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() diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 2b0c5e5471b..67f4df9c7a7 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -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) } diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index d8023a9e83e..3fd0553610e 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -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, diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/doc.go b/pkg/apis/dashboardsnapshot/v0alpha1/doc.go new file mode 100644 index 00000000000..a6b2fec52cb --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/doc.go @@ -0,0 +1,6 @@ +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:defaulter-gen=TypeMeta +// +groupName=dashboardsnapshot.grafana.app + +package v0alpha1 diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/register.go b/pkg/apis/dashboardsnapshot/v0alpha1/register.go new file mode 100644 index 00000000000..e8022d55bf7 --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/register.go @@ -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} +) diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/types.go b/pkg/apis/dashboardsnapshot/v0alpha1/types.go new file mode 100644 index 00000000000..6a0785bdedb --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/types.go @@ -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"` +} diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000000..51b5075f310 --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.deepcopy.go @@ -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 +} diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.defaults.go b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.defaults.go new file mode 100644 index 00000000000..238fc2f4edc --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.defaults.go @@ -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 +} diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi.go b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi.go new file mode 100644 index 00000000000..f5824a7d78a --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi.go @@ -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"}, + }, + }, + } +} diff --git a/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 00000000000..72af08dd20f --- /dev/null +++ b/pkg/apis/dashboardsnapshot/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -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 \ No newline at end of file diff --git a/pkg/registry/apis/apis.go b/pkg/registry/apis/apis.go index 86f757eb8ba..2a918867edc 100644 --- a/pkg/registry/apis/apis.go +++ b/pkg/registry/apis/apis.go @@ -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, diff --git a/pkg/registry/apis/dashboardsnapshot/conversions.go b/pkg/registry/apis/dashboardsnapshot/conversions.go new file mode 100644 index 00000000000..8071799de5a --- /dev/null +++ b/pkg/registry/apis/dashboardsnapshot/conversions.go @@ -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 +} diff --git a/pkg/registry/apis/dashboardsnapshot/exporter.go b/pkg/registry/apis/dashboardsnapshot/exporter.go new file mode 100644 index 00000000000..fdf8caab9e1 --- /dev/null +++ b/pkg/registry/apis/dashboardsnapshot/exporter.go @@ -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") +} diff --git a/pkg/registry/apis/dashboardsnapshot/options_storage.go b/pkg/registry/apis/dashboardsnapshot/options_storage.go new file mode 100644 index 00000000000..571010b9f5c --- /dev/null +++ b/pkg/registry/apis/dashboardsnapshot/options_storage.go @@ -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) +} diff --git a/pkg/registry/apis/dashboardsnapshot/register.go b/pkg/registry/apis/dashboardsnapshot/register.go new file mode 100644 index 00000000000..e59601696f3 --- /dev/null +++ b/pkg/registry/apis/dashboardsnapshot/register.go @@ -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 + }) +} diff --git a/pkg/registry/apis/dashboardsnapshot/sql_storage.go b/pkg/registry/apis/dashboardsnapshot/sql_storage.go new file mode 100644 index 00000000000..b21231a8613 --- /dev/null +++ b/pkg/registry/apis/dashboardsnapshot/sql_storage.go @@ -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 +} diff --git a/pkg/registry/apis/dashboardsnapshot/sub_body.go b/pkg/registry/apis/dashboardsnapshot/sub_body.go new file mode 100644 index 00000000000..45614b5e635 --- /dev/null +++ b/pkg/registry/apis/dashboardsnapshot/sub_body.go @@ -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 +} diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index e093f7a175e..3c5758da87b 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -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, diff --git a/pkg/services/dashboardsnapshots/database/database.go b/pkg/services/dashboardsnapshots/database/database.go index 4d1bb798df9..0acd68838bf 100644 --- a/pkg/services/dashboardsnapshots/database/database.go +++ b/pkg/services/dashboardsnapshots/database/database.go @@ -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 { diff --git a/pkg/services/dashboardsnapshots/database/database_test.go b/pkg/services/dashboardsnapshots/database/database_test.go index 6982d241776..f19461d4fdb 100644 --- a/pkg/services/dashboardsnapshots/database/database_test.go +++ b/pkg/services/dashboardsnapshots/database/database_test.go @@ -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) diff --git a/pkg/services/dashboardsnapshots/models.go b/pkg/services/dashboardsnapshots/models.go index 36c155b52ea..7e4becec46b 100644 --- a/pkg/services/dashboardsnapshots/models.go +++ b/pkg/services/dashboardsnapshots/models.go @@ -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"` +} diff --git a/pkg/services/dashboardsnapshots/service.go b/pkg/services/dashboardsnapshots/service.go index ae229d27fed..07bcea602c1 100644 --- a/pkg/services/dashboardsnapshots/service.go +++ b/pkg/services/dashboardsnapshots/service.go @@ -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) +} diff --git a/pkg/services/dashboardsnapshots/service/service.go b/pkg/services/dashboardsnapshots/service/service.go index 247f0e7aec9..c26f00e7d23 100644 --- a/pkg/services/dashboardsnapshots/service/service.go +++ b/pkg/services/dashboardsnapshots/service/service.go @@ -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 } diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go index 638a584d910..f4b80d9d9c9 100644 --- a/pkg/services/dashboardsnapshots/service/service_test.go +++ b/pkg/services/dashboardsnapshots/service/service_test.go @@ -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) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 8fdda78a12b..7cb19b47cfd 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -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 diff --git a/pkg/tests/apis/dashboardsnapshot/snapshots_test.go b/pkg/tests/apis/dashboardsnapshot/snapshots_test.go new file mode 100644 index 00000000000..c25c4029d0a --- /dev/null +++ b/pkg/tests/apis/dashboardsnapshot/snapshots_test.go @@ -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) + }) +} diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index 99da38d47d5..1fce6c5f1c1 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -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": { diff --git a/public/api-merged.json b/public/api-merged.json index 5828a0d7816..0595858477e 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -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": { diff --git a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx index c65584657f9..2e9503c3a11 100644 --- a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx @@ -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 = { label: t('share-modal.snapshot.expire-never', `Never`), @@ -121,8 +119,7 @@ export class ShareSnapshotTab extends SceneObjectBase { }; 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 }); diff --git a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx index 18130b09b57..2c93b86df28 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx @@ -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 { }; 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, diff --git a/public/app/features/dashboard/services/SnapshotSrv.ts b/public/app/features/dashboard/services/SnapshotSrv.ts index 293d5245fa3..e3e0e9db637 100644 --- a/public/app/features/dashboard/services/SnapshotSrv.ts +++ b/public/app/features/dashboard/services/SnapshotSrv.ts @@ -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; getSnapshots: () => Promise; getSharingOptions: () => Promise; deleteSnapshot: (key: string) => Promise; @@ -25,6 +42,7 @@ export interface DashboardSnapshotSrv { } const legacyDashboardSnapshotSrv: DashboardSnapshotSrv = { + create: (cmd: SnapshotCreateCommand) => getBackendSrv().post('/api/snapshots', cmd), getSnapshots: () => getBackendSrv().get('/api/dashboard/snapshots'), getSharingOptions: () => getBackendSrv().get('/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(this.url + '/create', cmd); + } + + async getSnapshots(): Promise { + const result = await getBackendSrv().get(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(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('/api/snapshot/shared-options'); + } + + async getSnapshot(uid: string): Promise { + const headers: Record = {}; + 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({ + url: this.url + '/' + uid + '/body', + method: 'GET', + headers: headers, + }) + .pipe( + map((response: FetchResponse) => { + 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; } diff --git a/public/openapi3.json b/public/openapi3.json index 5b63c944acf..b62fcb9db2b 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -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": {