diff --git a/Makefile b/Makefile index dc3a7873..0f5f8788 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,7 @@ SHELL = bash # a large amount of output from this `find`, can cause `make` to be much slower! GO_FILES := $(shell find * -name '*.go' -not -path 'old/*' -not -path 'tmp/*') MCL_FILES := $(shell find lang/ -name '*.mcl' -not -path 'old/*' -not -path 'tmp/*') +MISC_FILES := $(shell find engine/resources/http_ui/) SVERSION := $(or $(SVERSION),$(shell git describe --match '[0-9]*\.[0-9]*\.[0-9]*' --tags --dirty --always)) VERSION := $(or $(VERSION),$(shell git describe --match '[0-9]*\.[0-9]*\.[0-9]*' --tags --abbrev=0)) @@ -198,11 +199,15 @@ lang: ## generates the lexer/parser for the language frontend @# recursively run make in child dir named lang @$(MAKE) --quiet -C lang +resources: ## builds the resources dependencies required for the engine backend + @# recursively run make in child dir named engine/resources + @$(MAKE) --quiet -C engine/resources + # build a `mgmt` binary for current host os/arch $(PROGRAM): build/mgmt-${GOHOSTOS}-${GOHOSTARCH} ## build an mgmt binary for current host os/arch cp -a $< $@ -$(PROGRAM).static: $(GO_FILES) $(MCL_FILES) go.mod go.sum +$(PROGRAM).static: $(GO_FILES) $(MCL_FILES) $(MISC_FILES) go.mod go.sum @echo "Building: $(PROGRAM).static, version: $(SVERSION)..." go generate go build $(TRIMPATH) -a -installsuffix cgo -tags netgo -ldflags '-extldflags "-static" -X main.program=$(PROGRAM) -X main.version=$(SVERSION) -s -w' -o $(PROGRAM).static $(BUILD_FLAGS); @@ -221,7 +226,7 @@ baddev: $(PROGRAM) # extract os and arch from target pattern GOOS=$(firstword $(subst -, ,$*)) GOARCH=$(lastword $(subst -, ,$*)) -build/mgmt-%: $(GO_FILES) $(MCL_FILES) go.mod go.sum | lang funcgen +build/mgmt-%: $(GO_FILES) $(MCL_FILES) $(MISC_FILES) go.mod go.sum | lang resources funcgen @# If you need to run `go mod tidy` then this can trigger. @if [ "$(PKGNAME)" = "" ]; then echo "\$$(PKGNAME) is empty, test with: go list ."; exit 42; fi @echo "Building: $(PROGRAM), os/arch: $*, version: $(SVERSION)..." diff --git a/engine/graph/actions.go b/engine/graph/actions.go index 8de265b5..eda1825a 100644 --- a/engine/graph/actions.go +++ b/engine/graph/actions.go @@ -165,8 +165,68 @@ func (obj *Engine) Process(ctx context.Context, vertex pgraph.Vertex) error { } } } + + // XXX: maybe we want to receive from someone that was grouped? + // XXX: can obj.SendRecv() do that or do we have to modify it? + // XXX: it might already *just work* -- test it to double check! } + // If we contain grouped resources, maybe someone inside wants to recv? + // This code is similar to the above and was added for http:ui stuff. + // XXX: Maybe this block isn't needed, as mentioned we need to check! + if res, ok := vertex.(engine.GroupableRes); ok { + process := res.GetGroup() // look through these + grouped := []engine.GroupableRes{} // found resources + for len(process) > 0 { // recurse through any nesting + var x engine.GroupableRes + x, process = process[0], process[1:] // pop from front! + + g := x.GetGroup() + grouped = append(grouped, g...) // add to the end + } + + //for _, g := res.GetGroup() // non-recursive, one-layer method + for _, g := range grouped { // recursive method! + r, ok := g.(engine.RecvableRes) + if !ok { + continue + } + + // This section looks almost identical to the above one! + if updated, err := SendRecv(r, nil); err != nil { + return errwrap.Wrapf(err, "could not grouped SendRecv") + } else if len(updated) > 0 { + for r, m := range updated { // map[engine.RecvableRes]map[string]*engine.Send + v, ok := r.(pgraph.Vertex) + if !ok { + continue + } + _, stateExists := obj.state[v] // autogrouped children probably don't have a state + if !stateExists { + continue + } + for s, send := range m { + if !send.Changed { + continue + } + obj.Logf("Send/Recv: %v.%s -> %v.%s", send.Res, send.Key, r, s) + // if send.Changed == true, at least one was updated + // invalidate cache, mark as dirty + obj.state[v].setDirty() + //break // we might have more vertices now + } + + // re-validate after we change any values + if err := engine.Validate(r); err != nil { + return errwrap.Wrapf(err, "failed grouped Validate after SendRecv") + } + } + } + } + } + // XXX: this might not work with two merged "CompatibleRes" resources... + // XXX: fix that so we can have the mappings to do it in lang/interpret.go ? + var ok = true var applied = false // did we run an apply? var noop = res.MetaParams().Noop // lookup the noop value diff --git a/engine/graph/autogroup.go b/engine/graph/autogroup.go index 6426e6e7..94b3f2ca 100644 --- a/engine/graph/autogroup.go +++ b/engine/graph/autogroup.go @@ -95,11 +95,19 @@ func (obj *wrappedGrouper) VertexCmp(v1, v2 pgraph.Vertex) error { return fmt.Errorf("one of the autogroup flags is false") } - if r1.IsGrouped() { // already grouped! - return fmt.Errorf("already grouped") - } - if len(r2.GetGroup()) > 0 { // already has children grouped! - return fmt.Errorf("already has groups") + // We don't want to bail on these two conditions if the kinds are the + // same. This prevents us from having a linear chain of pkg->pkg->pkg, + // instead of flattening all of them into one arbitrary choice. But if + // we are doing hierarchical grouping, then we want to allow this type + // of grouping, or we won't end up building any hierarchies! This change + // was added for http:ui stuff. Check this condition is really required. + if r1.Kind() == r2.Kind() { // XXX: needed or do we unwrap the contents? + if r1.IsGrouped() { // already grouped! + return fmt.Errorf("already grouped") + } + if len(r2.GetGroup()) > 0 { // already has children grouped! + return fmt.Errorf("already has groups") + } } if err := r1.GroupCmp(r2); err != nil { // resource groupcmp failed! return errwrap.Wrapf(err, "the GroupCmp failed") diff --git a/engine/graph/autogroup/autogroup_test.go b/engine/graph/autogroup/autogroup_test.go index a0c8b187..b9947480 100644 --- a/engine/graph/autogroup/autogroup_test.go +++ b/engine/graph/autogroup/autogroup_test.go @@ -49,6 +49,13 @@ import ( func init() { engine.RegisterResource("nooptest", func() engine.Res { return &NoopResTest{} }) + engine.RegisterResource("nooptestkind:foo", func() engine.Res { return &NoopResTest{} }) + engine.RegisterResource("nooptestkind:foo:hello", func() engine.Res { return &NoopResTest{} }) + engine.RegisterResource("nooptestkind:foo:world", func() engine.Res { return &NoopResTest{} }) + engine.RegisterResource("nooptestkind:foo:world:big", func() engine.Res { return &NoopResTest{} }) + engine.RegisterResource("nooptestkind:foo:world:bad", func() engine.Res { return &NoopResTest{} }) + engine.RegisterResource("nooptestkind:foo:world:bazzz", func() engine.Res { return &NoopResTest{} }) + engine.RegisterResource("nooptestkind:this:is:very:long", func() engine.Res { return &NoopResTest{} }) } // NoopResTest is a no-op resource that groups strangely. @@ -108,19 +115,35 @@ func (obj *NoopResTest) GroupCmp(r engine.GroupableRes) error { } // TODO: implement this in vertexCmp for *testGrouper instead? - if strings.Contains(res.Name(), ",") { // HACK - return fmt.Errorf("already grouped") // element to be grouped is already grouped! + k1 := strings.HasPrefix(obj.Kind(), "nooptestkind:") + k2 := strings.HasPrefix(res.Kind(), "nooptestkind:") + if !k1 && !k2 { // XXX: compat mode, to skip during "kind" tests + if strings.Contains(res.Name(), ",") { // HACK + return fmt.Errorf("already grouped") // element to be grouped is already grouped! + } + } + + // XXX: make a better grouping algorithm for test expression + // XXX: this prevents us from re-using the same kind twice in a test... + // group different kinds if they're hierarchical (helpful hack for testing) + if obj.Kind() != res.Kind() { + s1 := strings.Split(obj.Kind(), ":") + s2 := strings.Split(res.Kind(), ":") + if len(s1) > len(s2) { // let longer get grouped INTO shorter + return fmt.Errorf("chunk inversion") + } } // group if they start with the same letter! (helpful hack for testing) if obj.Name()[0] != res.Name()[0] { return fmt.Errorf("different starting letter") } + //fmt.Printf("group of: %+v into: %+v\n", res.Kind(), obj.Kind()) return nil } -func NewNoopResTest(name string) *NoopResTest { - n, err := engine.NewNamedResource("nooptest", name) +func NewKindNoopResTest(kind, name string) *NoopResTest { + n, err := engine.NewNamedResource(kind, name) if err != nil { panic(fmt.Sprintf("unexpected error: %+v", err)) } @@ -138,6 +161,10 @@ func NewNoopResTest(name string) *NoopResTest { return x } +func NewNoopResTest(name string) *NoopResTest { + return NewKindNoopResTest("nooptest", name) +} + func NewNoopResTestSema(name string, semas []string) *NoopResTest { n := NewNoopResTest(name) n.MetaParams().Sema = semas @@ -174,21 +201,29 @@ func (obj *testGrouper) VertexCmp(v1, v2 pgraph.Vertex) error { return fmt.Errorf("v2 is not a GroupableRes") } - if r1.Kind() != r2.Kind() { // we must group similar kinds - // TODO: maybe future resources won't need this limitation? - return fmt.Errorf("the two resources aren't the same kind") - } + //if r1.Kind() != r2.Kind() { // we must group similar kinds + // // TODO: maybe future resources won't need this limitation? + // return fmt.Errorf("the two resources aren't the same kind") + //} // someone doesn't want to group! if r1.AutoGroupMeta().Disabled || r2.AutoGroupMeta().Disabled { return fmt.Errorf("one of the autogroup flags is false") } - if r1.IsGrouped() { // already grouped! - return fmt.Errorf("already grouped") - } - if len(r2.GetGroup()) > 0 { // already has children grouped! - return fmt.Errorf("already has groups") + // We don't want to bail on these two conditions if the kinds are the + // same. This prevents us from having a linear chain of pkg->pkg->pkg, + // instead of flattening all of them into one arbitrary choice. But if + // we are doing hierarchical grouping, then we want to allow this type + // of grouping, or we won't end up building any hierarchies! + if r1.Kind() == r2.Kind() { + if r1.IsGrouped() { // already grouped! + return fmt.Errorf("already grouped") + } + if len(r2.GetGroup()) > 0 { // already has children grouped! + return fmt.Errorf("already has groups") + } } + if err := r1.GroupCmp(r2); err != nil { // resource groupcmp failed! return errwrap.Wrapf(err, "the GroupCmp failed") } @@ -197,6 +232,8 @@ func (obj *testGrouper) VertexCmp(v1, v2 pgraph.Vertex) error { } func (obj *testGrouper) VertexMerge(v1, v2 pgraph.Vertex) (v pgraph.Vertex, err error) { + //fmt.Printf("merge of: %s into: %s\n", v2, v1) + // NOTE: this doesn't look at kind! r1 := v1.(engine.GroupableRes) r2 := v2.(engine.GroupableRes) if err := r1.GroupRes(r2); err != nil { // group them first @@ -273,8 +310,12 @@ Loop: for v1 := range g1.Adjacency() { // for each vertex in g1 r1 := v1.(engine.GroupableRes) l1 := strings.Split(r1.Name(), ",") // make list of everyone's names... - for _, x1 := range r1.GetGroup() { - l1 = append(l1, x1.Name()) // add my contents + // XXX: this should be recursive for hierarchical grouping... + // XXX: instead, hack it for now: + if !strings.HasPrefix(r1.Kind(), "nooptestkind:") { + for _, x1 := range r1.GetGroup() { + l1 = append(l1, x1.Name()) // add my contents + } } l1 = util.StrRemoveDuplicatesInList(l1) // remove duplicates sort.Strings(l1) @@ -283,8 +324,12 @@ Loop: for v2 := range g2.Adjacency() { // does it match in g2 ? r2 := v2.(engine.GroupableRes) l2 := strings.Split(r2.Name(), ",") - for _, x2 := range r2.GetGroup() { - l2 = append(l2, x2.Name()) + // XXX: this should be recursive for hierarchical grouping... + // XXX: instead, hack it for now: + if !strings.HasPrefix(r2.Kind(), "nooptestkind:") { + for _, x2 := range r2.GetGroup() { + l2 = append(l2, x2.Name()) + } } l2 = util.StrRemoveDuplicatesInList(l2) // remove duplicates sort.Strings(l2) @@ -771,9 +816,9 @@ func TestPgraphGrouping16(t *testing.T) { a := NewNoopResTest("a1,a2") b1 := NewNoopResTest("b1") c1 := NewNoopResTest("c1") - e1 := NE("e1") - e2 := NE("e2") - e3 := NE("e3") + e1 := NE("e1") // +e3 a bit? + e2 := NE("e2") // ok! + e3 := NE("e3") // +e1 a bit? g3.AddEdge(a, b1, e1) g3.AddEdge(b1, c1, e2) g3.AddEdge(a, c1, e3) @@ -859,9 +904,9 @@ func TestPgraphGrouping18(t *testing.T) { a := NewNoopResTest("a1,a2") b := NewNoopResTest("b1,b2") c1 := NewNoopResTest("c1") - e1 := NE("e1") - e2 := NE("e2,e4") - e3 := NE("e3") + e1 := NE("e1") // +e3 a bit? + e2 := NE("e2,e4") // ok! + e3 := NE("e3") // +e1 a bit? g3.AddEdge(a, b, e1) g3.AddEdge(b, c1, e2) g3.AddEdge(a, c1, e3) @@ -978,3 +1023,110 @@ func TestPgraphSemaphoreGrouping3(t *testing.T) { } runGraphCmp(t, g1, g2) } + +func TestPgraphGroupingKinds0(t *testing.T) { + g1, _ := pgraph.NewGraph("g1") // original graph + { + a1 := NewKindNoopResTest("nooptestkind:foo", "a1") + a2 := NewKindNoopResTest("nooptestkind:foo:hello", "a2") + g1.AddVertex(a1, a2) + } + g2, _ := pgraph.NewGraph("g2") // expected result ? + { + a := NewNoopResTest("a1,a2") + g2.AddVertex(a) + } + runGraphCmp(t, g1, g2) +} + +func TestPgraphGroupingKinds1(t *testing.T) { + g1, _ := pgraph.NewGraph("g1") // original graph + { + a1 := NewKindNoopResTest("nooptestkind:foo", "a1") + a2 := NewKindNoopResTest("nooptestkind:foo:world", "a2") + a3 := NewKindNoopResTest("nooptestkind:foo:world:big", "a3") + g1.AddVertex(a1, a2, a3) + } + g2, _ := pgraph.NewGraph("g2") // expected result ? + { + a := NewNoopResTest("a1,a2,a3") + g2.AddVertex(a) + } + runGraphCmp(t, g1, g2) +} + +func TestPgraphGroupingKinds2(t *testing.T) { + g1, _ := pgraph.NewGraph("g1") // original graph + { + a1 := NewKindNoopResTest("nooptestkind:foo", "a1") + a2 := NewKindNoopResTest("nooptestkind:foo:world", "a2") + a3 := NewKindNoopResTest("nooptestkind:foo:world:big", "a3") + a4 := NewKindNoopResTest("nooptestkind:foo:world:bad", "a4") + g1.AddVertex(a1, a2, a3, a4) + } + g2, _ := pgraph.NewGraph("g2") // expected result ? + { + a := NewNoopResTest("a1,a2,a3,a4") + g2.AddVertex(a) + } + runGraphCmp(t, g1, g2) +} + +func TestPgraphGroupingKinds3(t *testing.T) { + g1, _ := pgraph.NewGraph("g1") // original graph + { + a1 := NewKindNoopResTest("nooptestkind:foo", "a1") + a2 := NewKindNoopResTest("nooptestkind:foo:world", "a2") + a3 := NewKindNoopResTest("nooptestkind:foo:world:big", "a3") + a4 := NewKindNoopResTest("nooptestkind:foo:world:bad", "a4") + a5 := NewKindNoopResTest("nooptestkind:foo:world:bazzz", "a5") + g1.AddVertex(a1, a2, a3, a4, a5) + } + g2, _ := pgraph.NewGraph("g2") // expected result ? + { + a := NewNoopResTest("a1,a2,a3,a4,a5") + g2.AddVertex(a) + } + runGraphCmp(t, g1, g2) +} + +// This test is valid, but our test system doesn't support duplicate kinds atm. +//func TestPgraphGroupingKinds4(t *testing.T) { +// g1, _ := pgraph.NewGraph("g1") // original graph +// { +// a1 := NewKindNoopResTest("nooptestkind:foo", "a1") +// a2 := NewKindNoopResTest("nooptestkind:foo:world", "a2") +// a3 := NewKindNoopResTest("nooptestkind:foo:world:big", "a3") +// a4 := NewKindNoopResTest("nooptestkind:foo:world:big", "a4") +// g1.AddVertex(a1, a2, a3, a4) +// } +// g2, _ := pgraph.NewGraph("g2") // expected result ? +// { +// a := NewNoopResTest("a1,a2,a3,a4") +// g2.AddVertex(a) +// } +// runGraphCmp(t, g1, g2) +//} + +func TestPgraphGroupingKinds5(t *testing.T) { + g1, _ := pgraph.NewGraph("g1") // original graph + { + a1 := NewKindNoopResTest("nooptestkind:foo", "a1") + a2 := NewKindNoopResTest("nooptestkind:foo:world", "a2") + a3 := NewKindNoopResTest("nooptestkind:foo:world:big", "a3") + a4 := NewKindNoopResTest("nooptestkind:foo:world:bad", "a4") + a5 := NewKindNoopResTest("nooptestkind:foo:world:bazzz", "a5") + b1 := NewKindNoopResTest("nooptestkind:foo", "b1") + // NOTE: the very long one shouldn't group, but our test doesn't + // support detecting this pattern at the moment... + b2 := NewKindNoopResTest("nooptestkind:this:is:very:long", "b2") + g1.AddVertex(a1, a2, a3, a4, a5, b1, b2) + } + g2, _ := pgraph.NewGraph("g2") // expected result ? + { + a := NewNoopResTest("a1,a2,a3,a4,a5") + b := NewNoopResTest("b1,b2") + g2.AddVertex(a, b) + } + runGraphCmp(t, g1, g2) +} diff --git a/engine/resources/Makefile b/engine/resources/Makefile new file mode 100644 index 00000000..e72e8492 --- /dev/null +++ b/engine/resources/Makefile @@ -0,0 +1,43 @@ +# Mgmt +# Copyright (C) James Shubin and the project contributors +# Written by James Shubin 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 . +# +# 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. + +SHELL = /usr/bin/env bash +.PHONY: build clean +default: build + +WASM_FILE = http_ui/main.wasm + +build: $(WASM_FILE) + +$(WASM_FILE): http_ui/main.go + @echo "Generating: wasm..." + cd http_ui/ && env GOOS=js GOARCH=wasm go build -o `basename $(WASM_FILE)` + +clean: + @rm -f $(WASM_FILE) || true diff --git a/engine/resources/http_ui.go b/engine/resources/http_ui.go new file mode 100644 index 00000000..e8e0937a --- /dev/null +++ b/engine/resources/http_ui.go @@ -0,0 +1,791 @@ +// Mgmt +// Copyright (C) James Shubin and the project contributors +// Written by James Shubin 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 . +// +// 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 and 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 +} diff --git a/engine/resources/http_ui/.gitignore b/engine/resources/http_ui/.gitignore new file mode 100644 index 00000000..f7aa9b7a --- /dev/null +++ b/engine/resources/http_ui/.gitignore @@ -0,0 +1 @@ +main.wasm diff --git a/engine/resources/http_ui/README.md b/engine/resources/http_ui/README.md new file mode 100644 index 00000000..37aeb800 --- /dev/null +++ b/engine/resources/http_ui/README.md @@ -0,0 +1,8 @@ +This directory contains the golang wasm source for the `http_ui` resource. It +gets built automatically when you run `make` from the main project root +directory. + +After it gets built, the compiled artifact gets bundled into the main project +binary via go embed. + +It is not a normal package that should get built with everything else. diff --git a/engine/resources/http_ui/common/common.go b/engine/resources/http_ui/common/common.go new file mode 100644 index 00000000..87fdf475 --- /dev/null +++ b/engine/resources/http_ui/common/common.go @@ -0,0 +1,82 @@ +// Mgmt +// Copyright (C) James Shubin and the project contributors +// Written by James Shubin 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 . +// +// 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 common contains some code that is shared between the wasm and the +// http:ui packages. +package common + +const ( + // HTTPUIInputType represents the field in the "Type" map that specifies + // which input type we're using. + HTTPUIInputType = "type" + + // HTTPUIInputTypeText is the representation of the html "text" type. + HTTPUIInputTypeText = "text" + + // HTTPUIInputTypeRange is the representation of the html "range" type. + HTTPUIInputTypeRange = "range" + + // HTTPUIInputTypeRangeMin is the html input "range" min field. + HTTPUIInputTypeRangeMin = "min" + + // HTTPUIInputTypeRangeMax is the html input "range" max field. + HTTPUIInputTypeRangeMax = "max" + + // HTTPUIInputTypeRangeStep is the html input "range" step field. + HTTPUIInputTypeRangeStep = "step" +) + +// Form represents the entire form containing all the desired elements. +type Form struct { + // Elements is a list of form elements in this form. + // TODO: Maybe this should be an interface? + Elements []*FormElement `json:"elements"` +} + +// FormElement represents each form element. +type FormElement struct { + // Kind is the kind of form element that this is. + Kind string `json:"kind"` + + // ID is the unique public id for this form element. + ID string `json:"id"` + + // Type is a map that you can use to build the input field in the ui. + Type map[string]string `json:"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 `json:"sort"` +} + +// FormElementGeneric is a value store. +type FormElementGeneric struct { + // Value holds the string value we're interested in. + Value string `json:"value"` +} diff --git a/engine/resources/http_ui/index.html.tmpl b/engine/resources/http_ui/index.html.tmpl new file mode 100644 index 00000000..91f5c5eb --- /dev/null +++ b/engine/resources/http_ui/index.html.tmpl @@ -0,0 +1,163 @@ +{{- /* +Mgmt +Copyright (C) James Shubin and the project contributors +Written by James Shubin 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 . + +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. + +This was modified from the boiler-plate in the ~golang/misc/wasm/* directory. +*/ -}} + + + + +{{ if .title }} + {{ .title }} +{{ end }} +{{ if .head }} +{{ .head }} +{{ end }} + +{{ if .embedded }} + + +{{ else }} + + +{{ end }} + + + + + + + + + diff --git a/engine/resources/http_ui/main.go b/engine/resources/http_ui/main.go new file mode 100644 index 00000000..e97312b7 --- /dev/null +++ b/engine/resources/http_ui/main.go @@ -0,0 +1,314 @@ +// Mgmt +// Copyright (C) James Shubin and the project contributors +// Written by James Shubin 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 . +// +// 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 main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "syscall/js" + "time" + + "github.com/purpleidea/mgmt/engine/resources/http_ui/common" + "github.com/purpleidea/mgmt/util/errwrap" +) + +// Main is the main implementation of this process. It holds our shared data. +type Main struct { + // some values we pull in + program string + version string + hostname string + title string + path string + + document js.Value + body js.Value + + // window.location.origin (the base url with port for XHR) + wlo string + + // base is the wlo + the specific path suffix + base string + + response chan *Response +} + +// Init must be called before the Main struct is used. +func (obj *Main) Init() error { + fmt.Println("Hello from mgmt wasm!") + + obj.program = js.Global().Get("_mgmt_program").String() + obj.version = js.Global().Get("_mgmt_version").String() + obj.hostname = js.Global().Get("_mgmt_hostname").String() + obj.title = js.Global().Get("_mgmt_title").String() + obj.path = js.Global().Get("_mgmt_path").String() + + obj.document = js.Global().Get("document") + obj.body = obj.document.Get("body") + + obj.wlo = js.Global().Get("window").Get("location").Get("origin").String() + + obj.base = obj.wlo + obj.path + + obj.response = make(chan *Response) + + return nil +} + +// Run is the main execution of this program. +func (obj *Main) Run() error { + h1 := obj.document.Call("createElement", "h1") + h1.Set("innerHTML", obj.title) + obj.body.Call("appendChild", h1) + + h6 := obj.document.Call("createElement", "h6") + pre := obj.document.Call("createElement", "pre") + pre.Set("textContent", fmt.Sprintf("This is: %s, version: %s, on %s", obj.program, obj.version, obj.hostname)) + //pre.Set("innerHTML", fmt.Sprintf("This is: %s, version: %s, on %s", obj.program, obj.version, obj.hostname)) + h6.Call("appendChild", pre) + obj.body.Call("appendChild", h6) + + obj.body.Call("appendChild", obj.document.Call("createElement", "hr")) + + //document.baseURI + // XXX: how to get the base so we can add our own querystring??? + fmt.Println("URI: ", obj.document.Get("baseURI").String()) + fmt.Println("window.location.origin: ", obj.wlo) + + fmt.Println("BASE: ", obj.base) + + fieldset := obj.document.Call("createElement", "fieldset") + legend := obj.document.Call("createElement", "legend") + legend.Set("textContent", "live!") // XXX: pick some message here + fieldset.Call("appendChild", legend) + + // XXX: consider using this instead: https://github.com/hashicorp/go-retryablehttp + //client := retryablehttp.NewClient() + //client.RetryMax = 10 + client := &http.Client{ + //Timeout: time.Duration(timeout) * time.Second, + //CheckRedirect: checkRedirectFunc, + } + + // Startup form building... + // XXX: Add long polling to know if the form shape changes, and offer a + // refresh to the end-user to see the new form. + listURL := obj.base + "list/" + watchURL := obj.base + "watch/" + resp, err := client.Get(listURL) // works + if err != nil { + return errwrap.Wrapf(err, "could not list ui") + } + s, err := io.ReadAll(resp.Body) // TODO: apparently we can stream + resp.Body.Close() + if err != nil { + return errwrap.Wrapf(err, "could read from listed ui") + } + + fmt.Printf("Response: %+v\n", string(s)) + + var form *common.Form + if err := json.Unmarshal(s, &form); err != nil { + return errwrap.Wrapf(err, "could not unmarshal form") + } + //fmt.Printf("%+v\n", form) // debug + + // Sort according to the "sort" field so elements are in expected order. + sort.Slice(form.Elements, func(i, j int) bool { + return form.Elements[i].Sort < form.Elements[j].Sort + }) + + for _, x := range form.Elements { + id := x.ID + resp, err := client.Get(listURL + id) + if err != nil { + return errwrap.Wrapf(err, "could not get id %s", id) + } + s, err := io.ReadAll(resp.Body) // TODO: apparently we can stream + resp.Body.Close() + if err != nil { + return errwrap.Wrapf(err, "could not read from id %s", id) + } + fmt.Printf("Response: %+v\n", string(s)) + + var element *common.FormElementGeneric // XXX: switch based on x.Kind + if err := json.Unmarshal(s, &element); err != nil { + return errwrap.Wrapf(err, "could not unmarshal id %s", id) + } + //fmt.Printf("%+v\n", element) // debug + + inputType, exists := x.Type[common.HTTPUIInputType] // "text" or "range" ... + if !exists { + fmt.Printf("Element has no input type: %+v\n", element) + continue + } + + label := obj.document.Call("createElement", "label") + label.Call("setAttribute", "for", id) + label.Set("innerHTML", fmt.Sprintf("%s: ", id)) + fieldset.Call("appendChild", label) + + el := obj.document.Call("createElement", "input") + el.Set("id", id) + //el.Call("setAttribute", "id", id) + //el.Call("setAttribute", "name", id) + el.Set("type", inputType) + + if inputType == common.HTTPUIInputTypeRange { + if val, exists := x.Type[common.HTTPUIInputTypeRangeMin]; exists { + el.Set("min", val) + } + if val, exists := x.Type[common.HTTPUIInputTypeRangeMax]; exists { + el.Set("max", val) + } + if val, exists := x.Type[common.HTTPUIInputTypeRangeStep]; exists { + el.Set("step", val) + } + } + + el.Set("value", element.Value) // XXX: here or after change handler? + + // event handler + changeEvent := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + event := args[0] + value := event.Get("target").Get("value").String() + + //obj.wg.Add(1) + go func() { + //defer obj.wg.Done() + fmt.Println("Action!") + + u := obj.base + "save/" + values := url.Values{ + "id": {id}, + "value": {value}, + } + + resp, err := http.PostForm(u, values) + //fmt.Println(resp, err) // debug + s, err := io.ReadAll(resp.Body) // TODO: apparently we can stream + resp.Body.Close() + fmt.Printf("Response: %+v\n", string(s)) + fmt.Printf("Error: %+v\n", err) + obj.response <- &Response{ + Str: string(s), + Err: err, + } + }() + + return nil + }) + defer changeEvent.Release() + el.Call("addEventListener", "change", changeEvent) + + // http long poll + go func() { + for { + fmt.Printf("About to long poll for: %s\n", id) + //resp, err := client.Get(watchURL + id) // XXX: which? + resp, err := http.Get(watchURL + id) + if err != nil { + fmt.Println("Error fetching:", watchURL+id, err) // XXX: test error paths + time.Sleep(2 * time.Second) + continue + } + + s, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + fmt.Println("Error reading response:", err) + time.Sleep(2 * time.Second) + continue + } + + var element *common.FormElementGeneric // XXX: switch based on x.Kind + if err := json.Unmarshal(s, &element); err != nil { + fmt.Println("could not unmarshal id %s: %v", id, err) + time.Sleep(2 * time.Second) + continue + } + //fmt.Printf("%+v\n", element) // debug + + fmt.Printf("Long poll for %s got: %s\n", id, element.Value) + + obj.document.Call("getElementById", id).Set("value", element.Value) + //time.Sleep(1 * time.Second) + } + }() + + fieldset.Call("appendChild", el) + br := obj.document.Call("createElement", "br") + fieldset.Call("appendChild", br) + } + + obj.body.Call("appendChild", fieldset) + + // We need this mainloop for receiving the results of our async stuff... + for { + select { + case resp, ok := <-obj.response: + if !ok { + break + } + if err := resp.Err; err != nil { + fmt.Printf("Err: %+v\n", err) + continue + } + fmt.Printf("Str: %+v\n", resp.Str) + } + } + + return nil +} + +// Response is a standard response struct which we pass through. +type Response struct { + Str string + Err error +} + +func main() { + m := &Main{} + if err := m.Init(); err != nil { + fmt.Printf("Error: %+v\n", err) + return + } + + if err := m.Run(); err != nil { + fmt.Printf("Error: %+v\n", err) + return + } + + select {} // don't shutdown wasm +} diff --git a/engine/resources/http_ui/static/.gitignore b/engine/resources/http_ui/static/.gitignore new file mode 100644 index 00000000..36db336a --- /dev/null +++ b/engine/resources/http_ui/static/.gitignore @@ -0,0 +1,2 @@ +*.css +*.js diff --git a/engine/resources/http_ui/static/embed.go b/engine/resources/http_ui/static/embed.go new file mode 100644 index 00000000..46af371d --- /dev/null +++ b/engine/resources/http_ui/static/embed.go @@ -0,0 +1,51 @@ +// Mgmt +// Copyright (C) James Shubin and the project contributors +// Written by James Shubin 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 . +// +// 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. + +//go:build httpuistatic + +package static + +import ( + _ "embed" // embed data with go:embed +) + +const ( + // HTTPUIStaticEmbedded specifies whether files have been embedded. + HTTPUIStaticEmbedded = true +) + +var ( + // HTTPUIIndexStaticBootstrapCSS is the embedded data. It is embedded. + //go:embed http_ui/static/bootstrap.min.css + HTTPUIIndexStaticBootstrapCSS []byte + + // HTTPUIIndexStaticBootstrapJS is the embedded data. It is embedded. + //go:embed http_ui/static/bootstrap.bundle.min.js + HTTPUIIndexStaticBootstrapJS []byte +) diff --git a/engine/resources/http_ui/static/noembed.go b/engine/resources/http_ui/static/noembed.go new file mode 100644 index 00000000..6bc159fb --- /dev/null +++ b/engine/resources/http_ui/static/noembed.go @@ -0,0 +1,45 @@ +// Mgmt +// Copyright (C) James Shubin and the project contributors +// Written by James Shubin 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 . +// +// 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. + +//go:build !httpuistatic + +package static + +const ( + // HTTPUIStaticEmbedded specifies whether files have been embedded. + HTTPUIStaticEmbedded = false +) + +var ( + // HTTPUIIndexStaticBootstrapCSS is the embedded data. It is empty here. + HTTPUIIndexStaticBootstrapCSS []byte + + // HTTPUIIndexStaticBootstrapJS is the embedded data. It is empty here. + HTTPUIIndexStaticBootstrapJS []byte +) diff --git a/engine/resources/http_ui/static/static.go b/engine/resources/http_ui/static/static.go new file mode 100644 index 00000000..be654b68 --- /dev/null +++ b/engine/resources/http_ui/static/static.go @@ -0,0 +1,42 @@ +// Mgmt +// Copyright (C) James Shubin and the project contributors +// Written by James Shubin 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 . +// +// 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 static contains some optional embedded data which can be useful if we +// are running from an entirely offline, internet-absent scenario. +package static + +const ( + // HTTPUIIndexBootstrapCSS is the path to the bootstrap css file when + // embedded, relative to the parent directory. + HTTPUIIndexBootstrapCSS = "static/bootstrap.min.css" + + // HTTPUIIndexBootstrapJS is the path to the bootstrap js file when + // embedded, relative to the parent directory. + HTTPUIIndexBootstrapJS = "static/bootstrap.bundle.min.js" +) diff --git a/engine/resources/http_ui/wasm_exec.js b/engine/resources/http_ui/wasm_exec.js new file mode 100644 index 00000000..021c7e67 --- /dev/null +++ b/engine/resources/http_ui/wasm_exec.js @@ -0,0 +1,577 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This was copied from the original in the ~golang/lib/wasm/* directory. + +"use strict"; + +(() => { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!globalThis.path) { + globalThis.path = { + resolve(...pathSegments) { + return pathSegments.join("/"); + } + } + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + } + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const testCallExport = (a, b) => { + this._inst.exports.testExport0(); + return this._inst.exports.testExport(a, b); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + callExport: testCallExport, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8), + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } +})(); diff --git a/engine/resources/http_ui_input.go b/engine/resources/http_ui_input.go new file mode 100644 index 00000000..ec537567 --- /dev/null +++ b/engine/resources/http_ui_input.go @@ -0,0 +1,653 @@ +// Mgmt +// Copyright (C) James Shubin and the project contributors +// Written by James Shubin 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 . +// +// 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_ui/common" + "github.com/purpleidea/mgmt/engine/traits" + "github.com/purpleidea/mgmt/util/errwrap" +) + +const ( + httpUIInputKind = httpUIKind + ":input" + + httpUIInputStoreKey = "key" + httpUIInputStoreSchemeLocal = "local" + httpUIInputStoreSchemeWorld = "world" + + httpUIInputTypeText = common.HTTPUIInputTypeText // "text" + httpUIInputTypeRange = common.HTTPUIInputTypeRange // "range" +) + +func init() { + engine.RegisterResource(httpUIInputKind, func() engine.Res { return &HTTPUIInputRes{} }) +} + +// HTTPUIInputRes is a form element that exists within a http: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: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 HTTPUIInputRes struct { + traits.Base // add the base methods without re-implementation + traits.Edgeable // XXX: add autoedge support + traits.Groupable // can be grouped into HTTPUIRes + 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 *HTTPUIInputRes) Default() engine.Res { + return &HTTPUIInputRes{ + Type: "text://", + } +} + +// Validate checks if the resource data structure was populated correctly. +func (obj *HTTPUIInputRes) 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 *HTTPUIInputRes) 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 != httpUIInputTypeText && u.Scheme != httpUIInputTypeRange { + 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 != httpUIInputStoreSchemeLocal && u.Scheme != httpUIInputStoreSchemeWorld { + 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[httpUIInputStoreKey] + 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 *HTTPUIInputRes) 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 *HTTPUIInputRes) 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 *HTTPUIInputRes) ParentName() string { + return obj.Path +} + +// GetKind returns the kind of this resource. +func (obj *HTTPUIInputRes) 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 httpUIInputKind +} + +// GetID returns the actual ID we respond to. When ID is not specified, we use +// the Name. +func (obj *HTTPUIInputRes) 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 *HTTPUIInputRes) 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 *HTTPUIInputRes) setValue(ctx context.Context, val string) error { + obj.value = val + + select { + case obj.event <- struct{}{}: + default: + } + + return nil +} + +func (obj *HTTPUIInputRes) 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 *HTTPUIInputRes) 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 *HTTPUIInputRes) GetType() map[string]string { + m := make(map[string]string) + + if obj.typeURL.Scheme == httpUIInputTypeRange { + m = obj.rangeGetType() + } + + m[common.HTTPUIInputType] = obj.typeURL.Scheme + + return m +} + +func (obj *HTTPUIInputRes) rangeGetType() map[string]string { + m := make(map[string]string) + base := 10 + bits := 64 + + if sa, exists := obj.typeURLValues[common.HTTPUIInputTypeRangeMin]; exists && len(sa) > 0 { + if x, err := strconv.ParseInt(sa[0], base, bits); err == nil { + m[common.HTTPUIInputTypeRangeMin] = strconv.FormatInt(x, base) + } + } + if sa, exists := obj.typeURLValues[common.HTTPUIInputTypeRangeMax]; exists && len(sa) > 0 { + if x, err := strconv.ParseInt(sa[0], base, bits); err == nil { + m[common.HTTPUIInputTypeRangeMax] = strconv.FormatInt(x, base) + } + } + if sa, exists := obj.typeURLValues[common.HTTPUIInputTypeRangeStep]; exists && len(sa) > 0 { + if x, err := strconv.ParseInt(sa[0], base, bits); err == nil { + m[common.HTTPUIInputTypeRangeStep] = 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 *HTTPUIInputRes) 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 *HTTPUIInputRes) Watch(ctx context.Context) error { + if obj.Store != "" && obj.scheme == httpUIInputStoreSchemeLocal { + return obj.localWatch(ctx) + } + if obj.Store != "" && obj.scheme == httpUIInputStoreSchemeWorld { + 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 *HTTPUIInputRes) 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 *HTTPUIInputRes) 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 *HTTPUIInputRes) 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:ui parent. + // If we're in ".Store" mode, then we're reconciling between the "World" + // and the http:ui "Web". + + if obj.Store != "" { + return obj.storeCheckApply(ctx, apply) + } + + return obj.valueCheckApply(ctx, apply) + +} + +func (obj *HTTPUIInputRes) 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 { + return true, nil // expected value has already been sent + } + + if !apply { // XXX: does this break send/recv if we end early? + 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(&HTTPUIInputSends{ + 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:ui" change by and 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 *HTTPUIInputRes) 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 + return true, nil + } + + if !apply { // XXX: does this break send/recv if we end early? + 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(&HTTPUIInputSends{ + Value: &value, + }); err != nil { + return false, err + } + + return false, nil +} + +func (obj *HTTPUIInputRes) storeGet(ctx context.Context, key string) (string, bool, error) { + if obj.Store != "" && obj.scheme == httpUIInputStoreSchemeLocal { + 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 == httpUIInputStoreSchemeWorld { + 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 *HTTPUIInputRes) storeSet(ctx context.Context, key, val string) error { + + if obj.Store != "" && obj.scheme == httpUIInputStoreSchemeLocal { + return obj.init.Local.ValueSet(ctx, key, val) + } + + if obj.Store != "" && obj.scheme == httpUIInputStoreSchemeWorld { + 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 *HTTPUIInputRes) Cmp(r engine.Res) error { + // we can only compare HTTPUIInputRes to others of the same resource kind + res, ok := r.(*HTTPUIInputRes) + 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 *HTTPUIInputRes) UnmarshalYAML(unmarshal func(interface{}) error) error { + type rawRes HTTPUIInputRes // indirection to avoid infinite recursion + + def := obj.Default() // get the default + res, ok := def.(*HTTPUIInputRes) // put in the right format + if !ok { + return fmt.Errorf("could not convert to HTTPUIInputRes") + } + raw := rawRes(*res) // convert; the defaults go here + + if err := unmarshal(&raw); err != nil { + return err + } + + *obj = HTTPUIInputRes(raw) // restore from indirection with type conversion! + return nil +} + +// HTTPUIInputSends is the struct of data which is sent after a successful +// Apply. +type HTTPUIInputSends 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 *HTTPUIInputRes) Sends() interface{} { + return &HTTPUIInputSends{ + Value: nil, + } +} diff --git a/engine/resources/kv.go b/engine/resources/kv.go index 5c9dd6c5..802586fc 100644 --- a/engine/resources/kv.go +++ b/engine/resources/kv.go @@ -287,7 +287,11 @@ func (obj *KVRes) CheckApply(ctx context.Context, apply bool) (bool, error) { if val, exists := obj.init.Recv()["value"]; exists && val.Changed { // if we received on Value, and it changed, wooo, nothing to do. - obj.init.Logf("`value` was received!") + if obj.Value == nil { + obj.init.Logf("nil `value` was received!") + } else { + obj.init.Logf("`value` (%s) was received!", *obj.Value) + } } value, exists, err := obj.kvGet(ctx, obj.getKey()) diff --git a/engine/traits/autogroup.go b/engine/traits/autogroup.go index 36704735..9dd4bbb0 100644 --- a/engine/traits/autogroup.go +++ b/engine/traits/autogroup.go @@ -78,12 +78,25 @@ func (obj *Groupable) GroupCmp(res engine.GroupableRes) error { // GroupRes groups resource argument (res) into self. Callers of this method // should probably also run SetParent. func (obj *Groupable) GroupRes(res engine.GroupableRes) error { + // We can keep this check with hierarchical grouping by adding in the + // Kind test which we seen inside... If they're all the same, then we + // can't do it. But if they're dissimilar, then it's okay to group! if l := len(res.GetGroup()); l > 0 { - return fmt.Errorf("the `%s` resource already contains %d grouped resources", res, l) - } - if res.IsGrouped() { - return fmt.Errorf("the `%s` resource is already grouped", res) + kind := res.Kind() + ok := true // assume okay for now + for _, r := range res.GetGroup() { + if r.Kind() == kind { + ok = false // non-hierarchical grouping, error! + } + } + if !ok { + return fmt.Errorf("the `%s` resource already contains %d grouped resources", res, l) + } } + // XXX: Do we need to disable this to support hierarchical grouping? + //if res.IsGrouped() { + // return fmt.Errorf("the `%s` resource is already grouped", res) + //} obj.grouped = append(obj.grouped, res) res.SetGrouped(true) // i am contained _in_ a group diff --git a/engine/traits/sendrecv.go b/engine/traits/sendrecv.go index 0f4bb61b..a12e0975 100644 --- a/engine/traits/sendrecv.go +++ b/engine/traits/sendrecv.go @@ -72,7 +72,12 @@ type Recvable struct { Bug5819 interface{} // XXX: workaround } -// SetRecv is used to inject incoming values into the resource. +// SetRecv is used to inject incoming values into the resource. More +// specifically, it stores the mapping of what gets received from what, so that +// later on, we know which resources should ask which other resources for the +// values that they want to receive. Since this happens when we're building the +// graph, and before the autogrouping step, we'll have pointers to the original, +// ungrouped resources here, so that it will work even after they're grouped in! func (obj *Recvable) SetRecv(recv map[string]*engine.Send) { //if obj.recv == nil { // obj.recv = make(map[string]*engine.Send) diff --git a/examples/lang/http-ui-kv.mcl b/examples/lang/http-ui-kv.mcl new file mode 100644 index 00000000..cdad26b2 --- /dev/null +++ b/examples/lang/http-ui-kv.mcl @@ -0,0 +1,43 @@ +import "world" + +http:server ":8080" { # by default http uses :80 but using :8080 avoids needing root! + #address => ":8080", # you can override the name like this +} + +# you can add a raw file like this... +http:file "/file1" { + data => "hello, world, i'm file1 and i don't exist on disk!\n", +} + +http:ui "/ui/" { + #path => "/ui/", # we can override the name like this if needed + + data => struct{ + title => "mgmt http ui", + head => "", # XXX: type unification requires specifying all fields for now + }, +} + +$text1_id = "text1" +$range1_id = "range1" +http:ui:input $text1_id { + store => "world://", + sort => "a", +} + +http:ui:input $range1_id { + store => "world://", + type => "range://?min=0&max=5&step=1", + sort => "b", +} + +#Http:Ui:Input[$text1_id].value -> Kv[$text1_id].value +#kv $text1_id { # store in world +#} + +$ret1 = world.getval($text1_id) # name of kv resource + +test "get1" { + anotherstr => $ret1->value, + onlyshow => ["AnotherStr",], # displays nicer +} diff --git a/examples/lang/http-ui-value.mcl b/examples/lang/http-ui-value.mcl new file mode 100644 index 00000000..23688325 --- /dev/null +++ b/examples/lang/http-ui-value.mcl @@ -0,0 +1,65 @@ +import "value" + +http:server ":8080" { # by default http uses :80 but using :8080 avoids needing root! + #address => ":8080", # you can override the name like this +} + +# you can add a raw file like this... +http:file "/file1" { + data => "hello, world, i'm file1 and i don't exist on disk!\n", +} + +http:ui "/ui/" { + #path => "/ui/", # we can override the name like this if needed + + data => struct{ + title => "mgmt http ui", + head => "", # XXX: type unification requires specifying all fields for now + }, +} + +$text1_id = "text1" +$range1_id = "range1" +$text1_val = if $ret1->ready { + $ret1->value +} else { + "default" # some default +} +$range1_val = if $ret2->ready { + $ret2->value +} else { + "2" # some default +} +http:ui:input $text1_id { + value => $text1_val, # it passes back into itself! +} + +http:ui:input $range1_id { + value => $range1_val, + type => "range://?min=0&max=5&step=1", + sort => "b", +} + +Http:Ui:Input[$text1_id].value -> Value[$text1_id].any + +value $text1_id { + any => "whatever", # TODO: remove the temporary placeholder here + #any => "", # XXX: remove any placeholder to see the bug when absent +} +value $range1_id { + any => "whatever", # TODO: remove the temporary placeholder here + #any => "", # XXX: remove any placeholder to see the bug when absent +} + +$ret1 = value.get_str($text1_id) # name of value resource +$ret2 = value.get_str($range1_id) # name of value resource + +test "get1" { + anotherstr => $ret1->value, + onlyshow => ["AnotherStr",], # displays nicer +} + +test "get2" { + anotherstr => $ret2->value, + onlyshow => ["AnotherStr",], # displays nicer +} diff --git a/go.mod b/go.mod index 38a13d69..bc5f90b9 100644 --- a/go.mod +++ b/go.mod @@ -41,8 +41,8 @@ require ( go.etcd.io/etcd/client/v3 v3.5.18 go.etcd.io/etcd/server/v3 v3.5.18 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba - golang.org/x/crypto v0.32.0 - golang.org/x/sys v0.29.0 + golang.org/x/crypto v0.37.0 + golang.org/x/sys v0.32.0 golang.org/x/time v0.9.0 golang.org/x/tools v0.29.0 gopkg.in/yaml.v2 v2.4.0 @@ -61,10 +61,14 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.20.0 // indirect github.com/bmc-toolbox/common v0.0.0-20250114061816-fab80349cae0 // indirect + github.com/bytedance/sonic v1.13.2 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chappjc/logrus-prefix v0.0.0-20180227015900-3a1d64819adb // indirect github.com/cloudflare/circl v1.5.0 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/distribution/reference v0.6.0 // indirect @@ -73,11 +77,18 @@ require ( github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/ghodss/yaml v1.0.0 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.10.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect @@ -107,6 +118,8 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 // indirect @@ -127,6 +140,7 @@ require ( github.com/nxadm/tail v1.4.11 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/prometheus/client_model v0.6.1 // indirect @@ -142,7 +156,9 @@ require ( github.com/spf13/pflag v1.0.6-0.20250109003754-5ca813443bd2 // indirect github.com/stmcginnis/gofish v0.20.0 // indirect github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 // indirect @@ -163,18 +179,20 @@ require ( go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + golang.org/x/arch v0.16.0 // indirect golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/term v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/text v0.24.0 // indirect google.golang.org/genproto v0.0.0-20250124145028-65684f501c47 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250124145028-65684f501c47 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect google.golang.org/grpc v1.70.0 // indirect - google.golang.org/protobuf v1.36.4 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.0.3 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index da35f4ed..112ddbbb 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,11 @@ github.com/bmc-toolbox/common v0.0.0-20250114061816-fab80349cae0 h1:tsKHYBkzElym github.com/bmc-toolbox/common v0.0.0-20250114061816-fab80349cae0/go.mod h1:Cdnkm+edb6C0pVkyCrwh3JTXAe0iUF9diDG/DztPI9I= github.com/bombsimon/logrusr/v2 v2.0.1 h1:1VgxVNQMCvjirZIYaT9JYn6sAVGVEcNtRE0y4mvaOAM= github.com/bombsimon/logrusr/v2 v2.0.1/go.mod h1:ByVAX+vHdLGAfdroiMg6q0zgq2FODY2lc5YJvzmOJio= +github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= +github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -63,6 +68,10 @@ github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys= github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA= github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= @@ -111,8 +120,14 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -136,7 +151,15 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= github.com/go-logr/zerologr v1.2.3/go.mod h1:BxwGo7y5zgSHYR1BjbnHPyF/5ZjVKfKxAZANVu6E8Ho= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -280,6 +303,10 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -292,6 +319,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/libvirt/libvirt-go v7.4.0+incompatible h1:crnSLkwPqCdXtg6jib/FxBG/hweAc/3Wxth1AehCXL4= github.com/libvirt/libvirt-go v7.4.0+incompatible/go.mod h1:34zsnB4iGeOv7Byj6qotuW8Ya4v4Tr43ttjz/F0wjLE= github.com/libvirt/libvirt-go-xml v7.4.0+incompatible h1:+BBo2XjlT8pAK4pm+aSX8mC/6nc/rdRac10ZukpW31U= @@ -364,6 +393,8 @@ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2sz github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pin/tftp/v3 v3.1.0 h1:rQaxd4pGwcAJnpId8zC+O2NX3B2/NscjDZQaqEjuE7c= @@ -445,6 +476,7 @@ github.com/stmcginnis/gofish v0.20.0 h1:hH2V2Qe898F2wWT1loApnkDUrXXiLKqbSlMaH3Y1 github.com/stmcginnis/gofish v0.20.0/go.mod h1:PzF5i8ecRG9A2ol8XT64npKUunyraJ+7t0kYMpQAtqU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -453,7 +485,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= @@ -461,9 +496,13 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1 github.com/tredoe/osutil v1.5.0 h1:UGVxbbHRoZi8xXVmbNZ2vgG6XoJ15ndE4LniiQ3rJKg= github.com/tredoe/osutil v1.5.0/go.mod h1:TEzphzUUunysbdDRfdOgqkg10POQbnfIPV50ynqOfIg= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/u-root/uio v0.0.0-20210528114334-82958018845c/go.mod h1:LpEX5FO/cB+WF4TYGY1V5qktpaZLkKkSegbr0V4eYXA= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk= github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= @@ -531,6 +570,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U= +golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -538,6 +579,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= @@ -575,6 +618,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -588,6 +633,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -632,15 +679,21 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -689,6 +742,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -724,5 +779,7 @@ honnef.co/go/augeas v0.0.0-20161110001225-ca62e35ed6b8 h1:FW42yWB1sGClqswyHIB68w honnef.co/go/augeas v0.0.0-20161110001225-ca62e35ed6b8/go.mod h1:44w9OfBSQ9l3o59rc2w3AnABtE44bmtNnRMNC7z+oKE= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/lang/interpret_test/TestAstFunc2/send-recv-3.txtar b/lang/interpret_test/TestAstFunc2/send-recv-3.txtar new file mode 100644 index 00000000..e98bbcfe --- /dev/null +++ b/lang/interpret_test/TestAstFunc2/send-recv-3.txtar @@ -0,0 +1,11 @@ +-- main.mcl -- +http:ui:input "text1" {} + +Http:Ui:Input["text1"].value -> Kv["kv1"].value + +kv "kv1" {} + +-- OUTPUT -- +Edge: http:ui:input[text1] -> kv[kv1] # http:ui:input[text1] -> kv[kv1] +Vertex: http:ui:input[text1] +Vertex: kv[kv1] diff --git a/test/test-gotest.sh b/test/test-gotest.sh index 2e87322d..daa896f1 100755 --- a/test/test-gotest.sh +++ b/test/test-gotest.sh @@ -49,6 +49,11 @@ if [[ "$@" = *"--integration"* ]]; then else for pkg in `go list -e ./... | grep -v "^${base}/vendor/" | grep -v "^${base}/examples/" | grep -v "^${base}/test/" | grep -v "^${base}/old" | grep -v "^${base}/old/" | grep -v "^${base}/tmp" | grep -v "^${base}/tmp/" | grep -v "^${base}/integration"`; do echo -e "\ttesting: $pkg" + + if [ "$pkg" = "github.com/purpleidea/mgmt/engine/resources/http_ui" ]; then + continue # skip this special main package + fi + if [[ "$@" = *"--race"* ]]; then # split up long tests to avoid CI timeouts if [ "$pkg" = "${base}/lang" ]; then # pkg lang is big! diff --git a/test/test-govet.sh b/test/test-govet.sh index 343088ba..5693cccb 100755 --- a/test/test-govet.sh +++ b/test/test-govet.sh @@ -129,6 +129,11 @@ function reflowed-comments() { # run go vet on a per-package basis base=$(go list .) for pkg in `go list -e ./... | grep -v "^${base}/vendor/" | grep -v "^${base}/examples/" | grep -v "^${base}/test/" | grep -v "^${base}/old" | grep -v "^${base}/old/" | grep -v "^${base}/tmp" | grep -v "^${base}/tmp/"`; do + + if [ "$pkg" = "github.com/purpleidea/mgmt/engine/resources/http_ui" ]; then + continue # skip this special main package + fi + echo -e "\tgo vet: $pkg" run-test go vet -source "$pkg" || fail_test "go vet -source did not pass pkg"