K8s/Snapshots: Add dashboardsnapshot api group (#77667)
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/datasource"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/example"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
|
||||
@@ -26,6 +27,7 @@ func ProvideRegistryServiceSink(
|
||||
_ *dashboard.DashboardsAPIBuilder,
|
||||
_ *playlist.PlaylistAPIBuilder,
|
||||
_ *example.TestingAPIBuilder,
|
||||
_ *dashboardsnapshot.SnapshotsAPIBuilder,
|
||||
_ *featuretoggle.FeatureFlagAPIBuilder,
|
||||
_ *datasource.DataSourceAPIBuilder,
|
||||
_ *folders.FolderAPIBuilder,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package dashboardsnapshot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
)
|
||||
|
||||
func convertDTOToSnapshot(v *dashboardsnapshots.DashboardSnapshotDTO, namespacer request.NamespaceMapper) *dashboardsnapshot.DashboardSnapshot {
|
||||
expires := v.Expires.UnixMilli()
|
||||
if v.Expires.After(time.Date(2070, time.January, 0, 0, 0, 0, 0, time.UTC)) {
|
||||
expires = 0 // ignore things expiring long into the future
|
||||
}
|
||||
snap := &dashboardsnapshot.DashboardSnapshot{
|
||||
TypeMeta: resourceInfo.TypeMeta(),
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: v.Key,
|
||||
ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()),
|
||||
CreationTimestamp: metav1.NewTime(v.Created),
|
||||
Namespace: namespacer(v.OrgID),
|
||||
},
|
||||
Spec: dashboardsnapshot.SnapshotInfo{
|
||||
Title: v.Name,
|
||||
ExternalURL: v.ExternalURL,
|
||||
Expires: expires,
|
||||
},
|
||||
}
|
||||
if v.Updated != v.Created {
|
||||
meta, _ := utils.MetaAccessor(snap)
|
||||
meta.SetUpdatedTimestamp(&v.Updated)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func convertSnapshotToK8sResource(v *dashboardsnapshots.DashboardSnapshot, namespacer request.NamespaceMapper) *dashboardsnapshot.DashboardSnapshot {
|
||||
expires := v.Expires.UnixMilli()
|
||||
if v.Expires.After(time.Date(2070, time.January, 0, 0, 0, 0, 0, time.UTC)) {
|
||||
expires = 0 // ignore things expiring long into the future
|
||||
}
|
||||
|
||||
info := dashboardsnapshot.SnapshotInfo{
|
||||
Title: v.Name,
|
||||
ExternalURL: v.ExternalURL,
|
||||
Expires: expires,
|
||||
}
|
||||
s := v.Dashboard.Get("snapshot")
|
||||
if s != nil {
|
||||
info.OriginalUrl, _ = s.Get("originalUrl").String()
|
||||
info.Timestamp, _ = s.Get("timestamp").String()
|
||||
}
|
||||
snap := &dashboardsnapshot.DashboardSnapshot{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: v.Key,
|
||||
ResourceVersion: fmt.Sprintf("%d", v.Updated.UnixMilli()),
|
||||
CreationTimestamp: metav1.NewTime(v.Created),
|
||||
Namespace: namespacer(v.OrgID),
|
||||
},
|
||||
Spec: info,
|
||||
}
|
||||
if v.Updated != v.Created {
|
||||
meta, _ := utils.MetaAccessor(snap)
|
||||
meta.SetUpdatedTimestamp(&v.Updated)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package dashboardsnapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gocloud.dev/blob"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
)
|
||||
|
||||
type dashExportStatus struct {
|
||||
Count int
|
||||
Index int
|
||||
Started int64
|
||||
Updated int64
|
||||
Finished int64
|
||||
Error string
|
||||
}
|
||||
|
||||
type dashExporter struct {
|
||||
status dashExportStatus
|
||||
|
||||
service dashboardsnapshots.Service
|
||||
sql db.DB
|
||||
}
|
||||
|
||||
func (d *dashExporter) getAPIRouteHandler() builder.APIRouteHandler {
|
||||
return builder.APIRouteHandler{
|
||||
Path: "admin/export",
|
||||
Spec: &spec3.PathProps{
|
||||
Summary: "an example at the root level",
|
||||
Description: "longer description here?",
|
||||
Post: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: []string{"export"},
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
StatusCodeResponses: map[int]*spec3.Response{
|
||||
200: {
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only let it start once
|
||||
if d.status.Started == 0 {
|
||||
go d.doExport()
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
_ = json.NewEncoder(w).Encode(d.status)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NO way to stop!!!!!!
|
||||
func (d *dashExporter) doExport() {
|
||||
defer func() {
|
||||
d.status.Finished = time.Now().UnixMilli()
|
||||
}()
|
||||
d.status = dashExportStatus{
|
||||
Started: time.Now().UnixMilli(),
|
||||
}
|
||||
if d.sql == nil {
|
||||
d.status.Error = "missing dependencies"
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
keys := []string{}
|
||||
err := d.sql.GetSqlxSession().Select(ctx,
|
||||
&keys, "SELECT key FROM dashboard_snapshot ORDER BY id asc")
|
||||
if err != nil {
|
||||
d.status.Error = err.Error()
|
||||
return
|
||||
}
|
||||
d.status.Count = len(keys)
|
||||
|
||||
bucket, err := blob.OpenBucket(ctx, "mem://?key=foo.txt&prefix=a/subfolder/")
|
||||
if err != nil {
|
||||
d.status.Error = err.Error()
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = bucket.Close()
|
||||
}()
|
||||
|
||||
for idx, key := range keys {
|
||||
d.status.Index = idx
|
||||
snap, err := d.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{
|
||||
Key: key,
|
||||
})
|
||||
if err != nil {
|
||||
d.status.Error = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
dash, err := snap.Dashboard.ToDB()
|
||||
if err != nil {
|
||||
d.status.Error = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("TODO, export: %s (len: %d)\n", snap.Key, len(dash))
|
||||
|
||||
// w, err := bucket.NewWriter(ctx, "foo.txt", nil)
|
||||
// if err != nil {
|
||||
// d.status.Error = err.Error()
|
||||
// return
|
||||
// }
|
||||
|
||||
time.Sleep(time.Second * 1)
|
||||
d.status.Updated = time.Now().UnixMilli()
|
||||
}
|
||||
fmt.Printf("done!\n")
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package dashboardsnapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
var (
|
||||
_ rest.Scoper = (*optionsStorage)(nil)
|
||||
_ rest.SingularNameProvider = (*optionsStorage)(nil)
|
||||
_ rest.Getter = (*optionsStorage)(nil)
|
||||
_ rest.Lister = (*optionsStorage)(nil)
|
||||
_ rest.Storage = (*optionsStorage)(nil)
|
||||
)
|
||||
|
||||
type sharingOptionsGetter = func(namespace string) (*dashboardsnapshot.SharingOptions, error)
|
||||
|
||||
func newSharingOptionsGetter(cfg *setting.Cfg) sharingOptionsGetter {
|
||||
s := &dashboardsnapshot.SharingOptions{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
CreationTimestamp: metav1.Now(),
|
||||
},
|
||||
Spec: dashboardsnapshot.SnapshotSharingOptions{
|
||||
SnapshotsEnabled: cfg.SnapshotEnabled,
|
||||
ExternalSnapshotURL: cfg.ExternalSnapshotUrl,
|
||||
ExternalSnapshotName: cfg.ExternalSnapshotName,
|
||||
ExternalEnabled: cfg.ExternalEnabled,
|
||||
},
|
||||
}
|
||||
return func(namespace string) (*dashboardsnapshot.SharingOptions, error) {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
type optionsStorage struct {
|
||||
getter sharingOptionsGetter
|
||||
tableConverter rest.TableConvertor
|
||||
}
|
||||
|
||||
func (s *optionsStorage) New() runtime.Object {
|
||||
return &dashboardsnapshot.SharingOptions{}
|
||||
}
|
||||
|
||||
func (s *optionsStorage) Destroy() {}
|
||||
|
||||
func (s *optionsStorage) NamespaceScoped() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *optionsStorage) GetSingularName() string {
|
||||
return "options"
|
||||
}
|
||||
|
||||
func (s *optionsStorage) NewList() runtime.Object {
|
||||
return &dashboardsnapshot.SharingOptionsList{}
|
||||
}
|
||||
|
||||
func (s *optionsStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *optionsStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.OrgID < 0 {
|
||||
return nil, fmt.Errorf("missing namespace")
|
||||
}
|
||||
v, err := s.getter(info.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := &dashboardsnapshot.SharingOptionsList{
|
||||
Items: []dashboardsnapshot.SharingOptions{*v},
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *optionsStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
return s.getter(name)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package dashboardsnapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
common "k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/grafana/grafana/pkg/util/errutil/errhttp"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupBuilder = (*SnapshotsAPIBuilder)(nil)
|
||||
|
||||
var resourceInfo = dashboardsnapshot.DashboardSnapshotResourceInfo
|
||||
|
||||
// This is used just so wire has something unique to return
|
||||
type SnapshotsAPIBuilder struct {
|
||||
service dashboardsnapshots.Service
|
||||
namespacer request.NamespaceMapper
|
||||
options sharingOptionsGetter
|
||||
exporter *dashExporter
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func NewSnapshotsAPIBuilder(
|
||||
p dashboardsnapshots.Service,
|
||||
cfg *setting.Cfg,
|
||||
exporter *dashExporter,
|
||||
) *SnapshotsAPIBuilder {
|
||||
return &SnapshotsAPIBuilder{
|
||||
service: p,
|
||||
options: newSharingOptionsGetter(cfg),
|
||||
namespacer: request.GetNamespaceMapper(cfg),
|
||||
exporter: exporter,
|
||||
logger: log.New("snapshots::RawHandlers"),
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
service dashboardsnapshots.Service,
|
||||
apiregistration builder.APIRegistrar,
|
||||
cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles,
|
||||
sql db.DB,
|
||||
) *SnapshotsAPIBuilder {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
return nil // skip registration unless opting into experimental apis
|
||||
}
|
||||
builder := NewSnapshotsAPIBuilder(service, cfg, &dashExporter{
|
||||
service: service,
|
||||
sql: sql,
|
||||
})
|
||||
apiregistration.RegisterAPI(builder)
|
||||
return builder
|
||||
}
|
||||
|
||||
func (b *SnapshotsAPIBuilder) GetGroupVersion() schema.GroupVersion {
|
||||
return resourceInfo.GroupVersion()
|
||||
}
|
||||
|
||||
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
|
||||
scheme.AddKnownTypes(gv,
|
||||
&dashboardsnapshot.DashboardSnapshot{},
|
||||
&dashboardsnapshot.DashboardSnapshotList{},
|
||||
&dashboardsnapshot.SharingOptions{},
|
||||
&dashboardsnapshot.SharingOptionsList{},
|
||||
&dashboardsnapshot.FullDashboardSnapshot{},
|
||||
&dashboardsnapshot.DashboardSnapshotWithDeleteKey{},
|
||||
&metav1.Status{},
|
||||
)
|
||||
}
|
||||
|
||||
func (b *SnapshotsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
gv := resourceInfo.GroupVersion()
|
||||
addKnownTypes(scheme, gv)
|
||||
|
||||
// Link this version to the internal representation.
|
||||
// This is used for server-side-apply (PATCH), and avoids the error:
|
||||
// "no kind is registered for the type"
|
||||
addKnownTypes(scheme, schema.GroupVersion{
|
||||
Group: gv.Group,
|
||||
Version: runtime.APIVersionInternal,
|
||||
})
|
||||
|
||||
// If multiple versions exist, then register conversions from zz_generated.conversion.go
|
||||
// if err := playlist.RegisterConversions(scheme); err != nil {
|
||||
// return err
|
||||
// }
|
||||
metav1.AddToGroupVersion(scheme, gv)
|
||||
return scheme.SetVersionPriority(gv)
|
||||
}
|
||||
|
||||
func (b *SnapshotsAPIBuilder) GetAPIGroupInfo(
|
||||
scheme *runtime.Scheme,
|
||||
codecs serializer.CodecFactory, // pointer?
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
dualWrite bool,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(dashboardsnapshot.GROUP, scheme, metav1.ParameterCodec, codecs)
|
||||
storage := map[string]rest.Storage{}
|
||||
|
||||
legacyStore := &legacyStorage{
|
||||
service: b.service,
|
||||
namespacer: b.namespacer,
|
||||
options: b.options,
|
||||
}
|
||||
legacyStore.tableConverter = utils.NewTableConverter(
|
||||
resourceInfo.GroupResource(),
|
||||
[]metav1.TableColumnDefinition{
|
||||
{Name: "Name", Type: "string", Format: "name"},
|
||||
{Name: "Title", Type: "string", Format: "string", Description: "The snapshot name"},
|
||||
{Name: "Created At", Type: "date"},
|
||||
},
|
||||
func(obj any) ([]interface{}, error) {
|
||||
m, ok := obj.(*dashboardsnapshot.DashboardSnapshot)
|
||||
if ok {
|
||||
return []interface{}{
|
||||
m.Name,
|
||||
m.Spec.Title,
|
||||
m.CreationTimestamp.UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("expected snapshot")
|
||||
},
|
||||
)
|
||||
storage[resourceInfo.StoragePath()] = legacyStore
|
||||
storage[resourceInfo.StoragePath("body")] = &subBodyREST{
|
||||
service: b.service,
|
||||
namespacer: b.namespacer,
|
||||
}
|
||||
|
||||
storage["options"] = &optionsStorage{
|
||||
getter: b.options,
|
||||
tableConverter: legacyStore.tableConverter,
|
||||
}
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[dashboardsnapshot.VERSION] = storage
|
||||
return &apiGroupInfo, nil
|
||||
}
|
||||
|
||||
func (b *SnapshotsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||
return dashboardsnapshot.GetOpenAPIDefinitions
|
||||
}
|
||||
|
||||
// Register additional routes with the server
|
||||
func (b *SnapshotsAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
|
||||
prefix := dashboardsnapshot.DashboardSnapshotResourceInfo.GroupResource().Resource
|
||||
defs := dashboardsnapshot.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} })
|
||||
createCmd := defs["github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateCommand"].Schema
|
||||
createExample := `{"dashboard":{"annotations":{"list":[{"name":"Annotations & Alerts","enable":true,"iconColor":"rgba(0, 211, 255, 1)","snapshotData":[],"type":"dashboard","builtIn":1,"hide":true}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":203,"links":[],"liveNow":false,"panels":[{"datasource":null,"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":0},"id":1,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"10.4.0-pre","snapshotData":[{"fields":[{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"showPoints":"auto","thresholdsStyle":{"mode":"off"}},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"time","type":"time","values":[1706030536378,1706034856378,1706039176378,1706043496378,1706047816378,1706052136378]},{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"A-series","type":"number","values":[1,20,90,30,50,0]}],"refId":"A"}],"targets":[],"title":"Simple example","type":"timeseries","links":[]}],"refresh":"","schemaVersion":39,"snapshot":{"timestamp":"2024-01-23T23:22:16.377Z"},"tags":[],"templating":{"list":[]},"time":{"from":"2024-01-23T17:22:20.380Z","to":"2024-01-23T23:22:20.380Z","raw":{"from":"now-6h","to":"now"}},"timepicker":{},"timezone":"","title":"simple and small","uid":"b22ec8db-399b-403b-b6c7-b0fb30ccb2a5","version":1,"weekStart":""},"name":"simple and small","expires":86400}`
|
||||
createRsp := defs["github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1.DashboardCreateResponse"].Schema
|
||||
|
||||
tags := []string{dashboardsnapshot.DashboardSnapshotResourceInfo.GroupVersionKind().Kind}
|
||||
routes := &builder.APIRoutes{
|
||||
Namespace: []builder.APIRouteHandler{
|
||||
{
|
||||
Path: prefix + "/create",
|
||||
Spec: &spec3.PathProps{
|
||||
Summary: "an example at the root level",
|
||||
Description: "longer description here?",
|
||||
Post: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: tags,
|
||||
Parameters: []*spec3.Parameter{
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "namespace",
|
||||
In: "path",
|
||||
Required: true,
|
||||
Example: "default",
|
||||
Description: "workspace",
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
},
|
||||
RequestBody: &spec3.RequestBody{
|
||||
RequestBodyProps: spec3.RequestBodyProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &createCmd,
|
||||
Example: createExample, // raw JSON body
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
StatusCodeResponses: map[int]*spec3.Response{
|
||||
200: {
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &createRsp,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := appcontext.User(r.Context())
|
||||
if err != nil {
|
||||
errhttp.Write(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
wrap := &contextmodel.ReqContext{
|
||||
Logger: b.logger,
|
||||
Context: &web.Context{
|
||||
Req: r,
|
||||
Resp: web.NewResponseWriter(r.Method, w),
|
||||
},
|
||||
SignedInUser: user,
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
info, err := request.ParseNamespace(vars["namespace"])
|
||||
if err != nil {
|
||||
wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil)
|
||||
return
|
||||
}
|
||||
if info.OrgID != user.OrgID {
|
||||
wrap.JsonApiErr(http.StatusBadRequest,
|
||||
fmt.Sprintf("user orgId does not match namespace (%d != %d)", info.OrgID, user.OrgID), nil)
|
||||
return
|
||||
}
|
||||
opts, err := b.options(info.Value)
|
||||
if err != nil {
|
||||
wrap.JsonApiErr(http.StatusBadRequest, "error getting options", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Use the existing snapshot service
|
||||
dashboardsnapshots.CreateDashboardSnapshot(wrap, opts.Spec, b.service)
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: prefix + "/delete/{deleteKey}",
|
||||
Spec: &spec3.PathProps{
|
||||
Summary: "an example at the root level",
|
||||
Description: "longer description here?",
|
||||
Delete: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: tags,
|
||||
Parameters: []*spec3.Parameter{
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "deleteKey",
|
||||
In: "path",
|
||||
Required: true,
|
||||
Description: "unique key returned in create",
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
vars := mux.Vars(r)
|
||||
key := vars["deleteKey"]
|
||||
|
||||
err := dashboardsnapshots.DeleteWithKey(ctx, key, b.service)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, fmt.Errorf("failed to delete external dashboard (%w)", err), w)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(&util.DynMap{
|
||||
"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.",
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// dev environment to export all snapshots to a blob store
|
||||
if b.exporter != nil && false {
|
||||
routes.Root = append(routes.Root, b.exporter.getAPIRouteHandler())
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
func (b *SnapshotsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
// TODO: this behavior must match the existing logic (it is currently more restrictive)
|
||||
//
|
||||
// https://github.com/grafana/grafana/blob/f63e43c113ac0cf8f78ed96ee2953874139bd2dc/pkg/middleware/auth.go#L203
|
||||
// func SnapshotPublicModeOrSignedIn(cfg *setting.Cfg) web.Handler {
|
||||
// return func(c *contextmodel.ReqContext) {
|
||||
// if cfg.SnapshotPublicMode {
|
||||
// return
|
||||
// }
|
||||
|
||||
// if !c.IsSignedIn {
|
||||
// notAuthorized(c)
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
return authorizer.AuthorizerFunc(
|
||||
func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
|
||||
// Everyone can view dashsnaps
|
||||
if attr.GetVerb() == "get" && attr.GetResource() == dashboardsnapshot.DashboardSnapshotResourceInfo.GroupResource().Resource {
|
||||
return authorizer.DecisionAllow, "", err
|
||||
}
|
||||
|
||||
// Fallback to the default behaviors (namespace matches org)
|
||||
return authorizer.DecisionNoOpinion, "", err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package dashboardsnapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
)
|
||||
|
||||
var (
|
||||
_ rest.Scoper = (*legacyStorage)(nil)
|
||||
_ rest.SingularNameProvider = (*legacyStorage)(nil)
|
||||
_ rest.Getter = (*legacyStorage)(nil)
|
||||
_ rest.Lister = (*legacyStorage)(nil)
|
||||
_ rest.Storage = (*legacyStorage)(nil)
|
||||
_ rest.GracefulDeleter = (*legacyStorage)(nil)
|
||||
)
|
||||
|
||||
type legacyStorage struct {
|
||||
service dashboardsnapshots.Service
|
||||
namespacer request.NamespaceMapper
|
||||
tableConverter rest.TableConvertor
|
||||
options sharingOptionsGetter
|
||||
}
|
||||
|
||||
func (s *legacyStorage) New() runtime.Object {
|
||||
return resourceInfo.NewFunc()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Destroy() {}
|
||||
|
||||
func (s *legacyStorage) NamespaceScoped() bool {
|
||||
return true // namespace == org
|
||||
}
|
||||
|
||||
func (s *legacyStorage) GetSingularName() string {
|
||||
return resourceInfo.GetSingularName()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) NewList() runtime.Object {
|
||||
return resourceInfo.NewListFunc()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) checkEnabled(ns string) error {
|
||||
opts, err := s.options(ns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !opts.Spec.SnapshotsEnabled {
|
||||
return fmt.Errorf("snapshots not enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err == nil {
|
||||
err = s.checkEnabled(info.Value)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := appcontext.User(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limit := 5000
|
||||
if options.Limit > 0 {
|
||||
limit = int(options.Limit)
|
||||
}
|
||||
res, err := s.service.SearchDashboardSnapshots(ctx, &dashboardsnapshots.GetDashboardSnapshotsQuery{
|
||||
OrgID: info.OrgID,
|
||||
SignedInUser: user,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := &dashboardsnapshot.DashboardSnapshotList{}
|
||||
for _, v := range res {
|
||||
list.Items = append(list.Items, *convertDTOToSnapshot(v, s.namespacer))
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err == nil {
|
||||
err = s.checkEnabled(info.Value)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v, err := s.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{
|
||||
Key: name,
|
||||
})
|
||||
if err != nil || v == nil {
|
||||
// if errors.Is(err, playlistsvc.ErrPlaylistNotFound) || err == nil {
|
||||
// err = k8serrors.NewNotFound(s.SingularQualifiedResource, name)
|
||||
// }
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return convertSnapshotToK8sResource(v, s.namespacer), nil
|
||||
}
|
||||
|
||||
// GracefulDeleter
|
||||
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
snap, err := s.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{
|
||||
Key: name,
|
||||
})
|
||||
if err != nil || snap == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// Delete the external one first
|
||||
if snap.ExternalDeleteURL != "" {
|
||||
err := dashboardsnapshots.DeleteExternalDashboardSnapshot(snap.ExternalDeleteURL)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
err = s.service.DeleteDashboardSnapshot(ctx, &dashboardsnapshots.DeleteDashboardSnapshotCommand{
|
||||
DeleteKey: snap.DeleteKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, true, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dashboardsnapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
|
||||
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
)
|
||||
|
||||
type subBodyREST struct {
|
||||
service dashboardsnapshots.Service
|
||||
namespacer request.NamespaceMapper
|
||||
}
|
||||
|
||||
var _ = rest.Connecter(&subBodyREST{})
|
||||
|
||||
func (r *subBodyREST) New() runtime.Object {
|
||||
return &dashboardsnapshot.FullDashboardSnapshot{}
|
||||
}
|
||||
|
||||
func (r *subBodyREST) Destroy() {}
|
||||
|
||||
func (r *subBodyREST) ConnectMethods() []string {
|
||||
return []string{"GET"}
|
||||
}
|
||||
|
||||
func (r *subBodyREST) NewConnectOptions() (runtime.Object, bool, string) {
|
||||
return nil, false, ""
|
||||
}
|
||||
|
||||
func (r *subBodyREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
snap, err := r.service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{
|
||||
Key: name,
|
||||
})
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := snap.Dashboard.Map()
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
r := convertSnapshotToK8sResource(snap, r.namespacer)
|
||||
responder.Object(200, &dashboardsnapshot.FullDashboardSnapshot{
|
||||
ObjectMeta: r.ObjectMeta,
|
||||
Info: r.Spec,
|
||||
Dashboard: common.Unstructured{Object: data},
|
||||
})
|
||||
}), nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/google/wire"
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/datasource"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/example"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/featuretoggle"
|
||||
@@ -26,6 +27,7 @@ var WireSet = wire.NewSet(
|
||||
playlist.RegisterAPIService,
|
||||
dashboard.RegisterAPIService,
|
||||
example.RegisterAPIService,
|
||||
dashboardsnapshot.RegisterAPIService,
|
||||
featuretoggle.RegisterAPIService,
|
||||
datasource.RegisterAPIService,
|
||||
folders.RegisterAPIService,
|
||||
|
||||
Reference in New Issue
Block a user