diff --git a/pkg/debug/panel.go b/pkg/debug/panel.go new file mode 100644 index 000000000..24de8f39c --- /dev/null +++ b/pkg/debug/panel.go @@ -0,0 +1,193 @@ +// Copyright (C) 2020 Storj Labs, Inc. +// See LICENSE for copying information. + +package debug + +import ( + "fmt" + "io" + "net/http" + "path" + "sort" + "strings" + "sync" + "text/template" + "unicode" + + "go.uber.org/zap" + + "storj.io/common/sync2" +) + +// Panel implements a serving of customized callbacks. +type Panel struct { + log *zap.Logger + + url string + + mu sync.RWMutex + lookup map[string]*Button + categories []*ButtonGroup +} + +// NewPanel creates a new panel. +func NewPanel(log *zap.Logger, url string) *Panel { + return &Panel{ + log: log, + url: url, + lookup: map[string]*Button{}, + } +} + +// ButtonGroup contains description of a collection of buttons. +type ButtonGroup struct { + Slug string + Name string + Buttons []*Button +} + +// Button defines a clickable button. +type Button struct { + Slug string + Name string + Call func(progress io.Writer) error +} + +// Add adds a button group to the panel. +func (panel *Panel) Add(cat *ButtonGroup) { + panel.mu.Lock() + defer panel.mu.Unlock() + + if cat.Slug == "" { + cat.Slug = slugify(cat.Name) + } + for _, but := range cat.Buttons { + but.Slug = slugify(but.Name) + + panel.lookup["/"+path.Join(cat.Slug, but.Slug)] = but + } + + panel.categories = append(panel.categories, cat) + + sort.Slice(panel.categories, func(i, k int) bool { + return panel.categories[i].Name < panel.categories[k].Name + }) +} + +// ServeHTTP serves buttons on the prefix and on +// other endpoints calls the specified call. +func (panel *Panel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + panel.mu.RLock() + defer panel.mu.RUnlock() + + url := strings.TrimPrefix(r.URL.Path, panel.url) + if len(url) >= len(r.URL.Path) { + http.Error(w, "not found", http.StatusNotFound) + return + } + + if url == "/" { + err := buttonsTemplate.Execute(w, map[string]interface{}{ + "URL": panel.url, + "Categories": panel.categories, + }) + if err != nil { + panel.log.Error("buttons template failed", zap.Error(err)) + } + return + } + + button, ok := panel.lookup[url] + if !ok { + http.Error(w, "control not found", http.StatusNotFound) + return + } + + panel.log.Debug("calling", zap.String("url", url)) + + w.Header().Add("Content-Type", "text/plain") + w.Header().Add("Cache-Control", "no-cache") + w.Header().Add("Cache-Control", "max-age=0") + + err := button.Call(w) + if err != nil { + panel.log.Error("failed to run button", zap.String("url", url), zap.Error(err)) + } +} + +var buttonsTemplate = template.Must(template.New("").Parse(` + + +Control Panel + + + +

Control Panel

+ {{ $url := .URL }} + {{ range $cat := .Categories }} +
+

{{$cat.Name}}

+ {{ range $but := $cat.Buttons }} + {{.Name}} + {{ end }} +
+ {{ end }} + +`)) + +// slugify converts text to a slug +func slugify(s string) string { + return strings.Map(func(r rune) rune { + switch { + case 'a' <= r && r <= 'z': + return r + case 'A' <= r && r <= 'Z': + return unicode.ToLower(r) + case '0' <= r && r <= '9': + return r + default: + return '-' + } + }, s) +} + +// Cycle returns button group for a cycle +func Cycle(name string, cycle *sync2.Cycle) *ButtonGroup { + return &ButtonGroup{ + Name: name, + Buttons: []*Button{ + { + Name: "Trigger", + Call: func(w io.Writer) error { + _, _ = fmt.Fprintln(w, "Triggering") + cycle.TriggerWait() + _, _ = fmt.Fprintln(w, "Done") + return nil + }, + }, { + Name: "Pause", + Call: func(w io.Writer) error { + cycle.Pause() + _, _ = fmt.Fprintln(w, "Paused") + return nil + }, + }, { + Name: "Resume", + Call: func(w io.Writer) error { + cycle.Restart() + _, _ = fmt.Fprintln(w, "Resumed") + return nil + }, + }, + }, + } +} diff --git a/pkg/debug/server.go b/pkg/debug/server.go index fd69fb56b..52aeb681b 100644 --- a/pkg/debug/server.go +++ b/pkg/debug/server.go @@ -31,6 +31,8 @@ func init() { // Config defines configuration for debug server. type Config struct { Address string `internal:"true"` + + Control bool `help:"expose control panel" releaseDefault:"false" devDefault:"true"` } // Server provides endpoints for debugging. @@ -41,10 +43,12 @@ type Server struct { server http.Server mux http.ServeMux + Panel *Panel + registry *monkit.Registry } -// NewServer returns a new debug.Server +// NewServer returns a new debug.Server. func NewServer(log *zap.Logger, listener net.Listener, registry *monkit.Registry, config Config) *Server { server := &Server{log: log} @@ -52,6 +56,13 @@ func NewServer(log *zap.Logger, listener net.Listener, registry *monkit.Registry server.server.Handler = &server.mux server.registry = registry + server.Panel = NewPanel(log.Named("control"), "/control") + if config.Control { + server.mux.Handle("/control/", server.Panel) + } + + server.mux.Handle("/version/", http.StripPrefix("/version", checker.NewDebugHandler(log.Named("version")))) + server.mux.HandleFunc("/debug/pprof/", pprof.Index) server.mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) server.mux.HandleFunc("/debug/pprof/profile", pprof.Profile) @@ -60,9 +71,9 @@ func NewServer(log *zap.Logger, listener net.Listener, registry *monkit.Registry server.mux.HandleFunc("/debug/run/trace/db", server.collectTraces) - server.mux.Handle("/version/", http.StripPrefix("/version", checker.NewDebugHandler(log.Named("version")))) server.mux.Handle("/mon/", http.StripPrefix("/mon", present.HTTP(server.registry))) server.mux.HandleFunc("/metrics", server.metrics) + server.mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprintln(w, "OK") }) @@ -89,13 +100,13 @@ func (server *Server) Run(ctx context.Context) error { return group.Wait() } -// Close closes server and underlying listener +// Close closes server and underlying listener. func (server *Server) Close() error { return Error.Wrap(server.server.Close()) } +// metrics writes https://prometheus.io/docs/instrumenting/exposition_formats/ func (server *Server) metrics(w http.ResponseWriter, r *http.Request) { - // writes https://prometheus.io/docs/instrumenting/exposition_formats/ // TODO(jt): deeper monkit integration so we can expose prometheus types // (https://prometheus.io/docs/concepts/metric_types/) server.registry.Stats(func(name string, val float64) { @@ -105,7 +116,7 @@ func (server *Server) metrics(w http.ResponseWriter, r *http.Request) { }) } -// collectTraces +// collectTraces collects traces until request is canceled. func (server *Server) collectTraces(w http.ResponseWriter, r *http.Request) { cancel := traces.CollectTraces() defer cancel() @@ -118,6 +129,7 @@ func (server *Server) collectTraces(w http.ResponseWriter, r *http.Request) { } } +// sanitize formats val to be suitable for prometheus. func sanitize(val string) string { // https://prometheus.io/docs/concepts/data_model/ // specifies all metric names must match [a-zA-Z_:][a-zA-Z0-9_:]* diff --git a/scripts/testdata/satellite-config.yaml.lock b/scripts/testdata/satellite-config.yaml.lock index 0576b32fe..d79c9a869 100644 --- a/scripts/testdata/satellite-config.yaml.lock +++ b/scripts/testdata/satellite-config.yaml.lock @@ -100,6 +100,9 @@ contact.external-address: "" # address to listen on for debug endpoints # debug.addr: 127.0.0.1:0 +# expose control panel +# debug.control: false + # If set, a path to write a process trace SVG to # debug.trace-out: ""