Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

http: fix a bug that would cause runtimeConfig to be cached #9923

Merged
merged 3 commits into from
Mar 25, 2021
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/9923.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-notes:bug
http: fix a bug in Consul Enterprise that would cause the UI to believe namespaces were supported, resulting in warning logs and incorrect UI behaviour.
```
7 changes: 2 additions & 5 deletions agent/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ func (s *HTTPHandlers) handler(enableDebug bool) http.Handler {
uiHandler := uiserver.NewHandler(
s.agent.config,
s.agent.logger.Named(logging.HTTP),
s.uiTemplateDataTransform(),
s.uiTemplateDataTransform,
)
s.configReloaders = append(s.configReloaders, uiHandler.ReloadConfig)

Expand All @@ -298,10 +298,7 @@ func (s *HTTPHandlers) handler(enableDebug bool) http.Handler {
uiHandler,
s.agent.config.HTTPResponseHeaders,
)
mux.Handle(
"/robots.txt",
uiHandlerWithHeaders,
)
mux.Handle("/robots.txt", uiHandlerWithHeaders)
mux.Handle(
s.agent.config.UIConfig.ContentPath,
http.StripPrefix(
Expand Down
3 changes: 1 addition & 2 deletions agent/http_oss.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"strings"

"github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/agent/uiserver"
)

func (s *HTTPHandlers) parseEntMeta(req *http.Request, entMeta *structs.EnterpriseMeta) error {
Expand Down Expand Up @@ -71,6 +70,6 @@ func (s *HTTPHandlers) enterpriseHandler(next http.Handler) http.Handler {

// uiTemplateDataTransform returns an optional uiserver.UIDataTransform to allow
// altering UI data in enterprise.
func (s *HTTPHandlers) uiTemplateDataTransform() uiserver.UIDataTransform {
func (s *HTTPHandlers) uiTemplateDataTransform(data map[string]interface{}) error {
return nil
}
120 changes: 47 additions & 73 deletions agent/uiserver/uiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import (
"sync/atomic"
"text/template"

"github.com/hashicorp/go-hclog"

"github.com/hashicorp/consul/agent/config"
"github.com/hashicorp/consul/logging"
"github.com/hashicorp/go-hclog"
)

const (
Expand All @@ -26,20 +27,12 @@ const (
// transformations on the index.html file and includes a proxy for metrics
// backends.
type Handler struct {
// state is a reloadableState struct accessed through an atomic value to make
// runtimeConfig is a struct accessed through an atomic value to make
// it safe to reload at run time. Each call to ServeHTTP will see the latest
// version of the state without internal locking needed.
state atomic.Value
logger hclog.Logger
transform UIDataTransform
}

// reloadableState encapsulates all the state that might be modified during
// ReloadConfig.
type reloadableState struct {
cfg *config.UIConfig
srv http.Handler
err error
runtimeConfig atomic.Value
logger hclog.Logger
transform UIDataTransform
}

// UIDataTransform is an optional dependency that allows the agent to add
Expand All @@ -50,71 +43,60 @@ type reloadableState struct {
// It is passed the current RuntimeConfig being applied and a map containing the
// current data that will be passed to the template. It should be modified
// directly to inject additional context.
type UIDataTransform func(cfg *config.RuntimeConfig, data map[string]interface{}) error
type UIDataTransform func(data map[string]interface{}) error

// NewHandler returns a Handler that can be used to serve UI http requests. It
// accepts a full agent config since properties like ACLs being enabled affect
// the UI so we need more than just UIConfig parts.
func NewHandler(agentCfg *config.RuntimeConfig, logger hclog.Logger, transform UIDataTransform) *Handler {
func NewHandler(runtimeCfg *config.RuntimeConfig, logger hclog.Logger, transform UIDataTransform) *Handler {
h := &Handler{
logger: logger.Named(logging.UIServer),
transform: transform,
}
// Don't return the error since this is likely the result of a
// misconfiguration and reloading config could fix it. Instead we'll capture
// it and return an error for all calls to ServeHTTP so the misconfiguration
// is visible. Sadly we can't log effectively
if err := h.ReloadConfig(agentCfg); err != nil {
h.state.Store(reloadableState{
err: err,
})
}
h.runtimeConfig.Store(runtimeCfg)
return h
}

// ServeHTTP implements http.Handler and serves UI HTTP requests
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {

// We need to support the path being trimmed by http.StripTags just like the
// file servers do since http.StripPrefix will remove the leading slash in our
// current config. Everything else works fine that way so we should to.
pathTrimmed := strings.TrimLeft(r.URL.Path, "/")
if pathTrimmed == compiledProviderJSPath {
h.serveUIMetricsProviders(w, r)
h.serveUIMetricsProviders(w)
return
}

s := h.getState()
if s == nil {
panic("nil state")
}
if s.err != nil {
srv, err := h.handleIndex()
if err != nil {
http.Error(w, "UI server is misconfigured.", http.StatusInternalServerError)
h.logger.Error("Failed to configure UI server: %s", s.err)
h.logger.Error("Failed to configure UI server: %s", err)
return
}
s.srv.ServeHTTP(w, r)
srv.ServeHTTP(w, r)
}

// ReloadConfig is called by the agent when the configuration is reloaded and
// updates the UIConfig values the handler uses to serve requests.
func (h *Handler) ReloadConfig(newCfg *config.RuntimeConfig) error {
newState := reloadableState{
cfg: &newCfg.UIConfig,
}
h.runtimeConfig.Store(newCfg)
return nil
}

var fs http.FileSystem
func (h *Handler) handleIndex() (http.Handler, error) {
cfg := h.getRuntimeConfig()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment about this returning nil


if newCfg.UIConfig.Dir == "" {
// Serve from assetFS
var fs http.FileSystem
if cfg.UIConfig.Dir == "" {
fs = assetFS()
} else {
fs = http.Dir(newCfg.UIConfig.Dir)
fs = http.Dir(cfg.UIConfig.Dir)
}

// Render a new index.html with the new config values ready to serve.
buf, info, err := h.renderIndex(newCfg, fs)
if _, ok := err.(*os.PathError); ok && newCfg.UIConfig.Dir != "" {
buf, info, err := h.renderIndex(cfg, fs)
if _, ok := err.(*os.PathError); ok && cfg.UIConfig.Dir != "" {
// A Path error indicates that there is no index.html. This could happen if
// the user configured their own UI dir and is serving something that is not
// our usual UI. This won't work perfectly because our uiserver will still
Expand All @@ -123,55 +105,47 @@ func (h *Handler) ReloadConfig(newCfg *config.RuntimeConfig) error {
// breaking change although quite an edge case. Instead, continue but just
// return a 404 response for the index.html and log a warning.
h.logger.Warn("ui_config.dir does not contain an index.html. Index templating and redirects to index.html are disabled.")
} else if err != nil {
return err
return http.FileServer(fs), nil
}

// buf can be nil in the PathError case above. We should skip this part but
// still serve the rest of the files in that case.
if buf != nil {
// Create a new fs that serves the rendered index file or falls back to the
// underlying FS.
fs = &bufIndexFS{
fs: fs,
indexRendered: buf,
indexInfo: info,
}

// Wrap the buffering FS our redirect FS. This needs to happen later so that
// redirected requests for /index.html get served the rendered version not the
// original.
fs = &redirectFS{fs: fs}
if err != nil {
return nil, err
}

newState.srv = http.FileServer(fs)
// Create a new fs that serves the rendered index file or falls back to the
// underlying FS.
fs = &bufIndexFS{
fs: fs,
indexRendered: buf,
indexInfo: info,
}

// Store the new state
h.state.Store(newState)
return nil
// Wrap the buffering FS our redirect FS. This needs to happen later so that
// redirected requests for /index.html get served the rendered version not the
// original.
return http.FileServer(&redirectFS{fs: fs}), nil
}

// getState is a helper to access the atomic internal state
func (h *Handler) getState() *reloadableState {
if cfg, ok := h.state.Load().(reloadableState); ok {
return &cfg
// getRuntimeConfig is a helper to atomically access the runtime config.
func (h *Handler) getRuntimeConfig() *config.RuntimeConfig {
if cfg, ok := h.runtimeConfig.Load().(*config.RuntimeConfig); ok {
return cfg
}
return nil
}

func (h *Handler) serveUIMetricsProviders(resp http.ResponseWriter, req *http.Request) {
func (h *Handler) serveUIMetricsProviders(resp http.ResponseWriter) {
// Reload config in case it's changed
state := h.getState()
cfg := h.getRuntimeConfig()
Copy link
Member

@rboyer rboyer Mar 24, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be nil and prior code forced a quick panic in that case which is no longer present. Just wanted to verify that was intended.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya, some callers did explicitly panic with a custom message, and others did not. In practice it should never be nil, but if there is a bug, it will panic on the next line either way. A panic on line 140 should be unambiguous because the UIConfig field is not a pointer, so only cfg could be nil.


if len(state.cfg.MetricsProviderFiles) < 1 {
if len(cfg.UIConfig.MetricsProviderFiles) < 1 {
http.Error(resp, "No provider JS files configured", http.StatusNotFound)
return
}

var buf bytes.Buffer

// Open each one and concatenate them
for _, file := range state.cfg.MetricsProviderFiles {
for _, file := range cfg.UIConfig.MetricsProviderFiles {
if err := concatFile(&buf, file); err != nil {
http.Error(resp, "Internal Server Error", http.StatusInternalServerError)
h.logger.Error("failed serving metrics provider js file", "file", file, "error", err)
Expand Down Expand Up @@ -237,7 +211,7 @@ func (h *Handler) renderIndex(cfg *config.RuntimeConfig, fs http.FileSystem) ([]

// Allow caller to apply additional data transformations if needed.
if h.transform != nil {
if err := h.transform(cfg, tplData); err != nil {
if err := h.transform(tplData); err != nil {
return nil, nil, fmt.Errorf("failed running transform: %w", err)
}
}
Expand Down
58 changes: 56 additions & 2 deletions agent/uiserver/uiserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import (
"strings"
"testing"

"github.com/hashicorp/go-hclog"
"github.com/stretchr/testify/require"
"golang.org/x/net/html"

"github.com/hashicorp/consul/agent/config"
"github.com/hashicorp/consul/sdk/testutil"
"github.com/stretchr/testify/require"
)

func TestUIServerIndex(t *testing.T) {
Expand Down Expand Up @@ -105,7 +106,7 @@ func TestUIServerIndex(t *testing.T) {
withMetricsProvider("foo"),
),
path: "/",
tx: func(cfg *config.RuntimeConfig, data map[string]interface{}) error {
tx: func(data map[string]interface{}) error {
data["SSOEnabled"] = true
o := data["UIConfig"].(map[string]interface{})
o["metrics_provider"] = "bar"
Expand Down Expand Up @@ -359,3 +360,56 @@ func TestCompiledJS(t *testing.T) {
}

}

func TestHandler_ServeHTTP_TransformIsEvaluatedOnEachRequest(t *testing.T) {
cfg := basicUIEnabledConfig()

value := "seeds"
transform := func(data map[string]interface{}) error {
data["apple"] = value
return nil
}
h := NewHandler(cfg, hclog.New(nil), transform)

t.Run("initial request", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)
expected := `{
"ACLsEnabled": false,
"LocalDatacenter": "dc1",
"ContentPath": "/ui/",
"UIConfig": {
"metrics_provider": "",
"metrics_proxy_enabled": false,
"dashboard_url_templates": null
},
"apple": "seeds"
}`
require.JSONEq(t, expected, extractUIConfig(t, rec.Body.String()))
})

t.Run("transform value has changed", func(t *testing.T) {

value = "plant"
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)
expected := `{
"ACLsEnabled": false,
"LocalDatacenter": "dc1",
"ContentPath": "/ui/",
"UIConfig": {
"metrics_provider": "",
"metrics_proxy_enabled": false,
"dashboard_url_templates": null
},
"apple": "plant"
}`
require.JSONEq(t, expected, extractUIConfig(t, rec.Body.String()))
})
}