diff --git a/Gopkg.lock b/Gopkg.lock index 235a315f1e8..854febe01e4 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -885,6 +885,7 @@ "github.com/aws/aws-sdk-go/service/sts", "github.com/benbjohnson/clock", "github.com/bmizerany/assert", + "github.com/bradfitz/gomemcache/memcache", "github.com/codegangsta/cli", "github.com/davecgh/go-spew/spew", "github.com/denisenkom/go-mssqldb", @@ -937,6 +938,7 @@ "gopkg.in/ldap.v3", "gopkg.in/macaron.v1", "gopkg.in/mail.v2", + "gopkg.in/redis.v2", "gopkg.in/square/go-jose.v2", "gopkg.in/yaml.v2", ] diff --git a/Gopkg.toml b/Gopkg.toml index d1bc0f55bae..95eb0a40a23 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -215,3 +215,7 @@ ignored = [ [[constraint]] name = "gopkg.in/ldap.v3" version = "3.0.0" + +[[constraint]] + name = "github.com/pkg/errors" + version = "0.8.0" diff --git a/pkg/api/api.go b/pkg/api/api.go index 6c60a6757e8..2313a5d1a3b 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -287,7 +287,7 @@ func (hs *HTTPServer) registerRoutes() { dashboardRoute.Delete("/uid/:uid", Wrap(DeleteDashboardByUID)) dashboardRoute.Get("/db/:slug", Wrap(GetDashboard)) - dashboardRoute.Delete("/db/:slug", Wrap(DeleteDashboard)) + dashboardRoute.Delete("/db/:slug", Wrap(DeleteDashboardBySlug)) dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), Wrap(CalculateDashboardDiff)) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index c47e8f31ccc..ca9221f1f59 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -153,7 +153,7 @@ func getDashboardHelper(orgID int64, slug string, id int64, uid string) (*m.Dash return query.Result, nil } -func DeleteDashboard(c *m.ReqContext) Response { +func DeleteDashboardBySlug(c *m.ReqContext) Response { query := m.GetDashboardsBySlugQuery{OrgId: c.OrgId, Slug: c.Params(":slug")} if err := bus.Dispatch(&query); err != nil { @@ -164,29 +164,15 @@ func DeleteDashboard(c *m.ReqContext) Response { return JSON(412, util.DynMap{"status": "multiple-slugs-exists", "message": m.ErrDashboardsWithSameSlugExists.Error()}) } - dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, "") - if rsp != nil { - return rsp - } - - guardian := guardian.New(dash.Id, c.OrgId, c.SignedInUser) - if canSave, err := guardian.CanSave(); err != nil || !canSave { - return dashboardGuardianResponse(err) - } - - cmd := m.DeleteDashboardCommand{OrgId: c.OrgId, Id: dash.Id} - if err := bus.Dispatch(&cmd); err != nil { - return Error(500, "Failed to delete dashboard", err) - } - - return JSON(200, util.DynMap{ - "title": dash.Title, - "message": fmt.Sprintf("Dashboard %s deleted", dash.Title), - }) + return deleteDashboard(c) } func DeleteDashboardByUID(c *m.ReqContext) Response { - dash, rsp := getDashboardHelper(c.OrgId, "", 0, c.Params(":uid")) + return deleteDashboard(c) +} + +func deleteDashboard(c *m.ReqContext) Response { + dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, c.Params(":uid")) if rsp != nil { return rsp } @@ -196,8 +182,10 @@ func DeleteDashboardByUID(c *m.ReqContext) Response { return dashboardGuardianResponse(err) } - cmd := m.DeleteDashboardCommand{OrgId: c.OrgId, Id: dash.Id} - if err := bus.Dispatch(&cmd); err != nil { + err := dashboards.NewService().DeleteDashboard(dash.Id, c.OrgId) + if err == m.ErrDashboardCannotDeleteProvisionedDashboard { + return Error(400, "Dashboard cannot be deleted because it was provisioned", err) + } else if err != nil { return Error(500, "Failed to delete dashboard", err) } diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index ea69c049115..ec6c404e641 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -102,7 +102,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { - CallDeleteDashboard(sc) + CallDeleteDashboardBySlug(sc) So(sc.resp.Code, ShouldEqual, 403) Convey("Should lookup dashboard by slug", func() { @@ -162,7 +162,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { - CallDeleteDashboard(sc) + CallDeleteDashboardBySlug(sc) So(sc.resp.Code, ShouldEqual, 200) Convey("Should lookup dashboard by slug", func() { @@ -273,7 +273,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { - CallDeleteDashboard(sc) + CallDeleteDashboardBySlug(sc) So(sc.resp.Code, ShouldEqual, 403) Convey("Should lookup dashboard by slug", func() { @@ -331,7 +331,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { - CallDeleteDashboard(sc) + CallDeleteDashboardBySlug(sc) So(sc.resp.Code, ShouldEqual, 403) Convey("Should lookup dashboard by slug", func() { @@ -400,7 +400,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { - CallDeleteDashboard(sc) + CallDeleteDashboardBySlug(sc) So(sc.resp.Code, ShouldEqual, 200) Convey("Should lookup dashboard by slug", func() { @@ -470,7 +470,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { - CallDeleteDashboard(sc) + CallDeleteDashboardBySlug(sc) So(sc.resp.Code, ShouldEqual, 403) Convey("Should lookup dashboard by slug", func() { @@ -529,7 +529,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { - CallDeleteDashboard(sc) + CallDeleteDashboardBySlug(sc) So(sc.resp.Code, ShouldEqual, 200) Convey("Should lookup dashboard by slug", func() { @@ -596,7 +596,7 @@ func TestDashboardApiEndpoint(t *testing.T) { }) loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { - CallDeleteDashboard(sc) + CallDeleteDashboardBySlug(sc) So(sc.resp.Code, ShouldEqual, 403) Convey("Should lookup dashboard by slug", func() { @@ -650,7 +650,7 @@ func TestDashboardApiEndpoint(t *testing.T) { role := m.ROLE_EDITOR loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { - CallDeleteDashboard(sc) + CallDeleteDashboardBySlug(sc) Convey("Should result in 412 Precondition failed", func() { So(sc.resp.Code, ShouldEqual, 412) @@ -897,6 +897,50 @@ func TestDashboardApiEndpoint(t *testing.T) { So(dto.Message, ShouldEqual, "Restored from version 1") }) }) + + Convey("Given provisioned dashboard", t, func() { + + bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error { + query.Result = []*m.Dashboard{{}} + return nil + }) + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = &m.Dashboard{Id: 1} + return nil + }) + + bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error { + query.Result = true + return nil + }) + + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = []*m.DashboardAclInfoDTO{ + {OrgId: TestOrgID, DashboardId: 1, UserId: TestUserID, Permission: m.PERMISSION_EDIT}, + } + return nil + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/dash", "/api/dashboards/db/:slug", m.ROLE_EDITOR, func(sc *scenarioContext) { + CallDeleteDashboardBySlug(sc) + + Convey("Should result in 400", func() { + So(sc.resp.Code, ShouldEqual, 400) + result := sc.ToJSON() + So(result.Get("error").MustString(), ShouldEqual, m.ErrDashboardCannotDeleteProvisionedDashboard.Error()) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/abcdefghi", "/api/dashboards/db/:uid", m.ROLE_EDITOR, func(sc *scenarioContext) { + CallDeleteDashboardByUID(sc) + + Convey("Should result in 400", func() { + So(sc.resp.Code, ShouldEqual, 400) + result := sc.ToJSON() + So(result.Get("error").MustString(), ShouldEqual, m.ErrDashboardCannotDeleteProvisionedDashboard.Error()) + }) + }) + }) } func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta { @@ -936,12 +980,12 @@ func CallGetDashboardVersions(sc *scenarioContext) { sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() } -func CallDeleteDashboard(sc *scenarioContext) { +func CallDeleteDashboardBySlug(sc *scenarioContext) { bus.AddHandler("test", func(cmd *m.DeleteDashboardCommand) error { return nil }) - sc.handlerFunc = DeleteDashboard + sc.handlerFunc = DeleteDashboardBySlug sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() } diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 0f3f56175fe..e54d0c11453 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -13,25 +13,26 @@ import ( // Typed errors var ( - ErrDashboardNotFound = errors.New("Dashboard not found") - ErrDashboardFolderNotFound = errors.New("Folder not found") - ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") - ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists") - ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists") - ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else") - ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty") - ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder") - ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists") - ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id") - ErrDashboardTypeMismatch = errors.New("Dashboard cannot be changed to a folder") - ErrDashboardFolderWithSameNameAsDashboard = errors.New("Folder name cannot be the same as one of its dashboards") - ErrDashboardWithSameNameAsFolder = errors.New("Dashboard name cannot be the same as folder") - ErrDashboardFolderNameExists = errors.New("A folder with that name already exists") - ErrDashboardUpdateAccessDenied = errors.New("Access denied to save dashboard") - ErrDashboardInvalidUid = errors.New("uid contains illegal characters") - ErrDashboardUidToLong = errors.New("uid to long. max 40 characters") - ErrDashboardCannotSaveProvisionedDashboard = errors.New("Cannot save provisioned dashboard") - RootFolderName = "General" + ErrDashboardNotFound = errors.New("Dashboard not found") + ErrDashboardFolderNotFound = errors.New("Folder not found") + ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") + ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists") + ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists") + ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else") + ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty") + ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder") + ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists") + ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id") + ErrDashboardTypeMismatch = errors.New("Dashboard cannot be changed to a folder") + ErrDashboardFolderWithSameNameAsDashboard = errors.New("Folder name cannot be the same as one of its dashboards") + ErrDashboardWithSameNameAsFolder = errors.New("Dashboard name cannot be the same as folder") + ErrDashboardFolderNameExists = errors.New("A folder with that name already exists") + ErrDashboardUpdateAccessDenied = errors.New("Access denied to save dashboard") + ErrDashboardInvalidUid = errors.New("uid contains illegal characters") + ErrDashboardUidToLong = errors.New("uid to long. max 40 characters") + ErrDashboardCannotSaveProvisionedDashboard = errors.New("Cannot save provisioned dashboard") + ErrDashboardCannotDeleteProvisionedDashboard = errors.New("provisioned dashboard cannot be deleted") + RootFolderName = "General" ) type UpdatePluginDashboardError struct { @@ -356,3 +357,7 @@ type GetDashboardRefByIdQuery struct { Id int64 Result *DashboardRef } + +type UnprovisionDashboardCommand struct { + Id int64 +} diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index f8df6763994..48c46e4d44d 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -9,12 +9,14 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/util" + "github.com/pkg/errors" ) // DashboardService service for operating on dashboards type DashboardService interface { SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) ImportDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) + DeleteDashboard(dashboardId int64, orgId int64) error } // DashboardProvisioningService service for operating on provisioned dashboards @@ -22,6 +24,8 @@ type DashboardProvisioningService interface { SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) SaveFolderForProvisionedDashboards(*SaveDashboardDTO) (*models.Dashboard, error) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) + UnprovisionDashboard(dashboardId int64) error + DeleteProvisionedDashboard(dashboardId int64, orgId int64) error } // NewService factory for creating a new dashboard service @@ -241,6 +245,33 @@ func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Da return cmd.Result, nil } +// DeleteDashboard removes dashboard from the DB. Errors out if the dashboard was provisioned. Should be used for +// operations by the user where we want to make sure user does not delete provisioned dashboard. +func (dr *dashboardServiceImpl) DeleteDashboard(dashboardId int64, orgId int64) error { + return dr.deleteDashboard(dashboardId, orgId, true) +} + +// DeleteProvisionedDashboard removes dashboard from the DB even if it is provisioned. +func (dr *dashboardServiceImpl) DeleteProvisionedDashboard(dashboardId int64, orgId int64) error { + return dr.deleteDashboard(dashboardId, orgId, false) +} + +func (dr *dashboardServiceImpl) deleteDashboard(dashboardId int64, orgId int64, validateProvisionedDashboard bool) error { + if validateProvisionedDashboard { + isDashboardProvisioned := &models.IsDashboardProvisionedQuery{DashboardId: dashboardId} + err := bus.Dispatch(isDashboardProvisioned) + if err != nil { + return errors.Wrap(err, "error while checking if dashboard is provisioned") + } + + if isDashboardProvisioned.Result { + return models.ErrDashboardCannotDeleteProvisionedDashboard + } + } + cmd := &models.DeleteDashboardCommand{OrgId: orgId, Id: dashboardId} + return bus.Dispatch(cmd) +} + func (dr *dashboardServiceImpl) ImportDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { cmd, err := dr.buildSaveDashboardCommand(dto, false, true) if err != nil { @@ -255,6 +286,13 @@ func (dr *dashboardServiceImpl) ImportDashboard(dto *SaveDashboardDTO) (*models. return cmd.Result, nil } +// UnprovisionDashboard removes info about dashboard being provisioned. Used after provisioning configs are changed +// and provisioned dashboards are left behind but not deleted. +func (dr *dashboardServiceImpl) UnprovisionDashboard(dashboardId int64) error { + cmd := &models.UnprovisionDashboardCommand{Id: dashboardId} + return bus.Dispatch(cmd) +} + type FakeDashboardService struct { SaveDashboardResult *models.Dashboard SaveDashboardError error @@ -275,6 +313,16 @@ func (s *FakeDashboardService) ImportDashboard(dto *SaveDashboardDTO) (*models.D return s.SaveDashboard(dto) } +func (s *FakeDashboardService) DeleteDashboard(dashboardId int64, orgId int64) error { + for index, dash := range s.SavedDashboards { + if dash.Dashboard.Id == dashboardId && dash.OrgId == orgId { + s.SavedDashboards = append(s.SavedDashboards[:index], s.SavedDashboards[index+1:]...) + break + } + } + return nil +} + func MockDashboardService(mock *FakeDashboardService) { NewService = func() DashboardService { return mock diff --git a/pkg/services/dashboards/dashboard_service_test.go b/pkg/services/dashboards/dashboard_service_test.go index b8300a5af8d..2ce5cc51159 100644 --- a/pkg/services/dashboards/dashboard_service_test.go +++ b/pkg/services/dashboards/dashboard_service_test.go @@ -1,13 +1,12 @@ package dashboards import ( - "errors" "testing" - "github.com/grafana/grafana/pkg/services/guardian" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/pkg/errors" . "github.com/smartystreets/goconvey/convey" ) @@ -200,8 +199,61 @@ func TestDashboardService(t *testing.T) { }) }) + Convey("Given provisioned dashboard", func() { + result := setupDeleteHandlers(true) + + Convey("DeleteProvisionedDashboard should delete it", func() { + err := service.DeleteProvisionedDashboard(1, 1) + So(err, ShouldBeNil) + So(result.deleteWasCalled, ShouldBeTrue) + }) + + Convey("DeleteDashboard should fail to delete it", func() { + err := service.DeleteDashboard(1, 1) + So(err, ShouldEqual, models.ErrDashboardCannotDeleteProvisionedDashboard) + So(result.deleteWasCalled, ShouldBeFalse) + }) + }) + + Convey("Given non provisioned dashboard", func() { + result := setupDeleteHandlers(false) + + Convey("DeleteProvisionedDashboard should delete it", func() { + err := service.DeleteProvisionedDashboard(1, 1) + So(err, ShouldBeNil) + So(result.deleteWasCalled, ShouldBeTrue) + }) + + Convey("DeleteDashboard should delete it", func() { + err := service.DeleteDashboard(1, 1) + So(err, ShouldBeNil) + So(result.deleteWasCalled, ShouldBeTrue) + }) + }) + Reset(func() { guardian.New = origNewDashboardGuardian }) }) } + +type Result struct { + deleteWasCalled bool +} + +func setupDeleteHandlers(provisioned bool) *Result { + bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error { + cmd.Result = provisioned + return nil + }) + + result := &Result{} + bus.AddHandler("test", func(cmd *models.DeleteDashboardCommand) error { + So(cmd.Id, ShouldEqual, 1) + So(cmd.OrgId, ShouldEqual, 1) + result.deleteWasCalled = true + return nil + }) + + return result +} diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index 1c1819df8a9..dd5f27dc272 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -25,10 +25,10 @@ var ( ) type fileReader struct { - Cfg *DashboardsAsConfig - Path string - log log.Logger - dashboardService dashboards.DashboardProvisioningService + Cfg *DashboardsAsConfig + Path string + log log.Logger + dashboardProvisioningService dashboards.DashboardProvisioningService } func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReader, error) { @@ -44,10 +44,10 @@ func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReade } return &fileReader{ - Cfg: cfg, - Path: path, - log: log, - dashboardService: dashboards.NewProvisioningService(), + Cfg: cfg, + Path: path, + log: log, + dashboardProvisioningService: dashboards.NewProvisioningService(), }, nil } @@ -86,12 +86,12 @@ func (fr *fileReader) startWalkingDisk() error { } } - folderId, err := getOrCreateFolderId(fr.Cfg, fr.dashboardService) + folderId, err := getOrCreateFolderId(fr.Cfg, fr.dashboardProvisioningService) if err != nil && err != ErrFolderNameMissing { return err } - provisionedDashboardRefs, err := getProvisionedDashboardByPath(fr.dashboardService, fr.Cfg.Name) + provisionedDashboardRefs, err := getProvisionedDashboardByPath(fr.dashboardProvisioningService, fr.Cfg.Name) if err != nil { return err } @@ -102,7 +102,7 @@ func (fr *fileReader) startWalkingDisk() error { return err } - fr.deleteDashboardIfFileIsMissing(provisionedDashboardRefs, filesFoundOnDisk) + fr.handleMissingDashboardFiles(provisionedDashboardRefs, filesFoundOnDisk) sanityChecker := newProvisioningSanityChecker(fr.Cfg.Name) @@ -119,11 +119,7 @@ func (fr *fileReader) startWalkingDisk() error { return nil } -func (fr *fileReader) deleteDashboardIfFileIsMissing(provisionedDashboardRefs map[string]*models.DashboardProvisioning, filesFoundOnDisk map[string]os.FileInfo) { - if fr.Cfg.DisableDeletion { - return - } - +func (fr *fileReader) handleMissingDashboardFiles(provisionedDashboardRefs map[string]*models.DashboardProvisioning, filesFoundOnDisk map[string]os.FileInfo) { // find dashboards to delete since json file is missing var dashboardToDelete []int64 for path, provisioningData := range provisionedDashboardRefs { @@ -132,13 +128,25 @@ func (fr *fileReader) deleteDashboardIfFileIsMissing(provisionedDashboardRefs ma dashboardToDelete = append(dashboardToDelete, provisioningData.DashboardId) } } - // delete dashboard that are missing json file - for _, dashboardId := range dashboardToDelete { - fr.log.Debug("deleting provisioned dashboard. missing on disk", "id", dashboardId) - cmd := &models.DeleteDashboardCommand{OrgId: fr.Cfg.OrgId, Id: dashboardId} - err := bus.Dispatch(cmd) - if err != nil { - fr.log.Error("failed to delete dashboard", "id", cmd.Id, "error", err) + + if fr.Cfg.DisableDeletion { + // If deletion is disabled for the provisioner we just remove provisioning metadata about the dashboard + // so afterwards the dashboard is considered unprovisioned. + for _, dashboardId := range dashboardToDelete { + fr.log.Debug("unprovisioning provisioned dashboard. missing on disk", "id", dashboardId) + err := fr.dashboardProvisioningService.UnprovisionDashboard(dashboardId) + if err != nil { + fr.log.Error("failed to unprovision dashboard", "dashboard_id", dashboardId, "error", err) + } + } + } else { + // delete dashboard that are missing json file + for _, dashboardId := range dashboardToDelete { + fr.log.Debug("deleting provisioned dashboard. missing on disk", "id", dashboardId) + err := fr.dashboardProvisioningService.DeleteProvisionedDashboard(dashboardId, fr.Cfg.OrgId) + if err != nil { + fr.log.Error("failed to delete dashboard", "id", dashboardId, "error", err) + } } } } @@ -189,7 +197,7 @@ func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.Fil CheckSum: jsonFile.checkSum, } - _, err = fr.dashboardService.SaveProvisionedDashboard(dash, dp) + _, err = fr.dashboardProvisioningService.SaveProvisionedDashboard(dash, dp) return provisioningMetadata, err } diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go index 1a9d2216e7a..8c0a04a808a 100644 --- a/pkg/services/provisioning/dashboards/file_reader_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_test.go @@ -1,6 +1,8 @@ package dashboards import ( + "github.com/grafana/grafana/pkg/util" + "math/rand" "os" "path/filepath" "runtime" @@ -20,6 +22,7 @@ var ( brokenDashboards = "testdata/test-dashboards/broken-dashboards" oneDashboard = "testdata/test-dashboards/one-dashboard" containingId = "testdata/test-dashboards/containing-id" + unprovision = "testdata/test-dashboards/unprovision" fakeService *fakeDashboardProvisioningService ) @@ -250,6 +253,62 @@ func TestDashboardFileReader(t *testing.T) { }) }) + Convey("Given missing dashboard file", func() { + cfg := &DashboardsAsConfig{ + Name: "Default", + Type: "file", + OrgId: 1, + Options: map[string]interface{}{ + "folder": unprovision, + }, + } + + fakeService.inserted = []*dashboards.SaveDashboardDTO{ + {Dashboard: &models.Dashboard{Id: 1}}, + {Dashboard: &models.Dashboard{Id: 2}}, + } + + absPath1, err := filepath.Abs(unprovision + "/dashboard1.json") + So(err, ShouldBeNil) + // This one does not exist on disc, simulating a deleted file + absPath2, err := filepath.Abs(unprovision + "/dashboard2.json") + So(err, ShouldBeNil) + + fakeService.provisioned = map[string][]*models.DashboardProvisioning{ + "Default": { + {DashboardId: 1, Name: "Default", ExternalId: absPath1}, + {DashboardId: 2, Name: "Default", ExternalId: absPath2}, + }, + } + + Convey("Missing dashboard should be unprovisioned if DisableDeletion = true", func() { + cfg.DisableDeletion = true + + reader, err := NewDashboardFileReader(cfg, logger) + So(err, ShouldBeNil) + + err = reader.startWalkingDisk() + So(err, ShouldBeNil) + + So(len(fakeService.provisioned["Default"]), ShouldEqual, 1) + So(fakeService.provisioned["Default"][0].ExternalId, ShouldEqual, absPath1) + + }) + + Convey("Missing dashboard should be deleted if DisableDeletion = false", func() { + reader, err := NewDashboardFileReader(cfg, logger) + So(err, ShouldBeNil) + + err = reader.startWalkingDisk() + So(err, ShouldBeNil) + + So(len(fakeService.provisioned["Default"]), ShouldEqual, 1) + So(fakeService.provisioned["Default"][0].ExternalId, ShouldEqual, absPath1) + So(len(fakeService.inserted), ShouldEqual, 1) + So(fakeService.inserted[0].Dashboard.Id, ShouldEqual, 1) + }) + }) + Reset(func() { dashboards.NewProvisioningService = origNewDashboardProvisioningService }) @@ -310,13 +369,39 @@ func (s *fakeDashboardProvisioningService) GetProvisionedDashboardData(name stri } func (s *fakeDashboardProvisioningService) SaveProvisionedDashboard(dto *dashboards.SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { + // Copy the structs as we need to change them but do not want to alter outside world. + var copyProvisioning = &models.DashboardProvisioning{} + *copyProvisioning = *provisioning + + var copyDto = &dashboards.SaveDashboardDTO{} + *copyDto = *dto + + if copyDto.Dashboard.Id == 0 { + copyDto.Dashboard.Id = rand.Int63n(1000000) + } else { + err := s.DeleteProvisionedDashboard(dto.Dashboard.Id, dto.Dashboard.OrgId) + // Lets delete existing so we do not have duplicates + if err != nil { + return nil, err + } + } + s.inserted = append(s.inserted, dto) if _, ok := s.provisioned[provisioning.Name]; !ok { s.provisioned[provisioning.Name] = []*models.DashboardProvisioning{} } - s.provisioned[provisioning.Name] = append(s.provisioned[provisioning.Name], provisioning) + for _, val := range s.provisioned[provisioning.Name] { + if val.DashboardId == dto.Dashboard.Id && val.Name == provisioning.Name { + // Do not insert duplicates + return dto.Dashboard, nil + } + } + + copyProvisioning.DashboardId = copyDto.Dashboard.Id + + s.provisioned[provisioning.Name] = append(s.provisioned[provisioning.Name], copyProvisioning) return dto.Dashboard, nil } @@ -325,6 +410,31 @@ func (s *fakeDashboardProvisioningService) SaveFolderForProvisionedDashboards(dt return dto.Dashboard, nil } +func (s *fakeDashboardProvisioningService) UnprovisionDashboard(dashboardId int64) error { + for key, val := range s.provisioned { + for index, dashboard := range val { + if dashboard.DashboardId == dashboardId { + s.provisioned[key] = append(s.provisioned[key][:index], s.provisioned[key][index+1:]...) + } + } + } + return nil +} + +func (s *fakeDashboardProvisioningService) DeleteProvisionedDashboard(dashboardId int64, orgId int64) error { + err := s.UnprovisionDashboard(dashboardId) + if err != nil { + return err + } + + for index, val := range s.inserted { + if val.Dashboard.Id == dashboardId { + s.inserted = append(s.inserted[:index], s.inserted[util.MinInt(index+1, len(s.inserted)):]...) + } + } + return nil +} + func mockGetDashboardQuery(cmd *models.GetDashboardQuery) error { for _, d := range fakeService.getDashboard { if d.Slug == cmd.Slug { diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/unprovision/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/unprovision/dashboard1.json new file mode 100644 index 00000000000..febb98be0e8 --- /dev/null +++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/unprovision/dashboard1.json @@ -0,0 +1,172 @@ +{ + "title": "Grafana1", + "tags": [], + "style": "dark", + "timezone": "browser", + "editable": true, + "rows": [ + { + "title": "New row", + "height": "150px", + "collapse": false, + "editable": true, + "panels": [ + { + "id": 1, + "span": 12, + "editable": true, + "type": "text", + "mode": "html", + "content": "
\n \n
", + "style": {}, + "title": "Welcome to" + } + ] + }, + { + "title": "Welcome to Grafana", + "height": "210px", + "collapse": false, + "editable": true, + "panels": [ + { + "id": 2, + "span": 6, + "type": "text", + "mode": "html", + "content": "
\n\n
\n
\n \n
\n
\n \n
\n
", + "style": {}, + "title": "Documentation Links" + }, + { + "id": 3, + "span": 6, + "type": "text", + "mode": "html", + "content": "
\n\n
\n
\n \n
\n
\n", + "style": {}, + "title": "Tips & Shortcuts" + } + ] + }, + { + "title": "test", + "height": "250px", + "editable": true, + "collapse": false, + "panels": [ + { + "id": 4, + "span": 12, + "type": "graph", + "x-axis": true, + "y-axis": true, + "scale": 1, + "y_formats": [ + "short", + "short" + ], + "grid": { + "max": null, + "min": null, + "leftMax": null, + "rightMax": null, + "leftMin": null, + "rightMin": null, + "threshold1": null, + "threshold2": null, + "threshold1Color": "rgba(216, 200, 27, 0.27)", + "threshold2Color": "rgba(234, 112, 112, 0.22)" + }, + "resolution": 100, + "lines": true, + "fill": 1, + "linewidth": 2, + "dashes": false, + "dashLength": 10, + "spaceLength": 10, + "points": false, + "pointradius": 5, + "bars": false, + "stack": true, + "spyable": true, + "options": false, + "legend": { + "show": true, + "values": false, + "min": false, + "max": false, + "current": false, + "total": false, + "avg": false + }, + "interactive": true, + "legend_counts": true, + "timezone": "browser", + "percentage": false, + "nullPointMode": "connected", + "steppedLine": false, + "tooltip": { + "value_type": "cumulative", + "query_as_alias": true + }, + "targets": [ + { + "target": "randomWalk('random walk')", + "function": "mean", + "column": "value" + } + ], + "aliasColors": {}, + "aliasYAxis": {}, + "title": "First Graph (click title to edit)", + "datasource": "graphite", + "renderer": "flot", + "annotate": { + "enable": false + } + } + ] + } + ], + "nav": [ + { + "type": "timepicker", + "collapse": false, + "enable": true, + "status": "Stable", + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ], + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "now": true + } + ], + "time": { + "from": "now-6h", + "to": "now" + }, + "templating": { + "list": [] + }, + "version": 5 + } diff --git a/pkg/services/sqlstore/dashboard_provisioning.go b/pkg/services/sqlstore/dashboard_provisioning.go index 9b180662278..b6c20682fae 100644 --- a/pkg/services/sqlstore/dashboard_provisioning.go +++ b/pkg/services/sqlstore/dashboard_provisioning.go @@ -9,6 +9,7 @@ func init() { bus.AddHandler("sql", GetProvisionedDashboardDataQuery) bus.AddHandler("sql", SaveProvisionedDashboard) bus.AddHandler("sql", GetProvisionedDataByDashboardId) + bus.AddHandler("sql", UnprovisionDashboard) } type DashboardExtras struct { @@ -44,11 +45,11 @@ func SaveProvisionedDashboard(cmd *models.SaveProvisionedDashboardCommand) error cmd.DashboardProvisioning.Updated = cmd.Result.Updated.Unix() } - return saveProvionedData(sess, cmd.DashboardProvisioning, cmd.Result) + return saveProvisionedData(sess, cmd.DashboardProvisioning, cmd.Result) }) } -func saveProvionedData(sess *DBSession, cmd *models.DashboardProvisioning, dashboard *models.Dashboard) error { +func saveProvisionedData(sess *DBSession, cmd *models.DashboardProvisioning, dashboard *models.Dashboard) error { result := &models.DashboardProvisioning{} exist, err := sess.Where("dashboard_id=? AND name = ?", dashboard.Id, cmd.Name).Get(result) @@ -78,3 +79,12 @@ func GetProvisionedDashboardDataQuery(cmd *models.GetProvisionedDashboardDataQue cmd.Result = result return nil } + +// UnprovisionDashboard removes row in dashboard_provisioning for the dashboard making it seem as if manually created. +// The dashboard will still have `created_by = -1` to see it was not created by any particular user. +func UnprovisionDashboard(cmd *models.UnprovisionDashboardCommand) error { + if _, err := x.Where("dashboard_id = ?", cmd.Id).Delete(&models.DashboardProvisioning{}); err != nil { + return err + } + return nil +} diff --git a/pkg/services/sqlstore/dashboard_provisioning_test.go b/pkg/services/sqlstore/dashboard_provisioning_test.go index 1b7a3976727..82ac294349c 100644 --- a/pkg/services/sqlstore/dashboard_provisioning_test.go +++ b/pkg/services/sqlstore/dashboard_provisioning_test.go @@ -81,7 +81,7 @@ func TestDashboardProvisioningTest(t *testing.T) { So(query.Result, ShouldBeFalse) }) - Convey("Deleteing folder should delete provision meta data", func() { + Convey("Deleting folder should delete provision meta data", func() { deleteCmd := &models.DeleteDashboardCommand{ Id: folderCmd.Result.Id, OrgId: 1, @@ -95,6 +95,20 @@ func TestDashboardProvisioningTest(t *testing.T) { So(err, ShouldBeNil) So(query.Result, ShouldBeFalse) }) + + Convey("UnprovisionDashboard should delete provisioning metadata", func() { + unprovisionCmd := &models.UnprovisionDashboardCommand{ + Id: dashId, + } + + So(UnprovisionDashboard(unprovisionCmd), ShouldBeNil) + + query := &models.IsDashboardProvisionedQuery{DashboardId: dashId} + + err = GetProvisionedDataByDashboardId(query) + So(err, ShouldBeNil) + So(query.Result, ShouldBeFalse) + }) }) }) } diff --git a/pkg/util/math.go b/pkg/util/math.go new file mode 100644 index 00000000000..391ab426081 --- /dev/null +++ b/pkg/util/math.go @@ -0,0 +1,17 @@ +package util + +// MaxInt returns the larger of x or y. +func MaxInt(x, y int) int { + if x < y { + return y + } + return x +} + +// MinInt returns the smaller of x or y. +func MinInt(x, y int) int { + if x > y { + return y + } + return x +} diff --git a/public/app/core/services/util_srv.ts b/public/app/core/services/util_srv.ts index 2b7538f1be2..c543c8c9b38 100644 --- a/public/app/core/services/util_srv.ts +++ b/public/app/core/services/util_srv.ts @@ -52,11 +52,6 @@ export class UtilSrv { showConfirmModal(payload) { const scope = this.$rootScope.$new(); - scope.onConfirm = () => { - payload.onConfirm(); - scope.dismiss(); - }; - scope.updateConfirmText = value => { scope.confirmTextValid = payload.confirmText.toLowerCase() === value.toLowerCase(); }; @@ -64,6 +59,7 @@ export class UtilSrv { scope.title = payload.title; scope.text = payload.text; scope.text2 = payload.text2; + scope.text2htmlBind = payload.text2htmlBind; scope.confirmText = payload.confirmText; scope.onConfirm = payload.onConfirm; diff --git a/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts index fc3b98b4848..014921a30bc 100755 --- a/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts +++ b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts @@ -182,6 +182,24 @@ export class SettingsCtrl { let confirmText = ''; let text2 = this.dashboard.title; + if (this.dashboard.meta.provisioned) { + appEvents.emit('confirm-modal', { + title: 'Cannot delete provisioned dashboard', + text: ` + This dashboard is managed by Grafanas provisioning and cannot be deleted. Remove the dashboard from the + config file to delete it. + `, + text2: ` + See + documentation for more information about provisioning. + `, + text2htmlBind: true, + icon: 'fa-trash', + noText: 'OK', + }); + return; + } + const alerts = _.sumBy(this.dashboard.panels, panel => { return panel.alert ? 1 : 0; }); diff --git a/public/app/partials/confirm_modal.html b/public/app/partials/confirm_modal.html index 5d80f59a41f..064ea0999d1 100644 --- a/public/app/partials/confirm_modal.html +++ b/public/app/partials/confirm_modal.html @@ -16,9 +16,8 @@
{{text}} -
- {{text2}} -
+
+
{{text2}}