mirror of https://github.com/hashicorp/consul
Paul Banks
4 years ago
20 changed files with 767 additions and 315 deletions
@ -0,0 +1,7 @@
|
||||
package agent |
||||
|
||||
import "github.com/hashicorp/consul/agent/config" |
||||
|
||||
// ConfigReloader is a function type which may be implemented to support reloading
|
||||
// of configuration.
|
||||
type ConfigReloader func(rtConfig *config.RuntimeConfig) error |
File diff suppressed because one or more lines are too long
@ -0,0 +1,21 @@
|
||||
package uiserver |
||||
|
||||
import ( |
||||
"net/http" |
||||
"os" |
||||
) |
||||
|
||||
// bufIndexFS is an implementation of http.FS that intercepts requests for
|
||||
// the index.html file and returns a pre-rendered file from memory.
|
||||
type bufIndexFS struct { |
||||
fs http.FileSystem |
||||
indexRendered []byte |
||||
indexInfo os.FileInfo |
||||
} |
||||
|
||||
func (fs *bufIndexFS) Open(name string) (http.File, error) { |
||||
if name == "/index.html" { |
||||
return newBufferedFile(fs.indexRendered, fs.indexInfo), nil |
||||
} |
||||
return fs.fs.Open(name) |
||||
} |
@ -0,0 +1,67 @@
|
||||
package uiserver |
||||
|
||||
import ( |
||||
"bytes" |
||||
"errors" |
||||
"os" |
||||
"time" |
||||
) |
||||
|
||||
// bufferedFile implements os.File and allows us to modify a file from disk by
|
||||
// writing out the new version into a buffer and then serving file reads from
|
||||
// that.
|
||||
type bufferedFile struct { |
||||
buf *bytes.Reader |
||||
info os.FileInfo |
||||
} |
||||
|
||||
func newBufferedFile(buf []byte, info os.FileInfo) *bufferedFile { |
||||
return &bufferedFile{ |
||||
buf: bytes.NewReader(buf), |
||||
info: info, |
||||
} |
||||
} |
||||
|
||||
func (t *bufferedFile) Read(p []byte) (n int, err error) { |
||||
return t.buf.Read(p) |
||||
} |
||||
|
||||
func (t *bufferedFile) Seek(offset int64, whence int) (int64, error) { |
||||
return t.buf.Seek(offset, whence) |
||||
} |
||||
|
||||
func (t *bufferedFile) Close() error { |
||||
return nil |
||||
} |
||||
|
||||
func (t *bufferedFile) Readdir(count int) ([]os.FileInfo, error) { |
||||
return nil, errors.New("not a directory") |
||||
} |
||||
|
||||
func (t *bufferedFile) Stat() (os.FileInfo, error) { |
||||
return t, nil |
||||
} |
||||
|
||||
func (t *bufferedFile) Name() string { |
||||
return t.info.Name() |
||||
} |
||||
|
||||
func (t *bufferedFile) Size() int64 { |
||||
return int64(t.buf.Len()) |
||||
} |
||||
|
||||
func (t *bufferedFile) Mode() os.FileMode { |
||||
return t.info.Mode() |
||||
} |
||||
|
||||
func (t *bufferedFile) ModTime() time.Time { |
||||
return t.info.ModTime() |
||||
} |
||||
|
||||
func (t *bufferedFile) IsDir() bool { |
||||
return false |
||||
} |
||||
|
||||
func (t *bufferedFile) Sys() interface{} { |
||||
return nil |
||||
} |
@ -0,0 +1,21 @@
|
||||
package uiserver |
||||
|
||||
import "net/http" |
||||
|
||||
// redirectFS is an http.FS that serves the index.html file for any path that is
|
||||
// not found on the underlying FS.
|
||||
//
|
||||
// TODO: it seems better to actually 404 bad paths or at least redirect them
|
||||
// rather than pretend index.html is everywhere but this is behavior changing
|
||||
// so I don't want to take it on as part of this refactor.
|
||||
type redirectFS struct { |
||||
fs http.FileSystem |
||||
} |
||||
|
||||
func (fs *redirectFS) Open(name string) (http.File, error) { |
||||
file, err := fs.fs.Open(name) |
||||
if err != nil { |
||||
file, err = fs.fs.Open("/index.html") |
||||
} |
||||
return file, err |
||||
} |
@ -0,0 +1,52 @@
|
||||
package uiserver |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"net/url" |
||||
|
||||
"github.com/hashicorp/consul/agent/config" |
||||
) |
||||
|
||||
// uiTemplateDataFromConfig returns the set of variables that should be injected
|
||||
// into the UI's Env based on the given runtime UI config.
|
||||
func uiTemplateDataFromConfig(cfg *config.RuntimeConfig) (map[string]interface{}, error) { |
||||
|
||||
uiCfg := map[string]interface{}{ |
||||
"metrics_provider": cfg.UIConfig.MetricsProvider, |
||||
// We explicitly MUST NOT pass the metrics_proxy object since it might
|
||||
// contain add_headers with secrets that the UI shouldn't know e.g. API
|
||||
// tokens for the backend. The provider should either require the proxy to
|
||||
// be configured and then use that or hit the backend directly from the
|
||||
// browser.
|
||||
"metrics_proxy_enabled": cfg.UIConfig.MetricsProxy.BaseURL != "", |
||||
"dashboard_url_templates": cfg.UIConfig.DashboardURLTemplates, |
||||
} |
||||
|
||||
// Only set this if there is some actual JSON or we'll cause a JSON
|
||||
// marshalling error later during serving which ends up being silent.
|
||||
if cfg.UIConfig.MetricsProviderOptionsJSON != "" { |
||||
uiCfg["metrics_provider_options"] = json.RawMessage(cfg.UIConfig.MetricsProviderOptionsJSON) |
||||
} |
||||
|
||||
d := map[string]interface{}{ |
||||
"ContentPath": cfg.UIConfig.ContentPath, |
||||
"ACLsEnabled": cfg.ACLsEnabled, |
||||
} |
||||
|
||||
err := uiTemplateDataFromConfigEnterprise(cfg, d, uiCfg) |
||||
|
||||
// Render uiCfg down to JSON ready to inject into the template
|
||||
bs, err := json.Marshal(uiCfg) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed marshalling UI Env JSON: %s", err) |
||||
} |
||||
// Need to also URLEncode it as it is passed through a META tag value. Path
|
||||
// variant is correct to avoid converting spaces to "+". Note we don't just
|
||||
// use html/template because it strips comments and uses a different encoding
|
||||
// for this param than Ember which is OK but just one more weird thing to
|
||||
// account for in the source...
|
||||
d["UIConfigJSON"] = url.PathEscape(string(bs)) |
||||
|
||||
return d, err |
||||
} |
@ -0,0 +1,9 @@
|
||||
// +build !consulent
|
||||
|
||||
package uiserver |
||||
|
||||
import "github.com/hashicorp/consul/agent/config" |
||||
|
||||
func uiTemplateDataFromConfigEnterprise(_ *config.RuntimeConfig, _ map[string]interface{}, _ map[string]interface{}) error { |
||||
return nil |
||||
} |
@ -0,0 +1,176 @@
|
||||
package uiserver |
||||
|
||||
import ( |
||||
"bytes" |
||||
"fmt" |
||||
"io/ioutil" |
||||
"net/http" |
||||
"os" |
||||
"regexp" |
||||
"strings" |
||||
"sync/atomic" |
||||
"text/template" |
||||
|
||||
"github.com/hashicorp/consul/agent/config" |
||||
"github.com/hashicorp/consul/logging" |
||||
"github.com/hashicorp/go-hclog" |
||||
) |
||||
|
||||
// Handler is the http.Handler that serves the Consul UI. It may serve from the
|
||||
// compiled-in AssetFS or from and external dir. It provides a few important
|
||||
// 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
|
||||
// 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 |
||||
} |
||||
|
||||
// reloadableState encapsulates all the state that might be modified during
|
||||
// ReloadConfig.
|
||||
type reloadableState struct { |
||||
cfg *config.UIConfig |
||||
srv http.Handler |
||||
err 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) *Handler { |
||||
h := &Handler{ |
||||
logger: logger.Named(logging.UIServer), |
||||
} |
||||
// 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, |
||||
}) |
||||
} |
||||
return h |
||||
} |
||||
|
||||
// ServeHTTP implements http.Handler and serves UI HTTP requests
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
||||
// TODO: special case for compiled metrics assets in later PR
|
||||
s := h.getState() |
||||
if s == nil { |
||||
panic("nil state") |
||||
} |
||||
if s.err != nil { |
||||
http.Error(w, "UI server is misconfigured.", http.StatusInternalServerError) |
||||
h.logger.Error("Failed to configure UI server: %s", s.err) |
||||
return |
||||
} |
||||
s.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, |
||||
} |
||||
|
||||
var fs http.FileSystem |
||||
|
||||
if newCfg.UIConfig.Dir == "" { |
||||
// Serve from assetFS
|
||||
fs = assetFS() |
||||
} else { |
||||
fs = http.Dir(newCfg.UIConfig.Dir) |
||||
} |
||||
|
||||
// Render a new index.html with the new config values ready to serve.
|
||||
buf, info, err := renderIndex(newCfg, fs) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// 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} |
||||
newState.srv = http.FileServer(fs) |
||||
|
||||
// Store the new state
|
||||
h.state.Store(newState) |
||||
return 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 |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func renderIndex(cfg *config.RuntimeConfig, fs http.FileSystem) ([]byte, os.FileInfo, error) { |
||||
// Open the original index.html
|
||||
f, err := fs.Open("/index.html") |
||||
if err != nil { |
||||
return nil, nil, err |
||||
} |
||||
defer f.Close() |
||||
|
||||
content, err := ioutil.ReadAll(f) |
||||
if err != nil { |
||||
return nil, nil, fmt.Errorf("failed reading index.html: %s", err) |
||||
} |
||||
info, err := f.Stat() |
||||
if err != nil { |
||||
return nil, nil, fmt.Errorf("failed reading metadata for index.html: %s", err) |
||||
} |
||||
|
||||
// Create template data from the current config.
|
||||
tplData, err := uiTemplateDataFromConfig(cfg) |
||||
if err != nil { |
||||
return nil, nil, fmt.Errorf("failed loading UI config for template: %s", err) |
||||
} |
||||
|
||||
// Sadly we can't perform all the replacements we need with Go template
|
||||
// because some of them end up being rendered into an escaped json encoded
|
||||
// meta tag by Ember build which messes up the Go template tags. After a few
|
||||
// iterations of grossness, this seemed like the least bad for now. note we
|
||||
// have to match the encoded double quotes around the JSON string value that
|
||||
// is there as a placeholder so the end result is an actual JSON bool not a
|
||||
// string containing "false" etc.
|
||||
re := regexp.MustCompile(`%22__RUNTIME_BOOL_[A-Za-z0-9-_]+__%22`) |
||||
|
||||
content = []byte(re.ReplaceAllStringFunc(string(content), func(str string) string { |
||||
// Trim the prefix and __ suffix
|
||||
varName := strings.TrimSuffix(strings.TrimPrefix(str, "%22__RUNTIME_BOOL_"), "__%22") |
||||
if v, ok := tplData[varName].(bool); ok && v { |
||||
return "true" |
||||
} |
||||
return "false" |
||||
})) |
||||
|
||||
tpl, err := template.New("index").Parse(string(content)) |
||||
if err != nil { |
||||
return nil, nil, fmt.Errorf("failed parsing index.html template: %s", err) |
||||
} |
||||
|
||||
var buf bytes.Buffer |
||||
|
||||
err = tpl.Execute(&buf, tplData) |
||||
if err != nil { |
||||
return nil, nil, fmt.Errorf("failed to render index.html: %s", err) |
||||
} |
||||
|
||||
return buf.Bytes(), info, nil |
||||
} |
@ -0,0 +1,230 @@
|
||||
package uiserver |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"net/url" |
||||
"regexp" |
||||
"testing" |
||||
|
||||
"github.com/hashicorp/consul/agent/config" |
||||
"github.com/hashicorp/consul/sdk/testutil" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestUIServer(t *testing.T) { |
||||
cases := []struct { |
||||
name string |
||||
cfg *config.RuntimeConfig |
||||
path string |
||||
wantStatus int |
||||
wantContains []string |
||||
wantNotContains []string |
||||
wantEnv map[string]interface{} |
||||
wantUICfgJSON string |
||||
}{ |
||||
{ |
||||
name: "basic UI serving", |
||||
cfg: basicUIEnabledConfig(), |
||||
path: "/", // Note /index.html redirects to /
|
||||
wantStatus: http.StatusOK, |
||||
wantContains: []string{"<!-- CONSUL_VERSION:"}, |
||||
wantEnv: map[string]interface{}{ |
||||
"CONSUL_ACLS_ENABLED": false, |
||||
}, |
||||
}, |
||||
{ |
||||
// TODO: is this really what we want? It's what we've always done but
|
||||
// seems a bit odd to not do an actual 301 but instead serve the
|
||||
// index.html from every path... It also breaks the UI probably.
|
||||
name: "unknown paths to serve index", |
||||
cfg: basicUIEnabledConfig(), |
||||
path: "/foo-bar-bazz-qux", |
||||
wantStatus: http.StatusOK, |
||||
wantContains: []string{"<!-- CONSUL_VERSION:"}, |
||||
}, |
||||
{ |
||||
name: "injecting metrics vars", |
||||
cfg: basicUIEnabledConfig( |
||||
withMetricsProvider("foo"), |
||||
withMetricsProviderOptions(`{"bar":1}`), |
||||
), |
||||
path: "/", |
||||
wantStatus: http.StatusOK, |
||||
wantContains: []string{ |
||||
"<!-- CONSUL_VERSION:", |
||||
}, |
||||
wantEnv: map[string]interface{}{ |
||||
"CONSUL_ACLS_ENABLED": false, |
||||
}, |
||||
wantUICfgJSON: `{ |
||||
"metrics_provider": "foo", |
||||
"metrics_provider_options": { |
||||
"bar":1 |
||||
}, |
||||
"metrics_proxy_enabled": false, |
||||
"dashboard_url_templates": null |
||||
}`, |
||||
}, |
||||
{ |
||||
name: "acls enabled", |
||||
cfg: basicUIEnabledConfig(withACLs()), |
||||
path: "/", |
||||
wantStatus: http.StatusOK, |
||||
wantContains: []string{"<!-- CONSUL_VERSION:"}, |
||||
wantEnv: map[string]interface{}{ |
||||
"CONSUL_ACLS_ENABLED": true, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
for _, tc := range cases { |
||||
tc := tc |
||||
t.Run(tc.name, func(t *testing.T) { |
||||
h := NewHandler(tc.cfg, testutil.Logger(t)) |
||||
|
||||
req := httptest.NewRequest("GET", tc.path, nil) |
||||
rec := httptest.NewRecorder() |
||||
|
||||
h.ServeHTTP(rec, req) |
||||
|
||||
require.Equal(t, tc.wantStatus, rec.Code) |
||||
for _, want := range tc.wantContains { |
||||
require.Contains(t, rec.Body.String(), want) |
||||
} |
||||
for _, wantNot := range tc.wantNotContains { |
||||
require.NotContains(t, rec.Body.String(), wantNot) |
||||
} |
||||
env := extractEnv(t, rec.Body.String()) |
||||
for k, v := range tc.wantEnv { |
||||
require.Equal(t, v, env[k]) |
||||
} |
||||
if tc.wantUICfgJSON != "" { |
||||
require.JSONEq(t, tc.wantUICfgJSON, extractUIConfig(t, rec.Body.String())) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func extractMetaJSON(t *testing.T, name, content string) string { |
||||
t.Helper() |
||||
|
||||
// Find and extract the env meta tag. Why yes I _am_ using regexp to parse
|
||||
// HTML thanks for asking. In this case it's HTML with a very limited format
|
||||
// so I don't feel too bad but maybe I should.
|
||||
// https://stackoverflow.com/questions/1732348/regex-match-open-tags-except-xhtml-self-contained-tags/1732454#1732454
|
||||
re := regexp.MustCompile(`<meta name="` + name + `+" content="([^"]*)"`) |
||||
|
||||
matches := re.FindStringSubmatch(content) |
||||
require.Len(t, matches, 2, "didn't find the %s meta tag", name) |
||||
|
||||
// Unescape the JSON
|
||||
jsonStr, err := url.PathUnescape(matches[1]) |
||||
require.NoError(t, err) |
||||
|
||||
return jsonStr |
||||
} |
||||
|
||||
func extractEnv(t *testing.T, content string) map[string]interface{} { |
||||
t.Helper() |
||||
|
||||
js := extractMetaJSON(t, "consul-ui/config/environment", content) |
||||
|
||||
var env map[string]interface{} |
||||
|
||||
err := json.Unmarshal([]byte(js), &env) |
||||
require.NoError(t, err) |
||||
|
||||
return env |
||||
} |
||||
|
||||
func extractUIConfig(t *testing.T, content string) string { |
||||
t.Helper() |
||||
return extractMetaJSON(t, "consul-ui/ui_config", content) |
||||
} |
||||
|
||||
type cfgFunc func(cfg *config.RuntimeConfig) |
||||
|
||||
func basicUIEnabledConfig(opts ...cfgFunc) *config.RuntimeConfig { |
||||
cfg := &config.RuntimeConfig{ |
||||
UIConfig: config.UIConfig{ |
||||
Enabled: true, |
||||
}, |
||||
} |
||||
for _, f := range opts { |
||||
f(cfg) |
||||
} |
||||
return cfg |
||||
} |
||||
|
||||
func withACLs() cfgFunc { |
||||
return func(cfg *config.RuntimeConfig) { |
||||
cfg.ACLDatacenter = "dc1" |
||||
cfg.ACLDefaultPolicy = "deny" |
||||
cfg.ACLsEnabled = true |
||||
} |
||||
} |
||||
|
||||
func withMetricsProvider(name string) cfgFunc { |
||||
return func(cfg *config.RuntimeConfig) { |
||||
cfg.UIConfig.MetricsProvider = name |
||||
} |
||||
} |
||||
|
||||
func withMetricsProviderOptions(jsonStr string) cfgFunc { |
||||
return func(cfg *config.RuntimeConfig) { |
||||
cfg.UIConfig.MetricsProviderOptionsJSON = jsonStr |
||||
} |
||||
} |
||||
|
||||
// TestMultipleIndexRequests validates that the buffered file mechanism works
|
||||
// beyond the first request. The initial implementation did not as it shared an
|
||||
// bytes.Reader between callers.
|
||||
func TestMultipleIndexRequests(t *testing.T) { |
||||
h := NewHandler(basicUIEnabledConfig(), testutil.Logger(t)) |
||||
|
||||
for i := 0; i < 3; i++ { |
||||
req := httptest.NewRequest("GET", "/", nil) |
||||
rec := httptest.NewRecorder() |
||||
|
||||
h.ServeHTTP(rec, req) |
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code) |
||||
require.Contains(t, rec.Body.String(), "<!-- CONSUL_VERSION:", |
||||
"request %d didn't return expected content", i+1) |
||||
} |
||||
} |
||||
|
||||
func TestReload(t *testing.T) { |
||||
h := NewHandler(basicUIEnabledConfig(), testutil.Logger(t)) |
||||
|
||||
{ |
||||
req := httptest.NewRequest("GET", "/", nil) |
||||
rec := httptest.NewRecorder() |
||||
|
||||
h.ServeHTTP(rec, req) |
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code) |
||||
require.Contains(t, rec.Body.String(), "<!-- CONSUL_VERSION:") |
||||
require.NotContains(t, rec.Body.String(), "exotic-metrics-provider-name") |
||||
} |
||||
|
||||
// Reload the config with the changed metrics provider name
|
||||
newCfg := basicUIEnabledConfig( |
||||
withMetricsProvider("exotic-metrics-provider-name"), |
||||
) |
||||
h.ReloadConfig(newCfg) |
||||
|
||||
// Now we should see the new provider name in the output of index
|
||||
{ |
||||
req := httptest.NewRequest("GET", "/", nil) |
||||
rec := httptest.NewRecorder() |
||||
|
||||
h.ServeHTTP(rec, req) |
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code) |
||||
require.Contains(t, rec.Body.String(), "<!-- CONSUL_VERSION:") |
||||
require.Contains(t, rec.Body.String(), "exotic-metrics-provider-name") |
||||
} |
||||
} |
Loading…
Reference in new issue