Files
mgmt/engine/resources/http_server_ui.go
James Shubin 74f36c5d73 engine: resources: Add some compile time checks for groupers
These can "break" silently and not autogroup if we change the resource
and it no longer fulfills the interface. Add this compile time check to
prevent that.
2025-05-25 03:47:47 -04:00

793 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_server_ui/common"
"github.com/purpleidea/mgmt/engine/resources/http_server_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 (
httpServerUIKind = httpServerKind + ":ui"
httpServerUIIndexHTMLTmpl = "index.html.tmpl"
)
var (
//go:embed http_server_ui/index.html.tmpl
httpServerUIIndexHTMLTmplData string
//go:embed http_server_ui/wasm_exec.js
httpServerUIWasmExecData []byte
//go:embed http_server_ui/main.wasm
httpServerUIMainWasmData []byte
)
func init() {
engine.RegisterResource(httpServerUIKind, func() engine.Res { return &HTTPServerUIRes{} })
}
var _ HTTPServerGroupableRes = &HTTPServerUIRes{} // compile time check
// 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:server: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
}
// HTTPServerUIResData represents some additional data to attach to the
// resource.
type HTTPServerUIResData 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"`
}
// HTTPServerUIRes 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 HTTPServerUIRes 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 *HTTPServerUIResData `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 *HTTPServerUIRes) Default() engine.Res {
return &HTTPServerUIRes{}
}
// 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 *HTTPServerUIRes) 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 *HTTPServerUIRes) 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 *HTTPServerUIRes) 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 *HTTPServerUIRes) 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 *HTTPServerUIRes) 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 *HTTPServerUIRes) 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 *HTTPServerUIRes) 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[httpServerUIIndexHTMLTmpl] = httpServerUIIndexHTMLTmplData // 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 *HTTPServerUIRes) 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.HTTPServerUIStaticEmbedded // 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, httpServerUIIndexHTMLTmpl, h)
})
router.GET(obj.routerPath("/main.wasm"), func(c *gin.Context) {
c.Data(http.StatusOK, "application/wasm", httpServerUIMainWasmData)
})
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", httpServerUIWasmExecData)
})
if static.HTTPServerUIStaticEmbedded {
router.GET(obj.routerPath("/"+static.HTTPServerUIIndexBootstrapCSS), func(c *gin.Context) {
c.Data(http.StatusOK, "text/css;charset=UTF-8", static.HTTPServerUIIndexStaticBootstrapCSS)
})
router.GET(obj.routerPath("/"+static.HTTPServerUIIndexBootstrapJS), func(c *gin.Context) {
c.Data(http.StatusOK, "text/javascript;charset=UTF-8", static.HTTPServerUIIndexStaticBootstrapJS)
})
}
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 *HTTPServerUIRes) 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 *HTTPServerUIRes) 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:Server: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 *HTTPServerUIRes) 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 *HTTPServerUIRes) 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
for {
if obj.init.Debug {
obj.init.Logf("Looping...")
}
select {
case <-startupChan:
startupChan = nil
//case err, ok := <-obj.eventStream:
// if !ok { // shouldn't happen
// obj.eventStream = nil
// continue
// }
// if err != nil {
// return err
// }
case err, ok := <-multiplexedChan:
if !ok { // shouldn't happen
multiplexedChan = nil
continue
}
if err != nil {
return err
}
case <-ctx.Done(): // closed by the engine to signal shutdown
return nil
}
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 *HTTPServerUIRes) 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 *HTTPServerUIRes) Cmp(r engine.Res) error {
// we can only compare HTTPServerUIRes to others of the same resource kind
res, ok := r.(*HTTPServerUIRes)
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 *HTTPServerUIRes) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawRes HTTPServerUIRes // indirection to avoid infinite recursion
def := obj.Default() // get the default
res, ok := def.(*HTTPServerUIRes) // put in the right format
if !ok {
return fmt.Errorf("could not convert to HTTPServerUIRes")
}
raw := rawRes(*res) // convert; the defaults go here
if err := unmarshal(&raw); err != nil {
return err
}
*obj = HTTPServerUIRes(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 *HTTPServerUIRes) 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 := httpServerUIKind + ":"
// http:server:ui:foo is okay, but http:server:file is not
if !strings.HasPrefix(r.Kind(), p) {
return fmt.Errorf("not one of our children")
}
// http:server:ui:foo is okay, but http:server: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", httpServerUIKind)
}
return nil
}