Many years ago I built and demoed a prototype of a simple web ui with a slider, and as you moved it left and right, it started up or shutdown some number of virtual machines. The webui was standalone code, but the rough idea of having events from a high-level overview flow into mgmt, was what I wanted to test out. At this stage, I didn't even have the language built yet. This prototype helped convince me of the way a web ui would fit into everything. Years later, I build an autogrouping prototype which looks quite similar to what we have today. I recently picked it back up to polish it a bit more. It's certainly not perfect, and might even be buggy, but it's useful enough that it's worth sharing. If I had more cycles, I'd probably consider removing the "store" mode, and replace it with the normal "value" system, but we would need the resource "mutate" API if we wanted this. This would allow us to directly change the "value" field, without triggering a graph swap, which would be a lot less clunky than the "store" situation. Of course I'd love to see a GTK version of this concept, but I figured it would be more practical to have a web ui over HTTP. One notable missing feature, is that if the "web ui" changes (rather than just a value changing) we need to offer to the user to reload it. It currently doesn't get an event for that, and so don't confuse your users. We also need to be better at validating "untrusted" input here. There's also no major reason to use the "gin" framework, we should probably redo this with the standard library alone, but it was easier for me to push out something quick this way. We can optimize that later. Lastly, this is all quite ugly since I'm not a very good web dev, so if you want to make this polished, please do! The wasm code is also quite terrible due to limitations in the compiler, and maybe one day when that works better and doesn't constantly deadlock, we can improve it.
792 lines
23 KiB
Go
792 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("/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
|
|
}
|