Provisioning: Watch file system for changes (#112184)

* trigger sync on any change

* better comments

* add deletes to test

* Update apps/provisioning/pkg/repository/local/watch.go

* Update pkg/services/provisioning/dashboards/file_reader.go

* Update apps/provisioning/pkg/repository/local/watch.go

---------

Co-authored-by: Stephanie Hingtgen <stephanie.hingtgen@grafana.com>
This commit is contained in:
Ryan McKinley
2025-10-10 17:26:59 +00:00
committed by GitHub
co-authored by Stephanie Hingtgen
parent 7a26d48e88
commit f1e456eb01
5 changed files with 265 additions and 2 deletions
+1
View File
@@ -3,6 +3,7 @@ module github.com/grafana/grafana/apps/provisioning
go 1.25.2
require (
github.com/fsnotify/fsnotify v1.9.0
github.com/google/go-github/v70 v70.0.0
github.com/google/uuid v1.6.0
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f
+2
View File
@@ -10,6 +10,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
@@ -0,0 +1,130 @@
package local
import (
"context"
"fmt"
"io/fs"
"math"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/grafana/grafana-app-sdk/logging"
)
type FileWatcher interface {
Watch(ctx context.Context, events chan<- string)
}
type fileWatcher struct {
prefix string
accept func(string) bool
waitFor time.Duration
timersMu sync.Mutex
timers map[string]*time.Timer
watcher *fsnotify.Watcher
logger logging.Logger
}
// File watcher that buffers events for 100ms before actually firing them
// this is helpful because editing a file may often update the same file many many times
// for what seems like a single operation.
// See: https://github.com/fsnotify/fsnotify/blob/main/cmd/fsnotify/dedup.go
func NewFileWatcher(path string, accept func(string) bool) (FileWatcher, error) {
info, _ := os.Stat(path)
if info == nil || !info.IsDir() {
return nil, fmt.Errorf("expecting to watch a folder")
}
w, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
if err := w.Add(path); err != nil {
_ = w.Close()
return nil, err
}
if err = filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if err = w.Add(path); err != nil {
return err
}
}
return nil
}); err != nil {
_ = w.Close()
return nil, err
}
return &fileWatcher{
prefix: path + "/",
accept: accept,
waitFor: 100 * time.Millisecond,
timers: make(map[string]*time.Timer),
watcher: w,
logger: logging.DefaultLogger.With("watch", path),
}, nil
}
// Keep watching for changes until the context is done
func (f *fileWatcher) Watch(ctx context.Context, events chan<- string) {
for {
select {
case <-ctx.Done():
close(events)
return
case _, ok := <-f.watcher.Errors:
if !ok { // Channel was closed (i.e. Watcher.Close() was called).
close(events)
return
}
// Read from Events.
case e, ok := <-f.watcher.Events:
if !ok { // Channel was closed (i.e. Watcher.Close() was called).
close(events)
return
}
name := filepath.Base(e.Name)
if strings.HasPrefix(name, ".") {
continue // ignore hidden files+folders
}
if !f.accept(name) {
info, _ := os.Stat(e.Name)
if info != nil && info.IsDir() {
if err := f.watcher.Add(e.Name); err != nil {
f.logger.Warn("error adding folder", "folder", e.Name, "error", err)
}
}
continue
}
f.timersMu.Lock()
t, ok := f.timers[e.Name]
if !ok {
nameCopy := e.Name
t = time.AfterFunc(math.MaxInt64, func() {
path, _ := strings.CutPrefix(nameCopy, f.prefix)
events <- path
f.timersMu.Lock()
delete(f.timers, nameCopy)
f.timersMu.Unlock()
})
f.timers[e.Name] = t
}
f.timersMu.Unlock()
t.Reset(f.waitFor)
}
}
}
@@ -0,0 +1,84 @@
package local
import (
"context"
"os"
"path/filepath"
"slices"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestWatch_TempFiles(t *testing.T) {
tmpdir := t.TempDir()
sub1 := filepath.Join(tmpdir, "sub1")
err := os.MkdirAll(sub1, 0700)
require.NoError(t, err)
watcher, err := NewFileWatcher(tmpdir, func(name string) bool {
return filepath.Ext(name) == ".txt"
})
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
events := make(chan string, 10)
go func() {
watcher.Watch(ctx, events)
}()
go func() {
time.Sleep(20 * time.Millisecond)
err = os.WriteFile(filepath.Join(tmpdir, "aaa.txt"), []byte("aaa"), 0600)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(tmpdir, ".hidden.txt"), []byte("hidden"), 0600)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(tmpdir, "bbb.txt"), []byte("bbb"), 0600)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(tmpdir, "xxx.json"), []byte("ignore"), 0600)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(sub1, "ccc.txt"), []byte("ccc"), 0600)
require.NoError(t, err)
// make a sub folder
sub2 := filepath.Join(tmpdir, "sub2")
err = os.MkdirAll(sub2, 0700)
require.NoError(t, err)
time.Sleep(50 * time.Millisecond)
err = os.WriteFile(filepath.Join(sub2, "ddd.txt"), []byte("ddd"), 0600)
require.NoError(t, err)
// Check all the paths we are watching
w, ok := watcher.(*fileWatcher)
require.True(t, ok, "explicit cast")
watching := w.watcher.WatchList()
slices.Sort(watching)
require.Equal(t, []string{
tmpdir, sub1, sub2,
}, watching)
// Removing the subfolder should trigger the event again
time.Sleep(time.Millisecond * 150)
err = os.RemoveAll(sub2)
require.NoError(t, err)
// Finish all the events
time.Sleep(time.Millisecond * 250)
cancel() // stops the context
}()
received := []string{}
for event := range events {
received = append(received, event)
}
slices.Sort(received)
require.Equal(t, []string{
"aaa.txt",
"bbb.txt",
"sub1/ccc.txt",
"sub2/ddd.txt", // first time because we added it
"sub2/ddd.txt", // second time because we removed it
}, received)
}
@@ -11,6 +11,7 @@ import (
"sync"
"time"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/local"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
@@ -80,12 +81,22 @@ func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.Dash
// pollChanges periodically runs walkDisk based on interval specified in the config.
func (fr *FileReader) pollChanges(ctx context.Context) {
ticker := time.NewTicker(time.Duration(int64(time.Second) * fr.Cfg.UpdateIntervalSeconds))
interval := fr.Cfg.UpdateIntervalSeconds
if interval <= 10 { // the minimum time
err := fr.watchChanges(ctx)
if err == nil {
return // finished
}
fr.log.Warn("error watching folder: %w", err)
interval = 30
}
ticker := time.NewTicker(time.Duration(int64(time.Second) * interval))
for {
select {
case <-ticker.C:
if err := fr.walkDisk(ctx); err != nil {
fr.log.Error("failed to search for dashboards", "error", err)
fr.log.Error("failed to walk provisioned dashboards", "error", err)
}
case <-ctx.Done():
return
@@ -93,6 +104,41 @@ func (fr *FileReader) pollChanges(ctx context.Context) {
}
}
func (fr *FileReader) watchChanges(ctx context.Context) error {
watcher, err := local.NewFileWatcher(fr.resolvedPath(), func(name string) bool {
return strings.HasSuffix(name, ".json")
})
if err != nil {
return err
}
changed := false
events := make(chan string, 10)
go func() {
for {
select {
case <-ctx.Done():
return
case _, ok := <-events:
// channel closed
if !ok {
return
}
changed = true
case <-time.After(time.Second * 5): // 5s maximum refresh
if changed {
if err := fr.walkDisk(ctx); err != nil {
fr.log.Error("failed to walk provisioned dashboards", "error", err)
}
changed = false
}
}
}
}()
watcher.Watch(ctx, events)
return nil
}
// walkDisk traverses the file system for the defined path, reading dashboard definition files,
// and applies any change to the database.
func (fr *FileReader) walkDisk(ctx context.Context) error {