From 98fd69acc7cc432605eb08792af8a38ecaec5220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juraci=20Paix=C3=A3o=20Kr=C3=B6hling?= Date: Thu, 8 Aug 2019 09:53:51 +0200 Subject: [PATCH] Added support for hot reload of UI config (#1688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Juraci Paixão Kröhling --- .../app/fixture/ui-config-hotreload.json | 8 ++ cmd/query/app/static_handler.go | 99 +++++++++++++++++-- cmd/query/app/static_handler_test.go | 50 +++++++++- 3 files changed, 147 insertions(+), 10 deletions(-) create mode 100644 cmd/query/app/fixture/ui-config-hotreload.json diff --git a/cmd/query/app/fixture/ui-config-hotreload.json b/cmd/query/app/fixture/ui-config-hotreload.json new file mode 100644 index 00000000000..35ea860347c --- /dev/null +++ b/cmd/query/app/fixture/ui-config-hotreload.json @@ -0,0 +1,8 @@ +{ + "menu": [ + { + "label": "About Jaeger" + } + ] +} + diff --git a/cmd/query/app/static_handler.go b/cmd/query/app/static_handler.go index 48060fcf8b5..91f6176b44b 100644 --- a/cmd/query/app/static_handler.go +++ b/cmd/query/app/static_handler.go @@ -22,7 +22,9 @@ import ( "path/filepath" "regexp" "strings" + "sync/atomic" + "github.com/fsnotify/fsnotify" "github.com/gorilla/mux" "github.com/pkg/errors" "go.uber.org/zap" @@ -44,17 +46,20 @@ func RegisterStaticHandler(r *mux.Router, logger *zap.Logger, qOpts *QueryOption staticHandler, err := NewStaticAssetsHandler(qOpts.StaticAssets, StaticAssetsHandlerOptions{ BasePath: qOpts.BasePath, UIConfigPath: qOpts.UIConfig, + Logger: logger, }) + if err != nil { logger.Panic("Could not create static assets handler", zap.Error(err)) } + staticHandler.RegisterRoutes(r) } // StaticAssetsHandler handles static assets type StaticAssetsHandler struct { options StaticAssetsHandlerOptions - indexHTML []byte + indexHTML atomic.Value // stores []byte assetsFS http.FileSystem } @@ -62,6 +67,7 @@ type StaticAssetsHandler struct { type StaticAssetsHandlerOptions struct { BasePath string UIConfigPath string + Logger *zap.Logger } // NewStaticAssetsHandler returns a StaticAssetsHandler @@ -70,7 +76,29 @@ func NewStaticAssetsHandler(staticAssetsRoot string, options StaticAssetsHandler if staticAssetsRoot != "" { assetsFS = http.Dir(staticAssetsRoot) } - indexBytes, err := loadIndexHTML(assetsFS.Open) + + if options.Logger == nil { + options.Logger = zap.NewNop() + } + + indexHTML, err := loadIndexBytes(assetsFS.Open, options) + if err != nil { + return nil, err + } + + h := &StaticAssetsHandler{ + options: options, + assetsFS: assetsFS, + } + + h.indexHTML.Store(indexHTML) + h.watch() + + return h, nil +} + +func loadIndexBytes(open func(string) (http.File, error), options StaticAssetsHandlerOptions) ([]byte, error) { + indexBytes, err := loadIndexHTML(open) if err != nil { return nil, errors.Wrap(err, "cannot load index.html") } @@ -94,11 +122,66 @@ func NewStaticAssetsHandler(staticAssetsRoot string, options StaticAssetsHandler } indexBytes = basePathPattern.ReplaceAll(indexBytes, []byte(fmt.Sprintf(basePathReplace, options.BasePath))) } - return &StaticAssetsHandler{ - options: options, - indexHTML: indexBytes, - assetsFS: assetsFS, - }, nil + + return indexBytes, nil +} + +func (sH *StaticAssetsHandler) watch() { + if sH.options.UIConfigPath == "" { + return + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + sH.options.Logger.Error("failed to create a new watcher for the UI config", zap.Error(err)) + return + } + + go func() { + for { + select { + case event := <-watcher.Events: + if event.Op&fsnotify.Remove == fsnotify.Remove { + // this might be related to a file inside the dir, so, just log a warn if this is about the file we care about + // otherwise, just ignore the event + if event.Name == sH.options.UIConfigPath { + sH.options.Logger.Warn("the UI config file has been removed, using the last known version") + } + continue + } + + // this will catch events for all files inside the same directory, which is OK if we don't have many changes + sH.options.Logger.Info("reloading UI config", zap.String("filename", sH.options.UIConfigPath)) + + content, err := loadIndexBytes(sH.assetsFS.Open, sH.options) + if err != nil { + sH.options.Logger.Error("error while reloading the UI config", zap.Error(err)) + } + + sH.indexHTML.Store(content) + case err, ok := <-watcher.Errors: + if !ok { + return + } + sH.options.Logger.Error("event", zap.Error(err)) + } + } + }() + + err = watcher.Add(sH.options.UIConfigPath) + if err != nil { + sH.options.Logger.Error("error adding watcher to file", zap.String("file", sH.options.UIConfigPath), zap.Error(err)) + } else { + sH.options.Logger.Info("watching", zap.String("file", sH.options.UIConfigPath)) + } + + dir := filepath.Dir(sH.options.UIConfigPath) + err = watcher.Add(dir) + if err != nil { + sH.options.Logger.Error("error adding watcher to dir", zap.String("dir", dir), zap.Error(err)) + } else { + sH.options.Logger.Info("watching", zap.String("dir", dir)) + } } func loadIndexHTML(open func(string) (http.File, error)) ([]byte, error) { @@ -155,5 +238,5 @@ func (sH *StaticAssetsHandler) RegisterRoutes(router *mux.Router) { func (sH *StaticAssetsHandler) notFound(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write(sH.indexHTML) + w.Write(sH.indexHTML.Load().([]byte)) } diff --git a/cmd/query/app/static_handler_test.go b/cmd/query/app/static_handler_test.go index 3545e92c491..7117c602582 100644 --- a/cmd/query/app/static_handler_test.go +++ b/cmd/query/app/static_handler_test.go @@ -20,6 +20,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "time" @@ -62,7 +63,7 @@ func TestRegisterStaticHandler(t *testing.T) { } for _, testCase := range testCases { t.Run("basePath="+testCase.basePath, func(t *testing.T) { - logger, buf := testutils.NewLogger() + logger, _ := testutils.NewLogger() r := mux.NewRouter() if testCase.subroute { r = r.PathPrefix(testCase.basePath).Subrouter() @@ -72,7 +73,6 @@ func TestRegisterStaticHandler(t *testing.T) { BasePath: testCase.basePath, UIConfig: "fixture/ui-config.json", }) - assert.Empty(t, buf.String(), "no logs during construction") server := httptest.NewServer(r) defer server.Close() @@ -113,6 +113,52 @@ func TestNewStaticAssetsHandlerErrors(t *testing.T) { } } +// This test is potentially intermittent +func TestHotReloadUIConfigTempFile(t *testing.T) { + tmpfile, err := ioutil.TempFile("", "ui-config-hotreload.*.json") + assert.NoError(t, err) + + tmpFileName := tmpfile.Name() + defer os.Remove(tmpFileName) + + content, err := ioutil.ReadFile("fixture/ui-config-hotreload.json") + assert.NoError(t, err) + + err = ioutil.WriteFile(tmpFileName, content, 0644) + assert.NoError(t, err) + + h, err := NewStaticAssetsHandler("fixture", StaticAssetsHandlerOptions{ + UIConfigPath: tmpFileName, + }) + assert.NoError(t, err) + + c := string(h.indexHTML.Load().([]byte)) + assert.Contains(t, c, "About Jaeger") + + newContent := strings.Replace(string(content), "About Jaeger", "About a new Jaeger", 1) + err = ioutil.WriteFile(tmpFileName, []byte(newContent), 0644) + assert.NoError(t, err) + + done := make(chan bool) + go func() { + for { + i := string(h.indexHTML.Load().([]byte)) + + if strings.Contains(i, "About a new Jaeger") { + done <- true + } + time.Sleep(10 * time.Millisecond) + } + }() + + select { + case <-done: + assert.Contains(t, string(h.indexHTML.Load().([]byte)), "About a new Jaeger") + case <-time.After(time.Second): + assert.Fail(t, "timed out waiting for the hot reload to kick in") + } +} + func TestLoadUIConfig(t *testing.T) { type testCase struct { configFile string