feat(grafanactl): add scratch folder support for user-writable dashboards - #276
Conversation
…ards Add a "scratchFolders" config option that creates Grafana folders where Viewers get Edit permission, allowing them to create/save/delete dashboards. Dashboards are auto-deleted after a configurable maxAge (based on creation time). Empty subfolders are cleaned up recursively. Cleanup errors (dashboard deletion, folder deletion, permission updates) are logged as warnings and never fail the pipeline. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
d093b6a to
727e82e
Compare
Track which dashboards were deleted during expiry and exclude them from the folder dashboard count, so subfolders emptied by expiry are cleaned up immediately rather than requiring a second sync cycle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds “scratch folder” support to grafanactl sync dashboards so users can create/edit dashboards in designated Grafana folders that are automatically permissioned and periodically cleaned up (expired dashboards removed; empty subfolders deleted leaf-first).
Changes:
- Introduces
scratchFoldersin the observability config (name,maxAge) with duration parsing/validation. - Extends the Grafana client wrapper with folder permission, folder search, and folder deletion operations.
- Implements scratch-folder sync: ensure folder exists, set Viewer→Edit permissions, delete expired dashboards (including in subfolders), and recursively delete empty subfolders; includes unit tests and injectable clock.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tools/grafanactl/internal/grafana/syncer.go | Injects a clock and wires scratch-folder sync into the main dashboard sync flow. |
| tools/grafanactl/internal/grafana/scratch.go | Implements scratch-folder creation/permissioning and expiry/cleanup logic. |
| tools/grafanactl/internal/grafana/scratch_test.go | Adds unit tests covering scratch-folder behavior, edge cases, and error handling. |
| tools/grafanactl/internal/grafana/client.go | Adds folder permission/search/delete helpers needed by scratch sync. |
| tools/grafanactl/config/config.go | Adds ScratchFolder config type and maxAge parsing/validation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
janboll
left a comment
There was a problem hiding this comment.
just a few nits, please check
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
tools/grafanactl/internal/grafana/syncer.go:105
Sync()already callsfetchExistingState()(ListFolders/ListDashboards). CallingsyncScratchFolders()immediately afterwards triggers another full folder + dashboard listing, which can significantly increase API calls and latency (and can contribute to rate-limit / timeout issues on large instances). Consider passingexistingFolders/existingDashboardsinto scratch sync (and only separately fetching folder parent relationships viaSearchFolders) so the sync pass reuses the data it already loaded.
if err := s.syncScratchFolders(ctx); err != nil {
return fmt.Errorf("failed to sync scratch folders: %w", err)
}
- Use sets.Set[string] instead of map[string]bool for deleted dashboards - Pass time.Time value instead of func() time.Time to helper functions - Pull logger from context instead of passing it as a parameter, use WithValues to attach uid as a structured field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
224f5be to
b118afb
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (3)
tools/grafanactl/internal/grafana/scratch.go:214
- PR description says folder deletion errors are logged as warnings. This is currently logged at error level even though deletion failures are explicitly non-fatal; consider lowering to Info (or equivalent) with an explicit warning message to match the stated behavior.
logger.Info("Deleting empty scratch subfolder")
if err := client.DeleteFolderByUID(ctx, uid); err != nil {
logger.Error(err, "Failed to delete empty scratch subfolder, continuing")
return false
}
tools/grafanactl/internal/grafana/scratch.go:27
- Importing k8s.io/apimachinery just to use util/sets significantly increases the dependency footprint of grafanactl for a small convenience. A plain
map[string]struct{}(or a small local set type) would avoid bringing in apimachinery as a direct dependency.
"time"
"github.com/go-logr/logr"
"github.com/grafana-tools/sdk"
"k8s.io/apimachinery/pkg/util/sets"
"github.com/Azure/ARO-Tools/tools/grafanactl/config"
tools/grafanactl/internal/grafana/scratch.go:153
- PR description says deletion errors are logged as warnings and should not look like fatal failures. Here the error is logged at error level even though the code continues, which may trigger alerting/noise. Consider logging at Info (or equivalent) with an explicit warning message instead.
This issue also appears on line 210 of the same file.
logger.Info("Deleting expired scratch dashboard", "title", db.Title, "uid", db.UID, "created", props.Created)
if err := client.DeleteDashboardByUID(ctx, db.UID); err != nil {
logger.Error(err, "Failed to delete expired scratch dashboard, continuing", "title", db.Title, "uid", db.UID)
} else {
deletedDashboards.Insert(db.UID)
https://redhat.atlassian.net/browse/AROSLSRE-1631
Summary
scratchFoldersconfig option tografana-dashboardsin the observability configgrafanactl sync dashboards, scratch folders are created (or found), Viewer role gets Edit permission, and dashboards older thanmaxAgeare auto-deleted based on creation timeDetails
Config example:
New/modified files:
config/config.go—ScratchFoldertype withMaxAge()duration parserinternal/grafana/client.go—GetFolderPermissions,UpdateFolderPermissions,SearchFolders,DeleteFolderByUIDinternal/grafana/syncer.go— Injectable clock, callssyncScratchFoldersfromSync()internal/grafana/scratch.go— Scratch folder sync logicinternal/grafana/scratch_test.go— 20 unit testsTesting
Tested end-to-end against a personal Azure Managed Grafana instance (
mmazurtesting) deployed viamake pipeline/Grafanawith a config override. All tests run usinggrafanactl sync dashboardspointed at the test instance.GET /api/foldersand permissions showed Viewer=Edit, Editor=Edit, Admin=Admin viaGET /api/folders/<uid>/permissions. ✅maxAge: 1sinobservability.yaml, waited 10 seconds, ran sync. Log showed "Deleting expired scratch dashboard" with the correct title and UID. Verified dashboard gone via API. ✅maxAge: 168h, created a fresh dashboard, ran sync immediately. Log showed "Scratch dashboard not expired". Dashboard confirmed still present. ✅GET /api/searchbefore and after scratch sync operations. All non-scratch folders (Azure Managed Prometheus, SRE, infra, etc.) retained their exact dashboard counts throughout all test runs. ✅POST /api/folderswithparentUid), added a dashboard, setmaxAge: 1s, waited 10 seconds, ran sync. Log showed "Deleting expired scratch dashboard" for the subfolder dashboard. ✅maxAge: 168h. Log showed "Scratch dashboard not expired", subfolder confirmed still present, no folder deletions. ✅GET /api/folders. ✅--dry-run. Log showed "DRY_RUN: Would delete expired scratch dashboard", "DRY_RUN: Would delete empty scratch subfolder", and "DRY_RUN: Would set permissions on scratch folder". Verified via API that dashboard and subfolder both still existed afterward. ✅TestSyncScratchFolders_DashboardDeleteErrorIsNonFatal,TestSyncScratchFolders_MetadataErrorIsNonFatal, andTestSyncScratchFolders_FolderDeleteErrorIsNonFatalconfirm that individual deletion failures are logged and skipped without failing the overall sync.TestSyncScratchFolders_PermissionErrorIsFatalandTestSyncScratchFolders_CreateFolderErrorIsFatalconfirm that permission and folder-creation failures are fatal. All 20 unit tests pass. ✅🤖 Generated with Claude Code