Skip to content

Commit

Permalink
Added support for hot reload of UI config (jaegertracing#1688)
Browse files Browse the repository at this point in the history
Signed-off-by: Juraci Paixão Kröhling <[email protected]>
  • Loading branch information
jpkrohling authored Aug 8, 2019
1 parent 9f0c9e1 commit 98fd69a
Show file tree
Hide file tree
Showing 3 changed files with 147 additions and 10 deletions.
8 changes: 8 additions & 0 deletions cmd/query/app/fixture/ui-config-hotreload.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"menu": [
{
"label": "About Jaeger"
}
]
}

99 changes: 91 additions & 8 deletions cmd/query/app/static_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -44,24 +46,28 @@ 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
}

// StaticAssetsHandlerOptions defines options for NewStaticAssetsHandler
type StaticAssetsHandlerOptions struct {
BasePath string
UIConfigPath string
Logger *zap.Logger
}

// NewStaticAssetsHandler returns a StaticAssetsHandler
Expand All @@ -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")
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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))
}
50 changes: 48 additions & 2 deletions cmd/query/app/static_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 98fd69a

Please sign in to comment.