uiserver: upstream refactors done elsewhere (#8891)

pull/8902/head
R.B. Boyer 2020-10-09 08:32:39 -05:00 committed by GitHub
parent 47d7df5c91
commit b6f686fecb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 149 additions and 102 deletions

View File

@ -276,7 +276,11 @@ func (s *HTTPHandlers) handler(enableDebug bool) http.Handler {
if s.IsUIEnabled() { if s.IsUIEnabled() {
// Note that we _don't_ support reloading ui_config.{enabled, content_dir, // Note that we _don't_ support reloading ui_config.{enabled, content_dir,
// content_path} since this only runs at initial startup. // content_path} since this only runs at initial startup.
uiHandler := uiserver.NewHandler(s.agent.config, s.agent.logger.Named(logging.HTTP)) uiHandler := uiserver.NewHandler(
s.agent.config,
s.agent.logger.Named(logging.HTTP),
s.uiTemplateDataTransform(),
)
s.configReloaders = append(s.configReloaders, uiHandler.ReloadConfig) s.configReloaders = append(s.configReloaders, uiHandler.ReloadConfig)
// Wrap it to add the headers specified by the http_config.response_headers // Wrap it to add the headers specified by the http_config.response_headers

View File

@ -8,6 +8,7 @@ import (
"strings" "strings"
"github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/agent/uiserver"
) )
func (s *HTTPHandlers) parseEntMeta(req *http.Request, entMeta *structs.EnterpriseMeta) error { func (s *HTTPHandlers) parseEntMeta(req *http.Request, entMeta *structs.EnterpriseMeta) error {
@ -67,3 +68,9 @@ func parseACLAuthMethodEnterpriseMeta(req *http.Request, _ *structs.ACLAuthMetho
func (s *HTTPHandlers) enterpriseHandler(next http.Handler) http.Handler { func (s *HTTPHandlers) enterpriseHandler(next http.Handler) http.Handler {
return next return next
} }
// uiTemplateDataTransform returns an optional uiserver.UIDataTransform to allow
// altering UI data in enterprise.
func (s *HTTPHandlers) uiTemplateDataTransform() uiserver.UIDataTransform {
return nil
}

File diff suppressed because one or more lines are too long

View File

@ -2,14 +2,12 @@ package uiserver
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/url"
"github.com/hashicorp/consul/agent/config" "github.com/hashicorp/consul/agent/config"
) )
// uiTemplateDataFromConfig returns the set of variables that should be injected // uiTemplateDataFromConfig returns the base set of variables that should be
// into the UI's Env based on the given runtime UI config. // injected into the UI's Env based on the given runtime UI config.
func uiTemplateDataFromConfig(cfg *config.RuntimeConfig) (map[string]interface{}, error) { func uiTemplateDataFromConfig(cfg *config.RuntimeConfig) (map[string]interface{}, error) {
uiCfg := map[string]interface{}{ uiCfg := map[string]interface{}{
@ -32,25 +30,9 @@ func uiTemplateDataFromConfig(cfg *config.RuntimeConfig) (map[string]interface{}
d := map[string]interface{}{ d := map[string]interface{}{
"ContentPath": cfg.UIConfig.ContentPath, "ContentPath": cfg.UIConfig.ContentPath,
"ACLsEnabled": cfg.ACLsEnabled, "ACLsEnabled": cfg.ACLsEnabled,
"UIConfig": uiCfg,
} }
err := uiTemplateDataFromConfigEnterprise(cfg, d, uiCfg)
if err != nil {
return nil, err
}
// 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))
// Also inject additional provider scripts if needed, otherwise strip the // Also inject additional provider scripts if needed, otherwise strip the
// comment. // comment.
if len(cfg.UIConfig.MetricsProviderFiles) > 0 { if len(cfg.UIConfig.MetricsProviderFiles) > 0 {
@ -59,5 +41,5 @@ func uiTemplateDataFromConfig(cfg *config.RuntimeConfig) (map[string]interface{}
} }
} }
return d, err return d, nil
} }

View File

@ -1,9 +0,0 @@
// +build !consulent
package uiserver
import "github.com/hashicorp/consul/agent/config"
func uiTemplateDataFromConfigEnterprise(_ *config.RuntimeConfig, _ map[string]interface{}, _ map[string]interface{}) error {
return nil
}

View File

@ -2,9 +2,11 @@ package uiserver
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url"
"os" "os"
"path" "path"
"regexp" "regexp"
@ -31,6 +33,7 @@ type Handler struct {
// version of the state without internal locking needed. // version of the state without internal locking needed.
state atomic.Value state atomic.Value
logger hclog.Logger logger hclog.Logger
transform UIDataTransform
} }
// reloadableState encapsulates all the state that might be modified during // reloadableState encapsulates all the state that might be modified during
@ -41,12 +44,23 @@ type reloadableState struct {
err error err error
} }
// UIDataTransform is an optional dependency that allows the agent to add
// additional data into the UI index as needed. For example we use this to
// inject enterprise-only feature flags into the template without making this
// package inherently dependent on Enterprise-only code.
//
// 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
// NewHandler returns a Handler that can be used to serve UI http requests. It // 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 // accepts a full agent config since properties like ACLs being enabled affect
// the UI so we need more than just UIConfig parts. // the UI so we need more than just UIConfig parts.
func NewHandler(agentCfg *config.RuntimeConfig, logger hclog.Logger) *Handler { func NewHandler(agentCfg *config.RuntimeConfig, logger hclog.Logger, transform UIDataTransform) *Handler {
h := &Handler{ h := &Handler{
logger: logger.Named(logging.UIServer), logger: logger.Named(logging.UIServer),
transform: transform,
} }
// Don't return the error since this is likely the result of a // Don't return the error since this is likely the result of a
// misconfiguration and reloading config could fix it. Instead we'll capture // misconfiguration and reloading config could fix it. Instead we'll capture
@ -101,7 +115,7 @@ func (h *Handler) ReloadConfig(newCfg *config.RuntimeConfig) error {
} }
// Render a new index.html with the new config values ready to serve. // Render a new index.html with the new config values ready to serve.
buf, info, err := renderIndex(newCfg, fs) buf, info, err := h.renderIndex(newCfg, fs)
if _, ok := err.(*os.PathError); ok && newCfg.UIConfig.Dir != "" { if _, ok := err.(*os.PathError); ok && newCfg.UIConfig.Dir != "" {
// A Path error indicates that there is no index.html. This could happen if // 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 // the user configured their own UI dir and is serving something that is not
@ -200,7 +214,7 @@ func concatFile(buf *bytes.Buffer, file string) error {
return nil return nil
} }
func renderIndex(cfg *config.RuntimeConfig, fs http.FileSystem) ([]byte, os.FileInfo, error) { func (h *Handler) renderIndex(cfg *config.RuntimeConfig, fs http.FileSystem) ([]byte, os.FileInfo, error) {
// Open the original index.html // Open the original index.html
f, err := fs.Open("/index.html") f, err := fs.Open("/index.html")
if err != nil { if err != nil {
@ -210,17 +224,24 @@ func renderIndex(cfg *config.RuntimeConfig, fs http.FileSystem) ([]byte, os.File
content, err := ioutil.ReadAll(f) content, err := ioutil.ReadAll(f)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed reading index.html: %s", err) return nil, nil, fmt.Errorf("failed reading index.html: %w", err)
} }
info, err := f.Stat() info, err := f.Stat()
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed reading metadata for index.html: %s", err) return nil, nil, fmt.Errorf("failed reading metadata for index.html: %w", err)
} }
// Create template data from the current config. // Create template data from the current config.
tplData, err := uiTemplateDataFromConfig(cfg) tplData, err := uiTemplateDataFromConfig(cfg)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed loading UI config for template: %s", err) return nil, nil, fmt.Errorf("failed loading UI config for template: %w", err)
}
// Allow caller to apply additional data transformations if needed.
if h.transform != nil {
if err := h.transform(cfg, tplData); err != nil {
return nil, nil, fmt.Errorf("failed running transform: %w", err)
}
} }
// Sadly we can't perform all the replacements we need with Go template // Sadly we can't perform all the replacements we need with Go template
@ -241,16 +262,24 @@ func renderIndex(cfg *config.RuntimeConfig, fs http.FileSystem) ([]byte, os.File
return "false" return "false"
})) }))
tpl, err := template.New("index").Parse(string(content)) tpl, err := template.New("index").Funcs(template.FuncMap{
"jsonEncodeAndEscape": func(data map[string]interface{}) (string, error) {
bs, err := json.Marshal(data)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed parsing index.html template: %s", err) return "", fmt.Errorf("failed jsonEncodeAndEscape: %w", err)
}
return url.PathEscape(string(bs)), nil
},
}).Parse(string(content))
if err != nil {
return nil, nil, fmt.Errorf("failed parsing index.html template: %w", err)
} }
var buf bytes.Buffer var buf bytes.Buffer
err = tpl.Execute(&buf, tplData) err = tpl.Execute(&buf, tplData)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to render index.html: %s", err) return nil, nil, fmt.Errorf("failed to render index.html: %w", err)
} }
return buf.Bytes(), info, nil return buf.Bytes(), info, nil

View File

@ -21,6 +21,7 @@ func TestUIServerIndex(t *testing.T) {
name string name string
cfg *config.RuntimeConfig cfg *config.RuntimeConfig
path string path string
tx UIDataTransform
wantStatus int wantStatus int
wantContains []string wantContains []string
wantNotContains []string wantNotContains []string
@ -51,20 +52,28 @@ func TestUIServerIndex(t *testing.T) {
name: "injecting metrics vars", name: "injecting metrics vars",
cfg: basicUIEnabledConfig( cfg: basicUIEnabledConfig(
withMetricsProvider("foo"), withMetricsProvider("foo"),
withMetricsProviderOptions(`{"bar":1}`), withMetricsProviderOptions(`{"a-very-unlikely-string":1}`),
), ),
path: "/", path: "/",
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantContains: []string{ wantContains: []string{
"<!-- CONSUL_VERSION:", "<!-- CONSUL_VERSION:",
}, },
wantNotContains: []string{
// This is a quick check to be sure that we actually URL encoded the
// JSON ui settings too. The assertions below could pass just fine even
// if we got that wrong because the decode would be a no-op if it wasn't
// URL encoded. But this just ensures that we don't see the raw values
// in the output because the quotes should be encoded.
`"a-very-unlikely-string"`,
},
wantEnv: map[string]interface{}{ wantEnv: map[string]interface{}{
"CONSUL_ACLS_ENABLED": false, "CONSUL_ACLS_ENABLED": false,
}, },
wantUICfgJSON: `{ wantUICfgJSON: `{
"metrics_provider": "foo", "metrics_provider": "foo",
"metrics_provider_options": { "metrics_provider_options": {
"bar":1 "a-very-unlikely-string":1
}, },
"metrics_proxy_enabled": false, "metrics_proxy_enabled": false,
"dashboard_url_templates": null "dashboard_url_templates": null
@ -80,6 +89,31 @@ func TestUIServerIndex(t *testing.T) {
"CONSUL_ACLS_ENABLED": true, "CONSUL_ACLS_ENABLED": true,
}, },
}, },
{
name: "external transformation",
cfg: basicUIEnabledConfig(
withMetricsProvider("foo"),
),
path: "/",
tx: func(cfg *config.RuntimeConfig, data map[string]interface{}) error {
data["SSOEnabled"] = true
o := data["UIConfig"].(map[string]interface{})
o["metrics_provider"] = "bar"
return nil
},
wantStatus: http.StatusOK,
wantContains: []string{
"<!-- CONSUL_VERSION:",
},
wantEnv: map[string]interface{}{
"CONSUL_SSO_ENABLED": true,
},
wantUICfgJSON: `{
"metrics_provider": "bar",
"metrics_proxy_enabled": false,
"dashboard_url_templates": null
}`,
},
{ {
name: "serving metrics provider js", name: "serving metrics provider js",
cfg: basicUIEnabledConfig( cfg: basicUIEnabledConfig(
@ -98,7 +132,7 @@ func TestUIServerIndex(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
tc := tc tc := tc
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
h := NewHandler(tc.cfg, testutil.Logger(t)) h := NewHandler(tc.cfg, testutil.Logger(t), tc.tx)
req := httptest.NewRequest("GET", tc.path, nil) req := httptest.NewRequest("GET", tc.path, nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -205,7 +239,7 @@ func withMetricsProviderOptions(jsonStr string) cfgFunc {
// beyond the first request. The initial implementation did not as it shared an // beyond the first request. The initial implementation did not as it shared an
// bytes.Reader between callers. // bytes.Reader between callers.
func TestMultipleIndexRequests(t *testing.T) { func TestMultipleIndexRequests(t *testing.T) {
h := NewHandler(basicUIEnabledConfig(), testutil.Logger(t)) h := NewHandler(basicUIEnabledConfig(), testutil.Logger(t), nil)
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
@ -220,7 +254,7 @@ func TestMultipleIndexRequests(t *testing.T) {
} }
func TestReload(t *testing.T) { func TestReload(t *testing.T) {
h := NewHandler(basicUIEnabledConfig(), testutil.Logger(t)) h := NewHandler(basicUIEnabledConfig(), testutil.Logger(t), nil)
{ {
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
@ -261,7 +295,7 @@ func TestCustomDir(t *testing.T) {
cfg := basicUIEnabledConfig() cfg := basicUIEnabledConfig()
cfg.UIConfig.Dir = uiDir cfg.UIConfig.Dir = uiDir
h := NewHandler(cfg, testutil.Logger(t)) h := NewHandler(cfg, testutil.Logger(t), nil)
req := httptest.NewRequest("GET", "/test-file", nil) req := httptest.NewRequest("GET", "/test-file", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -277,7 +311,7 @@ func TestCompiledJS(t *testing.T) {
withMetricsProvider("foo"), withMetricsProvider("foo"),
withMetricsProviderFiles("testdata/foo.js", "testdata/bar.js"), withMetricsProviderFiles("testdata/foo.js", "testdata/bar.js"),
) )
h := NewHandler(cfg, testutil.Logger(t)) h := NewHandler(cfg, testutil.Logger(t), nil)
paths := []string{ paths := []string{
"/" + compiledProviderJSPath, "/" + compiledProviderJSPath,

View File

@ -1,6 +1,6 @@
module.exports = ({ appName, environment, rootURL, config }) => ` module.exports = ({ appName, environment, rootURL, config }) => `
<!-- CONSUL_VERSION: ${config.CONSUL_VERSION} --> <!-- CONSUL_VERSION: ${config.CONSUL_VERSION} -->
<meta name="consul-ui/ui_config" content="{{ .UIConfigJSON }}" /> <meta name="consul-ui/ui_config" content="{{ jsonEncodeAndEscape .UIConfig }}" />
<link rel="icon" type="image/png" href="${rootURL}assets/favicon-32x32.png" sizes="32x32"> <link rel="icon" type="image/png" href="${rootURL}assets/favicon-32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="${rootURL}assets/favicon-16x16.png" sizes="16x16"> <link rel="icon" type="image/png" href="${rootURL}assets/favicon-16x16.png" sizes="16x16">