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
This commit is contained in:
Andrej Ocenas
2019-04-10 13:29:10 +02:00
committed by GitHub
parent 9f007137b3
commit 2d7fc55df7
17 changed files with 581 additions and 94 deletions

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}