Skip to content

Commit

Permalink
feat: add filewatch package to template-validator
Browse files Browse the repository at this point in the history
This package was copied directly from vm-console-proxy:
https://github.com/kubevirt/vm-console-proxy/tree/main/pkg/filewatch

In a future commit, it will replace file watch logic in
internal/template-validator/tlsinfo.

We do this to simplify watching multiple directories with
TRS certificate and TLS configuration.

Signed-off-by: Andrej Krejcir <akrejcir@redhat.com>
  • Loading branch information
akrejcir committed Oct 25, 2024
1 parent 2d0a747 commit 3b2e200
Show file tree
Hide file tree
Showing 2 changed files with 255 additions and 0 deletions.
118 changes: 118 additions & 0 deletions internal/template-validator/filewatch/filewatch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package filewatch

import (
"fmt"
"strings"
"sync"
"sync/atomic"

"github.com/fsnotify/fsnotify"
)

type Watch interface {
Add(path string, callback func()) error
Run(done <-chan struct{}) error
IsRunning() bool
}

func New() Watch {
return &watch{
callbacks: make(map[string]func()),
}
}

type watch struct {
lock sync.Mutex
callbacks map[string]func()
running atomic.Bool
}

var _ Watch = &watch{}

func (w *watch) Add(path string, callback func()) error {
w.lock.Lock()
defer w.lock.Unlock()

if w.running.Load() {
return fmt.Errorf("cannot add to a running watch")
}

w.callbacks[path] = callback
return nil
}

func (w *watch) Run(done <-chan struct{}) error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("could not create fsnotify.Watcher: %w", err)
}
// watcher.Close() never returns an error
defer func() { _ = watcher.Close() }()

func() {
// Before setting running to true, we need to acquire the lock,
// because Add() method may be running concurrently.
w.lock.Lock()
defer w.lock.Unlock()
w.running.Store(true)
}()
// Setting running to false is ok without a lock.
defer w.running.Store(false)

err = w.addCallbacks(watcher)
if err != nil {
return fmt.Errorf("could not add callbacks: %w", err)
}

return w.processEvents(watcher, done)
}

func (w *watch) IsRunning() bool {
return w.running.Load()
}

func (w *watch) addCallbacks(watcher *fsnotify.Watcher) error {
for path := range w.callbacks {
err := watcher.Add(path)
if err != nil {
return fmt.Errorf("failed watch %s: %w", path, err)
}
}
return nil
}

func (w *watch) processEvents(watcher *fsnotify.Watcher, done <-chan struct{}) error {
for {
select {
case <-done:
return nil

case event, ok := <-watcher.Events:
if !ok {
return nil
}
w.handleEvent(event)

case err, ok := <-watcher.Errors:
if !ok {
return nil
}
if err != nil {
return err
}
}
}
}

func (w *watch) handleEvent(event fsnotify.Event) {
const modificationEvents = fsnotify.Create | fsnotify.Write | fsnotify.Remove
if event.Op&modificationEvents == 0 {
return
}

for path, callback := range w.callbacks {
if strings.HasPrefix(event.Name, path) {
callback()
}
}
}
137 changes: 137 additions & 0 deletions internal/template-validator/filewatch/filewatch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package filewatch

import (
"os"
"path/filepath"
"runtime"
"sync/atomic"
"testing"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("Filewatch", func() {
Context("watching file", func() {
var (
callback func()
tempFileName string
)

BeforeEach(func() {
tmpDir := GinkgoT().TempDir()
tempFileName = filepath.Join(tmpDir, "test-file")
Expect(os.WriteFile(tempFileName, []byte("test content"), 0777)).To(Succeed())

callback = func() {
defer GinkgoRecover()
panic("callback was not set")
}

startWatch(tempFileName, func() { callback() })
})

It("should call callback on file change", func() {
called := atomic.Bool{}
callback = func() { called.Store(true) }

Expect(os.WriteFile(tempFileName, []byte("different content"), 0777)).To(Succeed())

Eventually(called.Load, time.Second, 50*time.Millisecond).Should(BeTrue())
})

It("should call callback on file deletion", func() {
called := atomic.Bool{}
callback = func() { called.Store(true) }

Expect(os.Remove(tempFileName)).ToNot(HaveOccurred())

Eventually(called.Load, time.Second, 50*time.Millisecond).Should(BeTrue())
})
})

Context("watching directory", func() {
var (
callback func()
tempDirName string
)

BeforeEach(func() {
tempDirName = GinkgoT().TempDir()

callback = func() {
defer GinkgoRecover()
panic("callback was not set")
}

startWatch(tempDirName, func() { callback() })
})

It("should call callback on file creation", func() {
called := atomic.Bool{}
callback = func() { called.Store(true) }

Expect(os.WriteFile(filepath.Join(tempDirName, "created-file"), []byte("content"), 0777)).To(Succeed())

Eventually(called.Load, time.Second, 50*time.Millisecond).Should(BeTrue())
})

It("should call callback on file change", func() {
called := atomic.Bool{}
callback = func() { called.Store(true) }

const filename = "test-file"
Expect(os.WriteFile(filepath.Join(tempDirName, filename), []byte("content"), 0777)).To(Succeed())

Eventually(called.Load, time.Second, 50*time.Millisecond).Should(BeTrue())

called.Store(false)

Expect(os.WriteFile(filepath.Join(tempDirName, filename), []byte("updated content"), 0777)).To(Succeed())

Eventually(called.Load, time.Second, 50*time.Millisecond).Should(BeTrue())
})

It("should call callback on file deletion", func() {
called := atomic.Bool{}
callback = func() { called.Store(true) }

const filename = "test-file"
Expect(os.WriteFile(filepath.Join(tempDirName, filename), []byte("content"), 0777)).To(Succeed())

Eventually(called.Load, time.Second, 50*time.Millisecond).Should(BeTrue())

called.Store(false)

Expect(os.Remove(filepath.Join(tempDirName, filename))).ToNot(HaveOccurred())

Eventually(called.Load, time.Second, 50*time.Millisecond).Should(BeTrue())
})
})
})

func startWatch(path string, callback func()) {
testWatch := New()
err := testWatch.Add(path, callback)
Expect(err).ToNot(HaveOccurred())

done := make(chan struct{})
DeferCleanup(func() {
close(done)
})

go func() {
defer GinkgoRecover()
Expect(testWatch.Run(done)).To(Succeed())
}()

// Wait for a short time to let the watch goroutine run
runtime.Gosched()
Eventually(testWatch.IsRunning, time.Second, 50*time.Millisecond).Should(BeTrue())
}

func TestFilewatch(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Filewatch Suite")
}

0 comments on commit 3b2e200

Please sign in to comment.