Files
mgmt/engine/resources/http_ui.go
James Shubin 861ba50f9c engine: resources: Add a ui redirect
I always forget the /index.html part so make it easier!
2025-05-15 02:52:57 -04:00

796 lines
23 KiB
Go

// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.
package resources
import (
"context"
_ "embed" // embed data with go:embed
"fmt"
"html/template"
"net/http"
"sort"
"strings"
"sync"
"github.com/purpleidea/mgmt/engine"
"github.com/purpleidea/mgmt/engine/resources/http_ui/common"
"github.com/purpleidea/mgmt/engine/resources/http_ui/static"
"github.com/purpleidea/mgmt/engine/traits"
"github.com/purpleidea/mgmt/pgraph"
"github.com/purpleidea/mgmt/util/errwrap"
"github.com/gin-gonic/gin"
)
const (
httpUIKind = httpKind + ":ui"
httpUIIndexHTMLTmpl = "index.html.tmpl"
)
var (
//go:embed http_ui/index.html.tmpl
httpUIIndexHTMLTmplData string
//go:embed http_ui/wasm_exec.js
httpUIWasmExecData []byte
//go:embed http_ui/main.wasm
httpUIMainWasmData []byte
)
func init() {
engine.RegisterResource(httpUIKind, func() engine.Res { return &HTTPUIRes{} })
}
// HTTPServerUIGroupableRes is the interface that you must implement if you want
// to allow a resource the ability to be grouped into the http server ui
// resource. As an added safety, the Kind must also begin with "http:ui:", and
// not have more than one colon to avoid accidents of unwanted grouping.
type HTTPServerUIGroupableRes interface {
engine.Res
// ParentName is used to limit which resources autogroup into this one.
// If it's empty then it's ignored, otherwise it must match the Name of
// the parent to get grouped.
ParentName() string
// GetKind returns the "kind" of resource that this UI element is. This
// is technically different than the Kind() field, because it can be a
// unique kind that's specific to the HTTP form UI resources.
GetKind() string
// GetID returns the unique ID that this UI element responds to. Note
// that this is NOT replaceable by Name() because this ID is used in
// places that might be public, such as in webui form source code.
GetID() string
// SetValue sends the new value that was obtained from submitting the
// form. This is the raw, unsafe value that you must validate first.
SetValue(context.Context, []string) error
// GetValue gets a string representation for the form value, that we'll
// use in our html form.
GetValue(context.Context) (string, error)
// GetType returns a map that you can use to build the input field in
// the ui.
GetType() map[string]string
// GetSort returns a string that you can use to determine the global
// sorted display order of all the elements in a ui.
GetSort() string
}
// HTTPUIResData represents some additional data to attach to the resource.
type HTTPUIResData struct {
// Title is the generated page title that is displayed to the user.
Title string `lang:"title" yaml:"title"`
// Head is a list of strings to insert into the <head> and </head> tags
// of your page. This string allows HTML, so choose carefully!
// XXX: a *string should allow a partial struct here without having this
// field, but our type unification algorithm isn't this fancy yet...
Head string `lang:"head" yaml:"head"`
}
// HTTPUIRes is a web UI resource that exists within an http server. The name is
// used as the public path of the ui, unless the path field is specified, and in
// that case it is used instead. The way this works is that it autogroups at
// runtime with an existing http server resource, and in doing so makes the form
// associated with this resource available for serving from that http server.
type HTTPUIRes struct {
traits.Base // add the base methods without re-implementation
traits.Edgeable // XXX: add autoedge support
traits.Groupable // can be grouped into HTTPServerRes
init *engine.Init
// Server is the name of the http server resource to group this into. If
// it is omitted, and there is only a single http resource, then it will
// be grouped into it automatically. If there is more than one main http
// resource being used, then the grouping behaviour is *undefined* when
// this is not specified, and it is not recommended to leave this blank!
Server string `lang:"server" yaml:"server"`
// Path is the name of the path that this should be exposed under. For
// example, you might want to name this "/ui/" to expose it as "ui"
// under the server root. This overrides the name variable that is set.
Path string `lang:"path" yaml:"path"`
// Data represents some additional data to attach to the resource.
Data *HTTPUIResData `lang:"data" yaml:"data"`
//eventStream chan error
eventsChanMap map[engine.Res]chan error
// notifications contains a channel for every long poller waiting for a
// reply.
notifications map[engine.Res]map[chan struct{}]struct{}
// rwmutex guards the notifications map.
rwmutex *sync.RWMutex
ctx context.Context // set by Watch
}
// Default returns some sensible defaults for this resource.
func (obj *HTTPUIRes) Default() engine.Res {
return &HTTPUIRes{}
}
// getPath returns the actual path we respond to. When Path is not specified, we
// use the Name. Note that this is the handler path that will be seen on the
// root http server, and this ui application might use a querystring and/or POST
// data as well.
func (obj *HTTPUIRes) getPath() string {
if obj.Path != "" {
return obj.Path
}
return obj.Name()
}
// routerPath returns an appropriate path for our router based on what we want
// to achieve using our parent prefix.
func (obj *HTTPUIRes) routerPath(p string) string {
if strings.HasPrefix(p, "/") {
return obj.getPath() + p[1:]
}
return obj.getPath() + p
}
// ParentName is used to limit which resources autogroup into this one. If it's
// empty then it's ignored, otherwise it must match the Name of the parent to
// get grouped.
func (obj *HTTPUIRes) ParentName() string {
return obj.Server
}
// AcceptHTTP determines whether we will respond to this request. Return nil to
// accept, or any error to pass.
func (obj *HTTPUIRes) AcceptHTTP(req *http.Request) error {
requestPath := req.URL.Path // TODO: is this what we want here?
//if requestPath != obj.getPath() {
// return fmt.Errorf("unhandled path")
//}
if !strings.HasPrefix(requestPath, obj.getPath()) {
return fmt.Errorf("unhandled path")
}
return nil
}
// getResByID returns the grouped resource with the id we're searching for if it
// exists, otherwise nil and false.
func (obj *HTTPUIRes) getResByID(id string) (HTTPServerUIGroupableRes, bool) {
for _, x := range obj.GetGroup() { // grouped elements
res, ok := x.(HTTPServerUIGroupableRes) // convert from Res
if !ok {
continue
}
if obj.init.Debug {
obj.init.Logf("Got grouped resource: %s", res.String())
}
if id != res.GetID() {
continue
}
return res, true
}
return nil, false
}
// ginLogger is a helper to get structured logs out of gin.
func (obj *HTTPUIRes) ginLogger() gin.HandlerFunc {
return func(c *gin.Context) {
//start := time.Now()
c.Next()
//duration := time.Since(start)
//timestamp := time.Now().Format(time.RFC3339)
method := c.Request.Method
path := c.Request.URL.Path
status := c.Writer.Status()
//latency := duration
clientIP := c.ClientIP()
if obj.init.Debug {
return
}
obj.init.Logf("%v %s %s (%d)", clientIP, method, path, status)
}
}
// getTemplate builds the super template that contains the map of each file name
// so that it can be used easily to send out named, templated documents.
func (obj *HTTPUIRes) getTemplate() (*template.Template, error) {
// XXX: get this from somewhere
m := make(map[string]string)
//m["foo.tmpl"] = "hello from file1" // TODO: add more content?
m[httpUIIndexHTMLTmpl] = httpUIIndexHTMLTmplData // index.html.tmpl
filenames := []string{}
for filename := range m {
filenames = append(filenames, filename)
}
sort.Strings(filenames) // deterministic order
var t *template.Template
// This logic from golang/src/html/template/template.go:parseFiles(...)
for _, filename := range filenames {
data := m[filename]
var tmpl *template.Template
if t == nil {
t = template.New(filename)
}
if filename == t.Name() {
tmpl = t
} else {
tmpl = t.New(filename)
}
if _, err := tmpl.Parse(data); err != nil {
return nil, err
}
}
t = t.Option("missingkey=error") // be thorough
return t, nil
}
// ServeHTTP is the standard HTTP handler that will be used here.
func (obj *HTTPUIRes) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// XXX: do all the router bits in Init() if we can...
gin.SetMode(gin.ReleaseMode) // for production
router := gin.New()
router.Use(obj.ginLogger(), gin.Recovery())
templ, err := obj.getTemplate() // do in init?
if err != nil {
obj.init.Logf("template error: %+v", err)
return
}
router.SetHTMLTemplate(templ)
router.GET(obj.routerPath("/"), func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, obj.routerPath("/index.html"))
})
router.GET(obj.routerPath("/index.html"), func(c *gin.Context) {
h := gin.H{}
h["program"] = obj.init.Program
h["version"] = obj.init.Version
h["hostname"] = obj.init.Hostname
h["embedded"] = static.HTTPUIStaticEmbedded // true or false
h["title"] = "" // key must be specified
h["path"] = obj.getPath()
if obj.Data != nil {
h["title"] = obj.Data.Title // template var
h["head"] = template.HTML(obj.Data.Head)
}
c.HTML(http.StatusOK, httpUIIndexHTMLTmpl, h)
})
router.GET(obj.routerPath("/main.wasm"), func(c *gin.Context) {
c.Data(http.StatusOK, "application/wasm", httpUIMainWasmData)
})
router.GET(obj.routerPath("/wasm_exec.js"), func(c *gin.Context) {
// the version of this file has to match compiler version
// the original came from: ~golang/lib/wasm/wasm_exec.js
// XXX: add a test to ensure this matches the compiler version
// the content-type matters or this won't work in the browser
c.Data(http.StatusOK, "text/javascript;charset=UTF-8", httpUIWasmExecData)
})
if static.HTTPUIStaticEmbedded {
router.GET(obj.routerPath("/"+static.HTTPUIIndexBootstrapCSS), func(c *gin.Context) {
c.Data(http.StatusOK, "text/css;charset=UTF-8", static.HTTPUIIndexStaticBootstrapCSS)
})
router.GET(obj.routerPath("/"+static.HTTPUIIndexBootstrapJS), func(c *gin.Context) {
c.Data(http.StatusOK, "text/javascript;charset=UTF-8", static.HTTPUIIndexStaticBootstrapJS)
})
}
router.POST(obj.routerPath("/save/"), func(c *gin.Context) {
id, ok := c.GetPostForm("id")
if !ok || id == "" {
msg := "missing id"
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
values, ok := c.GetPostFormArray("value")
if !ok {
msg := "missing value"
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
res, ok := obj.getResByID(id)
if !ok {
msg := fmt.Sprintf("id `%s` not found", id)
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
// we're storing data...
if err := res.SetValue(obj.ctx, values); err != nil {
msg := fmt.Sprintf("bad data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
// XXX: instead of an event to everything, instead if SetValue
// is an active sub resource (instead of something that noop's)
// that should send an event and eventually propagate to here,
// so skip sending this global one...
// Trigger a Watch() event so that CheckApply() calls Send/Recv,
// so our newly received POST value gets sent through the graph.
//select {
//case obj.eventStream <- nil: // send an event
//case <-obj.ctx.Done(): // in case Watch dies
// c.JSON(http.StatusInternalServerError, gin.H{
// "error": "Internal Server Error",
// "code": 500,
// })
//}
c.JSON(http.StatusOK, nil)
})
router.GET(obj.routerPath("/list/"), func(c *gin.Context) {
elements := []*common.FormElement{}
for _, x := range obj.GetGroup() { // grouped elements
res, ok := x.(HTTPServerUIGroupableRes) // convert from Res
if !ok {
continue
}
element := &common.FormElement{
Kind: res.GetKind(),
ID: res.GetID(),
Type: res.GetType(),
Sort: res.GetSort(),
}
elements = append(elements, element)
}
form := &common.Form{
Elements: elements,
}
// XXX: c.JSON or c.PureJSON ?
c.JSON(http.StatusOK, form) // send the struct as json
})
router.GET(obj.routerPath("/list/:id"), func(c *gin.Context) {
id := c.Param("id")
res, ok := obj.getResByID(id)
if !ok {
msg := fmt.Sprintf("id `%s` not found", id)
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
val, err := res.GetValue(obj.ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"code": 500,
})
return
}
el := &common.FormElementGeneric{ // XXX: text or string?
Value: val,
}
c.JSON(http.StatusOK, el) // send the struct as json
})
router.GET(obj.routerPath("/watch/:id"), func(c *gin.Context) {
id := c.Param("id")
res, ok := obj.getResByID(id)
if !ok {
msg := fmt.Sprintf("id `%s` not found", id)
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
ch := make(chan struct{})
//defer close(ch) // don't close, let it gc instead
obj.rwmutex.Lock()
obj.notifications[res][ch] = struct{}{} // add to notification "list"
obj.rwmutex.Unlock()
defer func() {
obj.rwmutex.Lock()
delete(obj.notifications[res], ch)
obj.rwmutex.Unlock()
}()
select {
case <-ch: // http long poll
// pass
//case <-obj.???[res].Done(): // in case Watch dies
// c.JSON(http.StatusInternalServerError, gin.H{
// "error": "Internal Server Error",
// "code": 500,
// })
case <-obj.ctx.Done(): // in case Watch dies
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"code": 500,
})
return
}
val, err := res.GetValue(obj.ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"code": 500,
})
return
}
el := &common.FormElementGeneric{ // XXX: text or string?
Value: val,
}
c.JSON(http.StatusOK, el) // send the struct as json
})
router.GET(obj.routerPath("/ping"), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "pong",
})
})
router.ServeHTTP(w, req)
return
}
// Validate checks if the resource data structure was populated correctly.
func (obj *HTTPUIRes) Validate() error {
if obj.getPath() == "" {
return fmt.Errorf("empty path")
}
// FIXME: does getPath need to start with a slash or end with one?
if !strings.HasPrefix(obj.getPath(), "/") {
return fmt.Errorf("the Path must be absolute")
}
if !strings.HasSuffix(obj.getPath(), "/") {
return fmt.Errorf("the Path must end with a slash")
}
return nil
}
// Init runs some startup code for this resource.
func (obj *HTTPUIRes) Init(init *engine.Init) error {
obj.init = init // save for later
//obj.eventStream = make(chan error)
obj.eventsChanMap = make(map[engine.Res]chan error)
obj.notifications = make(map[engine.Res]map[chan struct{}]struct{})
obj.rwmutex = &sync.RWMutex{}
// NOTE: If we don't Init anything that's autogrouped, then it won't
// even get an Init call on it.
// TODO: should we do this in the engine? Do we want to decide it here?
for _, res := range obj.GetGroup() { // grouped elements
// NOTE: We build a new init, but it's not complete. We only add
// what we're planning to use, and we ignore the rest for now...
r := res // bind the variable!
obj.eventsChanMap[r] = make(chan error)
obj.notifications[r] = make(map[chan struct{}]struct{})
event := func() {
select {
case obj.eventsChanMap[r] <- nil:
// send!
}
obj.rwmutex.RLock()
for ch := range obj.notifications[r] {
select {
case ch <- struct{}{}:
// send!
default:
// skip immediately if nobody is listening
}
}
obj.rwmutex.RUnlock()
// We don't do this here (why?) we instead read from the
// above channel and then send on multiplexedChan to the
// main loop, where it runs the obj.init.Event function.
//obj.init.Event() // notify engine of an event (this can block)
}
newInit := &engine.Init{
Program: obj.init.Program,
Version: obj.init.Version,
Hostname: obj.init.Hostname,
// Watch:
Running: event,
Event: event,
// CheckApply:
//Refresh: func() bool { // TODO: do we need this?
// innerRes, ok := r.(engine.RefreshableRes)
// if !ok {
// panic("res does not support the Refreshable trait")
// }
// return innerRes.Refresh()
//},
Send: engine.GenerateSendFunc(r),
Recv: engine.GenerateRecvFunc(r), // unused
FilteredGraph: func() (*pgraph.Graph, error) {
panic("FilteredGraph for HTTP:UI not implemented")
},
Local: obj.init.Local,
World: obj.init.World,
//VarDir: obj.init.VarDir, // TODO: wrap this
Debug: obj.init.Debug,
Logf: func(format string, v ...interface{}) {
obj.init.Logf(res.Kind()+": "+format, v...)
},
}
if err := res.Init(newInit); err != nil {
return errwrap.Wrapf(err, "autogrouped Init failed")
}
}
return nil
}
// Cleanup is run by the engine to clean up after the resource is done.
func (obj *HTTPUIRes) Cleanup() error {
return nil
}
// Watch is the primary listener for this resource and it outputs events. This
// particular one does absolutely nothing but block until we've received a done
// signal.
func (obj *HTTPUIRes) Watch(ctx context.Context) error {
multiplexedChan := make(chan error)
defer close(multiplexedChan) // closes after everyone below us is finished
wg := &sync.WaitGroup{}
defer wg.Wait()
innerCtx, cancel := context.WithCancel(ctx) // store for ServeHTTP
defer cancel()
obj.ctx = innerCtx
for _, r := range obj.GetGroup() { // grouped elements
res := r // optional in newer golang
wg.Add(1)
go func() {
defer wg.Done()
defer close(obj.eventsChanMap[res]) // where Watch sends events
if err := res.Watch(ctx); err != nil {
select {
case multiplexedChan <- err:
case <-ctx.Done():
}
}
}()
// wait for Watch first Running() call or immediate error...
select {
case <-obj.eventsChanMap[res]: // triggers on start or on err...
}
wg.Add(1)
go func() {
defer wg.Done()
for {
var ok bool
var err error
select {
// receive
case err, ok = <-obj.eventsChanMap[res]:
if !ok {
return
}
}
// send (multiplex)
select {
case multiplexedChan <- err:
case <-ctx.Done():
return
}
}
}()
}
// we block until all the children are started first...
obj.init.Running() // when started, notify engine that we're running
startupChan := make(chan struct{})
close(startupChan) // send one initial signal
var send = false // send event?
for {
if obj.init.Debug {
obj.init.Logf("Looping...")
}
select {
case <-startupChan:
startupChan = nil
send = true
//case err, ok := <-obj.eventStream:
// if !ok { // shouldn't happen
// obj.eventStream = nil
// continue
// }
// if err != nil {
// return err
// }
// send = true
case err, ok := <-multiplexedChan:
if !ok { // shouldn't happen
multiplexedChan = nil
continue
}
if err != nil {
return err
}
send = true
case <-ctx.Done(): // closed by the engine to signal shutdown
return nil
}
// do all our event sending all together to avoid duplicate msgs
if send {
send = false
obj.init.Event() // notify engine of an event (this can block)
}
}
//return nil // unreachable
}
// CheckApply is responsible for the Send/Recv aspects of the autogrouped
// resources. It recursively calls any autogrouped children.
func (obj *HTTPUIRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
if obj.init.Debug {
obj.init.Logf("CheckApply")
}
checkOK := true
for _, res := range obj.GetGroup() { // grouped elements
if c, err := res.CheckApply(ctx, apply); err != nil {
return false, errwrap.Wrapf(err, "autogrouped CheckApply failed")
} else if !c {
checkOK = false
}
}
return checkOK, nil // w00t
}
// Cmp compares two resources and returns an error if they are not equivalent.
func (obj *HTTPUIRes) Cmp(r engine.Res) error {
// we can only compare HTTPUIRes to others of the same resource kind
res, ok := r.(*HTTPUIRes)
if !ok {
return fmt.Errorf("res is not the same kind")
}
if obj.Server != res.Server {
return fmt.Errorf("the Server field differs")
}
if obj.Path != res.Path {
return fmt.Errorf("the Path differs")
}
return nil
}
// UnmarshalYAML is the custom unmarshal handler for this struct. It is
// primarily useful for setting the defaults.
func (obj *HTTPUIRes) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawRes HTTPUIRes // indirection to avoid infinite recursion
def := obj.Default() // get the default
res, ok := def.(*HTTPUIRes) // put in the right format
if !ok {
return fmt.Errorf("could not convert to HTTPUIRes")
}
raw := rawRes(*res) // convert; the defaults go here
if err := unmarshal(&raw); err != nil {
return err
}
*obj = HTTPUIRes(raw) // restore from indirection with type conversion!
return nil
}
// GroupCmp returns whether two resources can be grouped together or not. Can
// these two resources be merged, aka, does this resource support doing so? Will
// resource allow itself to be grouped _into_ this obj?
func (obj *HTTPUIRes) GroupCmp(r engine.GroupableRes) error {
res, ok := r.(HTTPServerUIGroupableRes) // different from what we usually do!
if !ok {
return fmt.Errorf("resource is not the right kind")
}
// If the http resource has the parent name field specified, then it
// must match against our name field if we want it to group with us.
if pn := res.ParentName(); pn != "" && pn != obj.Name() {
return fmt.Errorf("resource groups with a different parent name")
}
p := httpUIKind + ":"
// http:ui:foo is okay, but http:file is not
if !strings.HasPrefix(r.Kind(), p) {
return fmt.Errorf("not one of our children")
}
// http:ui:foo is okay, but http:ui:foo:bar is not
s := strings.TrimPrefix(r.Kind(), p)
if len(s) != len(r.Kind()) && strings.Count(s, ":") > 0 { // has prefix
return fmt.Errorf("maximum one resource after `%s` prefix", httpUIKind)
}
return nil
}