Files
mgmt/engine/resources/http_server_ui_input.go
James Shubin 654e958d3f engine: resources: Add the proper prefix to grouped http resources
Resources that can be grouped into the http:server resource must have
that prefix. Grouping is basically hierarchical, and without that common
prefix, it means we'd have to special-case our grouping algorithm.
2025-05-25 01:40:25 -04:00

674 lines
20 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"
"fmt"
"net/url"
"strconv"
"sync"
"github.com/purpleidea/mgmt/engine"
"github.com/purpleidea/mgmt/engine/resources/http_server_ui/common"
"github.com/purpleidea/mgmt/engine/traits"
"github.com/purpleidea/mgmt/util/errwrap"
)
const (
httpServerUIInputKind = httpServerUIKind + ":input"
httpServerUIInputStoreKey = "key"
httpServerUIInputStoreSchemeLocal = "local"
httpServerUIInputStoreSchemeWorld = "world"
httpServerUIInputTypeText = common.HTTPServerUIInputTypeText // "text"
httpServerUIInputTypeRange = common.HTTPServerUIInputTypeRange // "range"
)
func init() {
engine.RegisterResource(httpServerUIInputKind, func() engine.Res { return &HTTPServerUIInputRes{} })
}
// HTTPServerUIInputRes is a form element that exists within a http:server:ui
// resource, which exists within an http server. The name is used as the unique
// id of the field, unless the id 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:ui resource, and in doing so makes the form field
// associated with this resource available as part of that ui which is itself
// grouped and served from the http server resource.
type HTTPServerUIInputRes struct {
traits.Base // add the base methods without re-implementation
traits.Edgeable // XXX: add autoedge support
traits.Groupable // can be grouped into HTTPServerUIRes
traits.Sendable
init *engine.Init
// Path is the name of the http ui resource to group this into. If it is
// omitted, and there is only a single http ui resource, then it will
// be grouped into it automatically. If there is more than one main http
// ui resource being used, then the grouping behaviour is *undefined*
// when this is not specified, and it is not recommended to leave this
// blank!
Path string `lang:"path" yaml:"path"`
// ID is the unique id for this element. It is used in form fields and
// should not be a private identifier. It must be unique within a given
// http ui.
ID string `lang:"id" yaml:"id"`
// Value is the default value to use for the form field. If you change
// it, then the resource graph will change and we'll rebuild and have
// the new value visible. You can use either this or the Store field.
// XXX: If we ever add our resource mutate API, we might not need to
// swap to a new resource graph, and maybe Store is not needed?
Value string `lang:"value" yaml:"value"`
// Store the data in this source. It will also read in a default value
// from there if one is present. It will watch it for changes as well,
// and update the displayed value if it's changed from another source.
// This cannot be used at the same time as the Value field.
Store string `lang:"store" yaml:"store"`
// Type specifies the type of input field this is, and some information
// about it.
// XXX: come up with a format such as "multiline://?max=60&style=foo"
Type string `lang:"type" yaml:"type"`
// Sort is a string that you can use to determine the global sorted
// display order of all the elements in a ui.
Sort string `lang:"sort" yaml:"sort"`
scheme string // the scheme we're using with Store, cached for later
key string // the key we're using with Store, cached for later
typeURL *url.URL // the type data, cached for later
typeURLValues url.Values // the type data, cached for later
last *string // the last value we sent
value string // what we've last received from SetValue
storeEvent bool // did a store event happen?
mutex *sync.Mutex // guards storeEvent and value
event chan struct{} // local event that the setValue sends
}
// Default returns some sensible defaults for this resource.
func (obj *HTTPServerUIInputRes) Default() engine.Res {
return &HTTPServerUIInputRes{
Type: "text://",
}
}
// Validate checks if the resource data structure was populated correctly.
func (obj *HTTPServerUIInputRes) Validate() error {
if obj.GetID() == "" {
return fmt.Errorf("empty id")
}
if obj.Value != "" && obj.Store != "" {
return fmt.Errorf("may only use either Value or Store")
}
if obj.Value != "" {
if err := obj.checkValue(obj.Value); err != nil {
return errwrap.Wrapf(err, "the Value field is invalid")
}
}
if obj.Store != "" {
// XXX: check the URI format
}
return nil
}
// Init runs some startup code for this resource.
func (obj *HTTPServerUIInputRes) Init(init *engine.Init) error {
obj.init = init // save for later
u, err := url.Parse(obj.Type)
if err != nil {
return err
}
if u == nil {
return fmt.Errorf("can't parse Type")
}
if u.Scheme != httpServerUIInputTypeText && u.Scheme != httpServerUIInputTypeRange {
return fmt.Errorf("unknown scheme: %s", u.Scheme)
}
values, err := url.ParseQuery(u.RawQuery)
if err != nil {
return err
}
obj.typeURL = u
obj.typeURLValues = values
if obj.Store != "" {
u, err := url.Parse(obj.Store)
if err != nil {
return err
}
if u == nil {
return fmt.Errorf("can't parse Store")
}
if u.Scheme != httpServerUIInputStoreSchemeLocal && u.Scheme != httpServerUIInputStoreSchemeWorld {
return fmt.Errorf("unknown scheme: %s", u.Scheme)
}
values, err := url.ParseQuery(u.RawQuery)
if err != nil {
return err
}
obj.scheme = u.Scheme // cache for later
obj.key = obj.Name() // default
x, exists := values[httpServerUIInputStoreKey]
if exists && len(x) > 0 && x[0] != "" { // ignore absent or broken keys
obj.key = x[0]
}
}
// populate our obj.value cache somehow, so we don't mutate obj.Value
obj.value = obj.Value // copy
obj.mutex = &sync.Mutex{}
obj.event = make(chan struct{}, 1) // buffer to avoid blocks or deadlock
return nil
}
// Cleanup is run by the engine to clean up after the resource is done.
func (obj *HTTPServerUIInputRes) Cleanup() error {
return nil
}
// getKey returns the key to be used for this resource. If the Store field is
// specified, it will use that parsed part, otherwise it uses the Name.
func (obj *HTTPServerUIInputRes) getKey() string {
if obj.Store != "" {
return obj.key
}
return obj.Name()
}
// 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 *HTTPServerUIInputRes) ParentName() string {
return obj.Path
}
// GetKind returns the kind of this resource.
func (obj *HTTPServerUIInputRes) GetKind() string {
// NOTE: We don't *need* to return such a specific string, and "input"
// would be enough, but we might as well use this because we have it.
return httpServerUIInputKind
}
// GetID returns the actual ID we respond to. When ID is not specified, we use
// the Name.
func (obj *HTTPServerUIInputRes) GetID() string {
if obj.ID != "" {
return obj.ID
}
return obj.Name()
}
// SetValue stores the new value field that was obtained from submitting the
// form. This receives the raw, unsafe value that you must validate first.
func (obj *HTTPServerUIInputRes) SetValue(ctx context.Context, vs []string) error {
if len(vs) != 1 {
return fmt.Errorf("unexpected length of %d", len(vs))
}
value := vs[0]
if err := obj.checkValue(value); err != nil {
return err
}
obj.mutex.Lock()
obj.setValue(ctx, value) // also sends an event
obj.mutex.Unlock()
return nil
}
// setValue is the helper version where the caller must provide the mutex.
func (obj *HTTPServerUIInputRes) setValue(ctx context.Context, val string) error {
obj.value = val
select {
case obj.event <- struct{}{}:
default:
}
return nil
}
func (obj *HTTPServerUIInputRes) checkValue(value string) error {
// XXX: validate based on obj.Type
// XXX: validate what kind of values are allowed, probably no \n, etc...
return nil
}
// GetValue gets a string representation for the form value, that we'll use in
// our html form.
func (obj *HTTPServerUIInputRes) GetValue(ctx context.Context) (string, error) {
obj.mutex.Lock()
defer obj.mutex.Unlock()
if obj.storeEvent {
val, exists, err := obj.storeGet(ctx, obj.getKey())
if err != nil {
return "", errwrap.Wrapf(err, "error during get")
}
if !exists {
return "", nil // default
}
return val, nil
}
return obj.value, nil
}
// GetType returns a map that you can use to build the input field in the ui.
func (obj *HTTPServerUIInputRes) GetType() map[string]string {
m := make(map[string]string)
if obj.typeURL.Scheme == httpServerUIInputTypeRange {
m = obj.rangeGetType()
}
m[common.HTTPServerUIInputType] = obj.typeURL.Scheme
return m
}
func (obj *HTTPServerUIInputRes) rangeGetType() map[string]string {
m := make(map[string]string)
base := 10
bits := 64
if sa, exists := obj.typeURLValues[common.HTTPServerUIInputTypeRangeMin]; exists && len(sa) > 0 {
if x, err := strconv.ParseInt(sa[0], base, bits); err == nil {
m[common.HTTPServerUIInputTypeRangeMin] = strconv.FormatInt(x, base)
}
}
if sa, exists := obj.typeURLValues[common.HTTPServerUIInputTypeRangeMax]; exists && len(sa) > 0 {
if x, err := strconv.ParseInt(sa[0], base, bits); err == nil {
m[common.HTTPServerUIInputTypeRangeMax] = strconv.FormatInt(x, base)
}
}
if sa, exists := obj.typeURLValues[common.HTTPServerUIInputTypeRangeStep]; exists && len(sa) > 0 {
if x, err := strconv.ParseInt(sa[0], base, bits); err == nil {
m[common.HTTPServerUIInputTypeRangeStep] = strconv.FormatInt(x, base)
}
}
return m
}
// GetSort returns a string that you can use to determine the global sorted
// display order of all the elements in a ui.
func (obj *HTTPServerUIInputRes) GetSort() string {
return obj.Sort
}
// 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 *HTTPServerUIInputRes) Watch(ctx context.Context) error {
if obj.Store != "" && obj.scheme == httpServerUIInputStoreSchemeLocal {
return obj.localWatch(ctx)
}
if obj.Store != "" && obj.scheme == httpServerUIInputStoreSchemeWorld {
return obj.worldWatch(ctx)
}
obj.init.Running() // when started, notify engine that we're running
// XXX: do we need to watch on obj.event for normal .Value stuff?
select {
case <-ctx.Done(): // closed by the engine to signal shutdown
}
//obj.init.Event() // notify engine of an event (this can block)
return nil
}
func (obj *HTTPServerUIInputRes) localWatch(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ch, err := obj.init.Local.ValueWatch(ctx, obj.getKey()) // get possible events!
if err != nil {
return errwrap.Wrapf(err, "error during watch")
}
obj.init.Running() // when started, notify engine that we're running
for {
select {
case _, ok := <-ch:
if !ok { // channel shutdown
return nil
}
obj.mutex.Lock()
obj.storeEvent = true
obj.mutex.Unlock()
case <-obj.event:
case <-ctx.Done(): // closed by the engine to signal shutdown
return nil
}
if obj.init.Debug {
obj.init.Logf("event!")
}
obj.init.Event() // notify engine of an event (this can block)
}
}
func (obj *HTTPServerUIInputRes) worldWatch(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ch, err := obj.init.World.StrWatch(ctx, obj.getKey()) // get possible events!
if err != nil {
return errwrap.Wrapf(err, "error during watch")
}
obj.init.Running() // when started, notify engine that we're running
for {
select {
case err, ok := <-ch:
if !ok { // channel shutdown
return nil
}
if err != nil {
return errwrap.Wrapf(err, "unknown %s watcher error", obj)
}
obj.mutex.Lock()
obj.storeEvent = true
obj.mutex.Unlock()
case <-obj.event:
case <-ctx.Done(): // closed by the engine to signal shutdown
return nil
}
if obj.init.Debug {
obj.init.Logf("event!")
}
obj.init.Event() // notify engine of an event (this can block)
}
}
// CheckApply performs the send/recv portion of this autogrouped resources. That
// can fail, but only if the send portion fails for some reason. If we're using
// the Store feature, then it also reads and writes to and from that store.
func (obj *HTTPServerUIInputRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
if obj.init.Debug {
obj.init.Logf("CheckApply")
}
// If we're in ".Value" mode, we want to look at the incoming value, and
// send it onwards. This function mostly exists as a stub in this case.
// The private value gets set by obj.SetValue from the http:server:ui
// parent. If we're in ".Store" mode, then we're reconciling between the
// "World" and the http:server:ui "Web".
if obj.Store != "" {
return obj.storeCheckApply(ctx, apply)
}
return obj.valueCheckApply(ctx, apply)
}
func (obj *HTTPServerUIInputRes) valueCheckApply(ctx context.Context, apply bool) (bool, error) {
obj.mutex.Lock()
value := obj.value // gets set by obj.SetValue
obj.mutex.Unlock()
if obj.last != nil && *obj.last == value {
if err := obj.init.Send(&HTTPServerUIInputSends{
Value: &value,
}); err != nil {
return false, err
}
return true, nil // expected value has already been sent
}
if !apply {
if err := obj.init.Send(&HTTPServerUIInputSends{
Value: &value, // XXX: arbitrary since we're in noop mode
}); err != nil {
return false, err
}
return false, nil
}
s := value // copy
obj.last = &s // cache
// XXX: This is getting called twice, what's the bug?
obj.init.Logf("sending: %s", value)
// send
if err := obj.init.Send(&HTTPServerUIInputSends{
Value: &value,
}); err != nil {
return false, err
}
return false, nil
//return true, nil // always succeeds, with nothing to do!
}
// storeCheckApply is a tricky function where we attempt to reconcile the state
// between a third-party changing the value in the World database, and a recent
// "http:server:ui" change by an end user. Basically whoever runs last is the
// "right" value that we want to use. We know who sent the event from reading
// the storeEvent variable, and if it was the World, we want to cache it
// locally, and if it was the Web, then we want to push it up to the store.
func (obj *HTTPServerUIInputRes) storeCheckApply(ctx context.Context, apply bool) (bool, error) {
v1, exists, err := obj.storeGet(ctx, obj.getKey())
if err != nil {
return false, errwrap.Wrapf(err, "error during get")
}
obj.mutex.Lock()
v2 := obj.value // gets set by obj.SetValue
storeEvent := obj.storeEvent
obj.storeEvent = false // reset it
obj.mutex.Unlock()
if exists && v1 == v2 { // both sides are happy
if err := obj.init.Send(&HTTPServerUIInputSends{
Value: &v2,
}); err != nil {
return false, err
}
return true, nil
}
if !apply {
if err := obj.init.Send(&HTTPServerUIInputSends{
Value: &v2, // XXX: arbitrary since we're in noop mode
}); err != nil {
return false, err
}
return false, nil
}
obj.mutex.Lock()
if storeEvent { // event from World, pull down the value
err = obj.setValue(ctx, v1) // also sends an event
}
value := obj.value
obj.mutex.Unlock()
if err != nil {
return false, err
}
if !exists || !storeEvent { // event from web, push up the value
if err := obj.storeSet(ctx, obj.getKey(), value); err != nil {
return false, errwrap.Wrapf(err, "error during set")
}
}
obj.init.Logf("sending: %s", value)
// send
if err := obj.init.Send(&HTTPServerUIInputSends{
Value: &value,
}); err != nil {
return false, err
}
return false, nil
}
func (obj *HTTPServerUIInputRes) storeGet(ctx context.Context, key string) (string, bool, error) {
if obj.Store != "" && obj.scheme == httpServerUIInputStoreSchemeLocal {
val, err := obj.init.Local.ValueGet(ctx, key)
if err != nil {
return "", false, err // real error
}
if val == nil { // if val is nil, and no error then it doesn't exist
return "", false, nil // val doesn't exist
}
s, ok := val.(string)
if !ok {
// TODO: support different types perhaps?
return "", false, fmt.Errorf("not a string") // real error
}
return s, true, nil
}
if obj.Store != "" && obj.scheme == httpServerUIInputStoreSchemeWorld {
val, err := obj.init.World.StrGet(ctx, key)
if err != nil && obj.init.World.StrIsNotExist(err) {
return "", false, nil // val doesn't exist
}
if err != nil {
return "", false, err // real error
}
return val, true, nil
}
return "", false, nil // something else
}
func (obj *HTTPServerUIInputRes) storeSet(ctx context.Context, key, val string) error {
if obj.Store != "" && obj.scheme == httpServerUIInputStoreSchemeLocal {
return obj.init.Local.ValueSet(ctx, key, val)
}
if obj.Store != "" && obj.scheme == httpServerUIInputStoreSchemeWorld {
return obj.init.World.StrSet(ctx, key, val)
}
return nil // something else
}
// Cmp compares two resources and returns an error if they are not equivalent.
func (obj *HTTPServerUIInputRes) Cmp(r engine.Res) error {
// we can only compare HTTPServerUIInputRes to others of the same resource kind
res, ok := r.(*HTTPServerUIInputRes)
if !ok {
return fmt.Errorf("res is not the same kind")
}
if obj.Path != res.Path {
return fmt.Errorf("the Path differs")
}
if obj.ID != res.ID {
return fmt.Errorf("the ID differs")
}
if obj.Value != res.Value {
return fmt.Errorf("the Value differs")
}
if obj.Store != res.Store {
return fmt.Errorf("the Store differs")
}
if obj.Type != res.Type {
return fmt.Errorf("the Type differs")
}
if obj.Sort != res.Sort {
return fmt.Errorf("the Sort differs")
}
return nil
}
// UnmarshalYAML is the custom unmarshal handler for this struct. It is
// primarily useful for setting the defaults.
func (obj *HTTPServerUIInputRes) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawRes HTTPServerUIInputRes // indirection to avoid infinite recursion
def := obj.Default() // get the default
res, ok := def.(*HTTPServerUIInputRes) // put in the right format
if !ok {
return fmt.Errorf("could not convert to HTTPServerUIInputRes")
}
raw := rawRes(*res) // convert; the defaults go here
if err := unmarshal(&raw); err != nil {
return err
}
*obj = HTTPServerUIInputRes(raw) // restore from indirection with type conversion!
return nil
}
// HTTPServerUIInputSends is the struct of data which is sent after a successful
// Apply.
type HTTPServerUIInputSends struct {
// Value is the text element value being sent.
Value *string `lang:"value"`
}
// Sends represents the default struct of values we can send using Send/Recv.
func (obj *HTTPServerUIInputRes) Sends() interface{} {
return &HTTPServerUIInputSends{
Value: nil,
}
}