Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions tools/grafanactl/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package config
import (
"fmt"
"os"
"time"

"sigs.k8s.io/yaml"
)
Expand All @@ -30,6 +31,7 @@ type ObservabilityConfig struct {
type GrafanaDashboardsConfig struct {
AzureManagedFolders []string `json:"azureManagedFolders"`
DashboardFolders []DashboardFolder `json:"dashboardFolders"`
ScratchFolders []ScratchFolder `json:"scratchFolders,omitempty"`
}

// DashboardFolder represents a folder containing dashboards to sync
Expand All @@ -38,6 +40,23 @@ type DashboardFolder struct {
Path string `json:"path"`
}

// ScratchFolder represents a user-writable folder where dashboards are auto-deleted after MaxAge.
type ScratchFolder struct {
Name string `json:"name"`
MaxAgeRaw string `json:"maxAge"`
}

func (f ScratchFolder) MaxAge() (time.Duration, error) {
d, err := time.ParseDuration(f.MaxAgeRaw)
if err != nil {
return 0, fmt.Errorf("invalid maxAge %q for scratch folder %q: %w", f.MaxAgeRaw, f.Name, err)
}
if d <= 0 {
return 0, fmt.Errorf("maxAge for scratch folder %q must be positive, got %s", f.Name, d)
}
return d, nil
}

// LoadFromFile reads and parses the observability config from a file
func LoadFromFile(path string) (*ObservabilityConfig, error) {
data, err := os.ReadFile(path)
Expand Down
2 changes: 1 addition & 1 deletion tools/grafanactl/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/grafana-tools/sdk v0.0.0-20220919052116-6562121319fc
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/spf13/cobra v1.10.2
k8s.io/apimachinery v0.35.3
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5
sigs.k8s.io/yaml v1.6.0
)
Expand Down Expand Up @@ -41,5 +42,4 @@ require (
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
k8s.io/apimachinery v0.35.3 // indirect
)
37 changes: 37 additions & 0 deletions tools/grafanactl/internal/grafana/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,40 @@ func (c *Client) DeleteDashboardByUID(ctx context.Context, uid string) error {

return nil
}

// GetFolderPermissions returns the permission list for a folder.
func (c *Client) GetFolderPermissions(ctx context.Context, folderUID string) ([]sdk.FolderPermission, error) {
perms, err := c.grafanaClient.GetFolderPermissions(ctx, folderUID)
if err != nil {
return nil, fmt.Errorf("failed to get permissions for folder %q: %w", folderUID, err)
}
return perms, nil
}

// UpdateFolderPermissions replaces the full permission list for a folder.
func (c *Client) UpdateFolderPermissions(ctx context.Context, folderUID string, permissions ...sdk.FolderPermission) error {
_, err := c.grafanaClient.UpdateFolderPermissions(ctx, folderUID, permissions...)
if err != nil {
return fmt.Errorf("failed to update permissions for folder %q: %w", folderUID, err)
}
return nil
}

// SearchFolders returns all folders visible in the Grafana instance via the search API.
// Unlike ListFolders, search results include FolderUID which identifies parent folders.
func (c *Client) SearchFolders(ctx context.Context) ([]sdk.FoundBoard, error) {
results, err := c.grafanaClient.Search(ctx, sdk.SearchType(sdk.SearchTypeFolder))
if err != nil {
return nil, fmt.Errorf("failed to search folders: %w", err)
}
return results, nil
}

// DeleteFolderByUID removes a folder by its UID.
func (c *Client) DeleteFolderByUID(ctx context.Context, uid string) error {
_, err := c.grafanaClient.DeleteFolderByUID(ctx, uid)
if err != nil {
return fmt.Errorf("failed to delete folder %q: %w", uid, err)
}
return nil
}
240 changes: 240 additions & 0 deletions tools/grafanactl/internal/grafana/scratch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
// Copyright 2025 Microsoft Corporation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package grafana

import (
"context"
"fmt"
"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"
)

type scratchGrafanaClient interface {
ListFolders(ctx context.Context) ([]sdk.Folder, error)
CreateFolder(ctx context.Context, title string) (sdk.Folder, error)
UpdateFolderPermissions(ctx context.Context, folderUID string, permissions ...sdk.FolderPermission) error
ListDashboards(ctx context.Context) ([]sdk.FoundBoard, error)
GetDashboardByUID(ctx context.Context, uid string) (sdk.Board, sdk.BoardProperties, error)
DeleteDashboardByUID(ctx context.Context, uid string) error
SearchFolders(ctx context.Context) ([]sdk.FoundBoard, error)
DeleteFolderByUID(ctx context.Context, uid string) error
}

var scratchFolderPermissions = []sdk.FolderPermission{
{Role: "Viewer", Permission: sdk.PermissionEdit},
{Role: "Editor", Permission: sdk.PermissionEdit},
{Role: "Admin", Permission: sdk.PermissionAdmin},
}

func (s *DashboardSyncer) syncScratchFolders(ctx context.Context) error {
if len(s.config.GrafanaDashboards.ScratchFolders) == 0 {
return nil
}
return syncScratchFolders(ctx, s.client, s.config.GrafanaDashboards.ScratchFolders, s.dryRun, s.now())
}

func syncScratchFolders(ctx context.Context, client scratchGrafanaClient, folders []config.ScratchFolder, dryRun bool, now time.Time) error {
logger := logr.FromContextOrDiscard(ctx)

existingFolders, err := client.ListFolders(ctx)
if err != nil {
return fmt.Errorf("failed to list folders for scratch sync: %w", err)
}

allDashboards, err := client.ListDashboards(ctx)
if err != nil {
return fmt.Errorf("failed to list dashboards for scratch sync: %w", err)
}

allSearchFolders, err := client.SearchFolders(ctx)
if err != nil {
return fmt.Errorf("failed to search folders for scratch sync: %w", err)
}

for _, sf := range folders {
maxAge, err := sf.MaxAge()
if err != nil {
return err
}

if err := syncOneScratchFolder(ctx, client, sf.Name, maxAge, existingFolders, allDashboards, allSearchFolders, dryRun, now); err != nil {
return fmt.Errorf("failed to sync scratch folder %q: %w", sf.Name, err)
}
logger.Info("Synced scratch folder", "name", sf.Name, "maxAge", maxAge)
}

return nil
}

// collectScratchFolderUIDs returns a set of UIDs that belong to the scratch folder tree:
// the root folder itself plus all nested subfolders (recursively).
func collectScratchFolderUIDs(rootUID string, allSearchFolders []sdk.FoundBoard) map[string]bool {
uids := map[string]bool{rootUID: true}
changed := true
for changed {
changed = false
for _, f := range allSearchFolders {
if f.Type != "dash-folder" {
continue
}
if uids[f.FolderUID] && !uids[f.UID] {
uids[f.UID] = true
changed = true
}
}
}
return uids
}

func syncOneScratchFolder(ctx context.Context, client scratchGrafanaClient, name string, maxAge time.Duration, existingFolders []sdk.Folder, allDashboards []sdk.FoundBoard, allSearchFolders []sdk.FoundBoard, dryRun bool, now time.Time) error {
logger := logr.FromContextOrDiscard(ctx)

folder, err := findOrCreateFolder(ctx, client, name, existingFolders, dryRun)
if err != nil {
return err
}

if dryRun {
logger.Info("DRY_RUN: Would set permissions on scratch folder", "name", name)
} else {
if err := client.UpdateFolderPermissions(ctx, folder.UID, scratchFolderPermissions...); err != nil {
return fmt.Errorf("failed to set permissions on folder %q: %w", name, err)
}
logger.Info("Set permissions on scratch folder", "name", name)
}
Comment thread
mmazur marked this conversation as resolved.

scratchUIDs := collectScratchFolderUIDs(folder.UID, allSearchFolders)

deletedDashboards := sets.New[string]()
cutoff := now.Add(-maxAge)
for _, db := range allDashboards {
if !scratchUIDs[db.FolderUID] {
continue
}

_, props, err := client.GetDashboardByUID(ctx, db.UID)
if err != nil {
logger.Error(err, "Failed to get metadata for scratch dashboard, skipping", "title", db.Title, "uid", db.UID)
continue
}

if !props.Created.Before(cutoff) {
logger.V(1).Info("Scratch dashboard not expired", "title", db.Title, "uid", db.UID, "created", props.Created, "cutoff", cutoff)
continue
}

if dryRun {
logger.Info("DRY_RUN: Would delete expired scratch dashboard", "title", db.Title, "uid", db.UID, "created", props.Created)
deletedDashboards.Insert(db.UID)
} else {
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)
}
}
}

deleteEmptySubfolders(ctx, client, folder.UID, allDashboards, allSearchFolders, scratchUIDs, deletedDashboards, dryRun)

return nil
}

// deleteEmptySubfolders removes subfolders of the scratch folder that contain no
// dashboards (after expiry deletion). Processes leaf-first so nested empty trees
// are fully removed.
func deleteEmptySubfolders(ctx context.Context, client scratchGrafanaClient, rootUID string, allDashboards []sdk.FoundBoard, allSearchFolders []sdk.FoundBoard, scratchUIDs map[string]bool, deletedDashboards sets.Set[string], dryRun bool) {
// Build parent→children map for subfolders only (exclude root).
children := make(map[string][]sdk.FoundBoard)
for _, f := range allSearchFolders {
if f.Type != "dash-folder" || !scratchUIDs[f.UID] || f.UID == rootUID {
continue
}
children[f.FolderUID] = append(children[f.FolderUID], f)
}

dashCount := make(map[string]int)
for _, db := range allDashboards {
if scratchUIDs[db.FolderUID] && !deletedDashboards.Has(db.UID) {
dashCount[db.FolderUID]++
}
}

// Recursively delete leaf-first, starting from direct children of root.
for _, child := range children[rootUID] {
deleteEmptyRecursive(ctx, client, child.UID, children, dashCount, dryRun)
}
}

// deleteEmptyRecursive walks the subfolder tree depth-first and deletes folders
// that are empty (no dashboards and no remaining children after recursion).
// Returns true if the folder at uid was deleted (or would be in dry-run).
func deleteEmptyRecursive(ctx context.Context, client scratchGrafanaClient, uid string, children map[string][]sdk.FoundBoard, dashCount map[string]int, dryRun bool) bool {
logger := logr.FromContextOrDiscard(ctx).WithValues("uid", uid)

hasChildren := false
for _, child := range children[uid] {
if !deleteEmptyRecursive(ctx, client, child.UID, children, dashCount, dryRun) {
hasChildren = true
}
}

if hasChildren || dashCount[uid] > 0 {
return false
}

if dryRun {
logger.Info("DRY_RUN: Would delete empty scratch subfolder")
return true
}

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
}
return true
}

func findOrCreateFolder(ctx context.Context, client scratchGrafanaClient, name string, existingFolders []sdk.Folder, dryRun bool) (sdk.Folder, error) {
logger := logr.FromContextOrDiscard(ctx)

for _, f := range existingFolders {
if f.Title == name {
logger.V(1).Info("Scratch folder already exists", "name", name, "uid", f.UID)
return f, nil
}
}

if dryRun {
logger.Info("DRY_RUN: Would create scratch folder", "name", name)
return sdk.Folder{Title: name, UID: "dry-run-" + name}, nil
}

folder, err := client.CreateFolder(ctx, name)
if err != nil {
return sdk.Folder{}, fmt.Errorf("failed to create scratch folder %q: %w", name, err)
}

logger.Info("Created scratch folder", "name", name, "uid", folder.UID)
return folder, nil
}
Loading
Loading