diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index eed79dd6f0d..4bf2c31a812 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -122,6 +122,7 @@ type DashboardsAPIBuilder struct { publicDashboardService publicdashboards.Service snapshotService dashboardsnapshots.Service snapshotOptions dashv0.SnapshotSharingOptions + snapshotStorage rest.Storage // for dual-write support in routes namespacer request.NamespaceMapper dashboardActivityChannel live.DashboardActivityChannel isStandalone bool // skips any handling including anything to do with legacy storage @@ -747,15 +748,26 @@ func (b *DashboardsAPIBuilder) storageForVersion( } } - // Legacy only (for now) and only v0alpha1 + // Snapshots - only v0alpha1 if snapshots != nil && dashboards.GroupVersion().Version == "v0alpha1" { snapshotLegacyStore := &snapshot.SnapshotLegacyStore{ ResourceInfo: *snapshots, Service: b.snapshotService, Namespacer: b.namespacer, } - storage[snapshots.StoragePath()] = snapshotLegacyStore - storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(dashboards, b.snapshotService) + + unifiedSnapshotStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, *snapshots, opts.OptsGetter) + if err != nil { + return err + } + snapshotGr := snapshots.GroupResource() + snapshotDualWrite, err := opts.DualWriteBuilder(snapshotGr, snapshotLegacyStore, unifiedSnapshotStore) + if err != nil { + return err + } + storage[snapshots.StoragePath()] = snapshotDualWrite + b.snapshotStorage = snapshotDualWrite // store for use in routes + storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(snapshotDualWrite) if err != nil { return err } @@ -979,7 +991,9 @@ func (b *DashboardsAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.API defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} }) searchAPIRoutes := b.search.GetAPIRoutes(defs) - snapshotAPIRoutes := snapshot.GetRoutes(b.snapshotService, b.snapshotOptions, defs) + snapshotAPIRoutes := snapshot.GetRoutes(b.snapshotService, b.snapshotOptions, defs, func() rest.Storage { + return b.snapshotStorage + }) return &builder.APIRoutes{ Namespace: append(searchAPIRoutes.Namespace, snapshotAPIRoutes.Namespace...), diff --git a/pkg/registry/apis/dashboard/snapshot/conversions.go b/pkg/registry/apis/dashboard/snapshot/conversions.go index 3ec48a61614..53c35e06b67 100644 --- a/pkg/registry/apis/dashboard/snapshot/conversions.go +++ b/pkg/registry/apis/dashboard/snapshot/conversions.go @@ -7,6 +7,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" dashV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" @@ -81,3 +82,68 @@ func convertSnapshotToK8sResource(v *dashboardsnapshots.DashboardSnapshot, names } return snap } + +// convertK8sResourceToCreateCommand converts a K8s Snapshot to a CreateDashboardSnapshotCommand +func convertK8sResourceToCreateCommand(snap *dashV0.Snapshot, orgID int64, userID int64) *dashboardsnapshots.CreateDashboardSnapshotCommand { + cmd := &dashboardsnapshots.CreateDashboardSnapshotCommand{ + OrgID: orgID, + UserID: userID, + } + + // Map title + if snap.Spec.Title != nil { + cmd.Name = *snap.Spec.Title + } + + // Map dashboard (convert map[string]interface{} to *common.Unstructured) + if snap.Spec.Dashboard != nil { + cmd.Dashboard = &common.Unstructured{Object: snap.Spec.Dashboard} + } + + // Map expires + if snap.Spec.Expires != nil { + cmd.Expires = *snap.Spec.Expires + } + + // Map external settings + if snap.Spec.External != nil && *snap.Spec.External { + cmd.External = true + if snap.Spec.ExternalUrl != nil { + cmd.ExternalURL = *snap.Spec.ExternalUrl + } + } + + return cmd +} + +// convertCreateCmdToK8sSnapshot converts a CreateDashboardSnapshotCommand request to a K8s Snapshot +// Used by routes.go to create a Snapshot object from the incoming create command +func convertCreateCmdToK8sSnapshot(cmd *dashboardsnapshots.CreateDashboardSnapshotCommand, namespace string) *dashV0.Snapshot { + snap := &dashV0.Snapshot{ + TypeMeta: dashV0.SnapshotResourceInfo.TypeMeta(), + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + }, + Spec: dashV0.SnapshotSpec{ + Title: &cmd.Name, + }, + } + + // Convert *common.Unstructured to map[string]interface{} + if cmd.Dashboard != nil { + snap.Spec.Dashboard = cmd.Dashboard.Object + } + + if cmd.Expires > 0 { + snap.Spec.Expires = &cmd.Expires + } + + if cmd.External { + snap.Spec.External = &cmd.External + if cmd.ExternalURL != "" { + snap.Spec.ExternalUrl = &cmd.ExternalURL + } + } + + return snap +} diff --git a/pkg/registry/apis/dashboard/snapshot/routes.go b/pkg/registry/apis/dashboard/snapshot/routes.go index 832589f5c68..daafe97a205 100644 --- a/pkg/registry/apis/dashboard/snapshot/routes.go +++ b/pkg/registry/apis/dashboard/snapshot/routes.go @@ -6,7 +6,10 @@ import ( "net/http" "github.com/gorilla/mux" + "github.com/grafana/grafana/pkg/setting" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8srequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/registry/rest" "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" "k8s.io/kube-openapi/pkg/validation/spec" @@ -14,6 +17,7 @@ import ( authlib "github.com/grafana/authlib/types" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/services/apiserver/builder" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" @@ -22,7 +26,7 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharingOptions, defs map[string]common.OpenAPIDefinition) *builder.APIRoutes { +func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharingOptions, defs map[string]common.OpenAPIDefinition, storageGetter func() rest.Storage) *builder.APIRoutes { prefix := dashv0.SnapshotResourceInfo.GroupResource().Resource tags := []string{dashv0.SnapshotResourceInfo.GroupVersionKind().Kind} @@ -97,9 +101,10 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin }, }, Handler: func(w http.ResponseWriter, r *http.Request) { - user, err := identity.GetRequester(r.Context()) + ctx := r.Context() + user, err := identity.GetRequester(ctx) if err != nil { - errhttp.Write(r.Context(), err, w) + errhttp.Write(ctx, err, w) return } wrap := &contextmodel.ReqContext{ @@ -107,11 +112,15 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin Req: r, Resp: web.NewResponseWriter(r.Method, w), }, - // SignedInUser: user, ???????????? } + if !options.SnapshotsEnabled { + wrap.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) + return + } vars := mux.Vars(r) - info, err := authlib.ParseNamespace(vars["namespace"]) + namespace := vars["namespace"] + info, err := authlib.ParseNamespace(namespace) if err != nil { wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil) return @@ -128,8 +137,82 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin return } - // Use the existing snapshot service - dashboardsnapshots.CreateDashboardSnapshot(wrap, options, cmd, service) + if cmd.External && !options.ExternalEnabled { + wrap.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) + return + } + + // fill cmd data + if cmd.Name == "" { + cmd.Name = "Unnamed snapshot" + } + cmd.OrgID = user.GetOrgID() + cmd.UserID, _ = identity.UserIdentifier(user.GetID()) + + //originalDashboardURL, err := dashboardsnapshots.CreateOriginalDashboardURL(&cmd) + + // TODO: add logic for external and internal snapshots + if cmd.External { + // TODO: if it is an external dashboard make a POST to the public snapshot server + } else { + + } + + // TODO: validate dashboard exists. Need to call dashboards api, Maybe in a validation hook? + + storage := storageGetter() + if storage == nil { + errhttp.Write(ctx, fmt.Errorf("snapshot storage not available"), w) + return + } + creater, ok := storage.(rest.Creater) + if !ok { + errhttp.Write(ctx, fmt.Errorf("snapshot storage does not support create"), w) + return + } + + // Convert command to K8s Snapshot + snapshot := convertCreateCmdToK8sSnapshot(&cmd, namespace) + + snapshot.SetGenerateName("snapshot-") + + // Set namespace in context for k8s storage layer + ctx = k8srequest.WithNamespace(ctx, namespace) + + // Create via storage (dual-write mode decides legacy, unified, or both) + result, err := creater.Create(ctx, snapshot, nil, &metav1.CreateOptions{}) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + // Extract key and deleteKey from result + accessor, err := utils.MetaAccessor(result) + if err != nil { + errhttp.Write(ctx, fmt.Errorf("failed to access result metadata: %w", err), w) + return + } + + deleteKey, err := util.GetRandomString(32) + if err != nil { + errhttp.Write(ctx, fmt.Errorf("failed to generate delete key: %w", err), w) + } + + key := accessor.GetName() + //deleteKey := "" + //if annotations := accessor.GetAnnotations(); annotations != nil { + // deleteKey = annotations["grafana.app/delete-key"] + //} + + // Build response + response := dashv0.DashboardCreateResponse{ + Key: key, + DeleteKey: deleteKey, + URL: setting.ToAbsUrl("dashboard/snapshot/" + key), + DeleteURL: setting.ToAbsUrl("api/snapshots-delete/" + deleteKey), + } + + wrap.JSON(http.StatusOK, response) }, }, { diff --git a/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go b/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go index 7ba2d4228c5..7bfc2373834 100644 --- a/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go +++ b/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go @@ -2,6 +2,7 @@ package snapshot import ( "context" + "fmt" "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -20,7 +21,10 @@ var ( _ rest.SingularNameProvider = (*SnapshotLegacyStore)(nil) _ rest.Getter = (*SnapshotLegacyStore)(nil) _ rest.Lister = (*SnapshotLegacyStore)(nil) + _ rest.Creater = (*SnapshotLegacyStore)(nil) + _ rest.Updater = (*SnapshotLegacyStore)(nil) _ rest.GracefulDeleter = (*SnapshotLegacyStore)(nil) + _ rest.CollectionDeleter = (*SnapshotLegacyStore)(nil) _ rest.Storage = (*SnapshotLegacyStore)(nil) ) @@ -129,3 +133,51 @@ func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *met } return nil, s.ResourceInfo.NewNotFound(name) } + +// Create implements rest.Creater +func (s *SnapshotLegacyStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + snap, ok := obj.(*dashV0.Snapshot) + if !ok { + return nil, fmt.Errorf("expected Snapshot object, got %T", obj) + } + + // Run validation if provided + if createValidation != nil { + if err := createValidation(ctx, obj); err != nil { + return nil, err + } + } + + // Get user identity from context + requester, err := identity.GetRequester(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get requester: %w", err) + } + + userID, err := requester.GetInternalID() + if err != nil { + return nil, fmt.Errorf("failed to get user ID: %w", err) + } + + // Convert K8s resource to service command + cmd := convertK8sResourceToCreateCommand(snap, requester.GetOrgID(), userID) + + // Create the snapshot via service + result, err := s.Service.CreateDashboardSnapshot(ctx, cmd) + if err != nil { + return nil, err + } + + // Convert result back to K8s resource + return convertSnapshotToK8sResource(result, s.Namespacer), nil +} + +// Update implements rest.Updater - snapshots are immutable, so this returns an error +func (s *SnapshotLegacyStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + return nil, false, fmt.Errorf("snapshots are immutable and cannot be updated") +} + +// DeleteCollection implements rest.CollectionDeleter +func (s *SnapshotLegacyStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + return nil, fmt.Errorf("delete collection is not supported for snapshots") +} diff --git a/pkg/registry/apis/dashboard/snapshot/sub_dashboard.go b/pkg/registry/apis/dashboard/snapshot/sub_dashboard.go index b43159939a4..d944cf02cc0 100644 --- a/pkg/registry/apis/dashboard/snapshot/sub_dashboard.go +++ b/pkg/registry/apis/dashboard/snapshot/sub_dashboard.go @@ -2,6 +2,7 @@ package snapshot import ( "context" + "fmt" "net/http" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -10,22 +11,19 @@ import ( dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/services/dashboardsnapshots" ) // Currently only works with v0alpha1 type dashboardREST struct { - Service dashboardsnapshots.Service + getter rest.Getter } func NewDashboardREST( - resourceInfo utils.ResourceInfo, - service dashboardsnapshots.Service, + getter rest.Getter, ) (rest.Storage, error) { return &dashboardREST{ - Service: service, + getter: getter, }, nil } @@ -58,22 +56,30 @@ func (r *dashboardREST) ProducesObject(verb string) interface{} { } func (r *dashboardREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { - _, err := request.NamespaceInfoFrom(ctx, true) + ns, err := request.NamespaceInfoFrom(ctx, true) if err != nil { return nil, err } - snap, err := r.Service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{Key: name}) + + // Get the snapshot from unified storage + obj, err := r.getter.Get(ctx, name, &metav1.GetOptions{}) if err != nil { return nil, err } + + snap, ok := obj.(*dashv0.Snapshot) + if !ok { + return nil, fmt.Errorf("expected Snapshot, got %T", obj) + } + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { // TODO... support conversions (not required in v0) dash := &dashv0.Dashboard{ ObjectMeta: metav1.ObjectMeta{ - Namespace: name, + Namespace: ns.Value, }, Spec: v0alpha1.Unstructured{ - Object: snap.Dashboard.MustMap(), + Object: snap.Spec.Dashboard, }, } responder.Object(200, dash) diff --git a/pkg/services/dashboardsnapshots/service.go b/pkg/services/dashboardsnapshots/service.go index 281afee38ac..819bbd4995e 100644 --- a/pkg/services/dashboardsnapshots/service.go +++ b/pkg/services/dashboardsnapshots/service.go @@ -37,10 +37,15 @@ var client = &http.Client{ } func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSharingOptions, cmd CreateDashboardSnapshotCommand, svc Service) { + // perform all validations in the beginning if !cfg.SnapshotsEnabled { c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) return } + if cmd.External && !cfg.ExternalEnabled { + c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) + return + } uid := cmd.Dashboard.GetNestedString("uid") user, err := identity.GetRequester(c.Req.Context()) @@ -67,17 +72,17 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh cmd.ExternalURL = "" cmd.OrgID = user.GetOrgID() cmd.UserID, _ = identity.UserIdentifier(user.GetID()) - originalDashboardURL, err := createOriginalDashboardURL(&cmd) + 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 - } + //if !cfg.ExternalEnabled { + // c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) + // return + //} resp, err := createExternalDashboardSnapshot(cmd, cfg.ExternalSnapshotURL) if err != nil { @@ -203,7 +208,7 @@ func createExternalDashboardSnapshot(cmd CreateDashboardSnapshotCommand, externa return &createSnapshotResponse, nil } -func createOriginalDashboardURL(cmd *CreateDashboardSnapshotCommand) (string, error) { +func CreateOriginalDashboardURL(cmd *CreateDashboardSnapshotCommand) (string, error) { dashUID := cmd.Dashboard.GetNestedString("uid") if ok := util.IsValidShortUID(dashUID); !ok { return "", fmt.Errorf("invalid dashboard UID") diff --git a/public/app/features/dashboard/services/SnapshotSrv.ts b/public/app/features/dashboard/services/SnapshotSrv.ts index ba74866499a..0de09f5798a 100644 --- a/public/app/features/dashboard/services/SnapshotSrv.ts +++ b/public/app/features/dashboard/services/SnapshotSrv.ts @@ -1,8 +1,8 @@ -import { lastValueFrom, map } from 'rxjs'; +import { lastValueFrom } from 'rxjs'; -import { config, getBackendSrv, FetchResponse } from '@grafana/runtime'; +import { config, getBackendSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; -import { DashboardDTO, SnapshotSpec } from 'app/types/dashboard'; +import { DashboardDataDTO, DashboardDTO } from 'app/types/dashboard'; import { getAPINamespace } from '../../../api/utils'; @@ -82,11 +82,12 @@ interface DashboardSnapshotList { items: K8sSnapshotResource[]; } -interface K8sDashboardSnapshot { +// Response from the /dashboard subresource - returns a Dashboard with raw dashboard data in spec +interface K8sDashboardSubresource { apiVersion: string; - kind: 'Snapshot'; + kind: 'Dashboard'; metadata: K8sMetadata; - spec: SnapshotSpec; + spec: DashboardDataDTO; } class K8sAPI implements DashboardSnapshotSrv { @@ -128,32 +129,45 @@ class K8sAPI implements DashboardSnapshotSrv { const token = `??? TODO, get anon token for snapshots (${contextSrv.user?.name}) ???`; headers['Authorization'] = `Bearer ${token}`; } - return lastValueFrom( - getBackendSrv() - .fetch({ + + // Fetch both snapshot metadata and dashboard content in parallel + const [snapshotResponse, dashboardResponse] = await Promise.all([ + lastValueFrom( + getBackendSrv().fetch({ url: this.url + '/' + uid, method: 'GET', headers: headers, }) - .pipe( - map((response: FetchResponse) => { - return { - dashboard: response.data.spec.dashboard, - meta: { - isSnapshot: true, - canSave: false, - canEdit: false, - canAdmin: false, - canStar: false, - canShare: false, - canDelete: false, - isFolder: false, - provisioned: false, - }, - }; - }) - ) - ); + ), + lastValueFrom( + getBackendSrv().fetch({ + url: this.url + '/' + uid + '/dashboard', + method: 'GET', + headers: headers, + }) + ), + ]); + + const snapshot = snapshotResponse.data; + const dashboard = dashboardResponse.data; + + return { + dashboard: dashboard.spec, + meta: { + isSnapshot: true, + canSave: false, + canEdit: false, + canAdmin: false, + canStar: false, + canShare: false, + canDelete: false, + isFolder: false, + provisioned: false, + created: snapshot.metadata.creationTimestamp, + expires: snapshot.spec.expires?.toString(), + k8s: snapshot.metadata, + }, + }; } }