Prechádzať zdrojové kódy

Provisioning: Do not allow deletion of provisioned dashboards (#16211)

* Unprovision dashboard in case of DisableDeletion = true

* Rename command struct

* Handle removed provision files

* Allow html in confirm-modal

* Do not show confirm button without onConfirm

* Show dialog on deleting provisioned dashboard

* Changed DeleteDashboard to DeleteProvisionedDashboard

* Remove unreachable return

* Add provisioned checks to API

* Remove filter func

* Fix and add tests for deleting dashboards

* Change delete confirm text

* Added and used pkg/errors for error wrapping
Andrej Ocenas 6 rokov pred
rodič
commit
2d7fc55df7

+ 2 - 0
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",
   ]

+ 4 - 0
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"

+ 1 - 1
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))
 

+ 11 - 23
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)
 	}
 

+ 55 - 11
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()
 }
 

+ 24 - 19
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
+}

+ 48 - 0
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

+ 55 - 3
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
+}

+ 32 - 24
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
 }
 

+ 111 - 1
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 {

+ 172 - 0
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": "<div class=\"text-center\" style=\"padding-top: 15px\">\n<img src=\"img/logo_transparent_200x.png\"> \n</div>",
+            "style": {},
+            "title": "Welcome to"
+          }
+        ]
+      },
+      {
+        "title": "Welcome to Grafana",
+        "height": "210px",
+        "collapse": false,
+        "editable": true,
+        "panels": [
+          {
+            "id": 2,
+            "span": 6,
+            "type": "text",
+            "mode": "html",
+            "content": "<br/>\n\n<div class=\"row-fluid\">\n  <div class=\"span6\">\n    <ul>\n      <li>\n        <a href=\"http://grafana.org/docs#configuration\" target=\"_blank\">Configuration</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/troubleshooting\" target=\"_blank\">Troubleshooting</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/support\" target=\"_blank\">Support</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/intro\" target=\"_blank\">Getting started</a>  (Must read!)\n      </li>\n    </ul>\n  </div>\n  <div class=\"span6\">\n    <ul>\n      <li>\n        <a href=\"http://grafana.org/docs/features/graphing\" target=\"_blank\">Graphing</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/annotations\" target=\"_blank\">Annotations</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/graphite\" target=\"_blank\">Graphite</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/influxdb\" target=\"_blank\">InfluxDB</a>\n      </li>\n      <li>\n        <a href=\"http://grafana.org/docs/features/opentsdb\" target=\"_blank\">OpenTSDB</a>\n      </li>\n    </ul>\n  </div>\n</div>",
+            "style": {},
+            "title": "Documentation Links"
+          },
+          {
+            "id": 3,
+            "span": 6,
+            "type": "text",
+            "mode": "html",
+            "content": "<br/>\n\n<div class=\"row-fluid\">\n  <div class=\"span12\">\n    <ul>\n      <li>Ctrl+S saves the current dashboard</li>\n      <li>Ctrl+F Opens the dashboard finder</li>\n      <li>Ctrl+H Hide/show row controls</li>\n      <li>Click and drag graph title to move panel</li>\n      <li>Hit Escape to exit graph when in fullscreen or edit mode</li>\n      <li>Click the colored icon in the legend to change series color</li>\n      <li>Ctrl or Shift + Click legend name to hide other series</li>\n    </ul>\n  </div>\n</div>\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
+  }

+ 12 - 2
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
+}

+ 15 - 1
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)
+			})
 		})
 	})
 }

+ 17 - 0
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
+}

+ 1 - 5
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;

+ 18 - 0
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: `
+          <i>See <a class="external-link" href="http://docs.grafana.org/administration/provisioning/#dashboards" target="_blank">
+          documentation</a> for more information about provisioning.</i>
+        `,
+        text2htmlBind: true,
+        icon: 'fa-trash',
+        noText: 'OK',
+      });
+      return;
+    }
+
     const alerts = _.sumBy(this.dashboard.panels, panel => {
       return panel.alert ? 1 : 0;
     });

+ 3 - 4
public/app/partials/confirm_modal.html

@@ -16,9 +16,8 @@
 
 		<div class="confirm-modal-text">
 			{{text}}
-			<div class="confirm-modal-text2" ng-show="text2">
-				{{text2}}
-			</div>
+			<div ng-if="text2 && text2htmlBind" class="confirm-modal-text2" ng-bind-html="text2"></div>
+      <div ng-if="text2 && !text2htmlBind" class="confirm-modal-text2">{{text2}}</div>
 		</div>
 
 		<div class="modal-content-confirm-text" ng-if="confirmText">
@@ -27,7 +26,7 @@
 
 		<div class="confirm-modal-buttons">
 			<button ng-show="onAltAction" type="button" class="btn btn-primary" ng-click="dismiss();onAltAction();">{{altActionText}}</button>
-			<button type="button" class="btn btn-danger" ng-click="onConfirm();dismiss();" ng-disabled="!confirmTextValid" give-focus="true">{{yesText}}</button>
+			<button ng-show="onConfirm" type="button" class="btn btn-danger" ng-click="onConfirm();dismiss();" ng-disabled="!confirmTextValid" give-focus="true">{{yesText}}</button>
 			<button type="button" class="btn btn-inverse" ng-click="dismiss()">{{noText}}</button>
 		</div>
 	</div>