diff --git a/Makefile b/Makefile index 6ebc252e9..6171d8021 100644 --- a/Makefile +++ b/Makefile @@ -34,12 +34,13 @@ COVER_FLAGS ?= -covermode=$(COVER_MODE) -coverprofile=$(COVER_PROFILE) COVER_PKGS_system = ./system/... COVER_PKGS_compose = ./compose/... COVER_PKGS_federation = ./federation/... +COVER_PKGS_automation = ./automation/... COVER_PKGS_pkg = ./pkg/... -COVER_PKGS_all = $(COVER_PKGS_pkg),$(COVER_PKGS_system),$(COVER_PKGS_compose),$(COVER_PKGS_federation) +COVER_PKGS_all = $(COVER_PKGS_pkg),$(COVER_PKGS_system),$(COVER_PKGS_compose),$(COVER_PKGS_federation),$(COVER_PKGS_automation) COVER_PKGS_integration = $(COVER_PKGS_all) TEST_SUITE_pkg = ./pkg/... -TEST_SUITE_services = ./system/... ./compose/... ./federation/... +TEST_SUITE_services = ./compose/... ./system/... ./federation/... ./automation/... TEST_SUITE_unit = $(TEST_SUITE_pkg) $(TEST_SUITE_services) TEST_SUITE_integration = ./tests/... TEST_SUITE_store = ./store/tests/... @@ -196,7 +197,7 @@ critic: $(GOCRITIC) $(GOCRITIC) check-project . staticcheck: $(STATICCHECK) - $(STATICCHECK) ./pkg/... ./system/... ./compose/... + $(STATICCHECK) ./pkg/... ./system/... ./compose/... ./automation/... qa: vet critic test diff --git a/app/boot_levels.go b/app/boot_levels.go index f3e9347a2..39637bda4 100644 --- a/app/boot_levels.go +++ b/app/boot_levels.go @@ -6,6 +6,7 @@ import ( "fmt" authService "github.com/cortezaproject/corteza-server/auth" authSettings "github.com/cortezaproject/corteza-server/auth/settings" + autService "github.com/cortezaproject/corteza-server/automation/service" cmpService "github.com/cortezaproject/corteza-server/compose/service" cmpEvent "github.com/cortezaproject/corteza-server/compose/service/event" fdrService "github.com/cortezaproject/corteza-server/federation/service" @@ -266,6 +267,18 @@ func (app *CortezaApp) InitServices(ctx context.Context) (err error) { return } + // Initializes automation services + // + // Note: this is a legacy approach, all services from all 3 apps + // will most likely be merged in the future + err = autService.Initialize(ctx, app.Log, app.Store, autService.Config{ + ActionLog: app.Opt.ActionLog, + }) + + if err != nil { + return + } + // Initializes compose services // // Note: this is a legacy approach, all services from all 3 apps @@ -353,6 +366,7 @@ func (app *CortezaApp) Activate(ctx context.Context) (err error) { corredor.Service().Watch(ctx) sysService.Watchers(ctx) + autService.Watchers(ctx) cmpService.Watchers(ctx) if app.Opt.Federation.Enabled { @@ -367,6 +381,10 @@ func (app *CortezaApp) Activate(ctx context.Context) (err error) { return err } + if err = autService.Activate(ctx); err != nil { + return err + } + if err = cmpService.Activate(ctx); err != nil { return err } diff --git a/app/servers.go b/app/servers.go index 9c0b39f25..14b046040 100644 --- a/app/servers.go +++ b/app/servers.go @@ -2,6 +2,7 @@ package app import ( "context" + automationRest "github.com/cortezaproject/corteza-server/automation/rest" composeRest "github.com/cortezaproject/corteza-server/compose/rest" "github.com/cortezaproject/corteza-server/docs" federationRest "github.com/cortezaproject/corteza-server/federation/rest" @@ -59,6 +60,7 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) { r.Route("/"+apiBaseUrl, func(r chi.Router) { r.Route("/system", systemRest.MountRoutes) + r.Route("/automation", automationRest.MountRoutes) r.Route("/compose", composeRest.MountRoutes) if app.Opt.Federation.Enabled { diff --git a/automation/automation/http_request_handler.gen.go b/automation/automation/http_request_handler.gen.go new file mode 100644 index 000000000..722f90555 --- /dev/null +++ b/automation/automation/http_request_handler.gen.go @@ -0,0 +1,314 @@ +package automation + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// automation/automation/http_request_handler.yaml + +import ( + "context" + atypes "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/wfexec" + "io" + "net/http" + "net/url" + "time" +) + +var _ wfexec.ExecResponse + +type ( + httpRequestHandlerRegistry interface { + AddFunctions(ff ...*atypes.Function) + Type(ref string) expr.Type + } +) + +func (h httpRequestHandler) register() { + h.reg.AddFunctions( + h.Send(), + ) +} + +type ( + httpRequestSendArgs struct { + hasUrl bool + Url string + + hasMethod bool + Method string + + hasParams bool + Params url.Values + + hasHeaders bool + Headers http.Header + + hasHeaderAuthBearer bool + HeaderAuthBearer string + + hasHeaderAuthUsername bool + HeaderAuthUsername string + + hasHeaderAuthPassword bool + HeaderAuthPassword string + + hasHeaderUserAgent bool + HeaderUserAgent string + + hasHeaderContentType bool + HeaderContentType string + + hasTimeout bool + Timeout time.Duration + + hasForm bool + Form url.Values + + hasBody bool + Body interface{} + bodyString string + bodyStream io.Reader + bodyRaw interface{} + } + + httpRequestSendResults struct { + Status string + StatusCode int64 + Headers map[string][]string + ContentLength int64 + ContentType string + Body io.Reader + } +) + +func (a httpRequestSendArgs) GetBody() (bool, string, io.Reader, interface{}) { + return a.hasBody, a.bodyString, a.bodyStream, a.bodyRaw +} + +// Send function Sends HTTP request +// +// expects implementation of send function: +// func (h httpRequestHandler) send(ctx context.Context, args *httpRequestSendArgs) (results *httpRequestSendResults, err error) { +// return +// } +func (h httpRequestHandler) Send() *atypes.Function { + return &atypes.Function{ + Ref: "httpRequestSend", + Kind: "function", + Meta: &atypes.FunctionMeta{ + Short: "Sends HTTP request", + }, + + Parameters: []*atypes.Param{ + { + Name: "url", + Types: []string{"String"}, Required: true, + }, + { + Name: "method", + Types: []string{"String"}, Required: true, + }, + { + Name: "params", + Types: []string{"KVV"}, + }, + { + Name: "headers", + Types: []string{"KVV"}, + }, + { + Name: "headerAuthBearer", + Types: []string{"String"}, + }, + { + Name: "headerAuthUsername", + Types: []string{"String"}, + }, + { + Name: "headerAuthPassword", + Types: []string{"String"}, + }, + { + Name: "headerUserAgent", + Types: []string{"String"}, + }, + { + Name: "headerContentType", + Types: []string{"String"}, + }, + { + Name: "timeout", + Types: []string{"Duration"}, + }, + { + Name: "form", + Types: []string{"KVV"}, + }, + { + Name: "body", + Types: []string{"String", "Reader", "Any"}, + }, + }, + + Results: []*atypes.Param{ + + { + Name: "status", + Types: []string{"String"}, + }, + + { + Name: "statusCode", + Types: []string{"Integer"}, + }, + + { + Name: "headers", + Types: []string{"KVV"}, + }, + + { + Name: "contentLength", + Types: []string{"Integer"}, + }, + + { + Name: "contentType", + Types: []string{"String"}, + }, + + { + Name: "body", + Types: []string{"Reader"}, + }, + }, + + Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { + var ( + args = &httpRequestSendArgs{ + hasUrl: in.Has("url"), + hasMethod: in.Has("method"), + hasParams: in.Has("params"), + hasHeaders: in.Has("headers"), + hasHeaderAuthBearer: in.Has("headerAuthBearer"), + hasHeaderAuthUsername: in.Has("headerAuthUsername"), + hasHeaderAuthPassword: in.Has("headerAuthPassword"), + hasHeaderUserAgent: in.Has("headerUserAgent"), + hasHeaderContentType: in.Has("headerContentType"), + hasTimeout: in.Has("timeout"), + hasForm: in.Has("form"), + hasBody: in.Has("body"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + // Converting Body argument + if args.hasBody { + aux := expr.Must(expr.Select(in, "body")) + switch aux.Type() { + case h.reg.Type("String").Type(): + args.bodyString = aux.Get().(string) + case h.reg.Type("Reader").Type(): + args.bodyStream = aux.Get().(io.Reader) + case h.reg.Type("Any").Type(): + args.bodyRaw = aux.Get().(interface{}) + } + } + + var results *httpRequestSendResults + if results, err = h.send(ctx, args); err != nil { + return + } + + out = &expr.Vars{} + + { + // converting results.Status (string) to String + var ( + tval expr.TypedValue + ) + + if tval, err = h.reg.Type("String").Cast(results.Status); err != nil { + return + } else if err = expr.Assign(out, "status", tval); err != nil { + return + } + } + + { + // converting results.StatusCode (int64) to Integer + var ( + tval expr.TypedValue + ) + + if tval, err = h.reg.Type("Integer").Cast(results.StatusCode); err != nil { + return + } else if err = expr.Assign(out, "statusCode", tval); err != nil { + return + } + } + + { + // converting results.Headers (map[string][]string) to KVV + var ( + tval expr.TypedValue + ) + + if tval, err = h.reg.Type("KVV").Cast(results.Headers); err != nil { + return + } else if err = expr.Assign(out, "headers", tval); err != nil { + return + } + } + + { + // converting results.ContentLength (int64) to Integer + var ( + tval expr.TypedValue + ) + + if tval, err = h.reg.Type("Integer").Cast(results.ContentLength); err != nil { + return + } else if err = expr.Assign(out, "contentLength", tval); err != nil { + return + } + } + + { + // converting results.ContentType (string) to String + var ( + tval expr.TypedValue + ) + + if tval, err = h.reg.Type("String").Cast(results.ContentType); err != nil { + return + } else if err = expr.Assign(out, "contentType", tval); err != nil { + return + } + } + + { + // converting results.Body (io.Reader) to Reader + var ( + tval expr.TypedValue + ) + + if tval, err = h.reg.Type("Reader").Cast(results.Body); err != nil { + return + } else if err = expr.Assign(out, "body", tval); err != nil { + return + } + } + + return + }, + } +} diff --git a/automation/automation/http_request_handler.go b/automation/automation/http_request_handler.go new file mode 100644 index 000000000..69c82ffc7 --- /dev/null +++ b/automation/automation/http_request_handler.go @@ -0,0 +1,167 @@ +package automation + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/version" + "io" + "net/http" + "net/url" + "strings" +) + +type ( + httpRequestHandler struct { + reg httpRequestHandlerRegistry + } +) + +func HttpRequestHandler(reg httpRequestHandlerRegistry) *httpRequestHandler { + h := &httpRequestHandler{ + reg: reg, + } + + h.register() + return h +} + +func (h httpRequestHandler) send(ctx context.Context, args *httpRequestSendArgs) (r *httpRequestSendResults, err error) { + var ( + req *http.Request + rsp *http.Response + ) + + r = &httpRequestSendResults{} + + req, err = h.makeRequest(ctx, args) + if err != nil { + return nil, err + } + + rsp, err = http.DefaultClient.Do(req) + if err != nil { + return + } + + r.StatusCode = int64(rsp.StatusCode) + r.Status = rsp.Status + r.Headers = rsp.Header + r.ContentLength = rsp.ContentLength + r.ContentType = rsp.Header.Get("Content-Type") + r.Body = rsp.Body + + return +} + +func (h httpRequestHandler) makeRequest(ctx context.Context, args *httpRequestSendArgs) (req *http.Request, err error) { + args.Method = strings.ToUpper(args.Method) + + if args.Method == "" && (len(args.Form) > 0 || args.Body != nil) { + // when no method is set and form or body are passed + args.Method = http.MethodPost + } + + err = func() error { + switch args.Method { + case http.MethodPost, http.MethodPut, http.MethodPatch: + default: + return nil + } + + // @todo handle (multiple) file upload as well + + if len(args.Form) > 0 { + if args.Body != nil { + return fmt.Errorf("can not not use form and body parameters at the same time") + } + + if !args.hasHeaderContentType { + args.HeaderContentType = "application/x-www-form-urlencoded" + } + + args.bodyStream = &bytes.Buffer{} + if _, err = args.bodyStream.(*bytes.Buffer).WriteString(args.Form.Encode()); err != nil { + return err + } + return nil + } + + if args.Body != nil { + return nil + } + + if args.hasBody && args.bodyStream == nil { + if args.bodyString != "" { + args.bodyStream = strings.NewReader(args.bodyString) + } else { + switch raw := args.bodyRaw.(type) { + case string: + args.bodyStream = strings.NewReader(raw) + case []byte: + args.bodyStream = bytes.NewReader(raw) + case io.Reader: + args.bodyStream = raw + default: + args.bodyStream = &bytes.Buffer{} + return json.NewEncoder(args.bodyStream.(*bytes.Buffer)).Encode(args.bodyRaw) + } + } + } + + return nil + }() + if err != nil { + return nil, err + } + + if args.Timeout > 0 { + var tfn context.CancelFunc + ctx, tfn = context.WithTimeout(ctx, args.Timeout) + defer tfn() + } + + if args.hasParams { + purl, err := url.Parse(args.Url) + if err != nil { + return nil, err + } + + purl.RawQuery = args.Params.Encode() + args.Url = purl.String() + } + + req, err = http.NewRequestWithContext(ctx, args.Method, args.Url, args.bodyStream) + if err != nil { + return nil, err + } + + if args.Headers == nil { + args.Headers = make(http.Header) + } + + if args.HeaderUserAgent == "" { + args.HeaderUserAgent = "Corteza-Automation-Client/" + version.Version + } + + args.Headers.Set("User-Agent", args.HeaderUserAgent) + + switch { + case len(args.HeaderAuthBearer) > 0: + args.Headers.Add("Authorization", "Bearer "+args.HeaderAuthBearer) + case len(args.HeaderAuthPassword+args.HeaderAuthPassword) > 0: + req.SetBasicAuth( + args.HeaderAuthPassword, + args.HeaderAuthPassword, + ) + } + + if len(args.HeaderContentType) > 0 { + args.Headers.Add("Content-Type", args.HeaderContentType) + } + + req.Header = args.Headers + + return +} diff --git a/automation/automation/http_request_handler.yaml b/automation/automation/http_request_handler.yaml new file mode 100644 index 000000000..12f634019 --- /dev/null +++ b/automation/automation/http_request_handler.yaml @@ -0,0 +1,93 @@ +name: httpRequest + +imports: + - time + - net/http + - net/url + - io + +params: + url: &url + required: true + types: + - { wf: String } + method: &method + required: true + types: + - { wf: String } + params: ¶ms + types: + - { wf: KVV, go: 'url.Values' } + headers: &headers + types: + - { wf: KVV, go: 'http.Header' } + headerAuthBearer: &headerAuthBearer + types: + - { wf: String } + headerAuthUsername: &headerAuthUsername + types: + - { wf: String } + headerAuthPassword: &headerAuthPassword + types: + - { wf: String } + headerContentType: &headerContentType + types: + - { wf: String } + headerUserAgent: &headerUserAgent + types: + - { wf: String } + timeout: &timeout + types: + - { wf: Duration } + form: &form + types: + - { wf: KVV, go: 'url.Values' } + body: &body + types: + - { wf: String, suffix: String } + - { wf: Reader, suffix: Stream } + - { wf: Any, suffix: Raw } + +results: + status: &rStatus + wf: String + + stautsCode: &rStatusCode + wf: Integer + + headers: &rHeaders + wf: KVV + + contentLength: &rContentLength + wf: Integer + + contentType: &rContentType + wf: String + + body: &rBody + wf: Reader + +functions: + send: + meta: + short: Sends HTTP request + params: + url: *url + method: *method + params: *params + headers: *headers + headerAuthBearer: *headerAuthBearer + headerAuthUsername: *headerAuthUsername + headerAuthPassword: *headerAuthPassword + headerUserAgent: *headerUserAgent + headerContentType: *headerContentType + timeout: *timeout + form: *form + body: *body + results: + status: *rStatus + statusCode: *rStatusCode + headers: *rHeaders + contentLength: *rContentLength + contentType: *rContentType + body: *rBody diff --git a/automation/automation/http_request_handler_test.go b/automation/automation/http_request_handler_test.go new file mode 100644 index 000000000..109aa5d9c --- /dev/null +++ b/automation/automation/http_request_handler_test.go @@ -0,0 +1,55 @@ +package automation + +import ( + "context" + "github.com/stretchr/testify/require" + "io/ioutil" + "net/http" + "net/url" + "testing" +) + +func TestHttpRequestMaker(t *testing.T) { + validateBody := func(r *require.Assertions, req *http.Request, expected string) { + reader, err := req.GetBody() + r.NoError(err) + body, err := ioutil.ReadAll(reader) + r.NoError(err) + + r.Equal(expected, string(body)) + + } + + t.Run("basic get", func(t *testing.T) { + var ( + r = require.New(t) + + req, err = httpRequestHandler{}.makeRequest(context.Background(), &httpRequestSendArgs{ + Url: "http://localhost/test", + Method: "GET", + }) + ) + + r.NoError(err) + r.Equal("GET", req.Method) + r.Equal("http://localhost/test", req.URL.String()) + }) + + t.Run("post form", func(t *testing.T) { + var ( + r = require.New(t) + in = &httpRequestSendArgs{ + Form: url.Values(map[string][]string{ + "a": {"a"}, + "b": {"b", "b"}, + "i": {"42"}, + }), + } + req, err = httpRequestHandler{}.makeRequest(context.Background(), in) + ) + + r.NoError(err) + r.Equal("POST", req.Method) + validateBody(r, req, "a=a&b=b&b=b&i=42") + }) +} diff --git a/automation/automation/iterators.go b/automation/automation/iterators.go new file mode 100644 index 000000000..f34987a7c --- /dev/null +++ b/automation/automation/iterators.go @@ -0,0 +1,106 @@ +package automation + +import ( + "bufio" + "context" + . "github.com/cortezaproject/corteza-server/pkg/expr" +) + +type ( + // iterates from start to stop by step + sequenceIterator struct { + counter, cFirst, cLast, cStep int64 + } +) + +func (i *sequenceIterator) More(context.Context, *Vars) (bool, error) { + return i.more(), nil +} + +func (i *sequenceIterator) more() bool { + return i.counter*(i.cStep/i.cStep) < i.cLast*(i.cStep/i.cStep) +} + +func (i *sequenceIterator) Start(context.Context, *Vars) error { return nil } + +func (i *sequenceIterator) Next(context.Context, *Vars) (*Vars, error) { + scope := RVars{ + "counter": Must(NewInteger(i.counter)), + "isFirst": Must(NewBoolean(i.counter == i.cFirst)), + "isLast": Must(NewBoolean(!i.more())), + }.Vars() + + i.counter = i.counter + i.cStep + return scope, nil +} + +type ( + // iterates from start to stop by step + conditionIterator struct { + expr Evaluable + } +) + +func (i *conditionIterator) More(ctx context.Context, scope *Vars) (bool, error) { + return i.expr.Test(ctx, scope) +} + +func (i *conditionIterator) Start(context.Context, *Vars) error { return nil } + +func (i *conditionIterator) Next(context.Context, *Vars) (*Vars, error) { + return &Vars{}, nil +} + +type ( + // iterates from start to stop by step + collectionIterator struct { + ptr int + set []TypedValue + } +) + +func (i *collectionIterator) More(context.Context, *Vars) (bool, error) { + return i.ptr < len(i.set), nil +} + +func (i *collectionIterator) Start(context.Context, *Vars) error { i.ptr = 0; return nil } + +func (i *collectionIterator) Next(context.Context, *Vars) (out *Vars, err error) { + var item TypedValue + switch c := i.set[i.ptr].(type) { + case TypedValue: + item = c + default: + if item, err = NewAny(c); err != nil { + return + } + } + + i.ptr++ + + return RVars{"item": item}.Vars(), nil +} + +type ( + // iterates from start to stop by step + lineIterator struct { + s *bufio.Scanner + } +) + +func (i *lineIterator) More(context.Context, *Vars) (bool, error) { + return i.s.Scan(), nil +} + +func (i *lineIterator) Start(context.Context, *Vars) error { + return nil +} + +func (i *lineIterator) Next(context.Context, *Vars) (*Vars, error) { + if err := i.s.Err(); err != nil { + return nil, err + } + + return RVars{"line": Must(NewString(i.s.Text()))}.Vars(), nil + +} diff --git a/automation/automation/log_handler.gen.go b/automation/automation/log_handler.gen.go new file mode 100644 index 000000000..186bdc14a --- /dev/null +++ b/automation/automation/log_handler.gen.go @@ -0,0 +1,242 @@ +package automation + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// automation/automation/log_handler.yaml + +import ( + "context" + atypes "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/wfexec" +) + +var _ wfexec.ExecResponse + +type ( + logHandlerRegistry interface { + AddFunctions(ff ...*atypes.Function) + Type(ref string) expr.Type + } +) + +func (h logHandler) register() { + h.reg.AddFunctions( + h.Debug(), + h.Info(), + h.Warn(), + h.Error(), + ) +} + +type ( + logDebugArgs struct { + hasMessage bool + Message string + + hasFields bool + Fields map[string]string + } +) + +// Debug function Writes debug log message +// +// expects implementation of debug function: +// func (h logHandler) debug(ctx context.Context, args *logDebugArgs) (err error) { +// return +// } +func (h logHandler) Debug() *atypes.Function { + return &atypes.Function{ + Ref: "logDebug", + Kind: "function", + Meta: &atypes.FunctionMeta{ + Short: "Writes debug log message", + }, + + Parameters: []*atypes.Param{ + { + Name: "message", + Types: []string{"String"}, Required: true, + }, + { + Name: "fields", + Types: []string{"KV"}, + }, + }, + + Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { + var ( + args = &logDebugArgs{ + hasMessage: in.Has("message"), + hasFields: in.Has("fields"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + return out, h.debug(ctx, args) + }, + } +} + +type ( + logInfoArgs struct { + hasMessage bool + Message string + + hasFields bool + Fields map[string]string + } +) + +// Info function Writes info log message +// +// expects implementation of info function: +// func (h logHandler) info(ctx context.Context, args *logInfoArgs) (err error) { +// return +// } +func (h logHandler) Info() *atypes.Function { + return &atypes.Function{ + Ref: "logInfo", + Kind: "function", + Meta: &atypes.FunctionMeta{ + Short: "Writes info log message", + }, + + Parameters: []*atypes.Param{ + { + Name: "message", + Types: []string{"String"}, Required: true, + }, + { + Name: "fields", + Types: []string{"KV"}, + }, + }, + + Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { + var ( + args = &logInfoArgs{ + hasMessage: in.Has("message"), + hasFields: in.Has("fields"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + return out, h.info(ctx, args) + }, + } +} + +type ( + logWarnArgs struct { + hasMessage bool + Message string + + hasFields bool + Fields map[string]string + } +) + +// Warn function Writes warn log message +// +// expects implementation of warn function: +// func (h logHandler) warn(ctx context.Context, args *logWarnArgs) (err error) { +// return +// } +func (h logHandler) Warn() *atypes.Function { + return &atypes.Function{ + Ref: "logWarn", + Kind: "function", + Meta: &atypes.FunctionMeta{ + Short: "Writes warn log message", + }, + + Parameters: []*atypes.Param{ + { + Name: "message", + Types: []string{"String"}, Required: true, + }, + { + Name: "fields", + Types: []string{"KV"}, + }, + }, + + Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { + var ( + args = &logWarnArgs{ + hasMessage: in.Has("message"), + hasFields: in.Has("fields"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + return out, h.warn(ctx, args) + }, + } +} + +type ( + logErrorArgs struct { + hasMessage bool + Message string + + hasFields bool + Fields map[string]string + } +) + +// Error function Writes error log message +// +// expects implementation of error function: +// func (h logHandler) error(ctx context.Context, args *logErrorArgs) (err error) { +// return +// } +func (h logHandler) Error() *atypes.Function { + return &atypes.Function{ + Ref: "logError", + Kind: "function", + Meta: &atypes.FunctionMeta{ + Short: "Writes error log message", + }, + + Parameters: []*atypes.Param{ + { + Name: "message", + Types: []string{"String"}, Required: true, + }, + { + Name: "fields", + Types: []string{"KV"}, + }, + }, + + Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { + var ( + args = &logErrorArgs{ + hasMessage: in.Has("message"), + hasFields: in.Has("fields"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + return out, h.error(ctx, args) + }, + } +} diff --git a/automation/automation/log_handler.go b/automation/automation/log_handler.go new file mode 100644 index 000000000..10fc4e399 --- /dev/null +++ b/automation/automation/log_handler.go @@ -0,0 +1,51 @@ +package automation + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/logger" + "go.uber.org/zap" +) + +type ( + logHandler struct { + reg logHandlerRegistry + } +) + +func zapFields(fields map[string]string) []zap.Field { + ff := make([]zap.Field, 0, len(fields)) + for k, v := range fields { + ff = append(ff, zap.String(k, v)) + } + + return ff +} + +func LogHandler(reg logHandlerRegistry) *logHandler { + h := &logHandler{ + reg: reg, + } + + h.register() + return h +} + +func (h logHandler) debug(ctx context.Context, args *logDebugArgs) (err error) { + logger.ContextValue(ctx, zap.NewNop()).Debug(args.Message, zapFields(args.Fields)...) + return nil +} + +func (h logHandler) info(ctx context.Context, args *logInfoArgs) (err error) { + logger.ContextValue(ctx, zap.NewNop()).Info(args.Message, zapFields(args.Fields)...) + return nil +} + +func (h logHandler) warn(ctx context.Context, args *logWarnArgs) (err error) { + logger.ContextValue(ctx, zap.NewNop()).Warn(args.Message, zapFields(args.Fields)...) + return nil +} + +func (h logHandler) error(ctx context.Context, args *logErrorArgs) (err error) { + logger.ContextValue(ctx, zap.NewNop()).Error(args.Message, zapFields(args.Fields)...) + return nil +} diff --git a/automation/automation/log_handler.yaml b/automation/automation/log_handler.yaml new file mode 100644 index 000000000..69bf57b63 --- /dev/null +++ b/automation/automation/log_handler.yaml @@ -0,0 +1,38 @@ +params: + message: &message + required: true + types: + - { wf: String } + fields: &fields + types: + - { wf: KV } + + +functions: + debug: + meta: + short: Writes debug log message + params: + message: *message + fields: *fields + + info: + meta: + short: Writes info log message + params: + message: *message + fields: *fields + + warn: + meta: + short: Writes warn log message + params: + message: *message + fields: *fields + + error: + meta: + short: Writes error log message + params: + message: *message + fields: *fields diff --git a/automation/automation/log_handler_test.go b/automation/automation/log_handler_test.go new file mode 100644 index 000000000..015e4d301 --- /dev/null +++ b/automation/automation/log_handler_test.go @@ -0,0 +1,87 @@ +package automation + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/logger" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" + "testing" +) + +func observableLog() (*zap.Logger, *observer.ObservedLogs) { + core, o := observer.New(zap.NewAtomicLevelAt(zap.DebugLevel)) + return zap.New(core), o +} + +func TestLogHandler_Debug(t *testing.T) { + var ( + req = require.New(t) + log, obs = observableLog() + handler = &logHandler{} + ctx = logger.ContextWithValue(context.Background(), log) + err = handler.debug(ctx, &logDebugArgs{Message: "123abc", Fields: map[string]string{"foo": "bar"}}) + ) + + req.NoError(err) + req.Equal(1, obs.Len()) + + entry := obs.TakeAll()[0] + req.Contains(entry.Message, "123abc") + req.Equal(entry.Level, zap.DebugLevel) + req.Contains(entry.ContextMap(), "foo") +} + +func TestLogHandler_Info(t *testing.T) { + var ( + req = require.New(t) + log, obs = observableLog() + handler = &logHandler{} + ctx = logger.ContextWithValue(context.Background(), log) + err = handler.info(ctx, &logInfoArgs{Message: "123abc", Fields: map[string]string{"foo": "bar"}}) + ) + + req.NoError(err) + req.Equal(1, obs.Len()) + + entry := obs.TakeAll()[0] + req.Contains(entry.Message, "123abc") + req.Equal(entry.Level, zap.InfoLevel) + req.Contains(entry.ContextMap(), "foo") +} + +func TestLogHandler_Warn(t *testing.T) { + var ( + req = require.New(t) + log, obs = observableLog() + handler = &logHandler{} + ctx = logger.ContextWithValue(context.Background(), log) + err = handler.warn(ctx, &logWarnArgs{Message: "123abc", Fields: map[string]string{"foo": "bar"}}) + ) + + req.NoError(err) + req.Equal(1, obs.Len()) + + entry := obs.TakeAll()[0] + req.Contains(entry.Message, "123abc") + req.Equal(entry.Level, zap.WarnLevel) + req.Contains(entry.ContextMap(), "foo") +} + +func TestLogHandler_Error(t *testing.T) { + var ( + req = require.New(t) + log, obs = observableLog() + handler = &logHandler{} + ctx = logger.ContextWithValue(context.Background(), log) + err = handler.error(ctx, &logErrorArgs{Message: "123abc", Fields: map[string]string{"foo": "bar"}}) + ) + + req.NoError(err) + req.Equal(1, obs.Len()) + + entry := obs.TakeAll()[0] + req.Contains(entry.Message, "123abc") + req.Equal(entry.Level, zap.ErrorLevel) + req.Contains(entry.ContextMap(), "foo") +} diff --git a/automation/automation/loop_handler.gen.go b/automation/automation/loop_handler.gen.go new file mode 100644 index 000000000..7d9c9ab38 --- /dev/null +++ b/automation/automation/loop_handler.gen.go @@ -0,0 +1,279 @@ +package automation + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// automation/automation/loop_handler.yaml + +import ( + "context" + atypes "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/wfexec" + "io" +) + +var _ wfexec.ExecResponse + +type ( + loopHandlerRegistry interface { + AddFunctions(ff ...*atypes.Function) + Type(ref string) expr.Type + } +) + +func (h loopHandler) register() { + h.reg.AddFunctions( + h.Sequence(), + h.Do(), + h.Each(), + h.Lines(), + ) +} + +type ( + loopSequenceArgs struct { + hasFirst bool + First int64 + + hasLast bool + Last int64 + + hasStep bool + Step int64 + } + + loopSequenceResults struct { + Counter int64 + IsFirst bool + IsLast bool + } +) + +// Sequence function Iterates over sequence of numbers +// +// expects implementation of sequence function: +// func (h loopHandler) sequence(ctx context.Context, args *loopSequenceArgs) (results *loopSequenceResults, err error) { +// return +// } +func (h loopHandler) Sequence() *atypes.Function { + return &atypes.Function{ + Ref: "loopSequence", + Kind: "iterator", + Meta: &atypes.FunctionMeta{ + Short: "Iterates over sequence of numbers", + }, + + Parameters: []*atypes.Param{ + { + Name: "first", + Types: []string{"Integer"}, + }, + { + Name: "last", + Types: []string{"Integer"}, + }, + { + Name: "step", + Types: []string{"Integer"}, + }, + }, + + Results: []*atypes.Param{ + + { + Name: "counter", + Types: []string{"Integer"}, + }, + + { + Name: "isFirst", + Types: []string{"Boolean"}, + }, + + { + Name: "isLast", + Types: []string{"Boolean"}, + }, + }, + + Iterator: func(ctx context.Context, in *expr.Vars) (out wfexec.IteratorHandler, err error) { + var ( + args = &loopSequenceArgs{ + hasFirst: in.Has("first"), + hasLast: in.Has("last"), + hasStep: in.Has("step"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + return h.sequence(ctx, args) + }, + } +} + +type ( + loopDoArgs struct { + hasWhile bool + While string + } +) + +// Do function Iterates while condition is true +// +// expects implementation of do function: +// func (h loopHandler) do(ctx context.Context, args *loopDoArgs) (err error) { +// return +// } +func (h loopHandler) Do() *atypes.Function { + return &atypes.Function{ + Ref: "loopDo", + Kind: "iterator", + Meta: &atypes.FunctionMeta{ + Short: "Iterates while condition is true", + }, + + Parameters: []*atypes.Param{ + { + Name: "while", + Types: []string{"String"}, Required: true, + Meta: &atypes.ParamMeta{ + Label: "Expression tested before each iteration", + Description: "Expression to be evaluated each iteration; loop will continue until expression is true", + }, + }, + }, + + Iterator: func(ctx context.Context, in *expr.Vars) (out wfexec.IteratorHandler, err error) { + var ( + args = &loopDoArgs{ + hasWhile: in.Has("while"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + return h.do(ctx, args) + }, + } +} + +type ( + loopEachArgs struct { + hasItems bool + Items []expr.TypedValue + } + + loopEachResults struct { + Item interface{} + } +) + +// Each function Iterates over set of items +// +// expects implementation of each function: +// func (h loopHandler) each(ctx context.Context, args *loopEachArgs) (results *loopEachResults, err error) { +// return +// } +func (h loopHandler) Each() *atypes.Function { + return &atypes.Function{ + Ref: "loopEach", + Kind: "iterator", + Meta: &atypes.FunctionMeta{ + Short: "Iterates over set of items", + }, + + Parameters: []*atypes.Param{ + { + Name: "items", + Types: []string{"Any"}, Required: true, IsArray: true, + }, + }, + + Results: []*atypes.Param{ + + { + Name: "item", + Types: []string{"Any"}, + }, + }, + + Iterator: func(ctx context.Context, in *expr.Vars) (out wfexec.IteratorHandler, err error) { + var ( + args = &loopEachArgs{ + hasItems: in.Has("items"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + return h.each(ctx, args) + }, + } +} + +type ( + loopLinesArgs struct { + hasStream bool + Stream io.Reader + } + + loopLinesResults struct { + Line string + } +) + +// Lines function Iterates over lines from stream +// +// expects implementation of lines function: +// func (h loopHandler) lines(ctx context.Context, args *loopLinesArgs) (results *loopLinesResults, err error) { +// return +// } +func (h loopHandler) Lines() *atypes.Function { + return &atypes.Function{ + Ref: "loopLines", + Kind: "iterator", + Meta: &atypes.FunctionMeta{ + Short: "Iterates over lines from stream", + }, + + Parameters: []*atypes.Param{ + { + Name: "stream", + Types: []string{"Reader"}, Required: true, + }, + }, + + Results: []*atypes.Param{ + + { + Name: "line", + Types: []string{"String"}, + }, + }, + + Iterator: func(ctx context.Context, in *expr.Vars) (out wfexec.IteratorHandler, err error) { + var ( + args = &loopLinesArgs{ + hasStream: in.Has("stream"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + return h.lines(ctx, args) + }, + } +} diff --git a/automation/automation/loop_handler.go b/automation/automation/loop_handler.go new file mode 100644 index 000000000..614593209 --- /dev/null +++ b/automation/automation/loop_handler.go @@ -0,0 +1,74 @@ +package automation + +import ( + "bufio" + "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/wfexec" +) + +type ( + loopHandler struct { + reg loopHandlerRegistry + parser expr.Parsable + } +) + +func LoopHandler(reg loopHandlerRegistry, p expr.Parsable) *loopHandler { + h := &loopHandler{ + reg: reg, + parser: p, + } + + h.register() + return h +} + +func (h loopHandler) sequence(_ context.Context, args *loopSequenceArgs) (wfexec.IteratorHandler, error) { + if !args.hasFirst { + args.First = 0 + } + + if !args.hasLast { + args.Last = 1 + } + + if !args.hasStep { + args.Step = 1 + } + + if args.First*(args.Step/args.Step) >= args.Last*(args.Step/args.Step) { + return nil, fmt.Errorf("failed to initialize counter iterator with first step greater than last") + } + + i := &sequenceIterator{ + counter: 0, + cFirst: args.First, + cLast: args.Last, + cStep: args.Step, + } + + return i, nil +} + +func (h loopHandler) do(_ context.Context, args *loopDoArgs) (wfexec.IteratorHandler, error) { + var ( + i = &conditionIterator{} + err error + ) + + if i.expr, err = h.parser.Parse(args.While); err != nil { + return nil, err + } + + return i, nil +} + +func (h loopHandler) each(_ context.Context, args *loopEachArgs) (wfexec.IteratorHandler, error) { + return &collectionIterator{set: args.Items}, nil +} + +func (h loopHandler) lines(_ context.Context, args *loopLinesArgs) (wfexec.IteratorHandler, error) { + return &lineIterator{s: bufio.NewScanner(args.Stream)}, nil +} diff --git a/automation/automation/loop_handler.yaml b/automation/automation/loop_handler.yaml new file mode 100644 index 000000000..5d9c38a61 --- /dev/null +++ b/automation/automation/loop_handler.yaml @@ -0,0 +1,61 @@ +name: loop + +imports: + - io + + +functions: + sequence: + kind: iterator + meta: + short: Iterates over sequence of numbers + params: + first: { types: [ { wf: Integer } ] } + last: { types: [ { wf: Integer } ] } + step: { types: [ { wf: Integer } ] } + results: + counter: + wf: Integer + isFirst: + wf: Boolean + isLast: + wf: Boolean + + do: + kind: iterator + meta: + short: Iterates while condition is true + params: + while: + required: true + types: [ { wf: String } ] + meta: + label: Expression tested before each iteration + description: Expression to be evaluated each iteration; loop will continue until expression is true + + each: + kind: iterator + meta: + short: Iterates over set of items + params: + items: + required: true + isArray: true + types: [ { wf: Any, go: 'expr.TypedValue' } ] + results: + item: + wf: Any + go: 'interface{}' + + lines: + kind: iterator + meta: + short: Iterates over lines from stream + params: + stream: + required: true + types: [ { wf: Reader, go: "io.Reader" } ] + results: + line: + wf: String + go: string diff --git a/automation/rest.yaml b/automation/rest.yaml new file mode 100644 index 000000000..0167f5725 --- /dev/null +++ b/automation/rest.yaml @@ -0,0 +1,293 @@ +--- + +# This is direct conversion from old api/*/spec.json files +# using https://www.json2yaml.com/ +# +# Next step: swagger. + +endpoints: +- title: Workflows + path: "/workflows" + entrypoint: workflow + authentication: [] + imports: + - github.com/cortezaproject/corteza-server/pkg/expr + - github.com/cortezaproject/corteza-server/automation/types + - github.com/cortezaproject/corteza-server/pkg/label + apis: + - name: list + method: GET + title: List workflows + path: "/" + parameters: + get: + - { name: workflowID, type: "[]string", title: "Filter by workflow ID" } + - { name: query, type: "string", title: "Filter workflows" } + - { name: deleted, type: "uint", title: "Exclude (0, default), include (1) or return only (2) deleted workflows" } + - { name: disabled, type: "uint", title: "Exclude (0, default), include (1) or return only (2) disabled workflows" } + - { name: labels, type: "map[string]string", title: "Labels", parser: "label.ParseStrings" } + - { name: limit, type: "uint", title: "Limit" } + - { name: pageCursor, type: "string", title: "Page cursor" } + - { name: sort, type: "string", title: "Sort items" } + - name: create + method: POST + title: Create workflow + path: "/" + parameters: + post: + - { name: handle, type: string, required: true, title: "Workflow name" } + - { name: labels, type: "map[string]string", title: "Labels", parser: "label.ParseStrings" } + - { name: meta, type: "*types.WorkflowMeta", title: "Workflow meta data", parser: "types.ParseWorkflowMeta" } + - { name: enabled, type: bool, title: "Is workflow enabled" } + - { name: trace, type: bool, title: "Trace workflow execution" } + - { name: keepSessions, type: int, title: "Keep old workflow sessions" } + - { name: scope, type: "*expr.Vars", title: "Workflow meta data", parser: "types.ParseWorkflowVariables" } + - { name: steps, type: "types.WorkflowStepSet", title: "Workflow steps definition", parser: "types.ParseWorkflowStepSet" } + - { name: paths, type: "types.WorkflowPathSet", title: "Workflow step paths definition", parser: "types.ParseWorkflowPathSet" } + - { name: runAs, type: uint64, required: true, title: "Is workflow enabled" } + - { name: ownedBy, type: uint64, required: true, title: "Owner of the workflow" } + - name: update + method: PUT + title: Update triger details + path: "/{workflowID}" + parameters: + path: [ { name: workflowID, type: uint64, required: true, title: "Workflow ID" } ] + post: + - { name: handle, type: string, required: true, title: "Workflow name" } + - { name: labels, type: "map[string]string", title: "Labels", parser: "label.ParseStrings" } + - { name: meta, type: "*types.WorkflowMeta", title: "Workflow meta data", parser: "types.ParseWorkflowMeta" } + - { name: enabled, type: bool, title: "Is workflow enabled" } + - { name: trace, type: bool, title: "Trace workflow execution" } + - { name: keepSessions, type: int, title: "Keep old workflow sessions" } + - { name: scope, type: "*expr.Vars", title: "Workflow meta data", parser: "types.ParseWorkflowVariables" } + - { name: steps, type: "types.WorkflowStepSet", title: "Workflow steps definition", parser: "types.ParseWorkflowStepSet" } + - { name: paths, type: "types.WorkflowPathSet", title: "Workflow step paths definition", parser: "types.ParseWorkflowPathSet" } + - { name: runAs, type: uint64, required: true, title: "Is workflow enabled" } + - { name: ownedBy, type: uint64, required: true, title: "Owner of the workflow" } + - name: read + method: GET + title: Read workflow details + path: "/{workflowID}" + parameters: { path: [ { name: workflowID, type: uint64, required: true, title: "Workflow ID" } ] } + - name: delete + method: DELETE + title: Remove workflow + path: "/{workflowID}" + parameters: { path: [ { name: workflowID, type: uint64, required: true, title: "Workflow ID" } ] } + - name: undelete + method: POST + title: Undelete workflow + path: "/{workflowID}/undelete" + parameters: { path: [ { name: workflowID, type: uint64, required: true, title: "Workflow ID" } ] } + - name: test + method: POST + title: Test workflow details + path: "/{workflowID}/test" + parameters: + path: [ { name: workflowID, type: uint64, required: true, title: "Workflow ID" } ] + post: + - { name: scope, type: "*expr.Vars", title: "Workflow meta data", parser: "types.ParseWorkflowVariables" } + - { name: runAs, type: bool, required: true, title: "Is workflow enabled" } + +- title: Triggers + path: "/triggers" + entrypoint: trigger + authentication: [] + imports: + - github.com/cortezaproject/corteza-server/automation/types + - github.com/cortezaproject/corteza-server/pkg/expr + - github.com/cortezaproject/corteza-server/pkg/label + apis: + - name: list + method: GET + title: List triggers + path: "/" + parameters: + get: + - { name: triggerID, type: "[]string", title: "Filter by trigger ID" } + - { name: workflowID, type: "[]string", title: "Filter by workflow ID" } + - { name: deleted, type: "uint", title: "Exclude (0, default), include (1) or return only (2) deleted triggers" } + - { name: disabled, type: "uint", title: "Exclude (0, default), include (1) or return only (2) disabled triggers" } + - { name: eventType, type: "string", title: "Filter triggers by event type" } + - { name: resourceType, type: "string", title: "Filter triggers by resource type" } + - { name: query, type: "string", title: "Filter workflows," } + - { name: labels, type: "map[string]string", title: "Labels", parser: "label.ParseStrings" } + - { name: limit, type: "uint", title: "Limit" } + - { name: pageCursor, type: "string", title: "Page cursor" } + - { name: sort, type: "string", title: "Sort items" } + - name: create + method: POST + title: Create trigger + path: "/" + parameters: + post: + - { name: eventType, type: string, required: true, title: "Event type" } + - { name: resourceType, type: string, required: true, title: "Resource type" } + - { name: enabled, type: bool, title: "Is trigger enabled" } + - { name: workflowID, type: uint64, required: true, title: "Workflow to be triggered" } + - { name: workflowStepID, type: uint64, required: true, title: "Start workflow in a specific step" } + - { name: input, type: "*expr.Vars", title: "Workflow meta data", parser: "types.ParseWorkflowVariables" } + - { name: labels, type: "map[string]string", title: "Labels", parser: "label.ParseStrings" } + - { name: meta, type: "*types.TriggerMeta", title: "Trigger meta data", parser: "types.ParseTriggerMeta" } + - { name: constraints, type: "types.TriggerConstraintSet", title: "Workflow steps definition", parser: "types.ParseTriggerConstraintSet" } + - { name: ownedBy, type: uint64, required: true, title: "Owner of the trigger" } + - name: update + method: PUT + title: Update trigger details + path: "/{triggerID}" + parameters: + path: [ { name: triggerID, type: uint64, required: true, title: "Trigger ID" } ] + post: + - { name: eventType, type: string, required: true, title: "Event type" } + - { name: resourceType, type: string, required: true, title: "Resource type" } + - { name: enabled, type: bool, title: "Is trigger enabled" } + - { name: workflowID, type: uint64, required: true, title: "Workflow to be triggered" } + - { name: workflowStepID, type: uint64, required: true, title: "Start workflow in a specific step" } + - { name: input, type: "*expr.Vars", title: "Workflow meta data", parser: "types.ParseWorkflowVariables" } + - { name: labels, type: "map[string]string", title: "Labels", parser: "label.ParseStrings" } + - { name: meta, type: "*types.TriggerMeta", title: "Trigger meta data", parser: "types.ParseTriggerMeta" } + - { name: constraints, type: "types.TriggerConstraintSet", title: "Workflow steps definition", parser: "types.ParseTriggerConstraintSet" } + - { name: ownedBy, type: uint64, required: true, title: "Owner of the trigger" } + - name: read + method: GET + title: Read trigger details + path: "/{triggerID}" + parameters: { path: [ { name: triggerID, type: uint64, required: true, title: "Trigger ID" } ] } + - name: delete + method: DELETE + title: Remove trigger + path: "/{triggerID}" + parameters: { path: [ { name: triggerID, type: uint64, required: true, title: "Trigger ID" } ] } + - name: undelete + method: POST + title: Undelete trigger + path: "/{triggerID}/undelete" + parameters: { path: [ { name: triggerID, type: uint64, required: true, title: "Trigger ID" } ] } + +- title: Sessions + path: "/sessions" + entrypoint: session + authentication: [] + imports: + - github.com/cortezaproject/corteza-server/pkg/expr + - github.com/cortezaproject/corteza-server/automation/types + apis: + - name: list + method: GET + title: List sessions + path: "/" + parameters: + get: + - { name: sessionID, type: "[]string", title: "Filter by session ID" } + - { name: workflowID, type: "[]string", title: "Filter by workflow ID" } + - { name: completed, type: "uint", title: "Exclude (0, default), include (1) or return only (2) completed sessions" } + - { name: suspended, type: "uint", title: "Exclude (0, default), include (1) or return only (2) suspended sessions" } + - { name: eventType, type: "string", title: "Filter sessions by event type" } + - { name: resourceType, type: "string", title: "Filter sessions by resource type" } + - { name: limit, type: "uint", title: "Limit" } + - { name: pageCursor, type: "string", title: "Page cursor" } + - { name: sort, type: "string", title: "Sort items" } + - name: read + method: GET + title: Read session details + path: "/{sessionID}" + parameters: { path: [ { name: sessionID, type: uint64, required: true, title: "Session ID" } ] } + - name: trace + method: GET + title: Read session trace info + path: "/{sessionID}/trace" + parameters: { path: [ { name: sessionID, type: uint64, required: true, title: "Session ID" } ] } + - name: delete + method: DELETE + title: Remove session + path: "/{sessionID}" + parameters: { path: [ { name: sessionID, type: uint64, required: true, title: "Session ID" } ] } + - name: resume + method: POST + title: Resume session + path: "/{sessionID}/resume" + parameters: + path: + - { name: sessionID, type: uint64, required: true, title: "Session ID" } + post: + - { name: stateID, type: uint64, required: true, title: "State ID" } + - { name: input, type: "*expr.Vars", title: "Workflow meta data", parser: "types.ParseWorkflowVariables" } + +- title: Functions + path: "/functions" + entrypoint: function + authentication: [] + apis: + - name: list + method: GET + title: Available workflow functions + path: "/" + + +- title: Types + path: "/types" + entrypoint: type + authentication: [] + apis: + - name: list + method: GET + title: Available workflow types + path: "/" +- title: Permissions + entrypoint: permissions + path: "/permissions" + authentication: + - Client ID + - Session ID + imports: + - github.com/cortezaproject/corteza-server/pkg/rbac + apis: + - name: list + path: "/" + method: GET + title: Retrieve defined permissions + - name: effective + path: "/effective" + method: GET + title: Effective rules for current user + parameters: + get: + - name: resource + type: string + required: false + title: Show only rules for a specific resource + - name: read + path: "/{roleID}/rules" + method: GET + title: Retrieve role permissions + parameters: + path: + - name: roleID + type: uint64 + required: true + title: Role ID + - name: delete + path: "/{roleID}/rules" + method: DELETE + title: Remove all defined role permissions + parameters: + path: + - name: roleID + type: uint64 + required: true + title: Role ID + - name: update + path: "/{roleID}/rules" + method: PATCH + title: Update permission settings + parameters: + path: + - name: roleID + type: uint64 + required: true + title: Role ID + post: + - name: rules + type: rbac.RuleSet + required: true + title: List of permission rules to set diff --git a/automation/rest/function.go b/automation/rest/function.go new file mode 100644 index 000000000..752c37858 --- /dev/null +++ b/automation/rest/function.go @@ -0,0 +1,29 @@ +package rest + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/rest/request" + "github.com/cortezaproject/corteza-server/automation/service" + "github.com/cortezaproject/corteza-server/automation/types" +) + +type ( + Function struct { + reg interface { + Functions() []*types.Function + } + } + + functionSetPayload struct { + Set []*types.Function `json:"set"` + } +) + +func (Function) New() *Function { + ctrl := &Function{reg: service.Registry()} + return ctrl +} + +func (ctrl Function) List(_ context.Context, _ *request.FunctionList) (interface{}, error) { + return functionSetPayload{Set: ctrl.reg.Functions()}, nil +} diff --git a/automation/rest/handlers/function.go b/automation/rest/handlers/function.go new file mode 100644 index 000000000..b4ddfbbb1 --- /dev/null +++ b/automation/rest/handlers/function.go @@ -0,0 +1,57 @@ +package handlers + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/rest/request" + "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/go-chi/chi" + "net/http" +) + +type ( + // Internal API interface + FunctionAPI interface { + List(context.Context, *request.FunctionList) (interface{}, error) + } + + // HTTP API interface + Function struct { + List func(http.ResponseWriter, *http.Request) + } +) + +func NewFunction(h FunctionAPI) *Function { + return &Function{ + List: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewFunctionList() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.List(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + } +} + +func (h Function) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Get("/functions/", h.List) + }) +} diff --git a/automation/rest/handlers/permissions.go b/automation/rest/handlers/permissions.go new file mode 100644 index 000000000..ac9a9961e --- /dev/null +++ b/automation/rest/handlers/permissions.go @@ -0,0 +1,133 @@ +package handlers + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/rest/request" + "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/go-chi/chi" + "net/http" +) + +type ( + // Internal API interface + PermissionsAPI interface { + List(context.Context, *request.PermissionsList) (interface{}, error) + Effective(context.Context, *request.PermissionsEffective) (interface{}, error) + Read(context.Context, *request.PermissionsRead) (interface{}, error) + Delete(context.Context, *request.PermissionsDelete) (interface{}, error) + Update(context.Context, *request.PermissionsUpdate) (interface{}, error) + } + + // HTTP API interface + Permissions struct { + List func(http.ResponseWriter, *http.Request) + Effective func(http.ResponseWriter, *http.Request) + Read func(http.ResponseWriter, *http.Request) + Delete func(http.ResponseWriter, *http.Request) + Update func(http.ResponseWriter, *http.Request) + } +) + +func NewPermissions(h PermissionsAPI) *Permissions { + return &Permissions{ + List: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewPermissionsList() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.List(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Effective: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewPermissionsEffective() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Effective(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Read: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewPermissionsRead() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Read(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Delete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewPermissionsDelete() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Delete(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Update: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewPermissionsUpdate() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Update(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + } +} + +func (h Permissions) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Get("/permissions/", h.List) + r.Get("/permissions/effective", h.Effective) + r.Get("/permissions/{roleID}/rules", h.Read) + r.Delete("/permissions/{roleID}/rules", h.Delete) + r.Patch("/permissions/{roleID}/rules", h.Update) + }) +} diff --git a/automation/rest/handlers/session.go b/automation/rest/handlers/session.go new file mode 100644 index 000000000..c6a08c7d5 --- /dev/null +++ b/automation/rest/handlers/session.go @@ -0,0 +1,133 @@ +package handlers + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/rest/request" + "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/go-chi/chi" + "net/http" +) + +type ( + // Internal API interface + SessionAPI interface { + List(context.Context, *request.SessionList) (interface{}, error) + Read(context.Context, *request.SessionRead) (interface{}, error) + Trace(context.Context, *request.SessionTrace) (interface{}, error) + Delete(context.Context, *request.SessionDelete) (interface{}, error) + Resume(context.Context, *request.SessionResume) (interface{}, error) + } + + // HTTP API interface + Session struct { + List func(http.ResponseWriter, *http.Request) + Read func(http.ResponseWriter, *http.Request) + Trace func(http.ResponseWriter, *http.Request) + Delete func(http.ResponseWriter, *http.Request) + Resume func(http.ResponseWriter, *http.Request) + } +) + +func NewSession(h SessionAPI) *Session { + return &Session{ + List: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewSessionList() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.List(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Read: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewSessionRead() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Read(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Trace: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewSessionTrace() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Trace(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Delete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewSessionDelete() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Delete(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Resume: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewSessionResume() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Resume(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + } +} + +func (h Session) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Get("/sessions/", h.List) + r.Get("/sessions/{sessionID}", h.Read) + r.Get("/sessions/{sessionID}/trace", h.Trace) + r.Delete("/sessions/{sessionID}", h.Delete) + r.Post("/sessions/{sessionID}/resume", h.Resume) + }) +} diff --git a/automation/rest/handlers/trigger.go b/automation/rest/handlers/trigger.go new file mode 100644 index 000000000..af8da4625 --- /dev/null +++ b/automation/rest/handlers/trigger.go @@ -0,0 +1,152 @@ +package handlers + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/rest/request" + "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/go-chi/chi" + "net/http" +) + +type ( + // Internal API interface + TriggerAPI interface { + List(context.Context, *request.TriggerList) (interface{}, error) + Create(context.Context, *request.TriggerCreate) (interface{}, error) + Update(context.Context, *request.TriggerUpdate) (interface{}, error) + Read(context.Context, *request.TriggerRead) (interface{}, error) + Delete(context.Context, *request.TriggerDelete) (interface{}, error) + Undelete(context.Context, *request.TriggerUndelete) (interface{}, error) + } + + // HTTP API interface + Trigger struct { + List func(http.ResponseWriter, *http.Request) + Create func(http.ResponseWriter, *http.Request) + Update func(http.ResponseWriter, *http.Request) + Read func(http.ResponseWriter, *http.Request) + Delete func(http.ResponseWriter, *http.Request) + Undelete func(http.ResponseWriter, *http.Request) + } +) + +func NewTrigger(h TriggerAPI) *Trigger { + return &Trigger{ + List: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTriggerList() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.List(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Create: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTriggerCreate() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Create(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Update: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTriggerUpdate() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Update(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Read: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTriggerRead() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Read(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Delete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTriggerDelete() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Delete(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Undelete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTriggerUndelete() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Undelete(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + } +} + +func (h Trigger) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Get("/triggers/", h.List) + r.Post("/triggers/", h.Create) + r.Put("/triggers/{triggerID}", h.Update) + r.Get("/triggers/{triggerID}", h.Read) + r.Delete("/triggers/{triggerID}", h.Delete) + r.Post("/triggers/{triggerID}/undelete", h.Undelete) + }) +} diff --git a/automation/rest/handlers/type.go b/automation/rest/handlers/type.go new file mode 100644 index 000000000..1e4457175 --- /dev/null +++ b/automation/rest/handlers/type.go @@ -0,0 +1,57 @@ +package handlers + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/rest/request" + "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/go-chi/chi" + "net/http" +) + +type ( + // Internal API interface + TypeAPI interface { + List(context.Context, *request.TypeList) (interface{}, error) + } + + // HTTP API interface + Type struct { + List func(http.ResponseWriter, *http.Request) + } +) + +func NewType(h TypeAPI) *Type { + return &Type{ + List: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewTypeList() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.List(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + } +} + +func (h Type) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Get("/types/", h.List) + }) +} diff --git a/automation/rest/handlers/workflow.go b/automation/rest/handlers/workflow.go new file mode 100644 index 000000000..fcd581873 --- /dev/null +++ b/automation/rest/handlers/workflow.go @@ -0,0 +1,171 @@ +package handlers + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/rest/request" + "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/go-chi/chi" + "net/http" +) + +type ( + // Internal API interface + WorkflowAPI interface { + List(context.Context, *request.WorkflowList) (interface{}, error) + Create(context.Context, *request.WorkflowCreate) (interface{}, error) + Update(context.Context, *request.WorkflowUpdate) (interface{}, error) + Read(context.Context, *request.WorkflowRead) (interface{}, error) + Delete(context.Context, *request.WorkflowDelete) (interface{}, error) + Undelete(context.Context, *request.WorkflowUndelete) (interface{}, error) + Test(context.Context, *request.WorkflowTest) (interface{}, error) + } + + // HTTP API interface + Workflow struct { + List func(http.ResponseWriter, *http.Request) + Create func(http.ResponseWriter, *http.Request) + Update func(http.ResponseWriter, *http.Request) + Read func(http.ResponseWriter, *http.Request) + Delete func(http.ResponseWriter, *http.Request) + Undelete func(http.ResponseWriter, *http.Request) + Test func(http.ResponseWriter, *http.Request) + } +) + +func NewWorkflow(h WorkflowAPI) *Workflow { + return &Workflow{ + List: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWorkflowList() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.List(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Create: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWorkflowCreate() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Create(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Update: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWorkflowUpdate() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Update(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Read: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWorkflowRead() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Read(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Delete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWorkflowDelete() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Delete(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Undelete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWorkflowUndelete() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Undelete(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Test: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewWorkflowTest() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Test(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + } +} + +func (h Workflow) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Get("/workflows/", h.List) + r.Post("/workflows/", h.Create) + r.Put("/workflows/{workflowID}", h.Update) + r.Get("/workflows/{workflowID}", h.Read) + r.Delete("/workflows/{workflowID}", h.Delete) + r.Post("/workflows/{workflowID}/undelete", h.Undelete) + r.Post("/workflows/{workflowID}/test", h.Test) + }) +} diff --git a/automation/rest/permissions.go b/automation/rest/permissions.go new file mode 100644 index 000000000..08dc6973c --- /dev/null +++ b/automation/rest/permissions.go @@ -0,0 +1,67 @@ +package rest + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/api" + + "github.com/cortezaproject/corteza-server/automation/rest/request" + "github.com/cortezaproject/corteza-server/automation/service" + "github.com/cortezaproject/corteza-server/pkg/rbac" +) + +type ( + Permissions struct { + ac permissionsAccessController + } + + permissionsAccessController interface { + Effective(context.Context) rbac.EffectiveSet + Whitelist() rbac.Whitelist + FindRulesByRoleID(context.Context, uint64) (rbac.RuleSet, error) + Grant(ctx context.Context, rr ...*rbac.Rule) error + } +) + +func (Permissions) New() *Permissions { + return &Permissions{ + ac: service.DefaultAccessControl, + } +} + +func (ctrl Permissions) Effective(ctx context.Context, r *request.PermissionsEffective) (interface{}, error) { + return ctrl.ac.Effective(ctx), nil +} + +func (ctrl Permissions) List(ctx context.Context, r *request.PermissionsList) (interface{}, error) { + return ctrl.ac.Whitelist().Flatten(), nil +} + +func (ctrl Permissions) Read(ctx context.Context, r *request.PermissionsRead) (interface{}, error) { + return ctrl.ac.FindRulesByRoleID(ctx, r.RoleID) +} + +func (ctrl Permissions) Delete(ctx context.Context, r *request.PermissionsDelete) (interface{}, error) { + rr, err := ctrl.ac.FindRulesByRoleID(ctx, r.RoleID) + if err != nil { + return nil, err + } + + _ = rr.Walk(func(rule *rbac.Rule) error { + // Setting access to "inherit" will make Grant remove the rule + rule.Access = rbac.Inherit + return nil + }) + + return api.OK(), ctrl.ac.Grant(ctx, rr...) +} + +func (ctrl Permissions) Update(ctx context.Context, r *request.PermissionsUpdate) (interface{}, error) { + rr := r.Rules + _ = rr.Walk(func(rule *rbac.Rule) error { + // Make sure everything is properly set + rule.RoleID = r.RoleID + return nil + }) + + return api.OK(), ctrl.ac.Grant(ctx, rr...) +} diff --git a/automation/rest/request/function.go b/automation/rest/request/function.go new file mode 100644 index 000000000..b7b462c90 --- /dev/null +++ b/automation/rest/request/function.go @@ -0,0 +1,60 @@ +package request + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// + +import ( + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/payload" + "github.com/go-chi/chi" + "io" + "mime/multipart" + "net/http" + "strings" +) + +// dummy vars to prevent +// unused imports complain +var ( + _ = chi.URLParam + _ = multipart.ErrMessageTooLarge + _ = payload.ParseUint64s +) + +type ( + // Internal API interface + FunctionList struct { + } +) + +// NewFunctionList request +func NewFunctionList() *FunctionList { + return &FunctionList{} +} + +// Auditable returns all auditable/loggable parameters +func (r FunctionList) Auditable() map[string]interface{} { + return map[string]interface{}{} +} + +// Fill processes request and fills internal variables +func (r *FunctionList) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + return err +} diff --git a/automation/rest/request/permissions.go b/automation/rest/request/permissions.go new file mode 100644 index 000000000..bbee21dd7 --- /dev/null +++ b/automation/rest/request/permissions.go @@ -0,0 +1,295 @@ +package request + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// + +import ( + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/payload" + "github.com/cortezaproject/corteza-server/pkg/rbac" + "github.com/go-chi/chi" + "io" + "mime/multipart" + "net/http" + "strings" +) + +// dummy vars to prevent +// unused imports complain +var ( + _ = chi.URLParam + _ = multipart.ErrMessageTooLarge + _ = payload.ParseUint64s +) + +type ( + // Internal API interface + PermissionsList struct { + } + + PermissionsEffective struct { + // Resource GET parameter + // + // Show only rules for a specific resource + Resource string + } + + PermissionsRead struct { + // RoleID PATH parameter + // + // Role ID + RoleID uint64 `json:",string"` + } + + PermissionsDelete struct { + // RoleID PATH parameter + // + // Role ID + RoleID uint64 `json:",string"` + } + + PermissionsUpdate struct { + // RoleID PATH parameter + // + // Role ID + RoleID uint64 `json:",string"` + + // Rules POST parameter + // + // List of permission rules to set + Rules rbac.RuleSet + } +) + +// NewPermissionsList request +func NewPermissionsList() *PermissionsList { + return &PermissionsList{} +} + +// Auditable returns all auditable/loggable parameters +func (r PermissionsList) Auditable() map[string]interface{} { + return map[string]interface{}{} +} + +// Fill processes request and fills internal variables +func (r *PermissionsList) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + return err +} + +// NewPermissionsEffective request +func NewPermissionsEffective() *PermissionsEffective { + return &PermissionsEffective{} +} + +// Auditable returns all auditable/loggable parameters +func (r PermissionsEffective) Auditable() map[string]interface{} { + return map[string]interface{}{ + "resource": r.Resource, + } +} + +// Auditable returns all auditable/loggable parameters +func (r PermissionsEffective) GetResource() string { + return r.Resource +} + +// Fill processes request and fills internal variables +func (r *PermissionsEffective) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + // GET params + tmp := req.URL.Query() + + if val, ok := tmp["resource"]; ok && len(val) > 0 { + r.Resource, err = val[0], nil + if err != nil { + return err + } + } + } + + return err +} + +// NewPermissionsRead request +func NewPermissionsRead() *PermissionsRead { + return &PermissionsRead{} +} + +// Auditable returns all auditable/loggable parameters +func (r PermissionsRead) Auditable() map[string]interface{} { + return map[string]interface{}{ + "roleID": r.RoleID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r PermissionsRead) GetRoleID() uint64 { + return r.RoleID +} + +// Fill processes request and fills internal variables +func (r *PermissionsRead) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "roleID") + r.RoleID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewPermissionsDelete request +func NewPermissionsDelete() *PermissionsDelete { + return &PermissionsDelete{} +} + +// Auditable returns all auditable/loggable parameters +func (r PermissionsDelete) Auditable() map[string]interface{} { + return map[string]interface{}{ + "roleID": r.RoleID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r PermissionsDelete) GetRoleID() uint64 { + return r.RoleID +} + +// Fill processes request and fills internal variables +func (r *PermissionsDelete) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "roleID") + r.RoleID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewPermissionsUpdate request +func NewPermissionsUpdate() *PermissionsUpdate { + return &PermissionsUpdate{} +} + +// Auditable returns all auditable/loggable parameters +func (r PermissionsUpdate) Auditable() map[string]interface{} { + return map[string]interface{}{ + "roleID": r.RoleID, + "rules": r.Rules, + } +} + +// Auditable returns all auditable/loggable parameters +func (r PermissionsUpdate) GetRoleID() uint64 { + return r.RoleID +} + +// Auditable returns all auditable/loggable parameters +func (r PermissionsUpdate) GetRules() rbac.RuleSet { + return r.Rules +} + +// Fill processes request and fills internal variables +func (r *PermissionsUpdate) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + //if val, ok := req.Form["rules[]"]; ok && len(val) > 0 { + // r.Rules, err = rbac.RuleSet(val), nil + // if err != nil { + // return err + // } + //} + } + + { + var val string + // path params + + val = chi.URLParam(req, "roleID") + r.RoleID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} diff --git a/automation/rest/request/session.go b/automation/rest/request/session.go new file mode 100644 index 000000000..61184798a --- /dev/null +++ b/automation/rest/request/session.go @@ -0,0 +1,488 @@ +package request + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// + +import ( + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/payload" + "github.com/go-chi/chi" + "io" + "mime/multipart" + "net/http" + "strings" +) + +// dummy vars to prevent +// unused imports complain +var ( + _ = chi.URLParam + _ = multipart.ErrMessageTooLarge + _ = payload.ParseUint64s +) + +type ( + // Internal API interface + SessionList struct { + // SessionID GET parameter + // + // Filter by session ID + SessionID []string + + // WorkflowID GET parameter + // + // Filter by workflow ID + WorkflowID []string + + // Completed GET parameter + // + // Exclude (0, default), include (1) or return only (2) completed sessions + Completed uint + + // Suspended GET parameter + // + // Exclude (0, default), include (1) or return only (2) suspended sessions + Suspended uint + + // EventType GET parameter + // + // Filter sessions by event type + EventType string + + // ResourceType GET parameter + // + // Filter sessions by resource type + ResourceType string + + // Limit GET parameter + // + // Limit + Limit uint + + // PageCursor GET parameter + // + // Page cursor + PageCursor string + + // Sort GET parameter + // + // Sort items + Sort string + } + + SessionRead struct { + // SessionID PATH parameter + // + // Session ID + SessionID uint64 `json:",string"` + } + + SessionTrace struct { + // SessionID PATH parameter + // + // Session ID + SessionID uint64 `json:",string"` + } + + SessionDelete struct { + // SessionID PATH parameter + // + // Session ID + SessionID uint64 `json:",string"` + } + + SessionResume struct { + // SessionID PATH parameter + // + // Session ID + SessionID uint64 `json:",string"` + + // StateID POST parameter + // + // State ID + StateID uint64 `json:",string"` + + // Input POST parameter + // + // Workflow meta data + Input *expr.Vars + } +) + +// NewSessionList request +func NewSessionList() *SessionList { + return &SessionList{} +} + +// Auditable returns all auditable/loggable parameters +func (r SessionList) Auditable() map[string]interface{} { + return map[string]interface{}{ + "sessionID": r.SessionID, + "workflowID": r.WorkflowID, + "completed": r.Completed, + "suspended": r.Suspended, + "eventType": r.EventType, + "resourceType": r.ResourceType, + "limit": r.Limit, + "pageCursor": r.PageCursor, + "sort": r.Sort, + } +} + +// Auditable returns all auditable/loggable parameters +func (r SessionList) GetSessionID() []string { + return r.SessionID +} + +// Auditable returns all auditable/loggable parameters +func (r SessionList) GetWorkflowID() []string { + return r.WorkflowID +} + +// Auditable returns all auditable/loggable parameters +func (r SessionList) GetCompleted() uint { + return r.Completed +} + +// Auditable returns all auditable/loggable parameters +func (r SessionList) GetSuspended() uint { + return r.Suspended +} + +// Auditable returns all auditable/loggable parameters +func (r SessionList) GetEventType() string { + return r.EventType +} + +// Auditable returns all auditable/loggable parameters +func (r SessionList) GetResourceType() string { + return r.ResourceType +} + +// Auditable returns all auditable/loggable parameters +func (r SessionList) GetLimit() uint { + return r.Limit +} + +// Auditable returns all auditable/loggable parameters +func (r SessionList) GetPageCursor() string { + return r.PageCursor +} + +// Auditable returns all auditable/loggable parameters +func (r SessionList) GetSort() string { + return r.Sort +} + +// Fill processes request and fills internal variables +func (r *SessionList) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + // GET params + tmp := req.URL.Query() + + if val, ok := tmp["sessionID[]"]; ok { + r.SessionID, err = val, nil + if err != nil { + return err + } + } else if val, ok := tmp["sessionID"]; ok { + r.SessionID, err = val, nil + if err != nil { + return err + } + } + if val, ok := tmp["workflowID[]"]; ok { + r.WorkflowID, err = val, nil + if err != nil { + return err + } + } else if val, ok := tmp["workflowID"]; ok { + r.WorkflowID, err = val, nil + if err != nil { + return err + } + } + if val, ok := tmp["completed"]; ok && len(val) > 0 { + r.Completed, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["suspended"]; ok && len(val) > 0 { + r.Suspended, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["eventType"]; ok && len(val) > 0 { + r.EventType, err = val[0], nil + if err != nil { + return err + } + } + if val, ok := tmp["resourceType"]; ok && len(val) > 0 { + r.ResourceType, err = val[0], nil + if err != nil { + return err + } + } + if val, ok := tmp["limit"]; ok && len(val) > 0 { + r.Limit, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["pageCursor"]; ok && len(val) > 0 { + r.PageCursor, err = val[0], nil + if err != nil { + return err + } + } + if val, ok := tmp["sort"]; ok && len(val) > 0 { + r.Sort, err = val[0], nil + if err != nil { + return err + } + } + } + + return err +} + +// NewSessionRead request +func NewSessionRead() *SessionRead { + return &SessionRead{} +} + +// Auditable returns all auditable/loggable parameters +func (r SessionRead) Auditable() map[string]interface{} { + return map[string]interface{}{ + "sessionID": r.SessionID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r SessionRead) GetSessionID() uint64 { + return r.SessionID +} + +// Fill processes request and fills internal variables +func (r *SessionRead) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "sessionID") + r.SessionID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewSessionTrace request +func NewSessionTrace() *SessionTrace { + return &SessionTrace{} +} + +// Auditable returns all auditable/loggable parameters +func (r SessionTrace) Auditable() map[string]interface{} { + return map[string]interface{}{ + "sessionID": r.SessionID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r SessionTrace) GetSessionID() uint64 { + return r.SessionID +} + +// Fill processes request and fills internal variables +func (r *SessionTrace) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "sessionID") + r.SessionID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewSessionDelete request +func NewSessionDelete() *SessionDelete { + return &SessionDelete{} +} + +// Auditable returns all auditable/loggable parameters +func (r SessionDelete) Auditable() map[string]interface{} { + return map[string]interface{}{ + "sessionID": r.SessionID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r SessionDelete) GetSessionID() uint64 { + return r.SessionID +} + +// Fill processes request and fills internal variables +func (r *SessionDelete) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "sessionID") + r.SessionID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewSessionResume request +func NewSessionResume() *SessionResume { + return &SessionResume{} +} + +// Auditable returns all auditable/loggable parameters +func (r SessionResume) Auditable() map[string]interface{} { + return map[string]interface{}{ + "sessionID": r.SessionID, + "stateID": r.StateID, + "input": r.Input, + } +} + +// Auditable returns all auditable/loggable parameters +func (r SessionResume) GetSessionID() uint64 { + return r.SessionID +} + +// Auditable returns all auditable/loggable parameters +func (r SessionResume) GetStateID() uint64 { + return r.StateID +} + +// Auditable returns all auditable/loggable parameters +func (r SessionResume) GetInput() *expr.Vars { + return r.Input +} + +// Fill processes request and fills internal variables +func (r *SessionResume) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + if val, ok := req.Form["stateID"]; ok && len(val) > 0 { + r.StateID, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["input[]"]; ok { + r.Input, err = types.ParseWorkflowVariables(val) + if err != nil { + return err + } + } else if val, ok := req.Form["input"]; ok { + r.Input, err = types.ParseWorkflowVariables(val) + if err != nil { + return err + } + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "sessionID") + r.SessionID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} diff --git a/automation/rest/request/trigger.go b/automation/rest/request/trigger.go new file mode 100644 index 000000000..113a2de01 --- /dev/null +++ b/automation/rest/request/trigger.go @@ -0,0 +1,924 @@ +package request + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// + +import ( + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/label" + "github.com/cortezaproject/corteza-server/pkg/payload" + "github.com/go-chi/chi" + "io" + "mime/multipart" + "net/http" + "strings" +) + +// dummy vars to prevent +// unused imports complain +var ( + _ = chi.URLParam + _ = multipart.ErrMessageTooLarge + _ = payload.ParseUint64s +) + +type ( + // Internal API interface + TriggerList struct { + // TriggerID GET parameter + // + // Filter by trigger ID + TriggerID []string + + // WorkflowID GET parameter + // + // Filter by workflow ID + WorkflowID []string + + // Deleted GET parameter + // + // Exclude (0, default), include (1) or return only (2) deleted triggers + Deleted uint + + // Disabled GET parameter + // + // Exclude (0, default), include (1) or return only (2) disabled triggers + Disabled uint + + // EventType GET parameter + // + // Filter triggers by event type + EventType string + + // ResourceType GET parameter + // + // Filter triggers by resource type + ResourceType string + + // Query GET parameter + // + // Filter workflows, + Query string + + // Labels GET parameter + // + // Labels + Labels map[string]string + + // Limit GET parameter + // + // Limit + Limit uint + + // PageCursor GET parameter + // + // Page cursor + PageCursor string + + // Sort GET parameter + // + // Sort items + Sort string + } + + TriggerCreate struct { + // EventType POST parameter + // + // Event type + EventType string + + // ResourceType POST parameter + // + // Resource type + ResourceType string + + // Enabled POST parameter + // + // Is trigger enabled + Enabled bool + + // WorkflowID POST parameter + // + // Workflow to be triggered + WorkflowID uint64 `json:",string"` + + // WorkflowStepID POST parameter + // + // Start workflow in a specific step + WorkflowStepID uint64 `json:",string"` + + // Input POST parameter + // + // Workflow meta data + Input *expr.Vars + + // Labels POST parameter + // + // Labels + Labels map[string]string + + // Meta POST parameter + // + // Trigger meta data + Meta *types.TriggerMeta + + // Constraints POST parameter + // + // Workflow steps definition + Constraints types.TriggerConstraintSet + + // OwnedBy POST parameter + // + // Owner of the trigger + OwnedBy uint64 `json:",string"` + } + + TriggerUpdate struct { + // TriggerID PATH parameter + // + // Trigger ID + TriggerID uint64 `json:",string"` + + // EventType POST parameter + // + // Event type + EventType string + + // ResourceType POST parameter + // + // Resource type + ResourceType string + + // Enabled POST parameter + // + // Is trigger enabled + Enabled bool + + // WorkflowID POST parameter + // + // Workflow to be triggered + WorkflowID uint64 `json:",string"` + + // WorkflowStepID POST parameter + // + // Start workflow in a specific step + WorkflowStepID uint64 `json:",string"` + + // Input POST parameter + // + // Workflow meta data + Input *expr.Vars + + // Labels POST parameter + // + // Labels + Labels map[string]string + + // Meta POST parameter + // + // Trigger meta data + Meta *types.TriggerMeta + + // Constraints POST parameter + // + // Workflow steps definition + Constraints types.TriggerConstraintSet + + // OwnedBy POST parameter + // + // Owner of the trigger + OwnedBy uint64 `json:",string"` + } + + TriggerRead struct { + // TriggerID PATH parameter + // + // Trigger ID + TriggerID uint64 `json:",string"` + } + + TriggerDelete struct { + // TriggerID PATH parameter + // + // Trigger ID + TriggerID uint64 `json:",string"` + } + + TriggerUndelete struct { + // TriggerID PATH parameter + // + // Trigger ID + TriggerID uint64 `json:",string"` + } +) + +// NewTriggerList request +func NewTriggerList() *TriggerList { + return &TriggerList{} +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerList) Auditable() map[string]interface{} { + return map[string]interface{}{ + "triggerID": r.TriggerID, + "workflowID": r.WorkflowID, + "deleted": r.Deleted, + "disabled": r.Disabled, + "eventType": r.EventType, + "resourceType": r.ResourceType, + "query": r.Query, + "labels": r.Labels, + "limit": r.Limit, + "pageCursor": r.PageCursor, + "sort": r.Sort, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerList) GetTriggerID() []string { + return r.TriggerID +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerList) GetWorkflowID() []string { + return r.WorkflowID +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerList) GetDeleted() uint { + return r.Deleted +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerList) GetDisabled() uint { + return r.Disabled +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerList) GetEventType() string { + return r.EventType +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerList) GetResourceType() string { + return r.ResourceType +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerList) GetQuery() string { + return r.Query +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerList) GetLabels() map[string]string { + return r.Labels +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerList) GetLimit() uint { + return r.Limit +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerList) GetPageCursor() string { + return r.PageCursor +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerList) GetSort() string { + return r.Sort +} + +// Fill processes request and fills internal variables +func (r *TriggerList) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + // GET params + tmp := req.URL.Query() + + if val, ok := tmp["triggerID[]"]; ok { + r.TriggerID, err = val, nil + if err != nil { + return err + } + } else if val, ok := tmp["triggerID"]; ok { + r.TriggerID, err = val, nil + if err != nil { + return err + } + } + if val, ok := tmp["workflowID[]"]; ok { + r.WorkflowID, err = val, nil + if err != nil { + return err + } + } else if val, ok := tmp["workflowID"]; ok { + r.WorkflowID, err = val, nil + if err != nil { + return err + } + } + if val, ok := tmp["deleted"]; ok && len(val) > 0 { + r.Deleted, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["disabled"]; ok && len(val) > 0 { + r.Disabled, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["eventType"]; ok && len(val) > 0 { + r.EventType, err = val[0], nil + if err != nil { + return err + } + } + if val, ok := tmp["resourceType"]; ok && len(val) > 0 { + r.ResourceType, err = val[0], nil + if err != nil { + return err + } + } + if val, ok := tmp["query"]; ok && len(val) > 0 { + r.Query, err = val[0], nil + if err != nil { + return err + } + } + if val, ok := tmp["labels[]"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } else if val, ok := tmp["labels"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } + if val, ok := tmp["limit"]; ok && len(val) > 0 { + r.Limit, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["pageCursor"]; ok && len(val) > 0 { + r.PageCursor, err = val[0], nil + if err != nil { + return err + } + } + if val, ok := tmp["sort"]; ok && len(val) > 0 { + r.Sort, err = val[0], nil + if err != nil { + return err + } + } + } + + return err +} + +// NewTriggerCreate request +func NewTriggerCreate() *TriggerCreate { + return &TriggerCreate{} +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerCreate) Auditable() map[string]interface{} { + return map[string]interface{}{ + "eventType": r.EventType, + "resourceType": r.ResourceType, + "enabled": r.Enabled, + "workflowID": r.WorkflowID, + "workflowStepID": r.WorkflowStepID, + "input": r.Input, + "labels": r.Labels, + "meta": r.Meta, + "constraints": r.Constraints, + "ownedBy": r.OwnedBy, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerCreate) GetEventType() string { + return r.EventType +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerCreate) GetResourceType() string { + return r.ResourceType +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerCreate) GetEnabled() bool { + return r.Enabled +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerCreate) GetWorkflowID() uint64 { + return r.WorkflowID +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerCreate) GetWorkflowStepID() uint64 { + return r.WorkflowStepID +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerCreate) GetInput() *expr.Vars { + return r.Input +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerCreate) GetLabels() map[string]string { + return r.Labels +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerCreate) GetMeta() *types.TriggerMeta { + return r.Meta +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerCreate) GetConstraints() types.TriggerConstraintSet { + return r.Constraints +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerCreate) GetOwnedBy() uint64 { + return r.OwnedBy +} + +// Fill processes request and fills internal variables +func (r *TriggerCreate) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + if val, ok := req.Form["eventType"]; ok && len(val) > 0 { + r.EventType, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["resourceType"]; ok && len(val) > 0 { + r.ResourceType, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["enabled"]; ok && len(val) > 0 { + r.Enabled, err = payload.ParseBool(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["workflowID"]; ok && len(val) > 0 { + r.WorkflowID, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["workflowStepID"]; ok && len(val) > 0 { + r.WorkflowStepID, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["input[]"]; ok { + r.Input, err = types.ParseWorkflowVariables(val) + if err != nil { + return err + } + } else if val, ok := req.Form["input"]; ok { + r.Input, err = types.ParseWorkflowVariables(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["labels[]"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } else if val, ok := req.Form["labels"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["meta[]"]; ok { + r.Meta, err = types.ParseTriggerMeta(val) + if err != nil { + return err + } + } else if val, ok := req.Form["meta"]; ok { + r.Meta, err = types.ParseTriggerMeta(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["constraints[]"]; ok { + r.Constraints, err = types.ParseTriggerConstraintSet(val) + if err != nil { + return err + } + } else if val, ok := req.Form["constraints"]; ok { + r.Constraints, err = types.ParseTriggerConstraintSet(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["ownedBy"]; ok && len(val) > 0 { + r.OwnedBy, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + } + + return err +} + +// NewTriggerUpdate request +func NewTriggerUpdate() *TriggerUpdate { + return &TriggerUpdate{} +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUpdate) Auditable() map[string]interface{} { + return map[string]interface{}{ + "triggerID": r.TriggerID, + "eventType": r.EventType, + "resourceType": r.ResourceType, + "enabled": r.Enabled, + "workflowID": r.WorkflowID, + "workflowStepID": r.WorkflowStepID, + "input": r.Input, + "labels": r.Labels, + "meta": r.Meta, + "constraints": r.Constraints, + "ownedBy": r.OwnedBy, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUpdate) GetTriggerID() uint64 { + return r.TriggerID +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUpdate) GetEventType() string { + return r.EventType +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUpdate) GetResourceType() string { + return r.ResourceType +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUpdate) GetEnabled() bool { + return r.Enabled +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUpdate) GetWorkflowID() uint64 { + return r.WorkflowID +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUpdate) GetWorkflowStepID() uint64 { + return r.WorkflowStepID +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUpdate) GetInput() *expr.Vars { + return r.Input +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUpdate) GetLabels() map[string]string { + return r.Labels +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUpdate) GetMeta() *types.TriggerMeta { + return r.Meta +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUpdate) GetConstraints() types.TriggerConstraintSet { + return r.Constraints +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUpdate) GetOwnedBy() uint64 { + return r.OwnedBy +} + +// Fill processes request and fills internal variables +func (r *TriggerUpdate) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + if val, ok := req.Form["eventType"]; ok && len(val) > 0 { + r.EventType, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["resourceType"]; ok && len(val) > 0 { + r.ResourceType, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["enabled"]; ok && len(val) > 0 { + r.Enabled, err = payload.ParseBool(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["workflowID"]; ok && len(val) > 0 { + r.WorkflowID, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["workflowStepID"]; ok && len(val) > 0 { + r.WorkflowStepID, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["input[]"]; ok { + r.Input, err = types.ParseWorkflowVariables(val) + if err != nil { + return err + } + } else if val, ok := req.Form["input"]; ok { + r.Input, err = types.ParseWorkflowVariables(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["labels[]"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } else if val, ok := req.Form["labels"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["meta[]"]; ok { + r.Meta, err = types.ParseTriggerMeta(val) + if err != nil { + return err + } + } else if val, ok := req.Form["meta"]; ok { + r.Meta, err = types.ParseTriggerMeta(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["constraints[]"]; ok { + r.Constraints, err = types.ParseTriggerConstraintSet(val) + if err != nil { + return err + } + } else if val, ok := req.Form["constraints"]; ok { + r.Constraints, err = types.ParseTriggerConstraintSet(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["ownedBy"]; ok && len(val) > 0 { + r.OwnedBy, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "triggerID") + r.TriggerID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewTriggerRead request +func NewTriggerRead() *TriggerRead { + return &TriggerRead{} +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerRead) Auditable() map[string]interface{} { + return map[string]interface{}{ + "triggerID": r.TriggerID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerRead) GetTriggerID() uint64 { + return r.TriggerID +} + +// Fill processes request and fills internal variables +func (r *TriggerRead) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "triggerID") + r.TriggerID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewTriggerDelete request +func NewTriggerDelete() *TriggerDelete { + return &TriggerDelete{} +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerDelete) Auditable() map[string]interface{} { + return map[string]interface{}{ + "triggerID": r.TriggerID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerDelete) GetTriggerID() uint64 { + return r.TriggerID +} + +// Fill processes request and fills internal variables +func (r *TriggerDelete) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "triggerID") + r.TriggerID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewTriggerUndelete request +func NewTriggerUndelete() *TriggerUndelete { + return &TriggerUndelete{} +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUndelete) Auditable() map[string]interface{} { + return map[string]interface{}{ + "triggerID": r.TriggerID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r TriggerUndelete) GetTriggerID() uint64 { + return r.TriggerID +} + +// Fill processes request and fills internal variables +func (r *TriggerUndelete) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "triggerID") + r.TriggerID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} diff --git a/automation/rest/request/type.go b/automation/rest/request/type.go new file mode 100644 index 000000000..528db3d47 --- /dev/null +++ b/automation/rest/request/type.go @@ -0,0 +1,60 @@ +package request + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// + +import ( + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/payload" + "github.com/go-chi/chi" + "io" + "mime/multipart" + "net/http" + "strings" +) + +// dummy vars to prevent +// unused imports complain +var ( + _ = chi.URLParam + _ = multipart.ErrMessageTooLarge + _ = payload.ParseUint64s +) + +type ( + // Internal API interface + TypeList struct { + } +) + +// NewTypeList request +func NewTypeList() *TypeList { + return &TypeList{} +} + +// Auditable returns all auditable/loggable parameters +func (r TypeList) Auditable() map[string]interface{} { + return map[string]interface{}{} +} + +// Fill processes request and fills internal variables +func (r *TypeList) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + return err +} diff --git a/automation/rest/request/workflow.go b/automation/rest/request/workflow.go new file mode 100644 index 000000000..1de80b991 --- /dev/null +++ b/automation/rest/request/workflow.go @@ -0,0 +1,1015 @@ +package request + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// + +import ( + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/label" + "github.com/cortezaproject/corteza-server/pkg/payload" + "github.com/go-chi/chi" + "io" + "mime/multipart" + "net/http" + "strings" +) + +// dummy vars to prevent +// unused imports complain +var ( + _ = chi.URLParam + _ = multipart.ErrMessageTooLarge + _ = payload.ParseUint64s +) + +type ( + // Internal API interface + WorkflowList struct { + // WorkflowID GET parameter + // + // Filter by workflow ID + WorkflowID []string + + // Query GET parameter + // + // Filter workflows + Query string + + // Deleted GET parameter + // + // Exclude (0, default), include (1) or return only (2) deleted workflows + Deleted uint + + // Disabled GET parameter + // + // Exclude (0, default), include (1) or return only (2) disabled workflows + Disabled uint + + // Labels GET parameter + // + // Labels + Labels map[string]string + + // Limit GET parameter + // + // Limit + Limit uint + + // PageCursor GET parameter + // + // Page cursor + PageCursor string + + // Sort GET parameter + // + // Sort items + Sort string + } + + WorkflowCreate struct { + // Handle POST parameter + // + // Workflow name + Handle string + + // Labels POST parameter + // + // Labels + Labels map[string]string + + // Meta POST parameter + // + // Workflow meta data + Meta *types.WorkflowMeta + + // Enabled POST parameter + // + // Is workflow enabled + Enabled bool + + // Trace POST parameter + // + // Trace workflow execution + Trace bool + + // KeepSessions POST parameter + // + // Keep old workflow sessions + KeepSessions int + + // Scope POST parameter + // + // Workflow meta data + Scope *expr.Vars + + // Steps POST parameter + // + // Workflow steps definition + Steps types.WorkflowStepSet + + // Paths POST parameter + // + // Workflow step paths definition + Paths types.WorkflowPathSet + + // RunAs POST parameter + // + // Is workflow enabled + RunAs uint64 `json:",string"` + + // OwnedBy POST parameter + // + // Owner of the workflow + OwnedBy uint64 `json:",string"` + } + + WorkflowUpdate struct { + // WorkflowID PATH parameter + // + // Workflow ID + WorkflowID uint64 `json:",string"` + + // Handle POST parameter + // + // Workflow name + Handle string + + // Labels POST parameter + // + // Labels + Labels map[string]string + + // Meta POST parameter + // + // Workflow meta data + Meta *types.WorkflowMeta + + // Enabled POST parameter + // + // Is workflow enabled + Enabled bool + + // Trace POST parameter + // + // Trace workflow execution + Trace bool + + // KeepSessions POST parameter + // + // Keep old workflow sessions + KeepSessions int + + // Scope POST parameter + // + // Workflow meta data + Scope *expr.Vars + + // Steps POST parameter + // + // Workflow steps definition + Steps types.WorkflowStepSet + + // Paths POST parameter + // + // Workflow step paths definition + Paths types.WorkflowPathSet + + // RunAs POST parameter + // + // Is workflow enabled + RunAs uint64 `json:",string"` + + // OwnedBy POST parameter + // + // Owner of the workflow + OwnedBy uint64 `json:",string"` + } + + WorkflowRead struct { + // WorkflowID PATH parameter + // + // Workflow ID + WorkflowID uint64 `json:",string"` + } + + WorkflowDelete struct { + // WorkflowID PATH parameter + // + // Workflow ID + WorkflowID uint64 `json:",string"` + } + + WorkflowUndelete struct { + // WorkflowID PATH parameter + // + // Workflow ID + WorkflowID uint64 `json:",string"` + } + + WorkflowTest struct { + // WorkflowID PATH parameter + // + // Workflow ID + WorkflowID uint64 `json:",string"` + + // Scope POST parameter + // + // Workflow meta data + Scope *expr.Vars + + // RunAs POST parameter + // + // Is workflow enabled + RunAs bool + } +) + +// NewWorkflowList request +func NewWorkflowList() *WorkflowList { + return &WorkflowList{} +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowList) Auditable() map[string]interface{} { + return map[string]interface{}{ + "workflowID": r.WorkflowID, + "query": r.Query, + "deleted": r.Deleted, + "disabled": r.Disabled, + "labels": r.Labels, + "limit": r.Limit, + "pageCursor": r.PageCursor, + "sort": r.Sort, + } +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowList) GetWorkflowID() []string { + return r.WorkflowID +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowList) GetQuery() string { + return r.Query +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowList) GetDeleted() uint { + return r.Deleted +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowList) GetDisabled() uint { + return r.Disabled +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowList) GetLabels() map[string]string { + return r.Labels +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowList) GetLimit() uint { + return r.Limit +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowList) GetPageCursor() string { + return r.PageCursor +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowList) GetSort() string { + return r.Sort +} + +// Fill processes request and fills internal variables +func (r *WorkflowList) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + // GET params + tmp := req.URL.Query() + + if val, ok := tmp["workflowID[]"]; ok { + r.WorkflowID, err = val, nil + if err != nil { + return err + } + } else if val, ok := tmp["workflowID"]; ok { + r.WorkflowID, err = val, nil + if err != nil { + return err + } + } + if val, ok := tmp["query"]; ok && len(val) > 0 { + r.Query, err = val[0], nil + if err != nil { + return err + } + } + if val, ok := tmp["deleted"]; ok && len(val) > 0 { + r.Deleted, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["disabled"]; ok && len(val) > 0 { + r.Disabled, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["labels[]"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } else if val, ok := tmp["labels"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } + if val, ok := tmp["limit"]; ok && len(val) > 0 { + r.Limit, err = payload.ParseUint(val[0]), nil + if err != nil { + return err + } + } + if val, ok := tmp["pageCursor"]; ok && len(val) > 0 { + r.PageCursor, err = val[0], nil + if err != nil { + return err + } + } + if val, ok := tmp["sort"]; ok && len(val) > 0 { + r.Sort, err = val[0], nil + if err != nil { + return err + } + } + } + + return err +} + +// NewWorkflowCreate request +func NewWorkflowCreate() *WorkflowCreate { + return &WorkflowCreate{} +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowCreate) Auditable() map[string]interface{} { + return map[string]interface{}{ + "handle": r.Handle, + "labels": r.Labels, + "meta": r.Meta, + "enabled": r.Enabled, + "trace": r.Trace, + "keepSessions": r.KeepSessions, + "scope": r.Scope, + "steps": r.Steps, + "paths": r.Paths, + "runAs": r.RunAs, + "ownedBy": r.OwnedBy, + } +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowCreate) GetHandle() string { + return r.Handle +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowCreate) GetLabels() map[string]string { + return r.Labels +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowCreate) GetMeta() *types.WorkflowMeta { + return r.Meta +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowCreate) GetEnabled() bool { + return r.Enabled +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowCreate) GetTrace() bool { + return r.Trace +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowCreate) GetKeepSessions() int { + return r.KeepSessions +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowCreate) GetScope() *expr.Vars { + return r.Scope +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowCreate) GetSteps() types.WorkflowStepSet { + return r.Steps +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowCreate) GetPaths() types.WorkflowPathSet { + return r.Paths +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowCreate) GetRunAs() uint64 { + return r.RunAs +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowCreate) GetOwnedBy() uint64 { + return r.OwnedBy +} + +// Fill processes request and fills internal variables +func (r *WorkflowCreate) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + if val, ok := req.Form["handle"]; ok && len(val) > 0 { + r.Handle, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["labels[]"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } else if val, ok := req.Form["labels"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["meta[]"]; ok { + r.Meta, err = types.ParseWorkflowMeta(val) + if err != nil { + return err + } + } else if val, ok := req.Form["meta"]; ok { + r.Meta, err = types.ParseWorkflowMeta(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["enabled"]; ok && len(val) > 0 { + r.Enabled, err = payload.ParseBool(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["trace"]; ok && len(val) > 0 { + r.Trace, err = payload.ParseBool(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["keepSessions"]; ok && len(val) > 0 { + r.KeepSessions, err = payload.ParseInt(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["scope[]"]; ok { + r.Scope, err = types.ParseWorkflowVariables(val) + if err != nil { + return err + } + } else if val, ok := req.Form["scope"]; ok { + r.Scope, err = types.ParseWorkflowVariables(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["steps[]"]; ok { + r.Steps, err = types.ParseWorkflowStepSet(val) + if err != nil { + return err + } + } else if val, ok := req.Form["steps"]; ok { + r.Steps, err = types.ParseWorkflowStepSet(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["paths[]"]; ok { + r.Paths, err = types.ParseWorkflowPathSet(val) + if err != nil { + return err + } + } else if val, ok := req.Form["paths"]; ok { + r.Paths, err = types.ParseWorkflowPathSet(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["runAs"]; ok && len(val) > 0 { + r.RunAs, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["ownedBy"]; ok && len(val) > 0 { + r.OwnedBy, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + } + + return err +} + +// NewWorkflowUpdate request +func NewWorkflowUpdate() *WorkflowUpdate { + return &WorkflowUpdate{} +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) Auditable() map[string]interface{} { + return map[string]interface{}{ + "workflowID": r.WorkflowID, + "handle": r.Handle, + "labels": r.Labels, + "meta": r.Meta, + "enabled": r.Enabled, + "trace": r.Trace, + "keepSessions": r.KeepSessions, + "scope": r.Scope, + "steps": r.Steps, + "paths": r.Paths, + "runAs": r.RunAs, + "ownedBy": r.OwnedBy, + } +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) GetWorkflowID() uint64 { + return r.WorkflowID +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) GetHandle() string { + return r.Handle +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) GetLabels() map[string]string { + return r.Labels +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) GetMeta() *types.WorkflowMeta { + return r.Meta +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) GetEnabled() bool { + return r.Enabled +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) GetTrace() bool { + return r.Trace +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) GetKeepSessions() int { + return r.KeepSessions +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) GetScope() *expr.Vars { + return r.Scope +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) GetSteps() types.WorkflowStepSet { + return r.Steps +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) GetPaths() types.WorkflowPathSet { + return r.Paths +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) GetRunAs() uint64 { + return r.RunAs +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUpdate) GetOwnedBy() uint64 { + return r.OwnedBy +} + +// Fill processes request and fills internal variables +func (r *WorkflowUpdate) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + if val, ok := req.Form["handle"]; ok && len(val) > 0 { + r.Handle, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["labels[]"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } else if val, ok := req.Form["labels"]; ok { + r.Labels, err = label.ParseStrings(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["meta[]"]; ok { + r.Meta, err = types.ParseWorkflowMeta(val) + if err != nil { + return err + } + } else if val, ok := req.Form["meta"]; ok { + r.Meta, err = types.ParseWorkflowMeta(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["enabled"]; ok && len(val) > 0 { + r.Enabled, err = payload.ParseBool(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["trace"]; ok && len(val) > 0 { + r.Trace, err = payload.ParseBool(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["keepSessions"]; ok && len(val) > 0 { + r.KeepSessions, err = payload.ParseInt(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["scope[]"]; ok { + r.Scope, err = types.ParseWorkflowVariables(val) + if err != nil { + return err + } + } else if val, ok := req.Form["scope"]; ok { + r.Scope, err = types.ParseWorkflowVariables(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["steps[]"]; ok { + r.Steps, err = types.ParseWorkflowStepSet(val) + if err != nil { + return err + } + } else if val, ok := req.Form["steps"]; ok { + r.Steps, err = types.ParseWorkflowStepSet(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["paths[]"]; ok { + r.Paths, err = types.ParseWorkflowPathSet(val) + if err != nil { + return err + } + } else if val, ok := req.Form["paths"]; ok { + r.Paths, err = types.ParseWorkflowPathSet(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["runAs"]; ok && len(val) > 0 { + r.RunAs, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + + if val, ok := req.Form["ownedBy"]; ok && len(val) > 0 { + r.OwnedBy, err = payload.ParseUint64(val[0]), nil + if err != nil { + return err + } + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "workflowID") + r.WorkflowID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewWorkflowRead request +func NewWorkflowRead() *WorkflowRead { + return &WorkflowRead{} +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowRead) Auditable() map[string]interface{} { + return map[string]interface{}{ + "workflowID": r.WorkflowID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowRead) GetWorkflowID() uint64 { + return r.WorkflowID +} + +// Fill processes request and fills internal variables +func (r *WorkflowRead) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "workflowID") + r.WorkflowID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewWorkflowDelete request +func NewWorkflowDelete() *WorkflowDelete { + return &WorkflowDelete{} +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowDelete) Auditable() map[string]interface{} { + return map[string]interface{}{ + "workflowID": r.WorkflowID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowDelete) GetWorkflowID() uint64 { + return r.WorkflowID +} + +// Fill processes request and fills internal variables +func (r *WorkflowDelete) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "workflowID") + r.WorkflowID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewWorkflowUndelete request +func NewWorkflowUndelete() *WorkflowUndelete { + return &WorkflowUndelete{} +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUndelete) Auditable() map[string]interface{} { + return map[string]interface{}{ + "workflowID": r.WorkflowID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowUndelete) GetWorkflowID() uint64 { + return r.WorkflowID +} + +// Fill processes request and fills internal variables +func (r *WorkflowUndelete) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "workflowID") + r.WorkflowID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewWorkflowTest request +func NewWorkflowTest() *WorkflowTest { + return &WorkflowTest{} +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowTest) Auditable() map[string]interface{} { + return map[string]interface{}{ + "workflowID": r.WorkflowID, + "scope": r.Scope, + "runAs": r.RunAs, + } +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowTest) GetWorkflowID() uint64 { + return r.WorkflowID +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowTest) GetScope() *expr.Vars { + return r.Scope +} + +// Auditable returns all auditable/loggable parameters +func (r WorkflowTest) GetRunAs() bool { + return r.RunAs +} + +// Fill processes request and fills internal variables +func (r *WorkflowTest) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + if val, ok := req.Form["scope[]"]; ok { + r.Scope, err = types.ParseWorkflowVariables(val) + if err != nil { + return err + } + } else if val, ok := req.Form["scope"]; ok { + r.Scope, err = types.ParseWorkflowVariables(val) + if err != nil { + return err + } + } + + if val, ok := req.Form["runAs"]; ok && len(val) > 0 { + r.RunAs, err = payload.ParseBool(val[0]), nil + if err != nil { + return err + } + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "workflowID") + r.WorkflowID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} diff --git a/automation/rest/router.go b/automation/rest/router.go new file mode 100644 index 000000000..8742d432b --- /dev/null +++ b/automation/rest/router.go @@ -0,0 +1,22 @@ +package rest + +import ( + "github.com/go-chi/chi" + + "github.com/cortezaproject/corteza-server/automation/rest/handlers" + "github.com/cortezaproject/corteza-server/pkg/auth" +) + +func MountRoutes(r chi.Router) { + // Protect all _private_ routes + r.Group(func(r chi.Router) { + r.Use(auth.MiddlewareValidOnly) + + handlers.NewWorkflow(Workflow{}.New()).MountRoutes(r) + handlers.NewTrigger(Trigger{}.New()).MountRoutes(r) + handlers.NewSession(Session{}.New()).MountRoutes(r) + handlers.NewFunction(Function{}.New()).MountRoutes(r) + handlers.NewType(Type{}.New()).MountRoutes(r) + handlers.NewPermissions(Permissions{}.New()).MountRoutes(r) + }) +} diff --git a/automation/rest/session.go b/automation/rest/session.go new file mode 100644 index 000000000..c7156d3c1 --- /dev/null +++ b/automation/rest/session.go @@ -0,0 +1,89 @@ +package rest + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/automation/rest/request" + "github.com/cortezaproject/corteza-server/automation/service" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/payload" +) + +type ( + Session struct { + svc interface { + Search(ctx context.Context, filter types.SessionFilter) (types.SessionSet, types.SessionFilter, error) + LookupByID(ctx context.Context, sessionID uint64) (*types.Session, error) + Resume(sessionID, stateID uint64, i auth.Identifiable, input *expr.Vars) error + } + } + + sessionSetPayload struct { + Filter types.SessionFilter `json:"filter"` + Set types.SessionSet `json:"set"` + } +) + +func (Session) New() *Session { + ctrl := &Session{} + ctrl.svc = service.DefaultSession + return ctrl +} + +func (ctrl Session) List(ctx context.Context, r *request.SessionList) (interface{}, error) { + var ( + err error + f = types.SessionFilter{ + WorkflowID: payload.ParseUint64s(r.WorkflowID), + SessionID: payload.ParseUint64s(r.SessionID), + EventType: r.EventType, + ResourceType: r.ResourceType, + Completed: filter.State(r.Completed), + Suspended: filter.State(r.Suspended), + } + ) + + if f.Paging, err = filter.NewPaging(r.Limit, r.PageCursor); err != nil { + return nil, err + } + + if f.Sorting, err = filter.NewSorting(r.Sort); err != nil { + return nil, err + } + + set, filter, err := ctrl.svc.Search(ctx, f) + return ctrl.makeFilterPayload(ctx, set, filter, err) +} + +func (ctrl Session) Read(ctx context.Context, r *request.SessionRead) (interface{}, error) { + return ctrl.svc.LookupByID(ctx, r.SessionID) +} + +func (ctrl Session) Delete(ctx context.Context, r *request.SessionDelete) (interface{}, error) { + return nil, fmt.Errorf("not implemented") + //return api.OK(), ctrl.svc.DeleteByID(ctx, r.SessionID) +} + +func (ctrl Session) Resume(ctx context.Context, r *request.SessionResume) (interface{}, error) { + return api.OK(), ctrl.svc.Resume(r.SessionID, r.StateID, auth.GetIdentityFromContext(ctx), r.Input) +} + +func (ctrl Session) Trace(ctx context.Context, trace *request.SessionTrace) (interface{}, error) { + return nil, fmt.Errorf("not implemented") +} + +func (ctrl Session) makeFilterPayload(ctx context.Context, uu types.SessionSet, f types.SessionFilter, err error) (*sessionSetPayload, error) { + if err != nil { + return nil, err + } + + if len(uu) == 0 { + uu = make([]*types.Session, 0) + } + + return &sessionSetPayload{Filter: f, Set: uu}, nil +} diff --git a/automation/rest/trigger.go b/automation/rest/trigger.go new file mode 100644 index 000000000..f5e808833 --- /dev/null +++ b/automation/rest/trigger.go @@ -0,0 +1,120 @@ +package rest + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/rest/request" + "github.com/cortezaproject/corteza-server/automation/service" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/payload" +) + +type ( + Trigger struct { + svc interface { + Search(ctx context.Context, filter types.TriggerFilter) (types.TriggerSet, types.TriggerFilter, error) + LookupByID(ctx context.Context, triggerID uint64) (*types.Trigger, error) + Create(ctx context.Context, new *types.Trigger) (*types.Trigger, error) + Update(ctx context.Context, upd *types.Trigger) (*types.Trigger, error) + DeleteByID(ctx context.Context, triggerID uint64) error + UndeleteByID(ctx context.Context, triggerID uint64) error + } + } + + triggerSetPayload struct { + Filter types.TriggerFilter `json:"filter"` + Set types.TriggerSet `json:"set"` + } +) + +func (Trigger) New() *Trigger { + ctrl := &Trigger{} + ctrl.svc = service.DefaultTrigger + return ctrl +} + +func (ctrl Trigger) List(ctx context.Context, r *request.TriggerList) (interface{}, error) { + var ( + err error + f = types.TriggerFilter{ + WorkflowID: payload.ParseUint64s(r.WorkflowID), + TriggerID: payload.ParseUint64s(r.TriggerID), + EventType: r.EventType, + ResourceType: r.ResourceType, + Labels: r.Labels, + Deleted: filter.State(r.Deleted), + Disabled: filter.State(r.Disabled), + } + ) + + if f.Paging, err = filter.NewPaging(r.Limit, r.PageCursor); err != nil { + return nil, err + } + + if f.Sorting, err = filter.NewSorting(r.Sort); err != nil { + return nil, err + } + + set, filter, err := ctrl.svc.Search(ctx, f) + return ctrl.makeFilterPayload(ctx, set, filter, err) +} + +func (ctrl Trigger) Create(ctx context.Context, r *request.TriggerCreate) (interface{}, error) { + trigger := &types.Trigger{ + Enabled: r.Enabled, + WorkflowID: r.WorkflowID, + StepID: r.WorkflowStepID, + ResourceType: r.ResourceType, + EventType: r.EventType, + Constraints: r.Constraints, + Input: r.Input, + Labels: r.Labels, + OwnedBy: r.OwnedBy, + Meta: r.Meta, + } + + return ctrl.svc.Create(ctx, trigger) +} + +func (ctrl Trigger) Update(ctx context.Context, r *request.TriggerUpdate) (interface{}, error) { + trigger := &types.Trigger{ + ID: r.TriggerID, + Enabled: r.Enabled, + WorkflowID: r.WorkflowID, + StepID: r.WorkflowStepID, + ResourceType: r.ResourceType, + EventType: r.EventType, + Constraints: r.Constraints, + Input: r.Input, + Labels: r.Labels, + OwnedBy: r.OwnedBy, + Meta: r.Meta, + } + + return ctrl.svc.Update(ctx, trigger) +} + +func (ctrl Trigger) Read(ctx context.Context, r *request.TriggerRead) (interface{}, error) { + return ctrl.svc.LookupByID(ctx, r.TriggerID) +} + +func (ctrl Trigger) Delete(ctx context.Context, r *request.TriggerDelete) (interface{}, error) { + return api.OK(), ctrl.svc.DeleteByID(ctx, r.TriggerID) +} + +func (ctrl Trigger) Undelete(ctx context.Context, r *request.TriggerUndelete) (interface{}, error) { + return api.OK(), ctrl.svc.UndeleteByID(ctx, r.TriggerID) +} + +func (ctrl Trigger) makeFilterPayload(ctx context.Context, uu types.TriggerSet, f types.TriggerFilter, err error) (*triggerSetPayload, error) { + if err != nil { + return nil, err + } + + if len(uu) == 0 { + uu = make([]*types.Trigger, 0) + } + + return &triggerSetPayload{Filter: f, Set: uu}, nil +} diff --git a/automation/rest/type.go b/automation/rest/type.go new file mode 100644 index 000000000..72d589ed5 --- /dev/null +++ b/automation/rest/type.go @@ -0,0 +1,28 @@ +package rest + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/rest/request" + "github.com/cortezaproject/corteza-server/automation/service" +) + +type ( + Type struct { + reg interface { + Types() []string + } + } + + typeSetPayload struct { + Set []string `json:"set"` + } +) + +func (Type) New() *Type { + ctrl := &Type{reg: service.Registry()} + return ctrl +} + +func (ctrl Type) List(_ context.Context, _ *request.TypeList) (interface{}, error) { + return typeSetPayload{Set: ctrl.reg.Types()}, nil +} diff --git a/automation/rest/workflow.go b/automation/rest/workflow.go new file mode 100644 index 000000000..594223d64 --- /dev/null +++ b/automation/rest/workflow.go @@ -0,0 +1,125 @@ +package rest + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/automation/rest/request" + "github.com/cortezaproject/corteza-server/automation/service" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/api" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/payload" +) + +type ( + Workflow struct { + svc interface { + Search(ctx context.Context, filter types.WorkflowFilter) (types.WorkflowSet, types.WorkflowFilter, error) + LookupByID(ctx context.Context, workflowID uint64) (*types.Workflow, error) + Create(ctx context.Context, new *types.Workflow) (*types.Workflow, error) + Update(ctx context.Context, upd *types.Workflow) (*types.Workflow, error) + DeleteByID(ctx context.Context, workflowID uint64) error + UndeleteByID(ctx context.Context, workflowID uint64) error + } + } + + workflowSetPayload struct { + Filter types.WorkflowFilter `json:"filter"` + Set types.WorkflowSet `json:"set"` + } +) + +func (Workflow) New() *Workflow { + ctrl := &Workflow{} + ctrl.svc = service.DefaultWorkflow + return ctrl +} + +func (ctrl Workflow) List(ctx context.Context, r *request.WorkflowList) (interface{}, error) { + var ( + err error + f = types.WorkflowFilter{ + WorkflowID: payload.ParseUint64s(r.WorkflowID), + Query: r.Query, + Labels: r.Labels, + Deleted: filter.State(r.Deleted), + Disabled: filter.State(r.Disabled), + } + ) + + if f.Paging, err = filter.NewPaging(r.Limit, r.PageCursor); err != nil { + return nil, err + } + + if f.Sorting, err = filter.NewSorting(r.Sort); err != nil { + return nil, err + } + + set, filter, err := ctrl.svc.Search(ctx, f) + return ctrl.makeFilterPayload(ctx, set, filter, err) +} + +func (ctrl Workflow) Create(ctx context.Context, r *request.WorkflowCreate) (interface{}, error) { + workflow := &types.Workflow{ + Handle: r.Handle, + Labels: r.Labels, + Meta: r.Meta, + Enabled: r.Enabled, + Trace: r.Trace, + KeepSessions: r.KeepSessions, + Scope: r.Scope, + Steps: r.Steps, + Paths: r.Paths, + RunAs: r.RunAs, + OwnedBy: r.OwnedBy, + } + + return ctrl.svc.Create(ctx, workflow) +} + +func (ctrl Workflow) Update(ctx context.Context, r *request.WorkflowUpdate) (interface{}, error) { + workflow := &types.Workflow{ + ID: r.WorkflowID, + Handle: r.Handle, + Labels: r.Labels, + Meta: r.Meta, + Enabled: r.Enabled, + Trace: r.Trace, + KeepSessions: r.KeepSessions, + Scope: r.Scope, + Steps: r.Steps, + Paths: r.Paths, + RunAs: r.RunAs, + OwnedBy: r.OwnedBy, + } + + return ctrl.svc.Update(ctx, workflow) +} + +func (ctrl Workflow) Read(ctx context.Context, r *request.WorkflowRead) (interface{}, error) { + return ctrl.svc.LookupByID(ctx, r.WorkflowID) +} + +func (ctrl Workflow) Test(ctx context.Context, r *request.WorkflowTest) (interface{}, error) { + return nil, fmt.Errorf("not implemented") +} + +func (ctrl Workflow) Delete(ctx context.Context, r *request.WorkflowDelete) (interface{}, error) { + return api.OK(), ctrl.svc.DeleteByID(ctx, r.WorkflowID) +} + +func (ctrl Workflow) Undelete(ctx context.Context, r *request.WorkflowUndelete) (interface{}, error) { + return api.OK(), ctrl.svc.UndeleteByID(ctx, r.WorkflowID) +} + +func (ctrl Workflow) makeFilterPayload(ctx context.Context, uu types.WorkflowSet, f types.WorkflowFilter, err error) (*workflowSetPayload, error) { + if err != nil { + return nil, err + } + + if len(uu) == 0 { + uu = make([]*types.Workflow, 0) + } + + return &workflowSetPayload{Filter: f, Set: uu}, nil +} diff --git a/automation/service/access_control.go b/automation/service/access_control.go new file mode 100644 index 000000000..40a91a76e --- /dev/null +++ b/automation/service/access_control.go @@ -0,0 +1,172 @@ +package service + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/actionlog" + internalAuth "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/rbac" +) + +type ( + accessControl struct { + permissions accessControlRBACServicer + actionlog actionlog.Recorder + } + + accessControlRBACServicer interface { + Can([]uint64, rbac.Resource, rbac.Operation, ...rbac.CheckAccessFunc) bool + Grant(context.Context, rbac.Whitelist, ...*rbac.Rule) error + FindRulesByRoleID(roleID uint64) (rr rbac.RuleSet) + } + + RBACResource interface { + RBACResource() rbac.Resource + } +) + +func AccessControl(perm accessControlRBACServicer) *accessControl { + return &accessControl{ + permissions: perm, + actionlog: DefaultActionlog, + } +} + +// Effective returns a list of effective service-level permissions +func (svc accessControl) Effective(ctx context.Context) (ee rbac.EffectiveSet) { + ee = rbac.EffectiveSet{} + + ee.Push(types.AutomationRBACResource, "access", svc.CanAccess(ctx)) + ee.Push(types.AutomationRBACResource, "grant", svc.CanGrant(ctx)) + ee.Push(types.AutomationRBACResource, "workflow.create", svc.CanCreateWorkflow(ctx)) + ee.Push(types.AutomationRBACResource, "sessions.search", svc.CanSearchSessions(ctx)) + ee.Push(types.AutomationRBACResource, "triggers.search", svc.CanSearchTriggers(ctx)) + + return +} + +func (svc accessControl) CanAccess(ctx context.Context) bool { + return svc.can(ctx, types.AutomationRBACResource, "access") +} + +func (svc accessControl) CanGrant(ctx context.Context) bool { + return svc.can(ctx, types.AutomationRBACResource, "grant") +} + +func (svc accessControl) CanCreateWorkflow(ctx context.Context) bool { + return svc.can(ctx, types.AutomationRBACResource, "workflow.create") +} + +func (svc accessControl) CanSearchTriggers(ctx context.Context) bool { + return svc.can(ctx, types.AutomationRBACResource, "triggers.search") +} + +func (svc accessControl) CanSearchSessions(ctx context.Context) bool { + return svc.can(ctx, types.AutomationRBACResource, "sessions.search") +} + +func (svc accessControl) CanReadWorkflow(ctx context.Context, u *types.Workflow) bool { + return svc.can(ctx, u.RBACResource(), "read") +} + +func (svc accessControl) CanUpdateWorkflow(ctx context.Context, u *types.Workflow) bool { + return svc.can(ctx, u.RBACResource(), "update") +} + +func (svc accessControl) CanDeleteWorkflow(ctx context.Context, u *types.Workflow) bool { + return svc.can(ctx, u.RBACResource(), "delete") +} + +func (svc accessControl) CanUndeleteWorkflow(ctx context.Context, u *types.Workflow) bool { + return svc.can(ctx, u.RBACResource(), "undelete") +} + +func (svc accessControl) CanExecuteWorkflow(ctx context.Context, u *types.Workflow) bool { + return svc.can(ctx, u.RBACResource(), "execute") +} + +func (svc accessControl) CanManageWorkflowTriggers(ctx context.Context, u *types.Workflow) bool { + return svc.can(ctx, u.RBACResource(), "triggers.manage") +} + +func (svc accessControl) CanManageWorkflowSessions(ctx context.Context, u *types.Workflow) bool { + return svc.can(ctx, u.RBACResource(), "sessions.manage") +} + +func (svc accessControl) can(ctx context.Context, res rbac.Resource, op rbac.Operation, ff ...rbac.CheckAccessFunc) bool { + var ( + u = internalAuth.GetIdentityFromContext(ctx) + roles = u.Roles() + ) + + if internalAuth.IsSuperUser(u) { + // Temp solution to allow migration from passing context to ResourceFilter + // and checking "superuser" privileges there to more sustainable solution + // (eg: creating super-role with allow-all) + return true + } + + return svc.permissions.Can(roles, res.RBACResource(), op, ff...) +} + +func (svc accessControl) Grant(ctx context.Context, rr ...*rbac.Rule) error { + if !svc.CanGrant(ctx) { + return AccessControlErrNotAllowedToSetPermissions() + } + + if err := svc.permissions.Grant(ctx, svc.Whitelist(), rr...); err != nil { + return AccessControlErrGeneric().Wrap(err) + } + + svc.logGrants(ctx, rr) + + return nil +} + +func (svc accessControl) logGrants(ctx context.Context, rr []*rbac.Rule) { + if svc.actionlog == nil { + return + } + + for _, r := range rr { + g := AccessControlActionGrant(&accessControlActionProps{r}) + g.log = r.String() + g.resource = r.Resource.String() + + svc.actionlog.Record(ctx, g.ToAction()) + } +} + +func (svc accessControl) FindRulesByRoleID(ctx context.Context, roleID uint64) (rbac.RuleSet, error) { + if !svc.CanGrant(ctx) { + return nil, AccessControlErrNotAllowedToSetPermissions() + } + + return svc.permissions.FindRulesByRoleID(roleID), nil +} + +func (svc accessControl) Whitelist() rbac.Whitelist { + var wl = rbac.Whitelist{} + + wl.Set( + types.AutomationRBACResource, + "access", + "grant", + "workflow.create", + "triggers.search", + "sessions.search", + ) + + wl.Set( + types.WorkflowRBACResource, + "read", + "update", + "delete", + "undelete", + "execute", + "triggers.manage", + "sessions.manage", + ) + + return wl +} diff --git a/automation/service/access_control_actions.gen.go b/automation/service/access_control_actions.gen.go new file mode 100644 index 000000000..2622455dc --- /dev/null +++ b/automation/service/access_control_actions.gen.go @@ -0,0 +1,287 @@ +package service + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// automation/service/access_control_actions.yaml + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/actionlog" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/pkg/rbac" + "strings" + "time" +) + +type ( + accessControlActionProps struct { + rule *rbac.Rule + } + + accessControlAction struct { + timestamp time.Time + resource string + action string + log string + severity actionlog.Severity + + // prefix for error when action fails + errorMessage string + + props *accessControlActionProps + } + + accessControlLogMetaKey struct{} + accessControlPropsMetaKey struct{} +) + +var ( + // just a placeholder to cover template cases w/o fmt package use + _ = fmt.Println +) + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Props methods +// setRule updates accessControlActionProps's rule +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *accessControlActionProps) setRule(rule *rbac.Rule) *accessControlActionProps { + p.rule = rule + return p +} + +// Serialize converts accessControlActionProps to actionlog.Meta +// +// This function is auto-generated. +// +func (p accessControlActionProps) Serialize() actionlog.Meta { + var ( + m = make(actionlog.Meta) + ) + + if p.rule != nil { + m.Set("rule.operation", p.rule.Operation, true) + m.Set("rule.roleID", p.rule.RoleID, true) + m.Set("rule.access", p.rule.Access, true) + m.Set("rule.resource", p.rule.Resource, true) + } + + return m +} + +// tr translates string and replaces meta value placeholder with values +// +// This function is auto-generated. +// +func (p accessControlActionProps) Format(in string, err error) string { + var ( + pairs = []string{"{err}"} + // first non-empty string + fns = func(ii ...interface{}) string { + for _, i := range ii { + if s := fmt.Sprintf("%v", i); len(s) > 0 { + return s + } + } + + return "" + } + ) + + if err != nil { + pairs = append(pairs, err.Error()) + } else { + pairs = append(pairs, "nil") + } + + if p.rule != nil { + // replacement for "{rule}" (in order how fields are defined) + pairs = append( + pairs, + "{rule}", + fns( + p.rule.Operation, + p.rule.RoleID, + p.rule.Access, + p.rule.Resource, + ), + ) + pairs = append(pairs, "{rule.operation}", fns(p.rule.Operation)) + pairs = append(pairs, "{rule.roleID}", fns(p.rule.RoleID)) + pairs = append(pairs, "{rule.access}", fns(p.rule.Access)) + pairs = append(pairs, "{rule.resource}", fns(p.rule.Resource)) + } + return strings.NewReplacer(pairs...).Replace(in) +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Action methods + +// String returns loggable description as string +// +// This function is auto-generated. +// +func (a *accessControlAction) String() string { + var props = &accessControlActionProps{} + + if a.props != nil { + props = a.props + } + + return props.Format(a.log, nil) +} + +func (e *accessControlAction) ToAction() *actionlog.Action { + return &actionlog.Action{ + Resource: e.resource, + Action: e.action, + Severity: e.severity, + Description: e.String(), + Meta: e.props.Serialize(), + } +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Action constructors + +// AccessControlActionGrant returns "automation:access_control.grant" action +// +// This function is auto-generated. +// +func AccessControlActionGrant(props ...*accessControlActionProps) *accessControlAction { + a := &accessControlAction{ + timestamp: time.Now(), + resource: "automation:access_control", + action: "grant", + log: "grant", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Error constructors + +// AccessControlErrGeneric returns "automation:access_control.generic" as *errors.Error +// +// +// This function is auto-generated. +// +func AccessControlErrGeneric(mm ...*accessControlActionProps) *errors.Error { + var p = &accessControlActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("failed to complete request due to internal error", nil), + + errors.Meta("type", "generic"), + errors.Meta("resource", "automation:access_control"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(accessControlLogMetaKey{}, "{err}"), + errors.Meta(accessControlPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// AccessControlErrNotAllowedToSetPermissions returns "automation:access_control.notAllowedToSetPermissions" as *errors.Error +// +// +// This function is auto-generated. +// +func AccessControlErrNotAllowedToSetPermissions(mm ...*accessControlActionProps) *errors.Error { + var p = &accessControlActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to set permissions", nil), + + errors.Meta("type", "notAllowedToSetPermissions"), + errors.Meta("resource", "automation:access_control"), + + errors.Meta(accessControlPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* + +// recordAction is a service helper function wraps function that can return error +// +// It will wrap unrecognized/internal errors with generic errors. +// +// This function is auto-generated. +// +func (svc accessControl) recordAction(ctx context.Context, props *accessControlActionProps, actionFn func(...*accessControlActionProps) *accessControlAction, err error) error { + if svc.actionlog == nil || actionFn == nil { + // action log disabled or no action fn passed, return error as-is + return err + } else if err == nil { + // action completed w/o error, record it + svc.actionlog.Record(ctx, actionFn(props).ToAction()) + return nil + } + + a := actionFn(props).ToAction() + + // Extracting error information and recording it as action + a.Error = err.Error() + + switch c := err.(type) { + case *errors.Error: + m := c.Meta() + + a.Error = err.Error() + a.Severity = actionlog.Severity(m.AsInt("severity")) + a.Description = props.Format(m.AsString(accessControlLogMetaKey{}), err) + + if p, has := m[accessControlPropsMetaKey{}]; has { + a.Meta = p.(*accessControlActionProps).Serialize() + } + + svc.actionlog.Record(ctx, a) + default: + svc.actionlog.Record(ctx, a) + } + + // Original error is passed on + return err +} diff --git a/automation/service/access_control_actions.yaml b/automation/service/access_control_actions.yaml new file mode 100644 index 000000000..fa6d21082 --- /dev/null +++ b/automation/service/access_control_actions.yaml @@ -0,0 +1,25 @@ +# List of security/audit events and errors that we need to log + +resource: automation:access_control +service: accessControl + +# Default sensitivity for actions +defaultActionSeverity: notice + +# default severity for errors +defaultErrorSeverity: alert + +import: + - github.com/cortezaproject/corteza-server/pkg/rbac + +props: + - name: rule + type: "*rbac.Rule" + fields: [ operation, roleID, access, resource ] + +actions: + - action: grant + +errors: + - error: notAllowedToSetPermissions + message: "not allowed to set permissions" diff --git a/automation/service/registry.go b/automation/service/registry.go new file mode 100644 index 000000000..9df2afb9e --- /dev/null +++ b/automation/service/registry.go @@ -0,0 +1,103 @@ +package service + +import ( + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/expr" + "sort" + "sync" +) + +type ( + registry struct { + lock *sync.RWMutex + functions map[string]*types.Function + types map[string]expr.Type + } + + handler interface { + Functions() []*types.Function + } +) + +var ( + defaultRegistry = initRegistry() +) + +func Registry() *registry { + return defaultRegistry +} + +func initRegistry() *registry { + return ®istry{ + lock: &sync.RWMutex{}, + functions: make(map[string]*types.Function), + types: make(map[string]expr.Type), + } +} + +func (r *registry) AddFunctions(ff ...*types.Function) { + defer r.lock.Unlock() + r.lock.Lock() + for _, fn := range ff { + r.functions[fn.Ref] = fn + } +} + +func (r *registry) AddHandlers(hh ...handler) { + for _, h := range hh { + r.AddFunctions(h.Functions()...) + } +} + +func (r registry) Function(ref string) *types.Function { + defer r.lock.RUnlock() + r.lock.RLock() + return r.functions[ref] +} + +func (r registry) Functions() []*types.Function { + var ( + rr = make([]string, 0, len(r.functions)) + ff = make([]*types.Function, 0, len(r.functions)) + ) + + for ref := range r.functions { + rr = append(rr, ref) + } + + sort.Strings(rr) + + for _, ref := range rr { + ff = append(ff, r.functions[ref]) + } + + return ff +} + +func (r *registry) AddTypes(tt ...expr.Type) { + defer r.lock.Unlock() + r.lock.Lock() + for _, t := range tt { + r.types[t.Type()] = t + } +} + +func (r *registry) Type(ref string) expr.Type { + defer r.lock.RUnlock() + r.lock.RLock() + return r.types[ref] +} + +func (r *registry) Types() []string { + var ( + rr = make([]string, 0, len(r.types)) + ) + + for ref := range r.types { + rr = append(rr, ref) + } + + sort.Strings(rr) + + return rr +} diff --git a/automation/service/service.go b/automation/service/service.go new file mode 100644 index 000000000..805469391 --- /dev/null +++ b/automation/service/service.go @@ -0,0 +1,151 @@ +package service + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/automation" + "github.com/cortezaproject/corteza-server/pkg/actionlog" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/id" + "github.com/cortezaproject/corteza-server/pkg/objstore" + "github.com/cortezaproject/corteza-server/pkg/options" + "github.com/cortezaproject/corteza-server/pkg/rbac" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/system/types" + "go.uber.org/zap" + "time" +) + +type ( + RBACServicer interface { + accessControlRBACServicer + Watch(ctx context.Context) + } + + Config struct { + ActionLog options.ActionLogOpt + } + + userService interface { + FindByID(ctx context.Context, userID uint64) (*types.User, error) + } +) + +var ( + DefaultObjectStore objstore.Store + + // DefaultStore is an interface to storage backend(s) + // ng (next-gen) is a temporary prefix + // so that we can differentiate between it and the file-only store + DefaultStore store.Storer + + DefaultLogger *zap.Logger + + // DefaultAccessControl Access control checking + DefaultAccessControl *accessControl + + DefaultActionlog actionlog.Recorder + + DefaultUser userService + DefaultWorkflow *workflow + DefaultTrigger *trigger + DefaultSession *session + + // wrapper around time.Now() that will aid service testing + now = func() *time.Time { + c := time.Now().Round(time.Second) + return &c + } + + // wrapper around nextID that will aid service testing + nextID = func() uint64 { + return id.Next() + } +) + +func Initialize(ctx context.Context, log *zap.Logger, s store.Storer, c Config) (err error) { + var ( + //hcd = healthcheck.Defaults() + ) + + // we're doing conversion to avoid having + // store interface exposed or generated inside app package + DefaultStore = s + + DefaultLogger = log.Named("service") + + { + tee := zap.NewNop() + policy := actionlog.MakeProductionPolicy() + + if !c.ActionLog.Enabled { + policy = actionlog.MakeDisabledPolicy() + } else if c.ActionLog.Debug { + policy = actionlog.MakeDebugPolicy() + tee = log + } + + DefaultActionlog = actionlog.NewService(DefaultStore, log, tee, policy) + } + + DefaultAccessControl = AccessControl(rbac.Global()) + + DefaultWorkflow = Workflow(DefaultLogger.Named("workflow")) + DefaultSession = Session(DefaultLogger.Named("session")) + DefaultTrigger = Trigger(DefaultLogger.Named("trigger")) + + DefaultWorkflow.triggers = DefaultTrigger + + Registry().AddTypes( + &expr.Any{}, + &expr.Boolean{}, + &expr.ID{}, + &expr.Integer{}, + &expr.UnsignedInteger{}, + &expr.Float{}, + &expr.String{}, + &expr.Handle{}, + &expr.DateTime{}, + &expr.Duration{}, + &expr.KV{}, + &expr.KVV{}, + &expr.Reader{}, + ) + + automation.HttpRequestHandler(Registry()) + automation.LogHandler(Registry()) + automation.LoopHandler(Registry(), DefaultWorkflow.parser) + + return +} + +func Activate(ctx context.Context) (err error) { + if err = DefaultWorkflow.Load(ctx); err != nil { + return + } + + return +} + +func Watchers(ctx context.Context) { + DefaultSession.Watch(ctx) + return +} + +// Data is stale when new date does not match updatedAt or createdAt (before first update) +func isStale(new *time.Time, updatedAt *time.Time, createdAt time.Time) bool { + if new == nil { + // Change to true for stale-data-check + return false + } + + if updatedAt != nil { + return !new.Equal(*updatedAt) + } + + return new.Equal(createdAt) +} + +// trim1st removes 1st param and returns only error +func trim1st(_ interface{}, err error) error { + return err +} diff --git a/automation/service/session.go b/automation/service/session.go new file mode 100644 index 000000000..fcca90c7c --- /dev/null +++ b/automation/service/session.go @@ -0,0 +1,291 @@ +package service + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/actionlog" + "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/sentry" + "github.com/cortezaproject/corteza-server/pkg/wfexec" + "github.com/cortezaproject/corteza-server/store" + "go.uber.org/zap" + "sync" +) + +type ( + session struct { + store store.Storer + actionlog actionlog.Recorder + ac sessionAccessController + log *zap.Logger + mux *sync.RWMutex + pool map[uint64]*types.Session + spawnQueue chan *spawn + } + + spawn struct { + session chan *wfexec.Session + graph *wfexec.Graph + trace bool + } + + sessionAccessController interface { + CanSearchSessions(context.Context) bool + CanManageWorkflowSessions(context.Context, *types.Workflow) bool + } + + WaitFn func(ctx context.Context) error +) + +func Session(log *zap.Logger) *session { + return &session{ + log: log, + actionlog: DefaultActionlog, + store: DefaultStore, + ac: DefaultAccessControl, + mux: &sync.RWMutex{}, + pool: make(map[uint64]*types.Session), + spawnQueue: make(chan *spawn), + } +} + +func (svc *session) Search(ctx context.Context, filter types.SessionFilter) (rr types.SessionSet, f types.SessionFilter, err error) { + var ( + sap = &sessionActionProps{filter: &filter} + ) + + err = func() (err error) { + if !svc.ac.CanSearchSessions(ctx) { + return SessionErrNotAllowedToSearch() + } + + if rr, f, err = store.SearchAutomationSessions(ctx, svc.store, filter); err != nil { + return err + } + + return nil + }() + + return rr, filter, svc.recordAction(ctx, sap, SessionActionSearch, err) +} + +func (svc *session) LookupByID(ctx context.Context, sessionID uint64) (res *types.Session, err error) { + var ( + sap = &sessionActionProps{session: &types.Session{ID: sessionID}} + wf *types.Workflow + ) + + err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error { + if !svc.ac.CanSearchSessions(ctx) { + return SessionErrNotAllowedToRead() + } + + if res, err = loadSession(ctx, s, sessionID); err != nil { + return err + } + + if wf, err = loadWorkflow(ctx, s, res.WorkflowID); err != nil { + return err + } + + if !svc.ac.CanManageWorkflowSessions(ctx, wf) { + return SessionErrNotAllowedToManage() + } + + return nil + }) + + return res, svc.recordAction(ctx, sap, SessionActionLookup, err) +} + +func (svc *session) resumeAll(ctx context.Context) error { + // @todo resume active sessions from storage + return nil +} + +func (svc *session) suspendAll(ctx context.Context) error { + // @todo suspend active sessions to storage + return nil +} + +// Start new workflow session on a specific step with a given identity and scope +// +// Start is an asynchronous operation +// +// It does not check user's permissions to execute workflow(s) so it should be used only when ! +func (svc *session) Start(g *wfexec.Graph, i auth.Identifiable, ssp types.SessionStartParams) (wait WaitFn, err error) { + var ( + ctx = auth.SetIdentityToContext(context.Background(), i) + ses = svc.spawn(g, ssp.Trace) + start wfexec.Step + ) + + wait = func(ctx context.Context) error { + return nil // no-op... + } + + if ssp.StepID == 0 { + // starting step is not explicitly workflows on trigger, find orphan step + switch oo := g.Orphans(); len(oo) { + case 1: + start = oo[0] + case 0: + return nil, fmt.Errorf("could not find step without parents") + default: + return nil, fmt.Errorf("multiple steps without parents") + } + } else if start = g.StepByID(ssp.StepID); start == nil { + return nil, fmt.Errorf("trigger staring step references nonexisting step") + } + + ses.CreatedAt = *now() + ses.CreatedBy = i.Identity() + ses.Apply(ssp) + + if err = store.CreateAutomationSession(context.TODO(), svc.store, ses); err != nil { + return + } + + if err = ses.Exec(ctx, start, ssp.Input); err != nil { + return + } + + return func(ctx context.Context) error { return ses.Wait(ctx) }, nil +} + +// Resume resumes suspended session/state +// +// Session can only be resumed by knowing session and state ID. Resume is an asynchronous operation +func (svc *session) Resume(sessionID, stateID uint64, i auth.Identifiable, input *expr.Vars) error { + var ( + ctx = auth.SetIdentityToContext(context.Background(), i) + ) + + defer svc.mux.RUnlock() + svc.mux.RLock() + ses := svc.pool[sessionID] + if ses == nil { + return errors.NotFound("session not found") + } + + return ses.Resume(ctx, stateID, input) +} + +// spawns a new session +// +// We need initial context for the session because we want to catch all cancellations or timeouts from there +// and not from any potential HTTP requests or similar temporary context that can prematurely destroy a workflow session +func (svc *session) spawn(g *wfexec.Graph, trace bool) (ses *types.Session) { + s := &spawn{make(chan *wfexec.Session, 1), g, trace} + + // Send new-session request + svc.spawnQueue <- s + + // blocks until session is set + ses = types.NewSession(<-s.session) + + svc.mux.Lock() + svc.pool[ses.ID] = ses + svc.mux.Unlock() + return ses +} + +func (svc *session) Watch(ctx context.Context) { + go func() { + defer sentry.Recover() + defer svc.log.Info("stopped") + + for { + select { + case <-ctx.Done(): + return + case s := <-svc.spawnQueue: + // + s.session <- wfexec.NewSession(ctx, + s.graph, + wfexec.SetHandler(svc.stateChangeHandler(ctx)), + wfexec.SetLogger(svc.log), + ) + // case time for a pool cleanup + // @todo cleanup pool when sessions are complete + } + } + + // @todo serialize sessions & suspended states + //svc.suspendAll(ctx) + }() + + svc.log.Debug("watcher initialized") +} + +func (svc *session) stateChangeHandler(ctx context.Context) wfexec.StateChangeHandler { + return func(i int, state *wfexec.State, s *wfexec.Session) { + log := svc.log.With(zap.Uint64("sessionID", s.ID())) + + defer svc.mux.RUnlock() + svc.mux.RLock() + ses := svc.pool[s.ID()] + if ses == nil { + log.Warn("could not find session to update") + return + } + + var ( + update = true + frame = state.MakeFrame() + ) + + if ses.Stacktrace != nil { + // Stacktrace will be set to !nil if frame collection is needed + ses.Stacktrace = append(ses.Stacktrace, frame) + } + + switch i { + case wfexec.SessionStepSuspended: + // @todo handle step suspension! + case wfexec.SessionSuspended: + ses.SuspendedAt = now() + ses.Status = types.SessionSuspended + + case wfexec.SessionCompleted: + ses.SuspendedAt = nil + ses.CompletedAt = now() + ses.Status = types.SessionCompleted + + case wfexec.SessionFailed: + ses.SuspendedAt = nil + ses.CompletedAt = now() + ses.Error = state.Error() + ses.Status = types.SessionFailed + + default: + // force update on every 10 new frames but only when stacktrace is not nil + update = ses.Stacktrace != nil && len(ses.Stacktrace)%10 == 0 + } + + if !update { + return + } + + if err := svc.store.UpdateAutomationSession(ctx, ses); err != nil { + log.Error("failed to update session", zap.Error(err)) + } else { + log.Debug("session updated", zap.Stringer("status", ses.Status)) + } + } +} + +func loadSession(ctx context.Context, s store.Storer, sessionID uint64) (res *types.Session, err error) { + if sessionID == 0 { + return nil, SessionErrInvalidID() + } + + if res, err = store.LookupAutomationSessionByID(ctx, s, sessionID); errors.IsNotFound(err) { + return nil, SessionErrNotFound() + } + + return +} diff --git a/automation/service/session_actions.gen.go b/automation/service/session_actions.gen.go new file mode 100644 index 000000000..c5588747c --- /dev/null +++ b/automation/service/session_actions.gen.go @@ -0,0 +1,643 @@ +package service + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// automation/service/session_actions.yaml + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/actionlog" + "github.com/cortezaproject/corteza-server/pkg/errors" + "strings" + "time" +) + +type ( + sessionActionProps struct { + session *types.Session + new *types.Session + update *types.Session + filter *types.SessionFilter + } + + sessionAction struct { + timestamp time.Time + resource string + action string + log string + severity actionlog.Severity + + // prefix for error when action fails + errorMessage string + + props *sessionActionProps + } + + sessionLogMetaKey struct{} + sessionPropsMetaKey struct{} +) + +var ( + // just a placeholder to cover template cases w/o fmt package use + _ = fmt.Println +) + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Props methods +// setSession updates sessionActionProps's session +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *sessionActionProps) setSession(session *types.Session) *sessionActionProps { + p.session = session + return p +} + +// setNew updates sessionActionProps's new +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *sessionActionProps) setNew(new *types.Session) *sessionActionProps { + p.new = new + return p +} + +// setUpdate updates sessionActionProps's update +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *sessionActionProps) setUpdate(update *types.Session) *sessionActionProps { + p.update = update + return p +} + +// setFilter updates sessionActionProps's filter +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *sessionActionProps) setFilter(filter *types.SessionFilter) *sessionActionProps { + p.filter = filter + return p +} + +// Serialize converts sessionActionProps to actionlog.Meta +// +// This function is auto-generated. +// +func (p sessionActionProps) Serialize() actionlog.Meta { + var ( + m = make(actionlog.Meta) + ) + + if p.session != nil { + m.Set("session.ID", p.session.ID, true) + } + if p.new != nil { + m.Set("new.ID", p.new.ID, true) + } + if p.update != nil { + m.Set("update.ID", p.update.ID, true) + } + if p.filter != nil { + } + + return m +} + +// tr translates string and replaces meta value placeholder with values +// +// This function is auto-generated. +// +func (p sessionActionProps) Format(in string, err error) string { + var ( + pairs = []string{"{err}"} + // first non-empty string + fns = func(ii ...interface{}) string { + for _, i := range ii { + if s := fmt.Sprintf("%v", i); len(s) > 0 { + return s + } + } + + return "" + } + ) + + if err != nil { + pairs = append(pairs, err.Error()) + } else { + pairs = append(pairs, "nil") + } + + if p.session != nil { + // replacement for "{session}" (in order how fields are defined) + pairs = append( + pairs, + "{session}", + fns( + p.session.ID, + ), + ) + pairs = append(pairs, "{session.ID}", fns(p.session.ID)) + } + + if p.new != nil { + // replacement for "{new}" (in order how fields are defined) + pairs = append( + pairs, + "{new}", + fns( + p.new.ID, + ), + ) + pairs = append(pairs, "{new.ID}", fns(p.new.ID)) + } + + if p.update != nil { + // replacement for "{update}" (in order how fields are defined) + pairs = append( + pairs, + "{update}", + fns( + p.update.ID, + ), + ) + pairs = append(pairs, "{update.ID}", fns(p.update.ID)) + } + + if p.filter != nil { + // replacement for "{filter}" (in order how fields are defined) + pairs = append( + pairs, + "{filter}", + fns(), + ) + } + return strings.NewReplacer(pairs...).Replace(in) +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Action methods + +// String returns loggable description as string +// +// This function is auto-generated. +// +func (a *sessionAction) String() string { + var props = &sessionActionProps{} + + if a.props != nil { + props = a.props + } + + return props.Format(a.log, nil) +} + +func (e *sessionAction) ToAction() *actionlog.Action { + return &actionlog.Action{ + Resource: e.resource, + Action: e.action, + Severity: e.severity, + Description: e.String(), + Meta: e.props.Serialize(), + } +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Action constructors + +// SessionActionSearch returns "automation:session.search" action +// +// This function is auto-generated. +// +func SessionActionSearch(props ...*sessionActionProps) *sessionAction { + a := &sessionAction{ + timestamp: time.Now(), + resource: "automation:session", + action: "search", + log: "searched for matching sessions", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// SessionActionLookup returns "automation:session.lookup" action +// +// This function is auto-generated. +// +func SessionActionLookup(props ...*sessionActionProps) *sessionAction { + a := &sessionAction{ + timestamp: time.Now(), + resource: "automation:session", + action: "lookup", + log: "looked-up for a {session}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// SessionActionCreate returns "automation:session.create" action +// +// This function is auto-generated. +// +func SessionActionCreate(props ...*sessionActionProps) *sessionAction { + a := &sessionAction{ + timestamp: time.Now(), + resource: "automation:session", + action: "create", + log: "created {session}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// SessionActionUpdate returns "automation:session.update" action +// +// This function is auto-generated. +// +func SessionActionUpdate(props ...*sessionActionProps) *sessionAction { + a := &sessionAction{ + timestamp: time.Now(), + resource: "automation:session", + action: "update", + log: "updated {session}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// SessionActionDelete returns "automation:session.delete" action +// +// This function is auto-generated. +// +func SessionActionDelete(props ...*sessionActionProps) *sessionAction { + a := &sessionAction{ + timestamp: time.Now(), + resource: "automation:session", + action: "delete", + log: "deleted {session}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// SessionActionUndelete returns "automation:session.undelete" action +// +// This function is auto-generated. +// +func SessionActionUndelete(props ...*sessionActionProps) *sessionAction { + a := &sessionAction{ + timestamp: time.Now(), + resource: "automation:session", + action: "undelete", + log: "undeleted {session}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Error constructors + +// SessionErrGeneric returns "automation:session.generic" as *errors.Error +// +// +// This function is auto-generated. +// +func SessionErrGeneric(mm ...*sessionActionProps) *errors.Error { + var p = &sessionActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("failed to complete request due to internal error", nil), + + errors.Meta("type", "generic"), + errors.Meta("resource", "automation:session"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(sessionLogMetaKey{}, "{err}"), + errors.Meta(sessionPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// SessionErrNotFound returns "automation:session.notFound" as *errors.Error +// +// +// This function is auto-generated. +// +func SessionErrNotFound(mm ...*sessionActionProps) *errors.Error { + var p = &sessionActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("session not found", nil), + + errors.Meta("type", "notFound"), + errors.Meta("resource", "automation:session"), + + errors.Meta(sessionPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// SessionErrInvalidID returns "automation:session.invalidID" as *errors.Error +// +// +// This function is auto-generated. +// +func SessionErrInvalidID(mm ...*sessionActionProps) *errors.Error { + var p = &sessionActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("invalid ID", nil), + + errors.Meta("type", "invalidID"), + errors.Meta("resource", "automation:session"), + + errors.Meta(sessionPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// SessionErrStaleData returns "automation:session.staleData" as *errors.Error +// +// +// This function is auto-generated. +// +func SessionErrStaleData(mm ...*sessionActionProps) *errors.Error { + var p = &sessionActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("stale data", nil), + + errors.Meta("type", "staleData"), + errors.Meta("resource", "automation:session"), + + errors.Meta(sessionPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// SessionErrNotAllowedToRead returns "automation:session.notAllowedToRead" as *errors.Error +// +// +// This function is auto-generated. +// +func SessionErrNotAllowedToRead(mm ...*sessionActionProps) *errors.Error { + var p = &sessionActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to read this session", nil), + + errors.Meta("type", "notAllowedToRead"), + errors.Meta("resource", "automation:session"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(sessionLogMetaKey{}, "failed to read {session}; insufficient permissions"), + errors.Meta(sessionPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// SessionErrNotAllowedToSearch returns "automation:session.notAllowedToSearch" as *errors.Error +// +// +// This function is auto-generated. +// +func SessionErrNotAllowedToSearch(mm ...*sessionActionProps) *errors.Error { + var p = &sessionActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to search sessions", nil), + + errors.Meta("type", "notAllowedToSearch"), + errors.Meta("resource", "automation:session"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(sessionLogMetaKey{}, "failed to list session; insufficient permissions"), + errors.Meta(sessionPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// SessionErrNotAllowedToDelete returns "automation:session.notAllowedToDelete" as *errors.Error +// +// +// This function is auto-generated. +// +func SessionErrNotAllowedToDelete(mm ...*sessionActionProps) *errors.Error { + var p = &sessionActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to delete this session", nil), + + errors.Meta("type", "notAllowedToDelete"), + errors.Meta("resource", "automation:session"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(sessionLogMetaKey{}, "failed to delete {session}; insufficient permissions"), + errors.Meta(sessionPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// SessionErrNotAllowedToManage returns "automation:session.notAllowedToManage" as *errors.Error +// +// +// This function is auto-generated. +// +func SessionErrNotAllowedToManage(mm ...*sessionActionProps) *errors.Error { + var p = &sessionActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to manage session's workflow", nil), + + errors.Meta("type", "notAllowedToManage"), + errors.Meta("resource", "automation:session"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(sessionLogMetaKey{}, "failed to manage {session}; insufficient permissions"), + errors.Meta(sessionPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* + +// recordAction is a service helper function wraps function that can return error +// +// It will wrap unrecognized/internal errors with generic errors. +// +// This function is auto-generated. +// +func (svc session) recordAction(ctx context.Context, props *sessionActionProps, actionFn func(...*sessionActionProps) *sessionAction, err error) error { + if svc.actionlog == nil || actionFn == nil { + // action log disabled or no action fn passed, return error as-is + return err + } else if err == nil { + // action completed w/o error, record it + svc.actionlog.Record(ctx, actionFn(props).ToAction()) + return nil + } + + a := actionFn(props).ToAction() + + // Extracting error information and recording it as action + a.Error = err.Error() + + switch c := err.(type) { + case *errors.Error: + m := c.Meta() + + a.Error = err.Error() + a.Severity = actionlog.Severity(m.AsInt("severity")) + a.Description = props.Format(m.AsString(sessionLogMetaKey{}), err) + + if p, has := m[sessionPropsMetaKey{}]; has { + a.Meta = p.(*sessionActionProps).Serialize() + } + + svc.actionlog.Record(ctx, a) + default: + svc.actionlog.Record(ctx, a) + } + + // Original error is passed on + return err +} diff --git a/automation/service/session_actions.yaml b/automation/service/session_actions.yaml new file mode 100644 index 000000000..dccd990b0 --- /dev/null +++ b/automation/service/session_actions.yaml @@ -0,0 +1,76 @@ +# List of loggable service actions + +resource: automation:session +service: session + +import: + - github.com/cortezaproject/corteza-server/automation/types + +# Default sensitivity for actions +defaultActionSeverity: info + +# default severity for errors +defaultErrorSeverity: error + + +props: + - name: session + type: "*types.Session" + fields: [ ID ] + - name: new + type: "*types.Session" + fields: [ ID ] + - name: update + type: "*types.Session" + fields: [ ID ] + - name: filter + type: "*types.SessionFilter" + +actions: + - action: search + log: "searched for matching sessions" + severity: info + + - action: lookup + log: "looked-up for a {session}" + severity: info + + - action: create + log: "created {session}" + + - action: update + log: "updated {session}" + + - action: delete + log: "deleted {session}" + + - action: undelete + log: "undeleted {session}" + +errors: + - error: notFound + message: "session not found" + + - error: invalidID + message: "invalid ID" + + - error: staleData + message: "stale data" + severity: warning + + - error: notAllowedToRead + message: "not allowed to read this session" + log: "failed to read {session}; insufficient permissions" + + - error: notAllowedToSearch + message: "not allowed to search sessions" + log: "failed to list session; insufficient permissions" + + - error: notAllowedToDelete + message: "not allowed to delete this session" + log: "failed to delete {session}; insufficient permissions" + + - error: notAllowedToManage + message: "not allowed to manage session's workflow" + log: "failed to manage {session}; insufficient permissions" + diff --git a/automation/service/trigger.go b/automation/service/trigger.go new file mode 100644 index 000000000..ca0999c78 --- /dev/null +++ b/automation/service/trigger.go @@ -0,0 +1,607 @@ +package service + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/actionlog" + "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/pkg/eventbus" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/label" + "github.com/cortezaproject/corteza-server/pkg/wfexec" + "github.com/cortezaproject/corteza-server/store" + "go.uber.org/zap" + "reflect" + "sync" +) + +type ( + trigger struct { + eventbus triggerEventTriggerHandler + store store.Storer + actionlog actionlog.Recorder + ac triggerAccessController + + log *zap.Logger + + // maps registered triggers (value, uintptr) to trigger ID (key, uint64) + // this will keep track of all our trigger registrations and help us do a cleanup on + // trigger update. + triggers map[uint64]uintptr + + reg map[uint64]map[uint64]uintptr + + workflow *workflow + session *session + + mux *sync.RWMutex + } + + triggerAccessController interface { + CanSearchTriggers(context.Context) bool + CanManageWorkflowTriggers(context.Context, *types.Workflow) bool + } + + triggerEventTriggerHandler interface { + Register(h eventbus.HandlerFn, ops ...eventbus.HandlerRegOp) uintptr + Unregister(ptrs ...uintptr) + } + + triggerUpdateHandler func(ctx context.Context, ns *types.Trigger) (triggerChanges, error) + triggerChanges uint8 +) + +const ( + triggerUnchanged triggerChanges = 0 + triggerChanged triggerChanges = 1 + triggerLabelsChanged triggerChanges = 2 +) + +func Trigger(log *zap.Logger) *trigger { + return &trigger{ + log: log, + eventbus: eventbus.Service(), + actionlog: DefaultActionlog, + store: DefaultStore, + ac: DefaultAccessControl, + session: DefaultSession, + workflow: DefaultWorkflow, + triggers: make(map[uint64]uintptr), + reg: make(map[uint64]map[uint64]uintptr), + mux: &sync.RWMutex{}, + } +} + +func (svc *trigger) Search(ctx context.Context, filter types.TriggerFilter) (rr types.TriggerSet, f types.TriggerFilter, err error) { + var ( + wap = &triggerActionProps{filter: &filter} + ) + + err = func() (err error) { + if !svc.ac.CanSearchTriggers(ctx) { + return TriggerErrNotAllowedToSearch() + } + + if len(filter.Labels) > 0 { + filter.LabeledIDs, err = label.Search( + ctx, + svc.store, + types.Trigger{}.LabelResourceKind(), + filter.Labels, + ) + + if err != nil { + return err + } + + // labels specified but no labeled resources found + if len(filter.LabeledIDs) == 0 { + return nil + } + } + + if rr, f, err = store.SearchAutomationTriggers(ctx, svc.store, filter); err != nil { + return err + } + + if err = label.Load(ctx, svc.store, toLabeledTriggers(rr)...); err != nil { + return err + } + + return nil + }() + + return rr, filter, svc.recordAction(ctx, wap, TriggerActionSearch, err) +} + +func (svc *trigger) LookupByID(ctx context.Context, triggerID uint64) (res *types.Trigger, err error) { + var ( + wap = &triggerActionProps{trigger: &types.Trigger{ID: triggerID}} + ) + + err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error { + if !svc.ac.CanSearchTriggers(ctx) { + return TriggerErrNotAllowedToRead() + } + + if res, err = loadTrigger(ctx, s, triggerID); err != nil { + return err + } + + if err = label.Load(ctx, svc.store, res); err != nil { + return err + } + + return nil + }) + + return res, svc.recordAction(ctx, wap, TriggerActionLookup, err) +} + +// Create adds new trigger resource and saves it into store +// It updates service's cache +func (svc *trigger) Create(ctx context.Context, new *types.Trigger) (res *types.Trigger, err error) { + var ( + wap = &triggerActionProps{new: new} + cUser = auth.GetIdentityFromContext(ctx).Identity() + ) + + err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) { + var ( + wf *types.Workflow + ) + + if wf, err = loadWorkflow(ctx, svc.store, new.WorkflowID); err != nil { + return err + } + + if !svc.ac.CanManageWorkflowTriggers(ctx, wf) { + return TriggerErrNotAllowedToCreate() + } + + res = &types.Trigger{ + ID: nextID(), + Enabled: new.Enabled, + WorkflowID: new.WorkflowID, + StepID: new.StepID, + ResourceType: new.ResourceType, + EventType: new.EventType, + Constraints: new.Constraints, + Input: new.Input, + Labels: new.Labels, + Meta: new.Meta, + OwnedBy: cUser, + CreatedAt: *now(), + CreatedBy: cUser, + } + + if err = store.CreateAutomationTrigger(ctx, s, res); err != nil { + return + } + + if err = label.Create(ctx, s, res); err != nil { + return + } + + if err = svc.registerWorkflow(ctx, wf, res); err != nil { + return + } + + return + }) + + return res, svc.recordAction(ctx, wap, TriggerActionCreate, err) +} + +// Update modifies existing trigger resource in the store +func (svc *trigger) Update(ctx context.Context, upd *types.Trigger) (*types.Trigger, error) { + return svc.updater(ctx, upd.ID, TriggerActionUpdate, func(ctx context.Context, res *types.Trigger) (triggerChanges, error) { + if err := svc.canManageTrigger(ctx, res, TriggerErrNotAllowedToUpdate()); err != nil { + return triggerUnchanged, err + } + + handler := svc.handleUpdate(upd) + return handler(ctx, res) + }) +} + +func (svc *trigger) DeleteByID(ctx context.Context, triggerID uint64) error { + return trim1st(svc.updater(ctx, triggerID, TriggerActionDelete, svc.handleDelete)) +} + +func (svc *trigger) UndeleteByID(ctx context.Context, triggerID uint64) error { + return trim1st(svc.updater(ctx, triggerID, TriggerActionUndelete, svc.handleUndelete)) +} + +func (svc trigger) updater(ctx context.Context, triggerID uint64, action func(...*triggerActionProps) *triggerAction, fn triggerUpdateHandler) (*types.Trigger, error) { + var ( + changes triggerChanges + res *types.Trigger + aProps = &triggerActionProps{trigger: &types.Trigger{ID: triggerID}} + err error + ) + + err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) { + res, err = loadTrigger(ctx, s, triggerID) + if err != nil { + return + } + + if err = label.Load(ctx, svc.store, res); err != nil { + return err + } + + aProps.setTrigger(res) + aProps.setUpdate(res) + + if changes, err = fn(ctx, res); err != nil { + return err + } + + if changes&triggerChanged > 0 { + if err = store.UpdateAutomationTrigger(ctx, svc.store, res); err != nil { + return err + } + } + + if changes&triggerLabelsChanged > 0 { + if err = label.Update(ctx, s, res); err != nil { + return + } + } + + return err + }) + + return res, svc.recordAction(ctx, aProps, action, err) +} + +func (svc trigger) handleUpdate(upd *types.Trigger) triggerUpdateHandler { + return func(ctx context.Context, res *types.Trigger) (changes triggerChanges, err error) { + if isStale(upd.UpdatedAt, res.UpdatedAt, res.CreatedAt) { + return triggerUnchanged, TriggerErrStaleData() + } + + if res.Enabled != upd.Enabled { + changes |= triggerChanged + res.Enabled = upd.Enabled + } + + if upd.Labels != nil { + if label.Changed(res.Labels, upd.Labels) { + changes |= triggerLabelsChanged + res.Labels = upd.Labels + } + } + + if res.StepID != upd.StepID { + changes |= triggerChanged + res.StepID = upd.StepID + } + + if res.EventType != upd.EventType { + changes |= triggerChanged + res.EventType = upd.EventType + } + + if res.ResourceType != upd.ResourceType { + changes |= triggerChanged + res.ResourceType = upd.ResourceType + } + + if upd.Meta != nil { + if !reflect.DeepEqual(upd.Meta, res.Meta) { + changes |= triggerChanged + res.Meta = upd.Meta + } + } + + if upd.Input != nil { + if !reflect.DeepEqual(upd.Input, res.Input) { + changes |= triggerChanged + res.Input = upd.Input + } + } + + if upd.Constraints != nil { + if !reflect.DeepEqual(upd.Constraints, res.Constraints) { + changes |= triggerChanged + res.Constraints = upd.Constraints + } + } + + if res.OwnedBy != upd.OwnedBy { + // @todo need to check against access control if current user can modify owner + changes |= triggerChanged + res.OwnedBy = upd.OwnedBy + } + + if changes&triggerChanged > 0 { + res.UpdatedAt = now() + } + + return + } +} + +func (svc trigger) handleDelete(ctx context.Context, res *types.Trigger) (triggerChanges, error) { + if err := svc.canManageTrigger(ctx, res, TriggerErrNotAllowedToDelete()); err != nil { + return triggerUnchanged, err + } + + if res.DeletedAt != nil { + // trigger already deleted + return triggerUnchanged, nil + } + + res.DeletedAt = now() + return triggerChanged, nil +} + +func (svc trigger) handleUndelete(ctx context.Context, res *types.Trigger) (triggerChanges, error) { + if err := svc.canManageTrigger(ctx, res, TriggerErrNotAllowedToUndelete()); err != nil { + return triggerUnchanged, err + } + + if res.DeletedAt == nil { + // trigger not deleted + return triggerUnchanged, nil + } + + res.DeletedAt = nil + return triggerChanged, nil +} + +func (svc trigger) canManageTrigger(ctx context.Context, res *types.Trigger, permErr error) error { + if wf, err := loadWorkflow(ctx, svc.store, res.WorkflowID); err != nil { + return err + } else if !svc.ac.CanManageWorkflowTriggers(ctx, wf) { + return permErr + } else { + return nil + } +} + +// registers all triggers on all given workflows +// before registering triggers on a workflow, all workflow triggers are unregistered +func (svc *trigger) registerWorkflows(ctx context.Context, workflows ...*types.Workflow) error { + // load ALL workflows directly from store + tt, _, err := store.SearchAutomationTriggers(ctx, svc.store, types.TriggerFilter{ + WorkflowID: types.WorkflowSet(workflows).IDs(), + Deleted: filter.StateInclusive, + Disabled: filter.StateExcluded, + }) + + if err != nil { + return err + } + + for _, wf := range workflows { + svc.unregisterWorkflows(wf) + + if err = svc.registerWorkflow(ctx, wf, tt.FilterByWorkflowID(wf.ID)...); err != nil { + return err + } + } + + return nil +} + +// updates trigger handler registration +// +// Loads associated workflow and registers specific trigger +func (svc *trigger) updateTriggerRegistration(ctx context.Context, t *types.Trigger) error { + wf, err := loadWorkflow(ctx, svc.store, t.WorkflowID) + if err != nil { + return err + } + + return svc.registerWorkflow(ctx, wf, t) +} + +// registers one workflow and a set of triggers +func (svc *trigger) registerWorkflow(ctx context.Context, wf *types.Workflow, tt ...*types.Trigger) (err error) { + var ( + runAs auth.Identifiable + ) + + if len(types.TriggerSet(tt).FilterByWorkflowID(wf.ID)) < len(tt) { + return fmt.Errorf("all triggers must reference the given workflow") + } + + if wf.RunAs > 0 { + if runAs, err = DefaultUser.FindByID(ctx, wf.RunAs); err != nil { + return fmt.Errorf("failed to load run-as user %d: %w", wf.RunAs, err) + } else if !runAs.Valid() { + return fmt.Errorf("invalid user %d used for workflow run-as", wf.RunAs) + } + } + + svc.registerTriggers(wf, runAs, tt...) + return nil +} + +// registerTriggers registers workflows triggers to eventbus +// +// It preloads run-as identity and finds a starting step for each trigger +func (svc *trigger) registerTriggers(wf *types.Workflow, runAs auth.Identifiable, tt ...*types.Trigger) { + var ( + err error + g *wfexec.Graph + wfLog = svc.log. + WithOptions(zap.AddStacktrace(zap.DPanicLevel)). + With(zap.Uint64("workflowID", wf.ID)) + ) + + if !wf.Enabled { + wfLog.Debug("skipping disabled workflow") + return + } + + if wf.DeletedAt != nil { + wfLog.Debug("skipping deleted workflow") + return + } + + if g, err = svc.workflow.toGraph(wf); err != nil { + wfLog.Error("failed to convert workflow to graph", zap.Error(err)) + return + } + + defer svc.mux.Unlock() + svc.mux.Lock() + + for _, t := range tt { + log := wfLog.With(zap.Uint64("triggerID", t.ID)) + + if !t.Enabled { + log.Debug("skipping disabled trigger") + continue + } + + if t.DeletedAt != nil { + log.Debug("skipping deleted trigger") + continue + } + + var ( + handler = func(ctx context.Context, ev eventbus.Event) error { + + var ( + // create session scope from predefined workflow scope and trigger input + scope = wf.Scope.Merge(t.Input) + wait WaitFn + ) + + // scope["event"] = ev + + if runAs == nil { + // @todo can/should we get alternative identity from Event? + // for example: + // - use http auth header and get username + // - use from/to/replyTo and use that as an identifier + runAs = auth.GetIdentityFromContext(ctx) + } + + log.Debug("handling triggered workflow", + zap.Any("event", ev), + zap.Uint64("runAs", runAs.Identity()), + ) + + wait, err = svc.session.Start(g, runAs, types.SessionStartParams{ + WorkflowID: wf.ID, + KeepFor: wf.KeepSessions, + Trace: wf.Trace, + Input: scope, + StepID: t.StepID, + EventType: t.EventType, + ResourceType: t.ResourceType, + }) + + if err != nil { + log.Error("workflow error", zap.Error(err)) + return err + } + + return wait(ctx) + } + + ops = make([]eventbus.HandlerRegOp, 0, len(t.Constraints)+2) + cnstr eventbus.ConstraintMatcher + err error + ) + + ops = append( + ops, + eventbus.On(t.EventType), + eventbus.For(t.ResourceType), + ) + + for _, c := range t.Constraints { + if cnstr, err = eventbus.ConstraintMaker(c.Name, c.Op, c.Values...); err != nil { + log.Debug( + "failed to make constraint for workflow trigger", + zap.Any("constraint", c), + zap.Error(err), + ) + } else { + ops = append(ops, eventbus.Constraint(cnstr)) + } + } + + if svc.reg[wf.ID] == nil { + svc.reg[wf.ID] = make(map[uint64]uintptr) + } else if ptr := svc.reg[wf.ID][t.ID]; ptr != 0 { + // unregister handlers for this trigger if they exist + svc.eventbus.Unregister(ptr) + } + + svc.reg[wf.ID][t.ID] = svc.eventbus.Register(handler, ops...) + + log.Debug("trigger registered", + zap.String("eventType", t.EventType), + zap.String("resourceType", t.ResourceType), + zap.Any("constraints", t.Constraints), + ) + } +} + +func (svc *trigger) unregisterWorkflows(wwf ...*types.Workflow) { + defer svc.mux.Unlock() + svc.mux.Lock() + + for _, wf := range wwf { + for triggerID, ptr := range svc.reg[wf.ID] { + svc.eventbus.Unregister(ptr) + svc.log.Debug("trigger unregistered", zap.Uint64("triggerID", triggerID), zap.Uint64("workflowID", wf.ID)) + delete(svc.triggers, wf.ID) + } + + delete(svc.reg, wf.ID) + } +} + +func (svc *trigger) unregisterTriggers(tt ...*types.Trigger) { + defer svc.mux.Unlock() + svc.mux.Lock() + + for _, t := range tt { + if svc.reg[t.WorkflowID] == nil { + return + } + + if ptr, has := svc.reg[t.WorkflowID][t.ID]; has { + svc.eventbus.Unregister(ptr) + svc.log.Debug("trigger unregistered", zap.Uint64("triggerID", t.ID), zap.Uint64("workflowID", t.WorkflowID)) + delete(svc.triggers, t.ID) + } + } +} + +func loadTrigger(ctx context.Context, s store.Storer, workflowID uint64) (res *types.Trigger, err error) { + if workflowID == 0 { + return nil, TriggerErrInvalidID() + } + + if res, err = store.LookupAutomationTriggerByID(ctx, s, workflowID); errors.IsNotFound(err) { + return nil, TriggerErrNotFound() + } + + return +} + +// toLabeledTriggers converts to []label.LabeledResource +func toLabeledTriggers(set []*types.Trigger) []label.LabeledResource { + if len(set) == 0 { + return nil + } + + ll := make([]label.LabeledResource, len(set)) + for i := range set { + ll[i] = set[i] + } + + return ll +} diff --git a/automation/service/trigger_actions.gen.go b/automation/service/trigger_actions.gen.go new file mode 100644 index 000000000..bb55311b5 --- /dev/null +++ b/automation/service/trigger_actions.gen.go @@ -0,0 +1,707 @@ +package service + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// automation/service/trigger_actions.yaml + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/actionlog" + "github.com/cortezaproject/corteza-server/pkg/errors" + "strings" + "time" +) + +type ( + triggerActionProps struct { + trigger *types.Trigger + new *types.Trigger + update *types.Trigger + filter *types.TriggerFilter + } + + triggerAction struct { + timestamp time.Time + resource string + action string + log string + severity actionlog.Severity + + // prefix for error when action fails + errorMessage string + + props *triggerActionProps + } + + triggerLogMetaKey struct{} + triggerPropsMetaKey struct{} +) + +var ( + // just a placeholder to cover template cases w/o fmt package use + _ = fmt.Println +) + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Props methods +// setTrigger updates triggerActionProps's trigger +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *triggerActionProps) setTrigger(trigger *types.Trigger) *triggerActionProps { + p.trigger = trigger + return p +} + +// setNew updates triggerActionProps's new +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *triggerActionProps) setNew(new *types.Trigger) *triggerActionProps { + p.new = new + return p +} + +// setUpdate updates triggerActionProps's update +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *triggerActionProps) setUpdate(update *types.Trigger) *triggerActionProps { + p.update = update + return p +} + +// setFilter updates triggerActionProps's filter +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *triggerActionProps) setFilter(filter *types.TriggerFilter) *triggerActionProps { + p.filter = filter + return p +} + +// Serialize converts triggerActionProps to actionlog.Meta +// +// This function is auto-generated. +// +func (p triggerActionProps) Serialize() actionlog.Meta { + var ( + m = make(actionlog.Meta) + ) + + if p.trigger != nil { + m.Set("trigger.ID", p.trigger.ID, true) + } + if p.new != nil { + m.Set("new.ID", p.new.ID, true) + } + if p.update != nil { + m.Set("update.ID", p.update.ID, true) + } + if p.filter != nil { + } + + return m +} + +// tr translates string and replaces meta value placeholder with values +// +// This function is auto-generated. +// +func (p triggerActionProps) Format(in string, err error) string { + var ( + pairs = []string{"{err}"} + // first non-empty string + fns = func(ii ...interface{}) string { + for _, i := range ii { + if s := fmt.Sprintf("%v", i); len(s) > 0 { + return s + } + } + + return "" + } + ) + + if err != nil { + pairs = append(pairs, err.Error()) + } else { + pairs = append(pairs, "nil") + } + + if p.trigger != nil { + // replacement for "{trigger}" (in order how fields are defined) + pairs = append( + pairs, + "{trigger}", + fns( + p.trigger.ID, + ), + ) + pairs = append(pairs, "{trigger.ID}", fns(p.trigger.ID)) + } + + if p.new != nil { + // replacement for "{new}" (in order how fields are defined) + pairs = append( + pairs, + "{new}", + fns( + p.new.ID, + ), + ) + pairs = append(pairs, "{new.ID}", fns(p.new.ID)) + } + + if p.update != nil { + // replacement for "{update}" (in order how fields are defined) + pairs = append( + pairs, + "{update}", + fns( + p.update.ID, + ), + ) + pairs = append(pairs, "{update.ID}", fns(p.update.ID)) + } + + if p.filter != nil { + // replacement for "{filter}" (in order how fields are defined) + pairs = append( + pairs, + "{filter}", + fns(), + ) + } + return strings.NewReplacer(pairs...).Replace(in) +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Action methods + +// String returns loggable description as string +// +// This function is auto-generated. +// +func (a *triggerAction) String() string { + var props = &triggerActionProps{} + + if a.props != nil { + props = a.props + } + + return props.Format(a.log, nil) +} + +func (e *triggerAction) ToAction() *actionlog.Action { + return &actionlog.Action{ + Resource: e.resource, + Action: e.action, + Severity: e.severity, + Description: e.String(), + Meta: e.props.Serialize(), + } +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Action constructors + +// TriggerActionSearch returns "automation:trigger.search" action +// +// This function is auto-generated. +// +func TriggerActionSearch(props ...*triggerActionProps) *triggerAction { + a := &triggerAction{ + timestamp: time.Now(), + resource: "automation:trigger", + action: "search", + log: "searched for matching triggers", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// TriggerActionLookup returns "automation:trigger.lookup" action +// +// This function is auto-generated. +// +func TriggerActionLookup(props ...*triggerActionProps) *triggerAction { + a := &triggerAction{ + timestamp: time.Now(), + resource: "automation:trigger", + action: "lookup", + log: "looked-up for a {trigger}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// TriggerActionCreate returns "automation:trigger.create" action +// +// This function is auto-generated. +// +func TriggerActionCreate(props ...*triggerActionProps) *triggerAction { + a := &triggerAction{ + timestamp: time.Now(), + resource: "automation:trigger", + action: "create", + log: "created {trigger}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// TriggerActionUpdate returns "automation:trigger.update" action +// +// This function is auto-generated. +// +func TriggerActionUpdate(props ...*triggerActionProps) *triggerAction { + a := &triggerAction{ + timestamp: time.Now(), + resource: "automation:trigger", + action: "update", + log: "updated {trigger}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// TriggerActionDelete returns "automation:trigger.delete" action +// +// This function is auto-generated. +// +func TriggerActionDelete(props ...*triggerActionProps) *triggerAction { + a := &triggerAction{ + timestamp: time.Now(), + resource: "automation:trigger", + action: "delete", + log: "deleted {trigger}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// TriggerActionUndelete returns "automation:trigger.undelete" action +// +// This function is auto-generated. +// +func TriggerActionUndelete(props ...*triggerActionProps) *triggerAction { + a := &triggerAction{ + timestamp: time.Now(), + resource: "automation:trigger", + action: "undelete", + log: "undeleted {trigger}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Error constructors + +// TriggerErrGeneric returns "automation:trigger.generic" as *errors.Error +// +// +// This function is auto-generated. +// +func TriggerErrGeneric(mm ...*triggerActionProps) *errors.Error { + var p = &triggerActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("failed to complete request due to internal error", nil), + + errors.Meta("type", "generic"), + errors.Meta("resource", "automation:trigger"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(triggerLogMetaKey{}, "{err}"), + errors.Meta(triggerPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TriggerErrNotFound returns "automation:trigger.notFound" as *errors.Error +// +// +// This function is auto-generated. +// +func TriggerErrNotFound(mm ...*triggerActionProps) *errors.Error { + var p = &triggerActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("trigger not found", nil), + + errors.Meta("type", "notFound"), + errors.Meta("resource", "automation:trigger"), + + errors.Meta(triggerPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TriggerErrInvalidID returns "automation:trigger.invalidID" as *errors.Error +// +// +// This function is auto-generated. +// +func TriggerErrInvalidID(mm ...*triggerActionProps) *errors.Error { + var p = &triggerActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("invalid ID", nil), + + errors.Meta("type", "invalidID"), + errors.Meta("resource", "automation:trigger"), + + errors.Meta(triggerPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TriggerErrStaleData returns "automation:trigger.staleData" as *errors.Error +// +// +// This function is auto-generated. +// +func TriggerErrStaleData(mm ...*triggerActionProps) *errors.Error { + var p = &triggerActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("stale data", nil), + + errors.Meta("type", "staleData"), + errors.Meta("resource", "automation:trigger"), + + errors.Meta(triggerPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TriggerErrNotAllowedToRead returns "automation:trigger.notAllowedToRead" as *errors.Error +// +// +// This function is auto-generated. +// +func TriggerErrNotAllowedToRead(mm ...*triggerActionProps) *errors.Error { + var p = &triggerActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to read this trigger", nil), + + errors.Meta("type", "notAllowedToRead"), + errors.Meta("resource", "automation:trigger"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(triggerLogMetaKey{}, "failed to read {trigger.ID}; insufficient permissions"), + errors.Meta(triggerPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TriggerErrNotAllowedToSearch returns "automation:trigger.notAllowedToSearch" as *errors.Error +// +// +// This function is auto-generated. +// +func TriggerErrNotAllowedToSearch(mm ...*triggerActionProps) *errors.Error { + var p = &triggerActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to search triggers", nil), + + errors.Meta("type", "notAllowedToSearch"), + errors.Meta("resource", "automation:trigger"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(triggerLogMetaKey{}, "failed to list trigger; insufficient permissions"), + errors.Meta(triggerPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TriggerErrNotAllowedToCreate returns "automation:trigger.notAllowedToCreate" as *errors.Error +// +// +// This function is auto-generated. +// +func TriggerErrNotAllowedToCreate(mm ...*triggerActionProps) *errors.Error { + var p = &triggerActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to create triggers", nil), + + errors.Meta("type", "notAllowedToCreate"), + errors.Meta("resource", "automation:trigger"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(triggerLogMetaKey{}, "failed to create trigger; insufficient permissions"), + errors.Meta(triggerPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TriggerErrNotAllowedToUpdate returns "automation:trigger.notAllowedToUpdate" as *errors.Error +// +// +// This function is auto-generated. +// +func TriggerErrNotAllowedToUpdate(mm ...*triggerActionProps) *errors.Error { + var p = &triggerActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to update this trigger", nil), + + errors.Meta("type", "notAllowedToUpdate"), + errors.Meta("resource", "automation:trigger"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(triggerLogMetaKey{}, "failed to update {trigger.ID}; insufficient permissions"), + errors.Meta(triggerPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TriggerErrNotAllowedToDelete returns "automation:trigger.notAllowedToDelete" as *errors.Error +// +// +// This function is auto-generated. +// +func TriggerErrNotAllowedToDelete(mm ...*triggerActionProps) *errors.Error { + var p = &triggerActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to delete this trigger", nil), + + errors.Meta("type", "notAllowedToDelete"), + errors.Meta("resource", "automation:trigger"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(triggerLogMetaKey{}, "failed to delete {trigger.ID}; insufficient permissions"), + errors.Meta(triggerPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// TriggerErrNotAllowedToUndelete returns "automation:trigger.notAllowedToUndelete" as *errors.Error +// +// +// This function is auto-generated. +// +func TriggerErrNotAllowedToUndelete(mm ...*triggerActionProps) *errors.Error { + var p = &triggerActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to undelete this trigger", nil), + + errors.Meta("type", "notAllowedToUndelete"), + errors.Meta("resource", "automation:trigger"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(triggerLogMetaKey{}, "failed to undelete {trigger.ID}; insufficient permissions"), + errors.Meta(triggerPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* + +// recordAction is a service helper function wraps function that can return error +// +// It will wrap unrecognized/internal errors with generic errors. +// +// This function is auto-generated. +// +func (svc trigger) recordAction(ctx context.Context, props *triggerActionProps, actionFn func(...*triggerActionProps) *triggerAction, err error) error { + if svc.actionlog == nil || actionFn == nil { + // action log disabled or no action fn passed, return error as-is + return err + } else if err == nil { + // action completed w/o error, record it + svc.actionlog.Record(ctx, actionFn(props).ToAction()) + return nil + } + + a := actionFn(props).ToAction() + + // Extracting error information and recording it as action + a.Error = err.Error() + + switch c := err.(type) { + case *errors.Error: + m := c.Meta() + + a.Error = err.Error() + a.Severity = actionlog.Severity(m.AsInt("severity")) + a.Description = props.Format(m.AsString(triggerLogMetaKey{}), err) + + if p, has := m[triggerPropsMetaKey{}]; has { + a.Meta = p.(*triggerActionProps).Serialize() + } + + svc.actionlog.Record(ctx, a) + default: + svc.actionlog.Record(ctx, a) + } + + // Original error is passed on + return err +} diff --git a/automation/service/trigger_actions.yaml b/automation/service/trigger_actions.yaml new file mode 100644 index 000000000..194a23cd9 --- /dev/null +++ b/automation/service/trigger_actions.yaml @@ -0,0 +1,84 @@ +# List of loggable service actions + +resource: automation:trigger +service: trigger + +import: + - github.com/cortezaproject/corteza-server/automation/types + +# Default sensitivity for actions +defaultActionSeverity: info + +# default severity for errors +defaultErrorSeverity: error + + +props: + - name: trigger + type: "*types.Trigger" + fields: [ ID ] + - name: new + type: "*types.Trigger" + fields: [ ID ] + - name: update + type: "*types.Trigger" + fields: [ ID ] + - name: filter + type: "*types.TriggerFilter" + + +actions: + - action: search + log: "searched for matching triggers" + severity: info + + - action: lookup + log: "looked-up for a {trigger}" + severity: info + + - action: create + log: "created {trigger}" + + - action: update + log: "updated {trigger}" + + - action: delete + log: "deleted {trigger}" + + - action: undelete + log: "undeleted {trigger}" + +errors: + - error: notFound + message: "trigger not found" + + - error: invalidID + message: "invalid ID" + + - error: staleData + message: "stale data" + severity: warning + + - error: notAllowedToRead + message: "not allowed to read this trigger" + log: "failed to read {trigger.ID}; insufficient permissions" + + - error: notAllowedToSearch + message: "not allowed to search triggers" + log: "failed to list trigger; insufficient permissions" + + - error: notAllowedToCreate + message: "not allowed to create triggers" + log: "failed to create trigger; insufficient permissions" + + - error: notAllowedToUpdate + message: "not allowed to update this trigger" + log: "failed to update {trigger.ID}; insufficient permissions" + + - error: notAllowedToDelete + message: "not allowed to delete this trigger" + log: "failed to delete {trigger.ID}; insufficient permissions" + + - error: notAllowedToUndelete + message: "not allowed to undelete this trigger" + log: "failed to undelete {trigger.ID}; insufficient permissions" diff --git a/automation/service/workflow.go b/automation/service/workflow.go new file mode 100644 index 000000000..a52908bbe --- /dev/null +++ b/automation/service/workflow.go @@ -0,0 +1,872 @@ +package service + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/actionlog" + intAuth "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/pkg/eventbus" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/handle" + "github.com/cortezaproject/corteza-server/pkg/label" + "github.com/cortezaproject/corteza-server/pkg/rbac" + "github.com/cortezaproject/corteza-server/pkg/wfexec" + "github.com/cortezaproject/corteza-server/store" + "go.uber.org/zap" + "reflect" + "strings" + "sync" +) + +type ( + workflow struct { + eventbus workflowEventTriggerHandler + store store.Storer + actionlog actionlog.Recorder + ac workflowAccessController + triggers *trigger + + log *zap.Logger + + // maps resolved workflow graphs to workflow ID (key, uint64) + wfgs map[uint64]*wfexec.Graph + + // workflow function registry + reg *registry + + mux *sync.RWMutex + parser expr.Parsable + } + + workflowAccessController interface { + CanAccess(context.Context) bool + + CanCreateWorkflow(context.Context) bool + CanReadWorkflow(context.Context, *types.Workflow) bool + CanUpdateWorkflow(context.Context, *types.Workflow) bool + CanDeleteWorkflow(context.Context, *types.Workflow) bool + CanUndeleteWorkflow(context.Context, *types.Workflow) bool + + Grant(ctx context.Context, rr ...*rbac.Rule) error + } + + workflowEventTriggerHandler interface { + Register(h eventbus.HandlerFn, ops ...eventbus.HandlerRegOp) uintptr + Unregister(ptrs ...uintptr) + } + + workflowUpdateHandler func(ctx context.Context, ns *types.Workflow) (workflowChanges, error) + workflowChanges uint8 +) + +const ( + workflowUnchanged workflowChanges = 0 + workflowChanged workflowChanges = 1 + workflowLabelsChanged workflowChanges = 2 + workflowDefChanged workflowChanges = 4 +) + +func Workflow(log *zap.Logger) *workflow { + return &workflow{ + log: log, + actionlog: DefaultActionlog, + store: DefaultStore, + ac: DefaultAccessControl, + triggers: DefaultTrigger, + eventbus: eventbus.Service(), + wfgs: make(map[uint64]*wfexec.Graph), + mux: &sync.RWMutex{}, + parser: expr.NewParser(), + reg: Registry(), + } +} + +func (svc *workflow) Search(ctx context.Context, filter types.WorkflowFilter) (rr types.WorkflowSet, f types.WorkflowFilter, err error) { + var ( + wap = &workflowActionProps{filter: &filter} + ) + + // For each fetched item, store backend will check if it is valid or not + filter.Check = func(res *types.Workflow) (bool, error) { + if !svc.ac.CanReadWorkflow(ctx, res) { + return false, nil + } + + return true, nil + } + + err = func() (err error) { + if filter.Deleted > 0 { + // If list with deleted or suspended users is requested + // user must have access permissions to system (ie: is admin) + // + // not the best solution but ATM it allows us to have at least + // some kind of control over who can see deleted or archived workflows + if !svc.ac.CanAccess(ctx) { + return WorkflowErrNotAllowedToSearch() + } + } + + if len(filter.Labels) > 0 { + filter.LabeledIDs, err = label.Search( + ctx, + svc.store, + types.Workflow{}.LabelResourceKind(), + filter.Labels, + ) + + if err != nil { + return err + } + + // labels specified but no labeled resources found + if len(filter.LabeledIDs) == 0 { + return nil + } + } + + if rr, f, err = store.SearchAutomationWorkflows(ctx, svc.store, filter); err != nil { + return err + } + + if err = label.Load(ctx, svc.store, toLabeledWorkflows(rr)...); err != nil { + return err + } + + return nil + }() + + return rr, filter, svc.recordAction(ctx, wap, WorkflowActionSearch, err) +} + +func (svc *workflow) LookupByID(ctx context.Context, workflowID uint64) (wf *types.Workflow, err error) { + var ( + wap = &workflowActionProps{workflow: &types.Workflow{ID: workflowID}} + ) + + err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error { + if wf, err = loadWorkflow(ctx, s, workflowID); err != nil { + return err + } + + if !svc.ac.CanReadWorkflow(ctx, wf) { + return WorkflowErrNotAllowedToRead() + } + + if err = label.Load(ctx, svc.store, wf); err != nil { + return err + } + + return nil + }) + + return wf, svc.recordAction(ctx, wap, WorkflowActionLookup, err) +} + +// Create adds new workflow resource and saves it into store +// It updates service's cache +func (svc *workflow) Create(ctx context.Context, new *types.Workflow) (wf *types.Workflow, err error) { + var ( + wap = &workflowActionProps{new: new} + cUser = intAuth.GetIdentityFromContext(ctx).Identity() + ) + + err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) { + if !svc.ac.CanCreateWorkflow(ctx) { + return WorkflowErrNotAllowedToCreate() + } + + if !handle.IsValid(new.Handle) { + return WorkflowErrInvalidHandle() + } + + if err = svc.uniqueCheck(ctx, new); err != nil { + return err + } + + if err = validateSteps(new.Steps...); err != nil { + return + } + + wf = &types.Workflow{ + ID: nextID(), + Handle: new.Handle, + Labels: new.Labels, + Meta: new.Meta, + Enabled: new.Enabled, + Trace: new.Trace, + KeepSessions: new.KeepSessions, + + Scope: new.Scope, + Steps: new.Steps, + Paths: new.Paths, + + // @todo need to check against access control if current user can modify security descriptor + RunAs: new.RunAs, + OwnedBy: cUser, + CreatedAt: *now(), + CreatedBy: cUser, + } + + if err = store.CreateAutomationWorkflow(ctx, s, wf); err != nil { + return + } + + if err = label.Create(ctx, s, wf); err != nil { + return + } + + return + }) + + return wf, svc.recordAction(ctx, wap, WorkflowActionCreate, err) +} + +// Update modifies existing workflow resource in the store +func (svc *workflow) Update(ctx context.Context, upd *types.Workflow) (*types.Workflow, error) { + return svc.updater(ctx, upd.ID, WorkflowActionUpdate, func(ctx context.Context, res *types.Workflow) (workflowChanges, error) { + if !svc.ac.CanUpdateWorkflow(ctx, res) { + return workflowUnchanged, WorkflowErrNotAllowedToUpdate() + } + + handler := svc.handleUpdate(upd) + return handler(ctx, res) + }) +} + +func (svc *workflow) DeleteByID(ctx context.Context, workflowID uint64) error { + return trim1st(svc.updater(ctx, workflowID, WorkflowActionDelete, svc.handleDelete)) +} + +func (svc *workflow) UndeleteByID(ctx context.Context, workflowID uint64) error { + return trim1st(svc.updater(ctx, workflowID, WorkflowActionUndelete, svc.handleUndelete)) +} + +// Start runs a new workflow +// +// Workflow execution is asynchronous operation. +func (svc *workflow) Start(ctx context.Context, workflowID uint64, scope *expr.Vars) error { + defer svc.mux.Unlock() + svc.mux.Lock() + return errors.Internal("pending implementation") +} + +func (svc workflow) uniqueCheck(ctx context.Context, res *types.Workflow) (err error) { + if res.Handle != "" { + if e, _ := store.LookupAutomationWorkflowByHandle(ctx, svc.store, res.Handle); e != nil && e.ID != res.ID { + return WorkflowErrHandleNotUnique() + } + } + + return nil +} + +func (svc workflow) updater(ctx context.Context, workflowID uint64, action func(...*workflowActionProps) *workflowAction, fn workflowUpdateHandler) (*types.Workflow, error) { + var ( + changes workflowChanges + res *types.Workflow + aProps = &workflowActionProps{workflow: &types.Workflow{ID: workflowID}} + err error + ) + + err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) { + res, err = loadWorkflow(ctx, s, workflowID) + if err != nil { + return + } + + if err = label.Load(ctx, svc.store, res); err != nil { + return err + } + + aProps.setWorkflow(res) + aProps.setUpdate(res) + + if changes, err = fn(ctx, res); err != nil { + return err + } + + if changes&workflowChanged > 0 { + if err = store.UpdateAutomationWorkflow(ctx, svc.store, res); err != nil { + return err + } + + } + + if changes&workflowDefChanged > 0 { + if err = svc.triggers.registerWorkflows(ctx, res); err != nil { + return err + } + } + + if changes&workflowLabelsChanged > 0 { + if err = label.Update(ctx, s, res); err != nil { + return + } + } + + return err + }) + + return res, svc.recordAction(ctx, aProps, action, err) +} + +func (svc workflow) handleUpdate(upd *types.Workflow) workflowUpdateHandler { + return func(ctx context.Context, res *types.Workflow) (changes workflowChanges, err error) { + if isStale(upd.UpdatedAt, res.UpdatedAt, res.CreatedAt) { + return workflowUnchanged, WorkflowErrStaleData() + } + + if upd.Handle != res.Handle && !handle.IsValid(upd.Handle) { + return workflowUnchanged, WorkflowErrInvalidHandle() + } + + if err := svc.uniqueCheck(ctx, upd); err != nil { + return workflowUnchanged, err + } + + if !svc.ac.CanUpdateWorkflow(ctx, res) { + return workflowUnchanged, WorkflowErrNotAllowedToUpdate() + } + + if res.Handle != upd.Handle { + changes |= workflowChanged + res.Handle = upd.Handle + } + + if res.Enabled != upd.Enabled { + changes |= workflowChanged | workflowDefChanged + res.Enabled = upd.Enabled + } + + if upd.Labels != nil { + if label.Changed(res.Labels, upd.Labels) { + changes |= workflowLabelsChanged + res.Labels = upd.Labels + } + } + + if res.Trace != upd.Trace { + changes |= workflowChanged | workflowDefChanged + res.Trace = upd.Trace + } + + if res.KeepSessions != upd.KeepSessions { + changes |= workflowChanged | workflowDefChanged + res.KeepSessions = upd.KeepSessions + } + + if upd.Meta != nil { + if !reflect.DeepEqual(upd.Meta, res.Meta) { + changes |= workflowChanged + res.Meta = upd.Meta + } + } + + if upd.Scope != nil { + if !reflect.DeepEqual(upd.Scope, res.Scope) { + changes |= workflowChanged | workflowDefChanged + res.Scope = upd.Scope + } + } + + if upd.Steps != nil { + if !reflect.DeepEqual(upd.Steps, res.Steps) { + if err = validateSteps(upd.Steps...); err != nil { + return + } + + changes |= workflowChanged | workflowDefChanged + res.Steps = upd.Steps + } + } + + if upd.Paths != nil { + if !reflect.DeepEqual(upd.Paths, res.Paths) { + changes |= workflowChanged | workflowDefChanged + res.Paths = upd.Paths + } + } + + if res.RunAs != upd.RunAs { + // @todo need to check against access control if current user can modify security descriptor + changes |= workflowChanged | workflowDefChanged + res.RunAs = upd.RunAs + } + + if res.OwnedBy != upd.OwnedBy { + // @todo need to check against access control if current user can modify owner + changes |= workflowChanged + res.OwnedBy = upd.OwnedBy + } + + if changes&workflowChanged > 0 { + res.UpdatedAt = now() + } + + return + } +} + +func (svc workflow) handleDelete(ctx context.Context, res *types.Workflow) (workflowChanges, error) { + if !svc.ac.CanDeleteWorkflow(ctx, res) { + return workflowUnchanged, WorkflowErrNotAllowedToDelete() + } + + if res.DeletedAt != nil { + // workflow already deleted + return workflowUnchanged, nil + } + + res.DeletedAt = now() + return workflowChanged, nil +} + +func (svc workflow) handleUndelete(ctx context.Context, res *types.Workflow) (workflowChanges, error) { + if !svc.ac.CanDeleteWorkflow(ctx, res) { + return workflowUnchanged, WorkflowErrNotAllowedToUndelete() + } + + if res.DeletedAt == nil { + // workflow not deleted + return workflowUnchanged, nil + } + + res.DeletedAt = nil + return workflowChanged, nil +} + +func (svc *workflow) Load(ctx context.Context) error { + wwf, _, err := store.SearchAutomationWorkflows(ctx, svc.store, types.WorkflowFilter{ + Deleted: filter.StateInclusive, + Disabled: filter.StateExcluded, + }) + + if err != nil { + return err + } + + return svc.triggers.registerWorkflows(ctx, wwf...) +} + +// Converts workflow definition to wf execution graph +func (svc *workflow) toGraph(def *types.Workflow) (*wfexec.Graph, error) { + var ( + g = wfexec.NewGraph() + ) + + for g.Len() < len(def.Steps) { + progress := false + for _, step := range def.Steps { + if g.StepByID(step.ID) != nil { + // resolved + continue + } + + if step.Kind == types.WorkflowStepKindVisual { + // make sure visual steps are skipped + continue + } + + // Collect all incoming and outgoing paths + inPaths := make([]*types.WorkflowPath, 0, 8) + outPaths := make([]*types.WorkflowPath, 0, 8) + for _, path := range def.Paths { + if path.ChildID == step.ID { + inPaths = append(inPaths, path) + } else if path.ParentID == step.ID { + outPaths = append(outPaths, path) + } + } + + if resolved, err := svc.workflowStepDefConv(g, step, inPaths, outPaths); err != nil { + return nil, err + } else if resolved { + progress = true + } + } + + if !progress { + // nothing resolved + return nil, errors.Internal("failed to resolve workflow step dependencies") + } + } + + for _, path := range def.Paths { + if g.StepByID(path.ChildID) == nil { + return nil, errors.Internal("failed to resolve step with ID %d", path.ChildID) + } + + if g.StepByID(path.ParentID) == nil { + return nil, errors.Internal("failed to resolve step with ID %d", path.ParentID) + } + + g.AddParent( + g.StepByID(path.ChildID), + g.StepByID(path.ParentID), + ) + } + + return g, nil +} + +// converts all step definitions into workflow.Step instances +// +// if this func returns nil for step and error, assume unresolved dependencies +func (svc *workflow) workflowStepDefConv(g *wfexec.Graph, s *types.WorkflowStep, in, out []*types.WorkflowPath) (bool, error) { + conv, err := func() (wfexec.Step, error) { + switch s.Kind { + case types.WorkflowStepKindVisual: + return nil, nil + + case types.WorkflowStepKindExpressions: + return svc.convExpressionStep(s) + + case types.WorkflowStepKindGateway: + return svc.convGateway(g, s, in, out) + + case types.WorkflowStepKindFunction, types.WorkflowStepKindIterator: + return svc.convFunctionStep(g, s, out) + + //case types.WorkflowStepKindMessage: + // return svc.convMessageStep(s) + + case types.WorkflowStepKindPrompt: + return svc.convPromptStep(s) + + case types.WorkflowStepKindErrHandler: + return svc.convErrorHandlerStep(g, out) + + default: + return nil, errors.Internal("unsupported step kind %q", s.Kind) + } + }() + + if err != nil { + return false, err + } else if conv != nil { + conv.SetID(s.ID) + g.AddStep(conv) + return true, err + } else { + // signal caller that we were unable to + // resolve definition at the moment + return false, nil + } +} + +func (svc *workflow) convGateway(g *wfexec.Graph, s *types.WorkflowStep, in, out []*types.WorkflowPath) (wfexec.Step, error) { + switch s.Ref { + case "fork": + return wfexec.ForkGateway(), nil + + case "join": + var ( + ss []wfexec.Step + ) + for _, p := range in { + if parent := g.StepByID(p.ParentID); parent != nil { + ss = append(ss, parent) + } else { + // unresolved parent, come back later. + return nil, nil + } + } + + return wfexec.JoinGateway(ss...), nil + + case "incl", "excl": + var ( + pp []*wfexec.GatewayPath + ) + + for _, c := range out { + child := g.StepByID(c.ChildID) + if child == nil { + return nil, nil + } + + if len(c.Expr) > 0 { + if err := svc.parser.ParseEvaluators(c); err != nil { + return nil, err + } + } + + // wrapping with fn to make sure that we're dealing with the right wf path inside gw-path tester fn + err := func(c types.WorkflowPath) error { + p, err := wfexec.NewGatewayPath(child, func(ctx context.Context, scope *expr.Vars) (bool, error) { + if len(c.Expr) == 0 { + return true, nil + } + + return c.Test(ctx, scope) + }) + + if err != nil { + return err + } + + pp = append(pp, p) + return nil + }(*c) + + if err != nil { + return nil, err + } + } + + if s.Ref == "excl" { + return wfexec.ExclGateway(pp...) + } else { + return wfexec.InclGateway(pp...) + } + } + + return nil, fmt.Errorf("unknown gateway type") +} + +func (svc *workflow) convErrorHandlerStep(g *wfexec.Graph, out []*types.WorkflowPath) (wfexec.Step, error) { + switch len(out) { + case 0: + return nil, fmt.Errorf("expecting at least one path out of error handling step") + case 1: + // remove error handler + return types.ErrorHandlerStep(nil), nil + case 2: + errorHandler := g.StepByID(out[1].ChildID) + if errorHandler == nil { + // wait for it to be resolved + return nil, nil + } + + return types.ErrorHandlerStep(errorHandler), nil + + default: + // this might be extended in the future to allow different paths using expression + // but then again, this can be solved by gateway path following the error handling step + return nil, fmt.Errorf("max 2 paths out of error handling step") + } +} + +func (svc *workflow) convExpressionStep(s *types.WorkflowStep) (wfexec.Step, error) { + if err := svc.parseExpressions(s.Arguments...); err != nil { + return nil, err + } + + return types.ExpressionsStep(s.Arguments...), nil +} + +func (svc *workflow) convFunctionStep(g *wfexec.Graph, s *types.WorkflowStep, out []*types.WorkflowPath) (wfexec.Step, error) { + if s.Ref == "" { + return nil, errors.Internal("function reference missing") + } + + reg := Registry() + + if def := reg.Function(s.Ref); def == nil { + return nil, errors.Internal("unknown function %q", s.Ref) + } else { + if def.Kind != string(s.Kind) { + return nil, fmt.Errorf("unexpected %s on %s step", def.Kind, s.Kind) + } + + var ( + err error + isIterator = def.Kind == types.FunctionKindIterator + ) + + if isIterator { + if len(out) != 2 { + return nil, fmt.Errorf("expecting exactly two paths (next, exit) out of iterator function step") + } + + if def.Iterator == nil { + return nil, errors.Internal("iterator handler for %q not set", s.Ref) + } + } else { + if def.Handler == nil { + return nil, errors.Internal("function handler for %q not set", s.Ref) + } + } + + if err = svc.parseExpressions(s.Arguments...); err != nil { + return nil, errors.Internal("failed to parse argument expressions for %s %s: %s", s.Kind, s.Ref, err).Wrap(err) + } else if err = def.Parameters.VerifyArguments(s.Arguments); err != nil { + return nil, errors.Internal("failed to verify argument expressions for %s %s: %s", s.Kind, s.Ref, err).Wrap(err) + } + + if err = svc.parseExpressions(s.Results...); err != nil { + return nil, errors.Internal("failed to parse result expressions for %s %s: %s", s.Kind, s.Ref, err).Wrap(err) + } else if err = def.Results.VerifyResults(s.Results); err != nil { + return nil, errors.Internal("failed to verify result expressions for %s %s: %s", s.Kind, s.Ref, err).Wrap(err) + } + + if isIterator { + var ( + next = g.StepByID(out[0].ChildID) + exit = g.StepByID(out[1].ChildID) + ) + + if next == nil || exit == nil { + // wait for steps to be resolved + return nil, nil + } + + return types.IteratorStep(def, s.Arguments, s.Results, next, exit) + + } else { + return types.FunctionStep(def, s.Arguments, s.Results) + } + } +} + +// converts prompt definition to wfexec.Step +func (svc *workflow) convPromptStep(s *types.WorkflowStep) (wfexec.Step, error) { + if err := svc.parseExpressions(s.Arguments...); err != nil { + return nil, err + } + + // Use expression step as base for prompt step + return types.PromptStep(s.Ref, types.ExpressionsStep(s.Arguments...)), nil +} + +func (svc *workflow) parseExpressions(ee ...*types.Expr) (err error) { + for _, e := range ee { + + if len(strings.TrimSpace(e.Expr)) > 0 { + if err = svc.parser.ParseEvaluators(e); err != nil { + return + } + } + + if err = e.SetType(exprTypeSetter(svc.reg, e)); err != nil { + return err + } + + for _, t := range e.Tests { + if err = svc.parser.ParseEvaluators(t); err != nil { + return + } + } + } + + return nil +} + +func validateSteps(ss ...*types.WorkflowStep) error { + var ( + IDs = make(map[uint64]int) + + noArgs = func(i int, s *types.WorkflowStep) error { + if len(s.Arguments) > 0 { + return errors.Internal("%s step (ID=%d, position=%d) does not accept arguments", s.Kind, s.ID, i) + } + + return nil + } + + reqArgs = func(i int, s *types.WorkflowStep) error { + if len(s.Arguments) == 0 { + return errors.Internal("%s step (ID=%d, position=%d) require defined arguments", s.Kind, s.ID, i) + } + + return nil + } + + noResults = func(i int, s *types.WorkflowStep) error { + if len(s.Results) > 0 { + return errors.Internal("%s step (ID=%d, position=%d) does not accept results", s.Kind, s.ID, i) + } + + return nil + } + + checks = make([]func(i int, s *types.WorkflowStep) error, 0) + ) + + for i, s := range ss { + if p, has := IDs[s.ID]; has { + return fmt.Errorf("duplicate step ID (%d) used for steps on positions %d and %d", s.ID, p, i) + } + + IDs[s.ID] = i + checks = nil + + switch s.Kind { + case types.WorkflowStepKindErrHandler: + + case types.WorkflowStepKindVisual: + checks = append(checks, noArgs, noResults) + + case types.WorkflowStepKindExpressions: + checks = append(checks, reqArgs, noResults) + + case types.WorkflowStepKindGateway: + checks = append(checks, noArgs, noResults) + + case types.WorkflowStepKindFunction, types.WorkflowStepKindIterator: + + case types.WorkflowStepKindPrompt: + checks = append(checks, noResults) + + default: + return errors.Internal("unknown step kind (ID=%d, position=%d)", s.ID, i) + } + + for _, check := range checks { + if err := check(i, s); err != nil { + return err + } + } + } + + return nil +} + +func loadWorkflow(ctx context.Context, s store.Storer, workflowID uint64) (res *types.Workflow, err error) { + if workflowID == 0 { + return nil, WorkflowErrInvalidID() + } + + if res, err = store.LookupAutomationWorkflowByID(ctx, s, workflowID); errors.IsNotFound(err) { + return nil, WorkflowErrNotFound() + } + + return +} + +// toLabeledWorkflows converts to []label.LabeledResource +func toLabeledWorkflows(set []*types.Workflow) []label.LabeledResource { + if len(set) == 0 { + return nil + } + + ll := make([]label.LabeledResource, len(set)) + for i := range set { + ll[i] = set[i] + } + + return ll +} + +func exprTypeSetter(reg *registry, e *types.Expr) func(string) (expr.Type, error) { + return func(name string) (expr.Type, error) { + if name == "" { + name = "Any" + } + + if typ := reg.Type(name); typ != nil { + return typ, nil + } else { + return nil, errors.NotFound( + "unknown or unregistered type %q used for expression %q on %q", + name, + e.Expr, + e.Target, + ) + } + } +} diff --git a/automation/service/workflow_actions.gen.go b/automation/service/workflow_actions.gen.go new file mode 100644 index 000000000..b7820d331 --- /dev/null +++ b/automation/service/workflow_actions.gen.go @@ -0,0 +1,778 @@ +package service + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// automation/service/workflow_actions.yaml + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/actionlog" + "github.com/cortezaproject/corteza-server/pkg/errors" + "strings" + "time" +) + +type ( + workflowActionProps struct { + workflow *types.Workflow + new *types.Workflow + update *types.Workflow + filter *types.WorkflowFilter + } + + workflowAction struct { + timestamp time.Time + resource string + action string + log string + severity actionlog.Severity + + // prefix for error when action fails + errorMessage string + + props *workflowActionProps + } + + workflowLogMetaKey struct{} + workflowPropsMetaKey struct{} +) + +var ( + // just a placeholder to cover template cases w/o fmt package use + _ = fmt.Println +) + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Props methods +// setWorkflow updates workflowActionProps's workflow +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *workflowActionProps) setWorkflow(workflow *types.Workflow) *workflowActionProps { + p.workflow = workflow + return p +} + +// setNew updates workflowActionProps's new +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *workflowActionProps) setNew(new *types.Workflow) *workflowActionProps { + p.new = new + return p +} + +// setUpdate updates workflowActionProps's update +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *workflowActionProps) setUpdate(update *types.Workflow) *workflowActionProps { + p.update = update + return p +} + +// setFilter updates workflowActionProps's filter +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *workflowActionProps) setFilter(filter *types.WorkflowFilter) *workflowActionProps { + p.filter = filter + return p +} + +// Serialize converts workflowActionProps to actionlog.Meta +// +// This function is auto-generated. +// +func (p workflowActionProps) Serialize() actionlog.Meta { + var ( + m = make(actionlog.Meta) + ) + + if p.workflow != nil { + m.Set("workflow.handle", p.workflow.Handle, true) + m.Set("workflow.ID", p.workflow.ID, true) + } + if p.new != nil { + m.Set("new.handle", p.new.Handle, true) + m.Set("new.ID", p.new.ID, true) + } + if p.update != nil { + m.Set("update.handle", p.update.Handle, true) + m.Set("update.ID", p.update.ID, true) + } + if p.filter != nil { + } + + return m +} + +// tr translates string and replaces meta value placeholder with values +// +// This function is auto-generated. +// +func (p workflowActionProps) Format(in string, err error) string { + var ( + pairs = []string{"{err}"} + // first non-empty string + fns = func(ii ...interface{}) string { + for _, i := range ii { + if s := fmt.Sprintf("%v", i); len(s) > 0 { + return s + } + } + + return "" + } + ) + + if err != nil { + pairs = append(pairs, err.Error()) + } else { + pairs = append(pairs, "nil") + } + + if p.workflow != nil { + // replacement for "{workflow}" (in order how fields are defined) + pairs = append( + pairs, + "{workflow}", + fns( + p.workflow.Handle, + p.workflow.ID, + ), + ) + pairs = append(pairs, "{workflow.handle}", fns(p.workflow.Handle)) + pairs = append(pairs, "{workflow.ID}", fns(p.workflow.ID)) + } + + if p.new != nil { + // replacement for "{new}" (in order how fields are defined) + pairs = append( + pairs, + "{new}", + fns( + p.new.Handle, + p.new.ID, + ), + ) + pairs = append(pairs, "{new.handle}", fns(p.new.Handle)) + pairs = append(pairs, "{new.ID}", fns(p.new.ID)) + } + + if p.update != nil { + // replacement for "{update}" (in order how fields are defined) + pairs = append( + pairs, + "{update}", + fns( + p.update.Handle, + p.update.ID, + ), + ) + pairs = append(pairs, "{update.handle}", fns(p.update.Handle)) + pairs = append(pairs, "{update.ID}", fns(p.update.ID)) + } + + if p.filter != nil { + // replacement for "{filter}" (in order how fields are defined) + pairs = append( + pairs, + "{filter}", + fns(), + ) + } + return strings.NewReplacer(pairs...).Replace(in) +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Action methods + +// String returns loggable description as string +// +// This function is auto-generated. +// +func (a *workflowAction) String() string { + var props = &workflowActionProps{} + + if a.props != nil { + props = a.props + } + + return props.Format(a.log, nil) +} + +func (e *workflowAction) ToAction() *actionlog.Action { + return &actionlog.Action{ + Resource: e.resource, + Action: e.action, + Severity: e.severity, + Description: e.String(), + Meta: e.props.Serialize(), + } +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Action constructors + +// WorkflowActionSearch returns "automation:workflow.search" action +// +// This function is auto-generated. +// +func WorkflowActionSearch(props ...*workflowActionProps) *workflowAction { + a := &workflowAction{ + timestamp: time.Now(), + resource: "automation:workflow", + action: "search", + log: "searched for matching workflows", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// WorkflowActionLookup returns "automation:workflow.lookup" action +// +// This function is auto-generated. +// +func WorkflowActionLookup(props ...*workflowActionProps) *workflowAction { + a := &workflowAction{ + timestamp: time.Now(), + resource: "automation:workflow", + action: "lookup", + log: "looked-up for a {workflow}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// WorkflowActionCreate returns "automation:workflow.create" action +// +// This function is auto-generated. +// +func WorkflowActionCreate(props ...*workflowActionProps) *workflowAction { + a := &workflowAction{ + timestamp: time.Now(), + resource: "automation:workflow", + action: "create", + log: "created {workflow}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// WorkflowActionUpdate returns "automation:workflow.update" action +// +// This function is auto-generated. +// +func WorkflowActionUpdate(props ...*workflowActionProps) *workflowAction { + a := &workflowAction{ + timestamp: time.Now(), + resource: "automation:workflow", + action: "update", + log: "updated {workflow}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// WorkflowActionDelete returns "automation:workflow.delete" action +// +// This function is auto-generated. +// +func WorkflowActionDelete(props ...*workflowActionProps) *workflowAction { + a := &workflowAction{ + timestamp: time.Now(), + resource: "automation:workflow", + action: "delete", + log: "deleted {workflow}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// WorkflowActionUndelete returns "automation:workflow.undelete" action +// +// This function is auto-generated. +// +func WorkflowActionUndelete(props ...*workflowActionProps) *workflowAction { + a := &workflowAction{ + timestamp: time.Now(), + resource: "automation:workflow", + action: "undelete", + log: "undeleted {workflow}", + severity: actionlog.Info, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* +// Error constructors + +// WorkflowErrGeneric returns "automation:workflow.generic" as *errors.Error +// +// +// This function is auto-generated. +// +func WorkflowErrGeneric(mm ...*workflowActionProps) *errors.Error { + var p = &workflowActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("failed to complete request due to internal error", nil), + + errors.Meta("type", "generic"), + errors.Meta("resource", "automation:workflow"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(workflowLogMetaKey{}, "{err}"), + errors.Meta(workflowPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// WorkflowErrNotFound returns "automation:workflow.notFound" as *errors.Error +// +// +// This function is auto-generated. +// +func WorkflowErrNotFound(mm ...*workflowActionProps) *errors.Error { + var p = &workflowActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("workflow not found", nil), + + errors.Meta("type", "notFound"), + errors.Meta("resource", "automation:workflow"), + + errors.Meta(workflowPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// WorkflowErrInvalidID returns "automation:workflow.invalidID" as *errors.Error +// +// +// This function is auto-generated. +// +func WorkflowErrInvalidID(mm ...*workflowActionProps) *errors.Error { + var p = &workflowActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("invalid ID", nil), + + errors.Meta("type", "invalidID"), + errors.Meta("resource", "automation:workflow"), + + errors.Meta(workflowPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// WorkflowErrInvalidHandle returns "automation:workflow.invalidHandle" as *errors.Error +// +// +// This function is auto-generated. +// +func WorkflowErrInvalidHandle(mm ...*workflowActionProps) *errors.Error { + var p = &workflowActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("invalid handle", nil), + + errors.Meta("type", "invalidHandle"), + errors.Meta("resource", "automation:workflow"), + + errors.Meta(workflowPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// WorkflowErrStaleData returns "automation:workflow.staleData" as *errors.Error +// +// +// This function is auto-generated. +// +func WorkflowErrStaleData(mm ...*workflowActionProps) *errors.Error { + var p = &workflowActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("stale data", nil), + + errors.Meta("type", "staleData"), + errors.Meta("resource", "automation:workflow"), + + errors.Meta(workflowPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// WorkflowErrNotAllowedToRead returns "automation:workflow.notAllowedToRead" as *errors.Error +// +// +// This function is auto-generated. +// +func WorkflowErrNotAllowedToRead(mm ...*workflowActionProps) *errors.Error { + var p = &workflowActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to read this workflow", nil), + + errors.Meta("type", "notAllowedToRead"), + errors.Meta("resource", "automation:workflow"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(workflowLogMetaKey{}, "failed to read {workflow.handle}; insufficient permissions"), + errors.Meta(workflowPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// WorkflowErrNotAllowedToSearch returns "automation:workflow.notAllowedToSearch" as *errors.Error +// +// +// This function is auto-generated. +// +func WorkflowErrNotAllowedToSearch(mm ...*workflowActionProps) *errors.Error { + var p = &workflowActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to search workflows", nil), + + errors.Meta("type", "notAllowedToSearch"), + errors.Meta("resource", "automation:workflow"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(workflowLogMetaKey{}, "failed to list workflow; insufficient permissions"), + errors.Meta(workflowPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// WorkflowErrNotAllowedToCreate returns "automation:workflow.notAllowedToCreate" as *errors.Error +// +// +// This function is auto-generated. +// +func WorkflowErrNotAllowedToCreate(mm ...*workflowActionProps) *errors.Error { + var p = &workflowActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to create workflows", nil), + + errors.Meta("type", "notAllowedToCreate"), + errors.Meta("resource", "automation:workflow"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(workflowLogMetaKey{}, "failed to create workflow; insufficient permissions"), + errors.Meta(workflowPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// WorkflowErrNotAllowedToUpdate returns "automation:workflow.notAllowedToUpdate" as *errors.Error +// +// +// This function is auto-generated. +// +func WorkflowErrNotAllowedToUpdate(mm ...*workflowActionProps) *errors.Error { + var p = &workflowActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to update this workflow", nil), + + errors.Meta("type", "notAllowedToUpdate"), + errors.Meta("resource", "automation:workflow"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(workflowLogMetaKey{}, "failed to update {workflow}; insufficient permissions"), + errors.Meta(workflowPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// WorkflowErrNotAllowedToDelete returns "automation:workflow.notAllowedToDelete" as *errors.Error +// +// +// This function is auto-generated. +// +func WorkflowErrNotAllowedToDelete(mm ...*workflowActionProps) *errors.Error { + var p = &workflowActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to delete this workflow", nil), + + errors.Meta("type", "notAllowedToDelete"), + errors.Meta("resource", "automation:workflow"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(workflowLogMetaKey{}, "failed to delete {workflow}; insufficient permissions"), + errors.Meta(workflowPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// WorkflowErrNotAllowedToUndelete returns "automation:workflow.notAllowedToUndelete" as *errors.Error +// +// +// This function is auto-generated. +// +func WorkflowErrNotAllowedToUndelete(mm ...*workflowActionProps) *errors.Error { + var p = &workflowActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to undelete this workflow", nil), + + errors.Meta("type", "notAllowedToUndelete"), + errors.Meta("resource", "automation:workflow"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(workflowLogMetaKey{}, "failed to undelete {workflow}; insufficient permissions"), + errors.Meta(workflowPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// WorkflowErrHandleNotUnique returns "automation:workflow.handleNotUnique" as *errors.Error +// +// +// This function is auto-generated. +// +func WorkflowErrHandleNotUnique(mm ...*workflowActionProps) *errors.Error { + var p = &workflowActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("workflow handle not unique", nil), + + errors.Meta("type", "handleNotUnique"), + errors.Meta("resource", "automation:workflow"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(workflowLogMetaKey{}, "duplicate handle used for workflow ({workflow})"), + errors.Meta(workflowPropsMetaKey{}, p), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// ********************************************************************************************************************* +// ********************************************************************************************************************* + +// recordAction is a service helper function wraps function that can return error +// +// It will wrap unrecognized/internal errors with generic errors. +// +// This function is auto-generated. +// +func (svc workflow) recordAction(ctx context.Context, props *workflowActionProps, actionFn func(...*workflowActionProps) *workflowAction, err error) error { + if svc.actionlog == nil || actionFn == nil { + // action log disabled or no action fn passed, return error as-is + return err + } else if err == nil { + // action completed w/o error, record it + svc.actionlog.Record(ctx, actionFn(props).ToAction()) + return nil + } + + a := actionFn(props).ToAction() + + // Extracting error information and recording it as action + a.Error = err.Error() + + switch c := err.(type) { + case *errors.Error: + m := c.Meta() + + a.Error = err.Error() + a.Severity = actionlog.Severity(m.AsInt("severity")) + a.Description = props.Format(m.AsString(workflowLogMetaKey{}), err) + + if p, has := m[workflowPropsMetaKey{}]; has { + a.Meta = p.(*workflowActionProps).Serialize() + } + + svc.actionlog.Record(ctx, a) + default: + svc.actionlog.Record(ctx, a) + } + + // Original error is passed on + return err +} diff --git a/automation/service/workflow_actions.yaml b/automation/service/workflow_actions.yaml new file mode 100644 index 000000000..1eae4f4dd --- /dev/null +++ b/automation/service/workflow_actions.yaml @@ -0,0 +1,91 @@ +# List of loggable service actions + +resource: automation:workflow +service: workflow + +import: + - github.com/cortezaproject/corteza-server/automation/types + +# Default sensitivity for actions +defaultActionSeverity: info + +# default severity for errors +defaultErrorSeverity: error + + +props: + - name: workflow + type: "*types.Workflow" + fields: [ handle, ID ] + - name: new + type: "*types.Workflow" + fields: [ handle, ID ] + - name: update + type: "*types.Workflow" + fields: [ handle, ID ] + - name: filter + type: "*types.WorkflowFilter" + + +actions: + - action: search + log: "searched for matching workflows" + severity: info + + - action: lookup + log: "looked-up for a {workflow}" + severity: info + + - action: create + log: "created {workflow}" + + - action: update + log: "updated {workflow}" + + - action: delete + log: "deleted {workflow}" + + - action: undelete + log: "undeleted {workflow}" + +errors: + - error: notFound + message: "workflow not found" + + - error: invalidID + message: "invalid ID" + + - error: invalidHandle + message: "invalid handle" + + - error: staleData + message: "stale data" + severity: warning + + - error: notAllowedToRead + message: "not allowed to read this workflow" + log: "failed to read {workflow.handle}; insufficient permissions" + + - error: notAllowedToSearch + message: "not allowed to search workflows" + log: "failed to list workflow; insufficient permissions" + + - error: notAllowedToCreate + message: "not allowed to create workflows" + log: "failed to create workflow; insufficient permissions" + + - error: notAllowedToUpdate + message: "not allowed to update this workflow" + log: "failed to update {workflow}; insufficient permissions" + + - error: notAllowedToDelete + message: "not allowed to delete this workflow" + log: "failed to delete {workflow}; insufficient permissions" + + - error: notAllowedToUndelete + message: "not allowed to undelete this workflow" + log: "failed to undelete {workflow}; insufficient permissions" + + - error: handleNotUnique + message: "workflow handle not unique" + log: "duplicate handle used for workflow ({workflow})" diff --git a/automation/types/error_handler.go b/automation/types/error_handler.go new file mode 100644 index 000000000..cd6bb9310 --- /dev/null +++ b/automation/types/error_handler.go @@ -0,0 +1,22 @@ +package types + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/wfexec" +) + +type ( + errorHandlerStep struct { + identifiableStep + handler wfexec.Step + } +) + +func ErrorHandlerStep(h wfexec.Step) *errorHandlerStep { + return &errorHandlerStep{handler: h} +} + +// Executes prompt step +func (h *errorHandlerStep) Exec(_ context.Context, _ *wfexec.ExecRequest) (wfexec.ExecResponse, error) { + return wfexec.ErrorHandler(h.handler), nil +} diff --git a/automation/types/expr.go b/automation/types/expr.go new file mode 100644 index 000000000..4799d7058 --- /dev/null +++ b/automation/types/expr.go @@ -0,0 +1,218 @@ +package types + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/wfexec" +) + +type ( + // Used for expression steps, arguments/results mapping and for input validation + Expr struct { + // Variable name to set results of the expression to + Target string `json:"target"` + + // Source of the value / name of the variable from scope; if set value is copied target + // + // Takes precedence to value + // + Source string `json:"source,omitempty"` + + // Expression to evaluate over the input variables; results will be set to target + // + Expr string `json:"expr,omitempty"` + + // Raw value to be set to target + // + // If expression is set and fails, evaluation defaults to value and suppresses the error + Value interface{} `json:"value,omitempty"` + + eval expr.Evaluable + + // Expected type of the input value + Type string `json:"type,omitempty"` + + typ expr.Type + + // Set of tests that can be run before input is evaluated and result copied to scope + Tests TestSet `json:"tests,omitempty"` + } + + ExprSet []*Expr + + // WorkflowStepExpression is created from WorkflowStep with kind=expressions + expressionsStep struct { + identifiableStep + Set ExprSet + } +) + +func NewExpr(target, typ, expr string) (e *Expr, err error) { + return &Expr{Expr: expr, Target: target, Type: typ}, nil +} + +func (e *Expr) SetType(fn func(string) (expr.Type, error)) error { + if typ, err := fn(e.Type); err != nil { + return err + } else { + e.typ = typ + return nil + } +} + +func (e Expr) GetExpr() string { return e.Expr } +func (e *Expr) SetEval(eval expr.Evaluable) { e.eval = eval } +func (e Expr) Eval(ctx context.Context, scope *expr.Vars) (interface{}, error) { + return e.eval.Eval(ctx, scope) +} +func (e Expr) Test(ctx context.Context, scope *expr.Vars) (bool, error) { + return e.eval.Test(ctx, scope) +} + +func (set ExprSet) GetByTarget(t string) *Expr { + for _, e := range set { + if e.Target == t { + return e + } + } + return nil +} + +func (set ExprSet) Validate(ctx context.Context, in *expr.Vars) (TestSet, error) { + var ( + out TestSet + vv TestSet + err error + + // Copy/create scope + scope = (&expr.Vars{}).Merge(in) + ) + + for _, e := range set { + vv, err = e.Tests.Validate(ctx, scope) + if err != nil { + return nil, err + } + + out = append(out, vv...) + } + + return out, nil +} + +// Eval on expression set (ExprSet) evaluates all expressions in the set and returns new scope with all set targets +func (set ExprSet) Eval(ctx context.Context, in *expr.Vars) (*expr.Vars, error) { + var ( + err error + + // Copy input to scope + scope = (&expr.Vars{}).Merge(in) + + // Prepare output scope + out = &expr.Vars{} + + // Untyped evaluation result + value interface{} + + knownType = func(p expr.Type) bool { + return p != nil && p.Type() != expr.Any{}.Type() && p.Type() != expr.Unresolved{}.Type() + } + ) + + for _, e := range set { + value = e.Value + + if e.typ == nil { + return nil, errors.Internal("type for target %q not initialized", e.Target) + } + + err = func() (err error) { + if len(e.Source) > 0 { + // can copy from existing variable + if !scope.Has(e.Source) { + return errors.NotFound("variable %q does not exist", e.Source) + } + + value, _ = expr.Select(scope, e.Source) + return + } + + if len(e.Expr) > 0 { + if e.eval == nil { + // no expression set, fallback to default value + return errors.Internal("expression language for target %q not initialized", e.Target) + } else if value, err = e.eval.Eval(ctx, scope); err != nil { + return errors.Internal("expression %q failed: %s", e.Expr, err.Error()).Wrap(err) + } + } + + return + }() + + if err != nil && e.Value == nil { + return nil, err + } + + typedValue, is := value.(expr.TypedValue) + if !is { + if e.typ == nil { + typedValue, _ = expr.NewAny(value) + } else if typedValue, err = e.typ.Cast(value); err != nil { + return nil, fmt.Errorf("cannot cast value on %s to type %s", e.Target, typedValue.Type()) + } + } + + if !knownType(e.typ) && !knownType(typedValue) && typedValue.Type() != e.typ.Type() { + // Both, expression & value have type set; + // check if it's the same type or return an error + return nil, fmt.Errorf("cannot set to %q (type %s) value of type %s", e.Target, e.typ.Type(), typedValue.Type()) + } + + if e.typ != nil { + if !knownType(typedValue) { + // Expression has fixed type but value does not + // cast the value of evaluation to type of the expressicason + if typedValue, err = e.typ.Cast(value); err != nil { + return nil, err + } + } else if e.typ.Type() != typedValue.Type() && e.typ.Type() != (expr.Any{}).Type() { + // + if typedValue, err = e.typ.Cast(value); err != nil { + return nil, err + } + } + } + + // Set result of the expression to scope + // + // Set() fn handles multi-level path (eg "base.level1.level2") + // that can set result of the expression deep into scope's value + if err = expr.Assign(scope, e.Target, typedValue); err != nil { + return nil, err + } + + // Take base of the path (1st part) and + // copy value of it to output scope + // + // This ensures us that the entire variable + // from the original scope will be present in the output + scope.Copy(out, expr.PathBase(e.Target)) + } + + return out, nil +} + +func ExpressionsStep(ee ...*Expr) *expressionsStep { + return &expressionsStep{Set: ee} +} + +func (s *expressionsStep) Exec(ctx context.Context, r *wfexec.ExecRequest) (wfexec.ExecResponse, error) { + result, err := s.Set.Eval(ctx, r.Scope.Merge(r.Input)) + if err != nil { + return nil, err + } + + return result, nil +} diff --git a/automation/types/expr_test.go b/automation/types/expr_test.go new file mode 100644 index 000000000..f8b89501e --- /dev/null +++ b/automation/types/expr_test.go @@ -0,0 +1,124 @@ +package types + +import ( + "context" + . "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/stretchr/testify/require" + "testing" +) + +func TestExprSet_Eval(t *testing.T) { + var ( + ctx = context.Background() + + cc = []struct { + name string + set ExprSet + input RVars + output RVars + err string + }{ + { + name: "empty", + set: ExprSet{}, + output: nil, + }, + { + name: "constant assignment", + set: ExprSet{&Expr{Target: "foo", Expr: `"bar"`}}, + output: RVars{"foo": Must(NewAny("bar"))}, + }, + { + name: "vars with path", + set: ExprSet{&Expr{Target: "l1.l2", Expr: `"bar"`}}, + input: RVars{"l1": RVars{}.Vars()}, + output: RVars{"l1": RVars{"l2": Must(NewAny("bar"))}.Vars()}, + }, + { + name: "copy vars with same types", + set: ExprSet{ + &Expr{Target: "aa", Value: "vv", typ: &String{}}, + &Expr{Target: "bb", Source: "aa", typ: &String{}}, + }, + output: RVars{ + "aa": Must(NewString("vv")), + "bb": Must(NewString("vv")), + }, + }, + { + name: "copy var with type", + set: ExprSet{ + &Expr{Target: "aa", Value: "should be always String", typ: &String{}}, + &Expr{Target: "bb", Source: "aa"}, + }, + output: RVars{ + "aa": Must(NewString("should be always String")), + "bb": Must(NewString("should be always String")), + }, + }, + { + name: "copy var to target with type", + set: ExprSet{ + &Expr{Target: "aa", Value: "42", typ: &String{}}, + &Expr{Target: "bb", Source: "aa", typ: &Integer{}}, + }, + output: RVars{ + "aa": Must(NewString("42")), + "bb": Must(NewInteger(42)), + }, + }, + { + name: "assign into incompatible", + set: ExprSet{ + &Expr{Target: "aa", Value: "foo", typ: &String{}}, + &Expr{Target: "bb", Source: "aa", typ: &Integer{}}, + }, + err: "unable to cast \"foo\" of type string to int64", + }, + { + name: "deep set into generated type", + set: ExprSet{ + &Expr{Target: "a", typ: &KV{}}, + &Expr{Target: "a.b", Value: "c", typ: &String{}}, + }, + output: RVars{ + "a": Must(NewKV(map[string]string{ + "b": "c", + })), + }, + }, + } + ) + + for _, c := range cc { + t.Run(c.name, func(t *testing.T) { + var ( + req = require.New(t) + ) + + for _, e := range c.set { + if e.Expr != "" { + req.NoError(NewGvalParser().ParseEvaluators(e)) + } + + if e.typ == nil { + e.typ = Any{} + } + } + + var ( + aux, _ = NewVars(c.input) + output, err = c.set.Eval(ctx, aux) + ) + + if c.err == "" { + req.NoError(err) + } else { + req.Error(err, c.err) + return + } + + req.Equal(c.output.Vars(), output) + }) + } +} diff --git a/automation/types/function.go b/automation/types/function.go new file mode 100644 index 000000000..1726f39ba --- /dev/null +++ b/automation/types/function.go @@ -0,0 +1,176 @@ +package types + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/logger" + "github.com/cortezaproject/corteza-server/pkg/wfexec" + "go.uber.org/zap" + "time" +) + +type ( + FunctionHandler func(ctx context.Context, in *expr.Vars) (*expr.Vars, error) + IteratorHandler func(ctx context.Context, in *expr.Vars) (wfexec.IteratorHandler, error) + + // workflow functions are defined in the core code and through plugins + Function struct { + Ref string `json:"ref,omitempty"` + Kind string `json:"kind,omitempty"` + Meta *FunctionMeta `json:"meta,omitempty"` + Parameters ParamSet `json:"parameters,omitempty"` + Results ParamSet `json:"results,omitempty"` + + Handler FunctionHandler `json:"-"` + Iterator IteratorHandler `json:"-"` + } + + FunctionMeta struct { + Short string `json:"short,omitempty"` + Description string `json:"description,omitempty"` + Visual map[string]interface{} `json:"visual,omitempty"` + } + + functionStep struct { + identifiableStep + def *Function + arguments ExprSet + results ExprSet + } + + iteratorStep struct { + identifiableStep + def *Function + arguments ExprSet + results ExprSet + next wfexec.Step + exit wfexec.Step + } +) + +const ( + FunctionKindFunction = "function" + FunctionKindIterator = "iterator" +) + +func FunctionStep(def *Function, arguments, results ExprSet) (*functionStep, error) { + if def.Kind != FunctionKindFunction { + return nil, fmt.Errorf("expecting function kind") + } + + return &functionStep{def: def, arguments: arguments, results: results}, nil +} + +func (f *functionStep) Exec(ctx context.Context, r *wfexec.ExecRequest) (wfexec.ExecResponse, error) { + var ( + started = time.Now() + args, results *expr.Vars + err error + + log = logger.ContextValue(ctx, zap.NewNop()).With( + zap.String("functionRef", f.def.Ref), + zap.String("functionKind", "function"), + ) + ) + + defer func() { + log := log.With(zap.Duration("execTime", time.Now().Sub(started))) + + if err == nil { + log.Debug("executed") + } else { + log.Warn("executed with errors", zap.Error(err)) + } + }() + + ctx = logger.ContextWithValue(ctx, log) + + if len(f.arguments) > 0 { + // Arguments defined, get values from scope and use them when calling + // function/handler + args, err = f.arguments.Eval(ctx, r.Scope.Merge(r.Input)) + if err != nil { + return nil, err + } + } + + results, err = f.def.Handler(ctx, args) + if err != nil { + return nil, err + } + + if len(f.results) == 0 { + // No results defined, nothing to return + return expr.NewVars(nil) + } + + results, err = f.results.Eval(ctx, results) + if err != nil { + return nil, err + } + + return results, nil +} + +func IteratorStep(def *Function, arguments, results ExprSet, next, exit wfexec.Step) (*iteratorStep, error) { + if def.Kind != FunctionKindIterator { + return nil, fmt.Errorf("expecting iterator kind") + } + + return &iteratorStep{ + def: def, + arguments: arguments, + results: results, + next: next, + exit: exit, + }, nil +} + +func (f *iteratorStep) Exec(ctx context.Context, r *wfexec.ExecRequest) (wfexec.ExecResponse, error) { + var ( + started = time.Now() + args *expr.Vars + err error + ih wfexec.IteratorHandler + + log = logger.ContextValue(ctx, zap.NewNop()).With( + zap.String("functionRef", f.def.Ref), + zap.String("functionKind", "iterator"), + ) + ) + + defer func() { + log := log.With(zap.Duration("execTime", time.Now().Sub(started))) + + if err == nil { + log.Debug("executed") + } else { + log.Warn("executed with errors", zap.Error(err)) + } + }() + + if len(f.arguments) > 0 { + // Arguments defined, get values from scope and use them when calling + // iterator/handler + args, err = f.arguments.Eval(ctx, r.Scope.Merge(r.Input)) + if err != nil { + return nil, err + } + } + + if ih, err = f.def.Iterator(ctx, args); err != nil { + return nil, err + } + + return wfexec.GenericIterator(f, f.next, f.exit, ih), nil +} + +func (f *iteratorStep) EvalResults(ctx context.Context, results *expr.Vars) (*expr.Vars, error) { + if results.Len() == 0 || len(f.results) == 0 { + // No results or result expressions defined, nothing to return + return &expr.Vars{}, nil + } + + return f.results.Eval(ctx, results) +} diff --git a/automation/types/param.go b/automation/types/param.go new file mode 100644 index 000000000..dc15a9e12 --- /dev/null +++ b/automation/types/param.go @@ -0,0 +1,131 @@ +package types + +import ( + "fmt" + "github.com/cortezaproject/corteza-server/pkg/expr" + "strings" +) + +type ( + ParamSet []*Param + Param struct { + Name string `json:"name,omitempty"` + Types []string `json:"types,omitempty"` + Required bool `json:"required,omitempty"` + IsArray bool `json:"isArray,omitempty"` + Meta *ParamMeta `json:"meta,omitempty"` + } + + ParamMeta struct { + Label string `json:"label,omitempty"` + Description string `json:"description,omitempty"` + Visual map[string]interface{} `json:"visual,omitempty"` + } + + paramOpt func(p *Param) +) + +//const +func NewParam(name string, opts ...paramOpt) *Param { + p := &Param{Name: name} + for _, opt := range opts { + opt(p) + } + + return p +} + +func Required(p *Param) { p.Required = !p.Required } +func IsArray(p *Param) { p.IsArray = !p.IsArray } + +func Types(tt ...expr.Type) paramOpt { + return func(p *Param) { + for _, t := range tt { + p.Types = append(p.Types, t.Type()) + } + } +} + +func (p Param) HasType(t string) bool { + for i := range p.Types { + if p.Types[i] == t { + return true + } + } + return false +} + +func (set ParamSet) GetByName(name string) *Param { + for _, p := range set { + if p.Name == name { + return p + } + } + return nil +} + +// CheckArguments validates (at compile-time) input data (arguments) +func (set ParamSet) VerifyArguments(ee ExprSet) error { + for _, e := range ee { + if set.GetByName(e.Target) == nil { + return fmt.Errorf("unknown parameter %s is used", e.Target) + + } + } + + for _, p := range set { + e := ee.GetByTarget(p.Name) + + if e == nil { + if p.Required { + return fmt.Errorf("parameter %s is required", p.Name) + } + + continue + } + + if !p.HasType(e.Type) && !p.HasType(expr.Any{}.Type()) { + msg := "incompatible argument type '%s' for parameter '%s', expecting %s" + if len(p.Types) > 1 { + msg = "incompatible argument type '%s' for parameter '%s', expecting one of %s" + } + + return fmt.Errorf( + msg, + e.Type, p.Name, + strings.Join(p.Types, ", "), + ) + } + + // @todo check if target holds set-of values (array of values) + // this could be implemented by generic wrapping type that would + // enable + } + + return nil +} + +// CheckArguments validates (at compile-time) input data (arguments) +func (set ParamSet) VerifyResults(ee ExprSet) error { + for _, e := range ee { + if len(e.Source) > 0 && set.GetByName(e.Source) == nil { + return fmt.Errorf("unknown result %s is used", e.Source) + } + } + + for _, p := range set { + e := ee.GetByTarget(p.Name) + if e == nil { + continue + } + + if e.Type != "" && !p.HasType(e.Type) { + return fmt.Errorf("incompatible type %s for result %s, expecting %s", + e.Type, p.Name, + strings.Join(p.Types, ", "), + ) + } + } + + return nil +} diff --git a/automation/types/parsers.go b/automation/types/parsers.go new file mode 100644 index 000000000..afed608da --- /dev/null +++ b/automation/types/parsers.go @@ -0,0 +1,34 @@ +package types + +import ( + "encoding/json" + "github.com/cortezaproject/corteza-server/pkg/expr" +) + +func ParseWorkflowVariables(ss []string) (p *expr.Vars, err error) { + p = &expr.Vars{} + return p, parseStringsInput(ss, &p) +} + +func ParseWorkflowMeta(ss []string) (p *WorkflowMeta, err error) { + p = &WorkflowMeta{} + return p, parseStringsInput(ss, p) +} + +func ParseWorkflowStepSet(ss []string) (p WorkflowStepSet, err error) { + p = WorkflowStepSet{} + return p, parseStringsInput(ss, &p) +} + +func ParseWorkflowPathSet(ss []string) (p WorkflowPathSet, err error) { + p = WorkflowPathSet{} + return p, parseStringsInput(ss, &p) +} + +func parseStringsInput(ss []string, p interface{}) (err error) { + if len(ss) == 0 { + return + } + + return json.Unmarshal([]byte(ss[0]), &p) +} diff --git a/automation/types/permission_resources.go b/automation/types/permission_resources.go new file mode 100644 index 000000000..4e14ef545 --- /dev/null +++ b/automation/types/permission_resources.go @@ -0,0 +1,8 @@ +package types + +import ( + "github.com/cortezaproject/corteza-server/pkg/rbac" +) + +const AutomationRBACResource = rbac.Resource("automation") +const WorkflowRBACResource = rbac.Resource("automation:workflow:") diff --git a/automation/types/prompt.go b/automation/types/prompt.go new file mode 100644 index 000000000..bc9a04edc --- /dev/null +++ b/automation/types/prompt.go @@ -0,0 +1,33 @@ +package types + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/wfexec" + "github.com/davecgh/go-spew/spew" +) + +type ( + promptStep struct { + *expressionsStep + } +) + +func PromptStep(kind string, wse *expressionsStep) *promptStep { + return &promptStep{wse} +} + +// Executes prompt step +func (p *promptStep) Exec(ctx context.Context, r *wfexec.ExecRequest) (wfexec.ExecResponse, error) { + testResults, err := p.Set.Validate(ctx, r.Scope.Merge(r.Input)) + if err != nil { + return nil, err + } + + if len(testResults) > 0 { + // @todo extend waitforinput to accept test results that are passable back to caller/client? + spew.Dump(testResults) + return wfexec.WaitForInput(), nil + } + + return p.Exec(ctx, r) +} diff --git a/automation/types/session.go b/automation/types/session.go new file mode 100644 index 000000000..059120403 --- /dev/null +++ b/automation/types/session.go @@ -0,0 +1,155 @@ +package types + +import ( + "context" + "database/sql/driver" + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/wfexec" + "time" +) + +type ( + // Instance of single workflow execution + Session struct { + ID uint64 `json:"sessionID,string"` + WorkflowID uint64 `json:"workflowID,string"` + + Status SessionStatus `json:"status,string"` + + EventType string `json:"eventType"` + ResourceType string `json:"resourceType"` + + Input *expr.Vars `json:"input"` + Output *expr.Vars `json:"output"` + + Stacktrace Stacktrace `json:"stacktrace"` + + CreatedAt time.Time `json:"createdAt,omitempty"` + CreatedBy uint64 `json:"createdBy,string"` + PurgeAt *time.Time `json:"purgeAt,omitempty"` + SuspendedAt *time.Time `json:"suspendedAt,omitempty"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + Error string `json:"error,omitempty"` + + session *wfexec.Session + } + + SessionStartParams struct { + WorkflowID uint64 + KeepFor int + Trace bool + Input *expr.Vars + StepID uint64 + EventType string + ResourceType string + } + + SessionFilter struct { + SessionID []uint64 `json:"sessionID"` + WorkflowID []uint64 `json:"workflowID"` + EventType string `json:"eventType"` + ResourceType string `json:"resourceType"` + + Completed filter.State `json:"deleted"` + Suspended filter.State `json:"disabled"` + + // Check fn is called by store backend for each resource found function can + // modify the resource and return false if store should not return it + // + // Store then loads additional resources to satisfy the paging parameters + Check func(*Session) (bool, error) `json:"-"` + + // Standard helpers for paging and sorting + filter.Sorting + filter.Paging + } + + Stacktrace []*wfexec.Frame + + SessionStatus int +) + +const ( + SessionStarted SessionStatus = iota + SessionSuspended + SessionFailed + SessionCompleted +) + +func NewSession(s *wfexec.Session) *Session { + return &Session{ + ID: s.ID(), + session: s, + } +} + +func (s Session) Exec(ctx context.Context, step wfexec.Step, input *expr.Vars) error { + return s.session.Exec(ctx, step, input) +} + +func (s Session) Resume(ctx context.Context, stateID uint64, input *expr.Vars) error { + return s.session.Resume(ctx, stateID, input) +} + +func (s Session) Wait(ctx context.Context) error { + return s.session.Wait(ctx) +} + +func (s *Session) Apply(ssp SessionStartParams) { + s.WorkflowID = ssp.WorkflowID + s.EventType = ssp.EventType + s.ResourceType = ssp.ResourceType + s.Input = ssp.Input + + if ssp.KeepFor > 0 { + at := time.Now().Add(time.Duration(ssp.KeepFor) * time.Second) + s.PurgeAt = &at + } + + if ssp.Trace { + // set prop + s.Stacktrace = Stacktrace{} + } +} + +func (set *Stacktrace) Scan(value interface{}) error { + //lint:ignore S1034 This typecast is intentional, we need to get []byte out of a []uint8 + switch value.(type) { + case nil: + *set = Stacktrace{} + case []uint8: + b := value.([]byte) + if err := json.Unmarshal(b, set); err != nil { + return fmt.Errorf("can not scan '%v' into Stacktrace: %w", string(b), err) + } + } + + return nil +} + +// Scan on WorkflowStepSet gracefully handles conversion from NULL +func (set Stacktrace) Value() (driver.Value, error) { + return json.Marshal(set) +} + +func (s SessionStatus) String() string { + switch s { + case SessionStarted: + return "started" + case SessionSuspended: + return "suspended" + case SessionFailed: + return "failed" + case SessionCompleted: + return "completed" + } + + return "unknown" +} + +func (s SessionStatus) MarshalJSON() ([]byte, error) { + return json.Marshal(s.String()) +} diff --git a/automation/types/state.go b/automation/types/state.go new file mode 100644 index 000000000..5716bb776 --- /dev/null +++ b/automation/types/state.go @@ -0,0 +1,25 @@ +package types + +import ( + "github.com/cortezaproject/corteza-server/pkg/expr" + "time" +) + +type ( + // WorkflowState tracks suspended sessions + // Session can have more than one state + State struct { + ID uint64 `json:"stateID,string"` + SessionID uint64 `json:"sessionID,string"` + + ResumeAt *time.Time `json:"resumeAt"` + WaitingForInput bool `json:"waitingForInput"` + + CreatedAt time.Time `json:"createdAt,omitempty"` + CreatedBy uint64 `json:"createdBy,string"` + + CallerID uint64 `json:"callerID,string"` + StepID uint64 `json:"stepID,string"` + Scope *expr.Vars `json:"scope"` + } +) diff --git a/automation/types/step.go b/automation/types/step.go new file mode 100644 index 000000000..ca4de52c4 --- /dev/null +++ b/automation/types/step.go @@ -0,0 +1,8 @@ +package types + +type ( + identifiableStep struct{ id uint64 } +) + +func (i *identifiableStep) ID() uint64 { return i.id } +func (i *identifiableStep) SetID(id uint64) { i.id = id } diff --git a/automation/types/test.go b/automation/types/test.go new file mode 100644 index 000000000..49b4acf76 --- /dev/null +++ b/automation/types/test.go @@ -0,0 +1,82 @@ +package types + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/expr" +) + +type ( + // Used for input evaluation + Test struct { + // Expression to evaluate over the input variables; results will be set to scope under variable Name + Expr string `json:"expr,omitempty"` + + eval expr.Evaluable + + // Error to be if test fails + Error string `json:"error"` + } + + TestSet []*Test +) + +func NewTest(expr, error string) (t *Test, err error) { + return &Test{Expr: expr, Error: error}, nil +} + +func (t Test) GetExpr() string { return t.Expr } +func (t *Test) SetEval(eval expr.Evaluable) { t.eval = eval } +func (t Test) Eval(ctx context.Context, scope *expr.Vars) (interface{}, error) { + return t.eval.Eval(ctx, scope) +} +func (t Test) Test(ctx context.Context, scope *expr.Vars) (bool, error) { + return t.eval.Test(ctx, scope) +} + +func (set TestSet) Validate(ctx context.Context, scope *expr.Vars) (TestSet, error) { + vres := make(TestSet, 0, len(set)) + + for _, t := range set { + r, err := t.eval.Test(ctx, scope) + if err != nil { + return nil, err + } + + if !r { + vres = append(vres, &Test{Error: t.Error}) + } + } + + return vres, nil +} + +func (set TestSet) Test(ctx context.Context, scope *expr.Vars) (bool, error) { + return set.TestAll(ctx, scope) +} + +func (set TestSet) TestAll(ctx context.Context, scope *expr.Vars) (bool, error) { + for _, t := range set { + r, err := t.Test(ctx, scope) + if err != nil || !r { + return false, err + } + } + + return true, nil +} + +// Returns true on first true +func (set TestSet) TestAny(ctx context.Context, scope *expr.Vars) (bool, error) { + for _, t := range set { + r, err := t.Test(ctx, scope) + if err != nil { + return false, err + } + + if r { + return true, nil + } + } + + return false, nil +} diff --git a/automation/types/trigger.go b/automation/types/trigger.go new file mode 100644 index 000000000..77853ed35 --- /dev/null +++ b/automation/types/trigger.go @@ -0,0 +1,147 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/filter" + "time" +) + +type ( + Trigger struct { + ID uint64 `json:"triggerID,string"` + Enabled bool `json:"enabled"` + + WorkflowID uint64 `json:"workflowID,string"` + // Start workflow on this step. If 0, find first (only) orphan + StepID uint64 `json:"stepID,string"` + + // Resource type that can trigger the workflow + ResourceType string `json:"resourceType"` + + // Event type that can trigger the workflow + EventType string `json:"eventType"` + + // Trigger constraints + Constraints TriggerConstraintSet `json:"constraints"` + + // Initial input scope, + // will be merged merged with workflow variables + Input *expr.Vars `json:"input"` + + Labels map[string]string `json:"labels,omitempty"` + Meta *TriggerMeta `json:"meta,omitempty"` + + OwnedBy uint64 `json:"ownedBy,string"` + CreatedAt time.Time `json:"createdAt,omitempty"` + CreatedBy uint64 `json:"createdBy,string" ` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + UpdatedBy uint64 `json:"updatedBy,string,omitempty"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + DeletedBy uint64 `json:"deletedBy,string,omitempty"` + } + + TriggerConstraint struct { + Name string `json:"name"` + Op string `json:"op,omitempty"` + Values []string `json:"values,omitempty"` + } + + TriggerMeta struct { + Description string `json:"description"` + Visual map[string]interface{} `json:"visual"` + } + + TriggerFilter struct { + TriggerID []uint64 `json:"triggerID"` + WorkflowID []uint64 `json:"workflowID"` + + EventType string `json:"eventType"` + ResourceType string `json:"resourceType"` + + Deleted filter.State `json:"deleted"` + Disabled filter.State `json:"disabled"` + + LabeledIDs []uint64 `json:"-"` + Labels map[string]string `json:"labels,omitempty"` + + // Check fn is called by store backend for each resource found function can + // modify the resource and return false if store should not return it + // + // Store then loads additional resources to satisfy the paging parameters + Check func(*Trigger) (bool, error) `json:"-"` + + // Standard helpers for paging and sorting + filter.Sorting + filter.Paging + } +) + +func ParseTriggerMeta(ss []string) (p *TriggerMeta, err error) { + p = &TriggerMeta{} + return p, parseStringsInput(ss, p) +} + +func ParseTriggerConstraintSet(ss []string) (p TriggerConstraintSet, err error) { + p = TriggerConstraintSet{} + return p, parseStringsInput(ss, &p) +} + +func (vv *TriggerConstraintSet) Scan(value interface{}) error { + //lint:ignore S1034 This typecast is intentional, we need to get []byte out of a []uint8 + switch value.(type) { + case nil: + *vv = TriggerConstraintSet{} + case []uint8: + b := value.([]byte) + if err := json.Unmarshal(b, vv); err != nil { + return fmt.Errorf("can not scan '%v' into TriggerConstraintSet: %w", string(b), err) + } + } + + return nil +} + +func (vv *TriggerMeta) Scan(value interface{}) error { + //lint:ignore S1034 This typecast is intentional, we need to get []byte out of a []uint8 + switch value.(type) { + case nil: + *vv = TriggerMeta{} + case []uint8: + b := value.([]byte) + if err := json.Unmarshal(b, vv); err != nil { + return fmt.Errorf("can not scan '%v' into TriggerMeta: %w", string(b), err) + } + } + + return nil +} + +// Scan on TriggerMeta gracefully handles conversion from NULL +func (vv *TriggerMeta) Value() (driver.Value, error) { + if vv == nil { + return []byte("null"), nil + } + + return json.Marshal(vv) +} + +// Scan on TriggerConstraintSet gracefully handles conversion from NULL +func (vv TriggerConstraintSet) Value() (driver.Value, error) { + return json.Marshal(vv) +} + +func (set TriggerSet) FilterByWorkflowID(workflowID uint64) (vv TriggerSet) { + // Make sure we never return nil + vv = TriggerSet{} + + for i := range set { + if set[i].WorkflowID == workflowID { + vv = append(vv, set[i]) + } + } + + return +} diff --git a/automation/types/type_labels.gen.go b/automation/types/type_labels.gen.go new file mode 100644 index 000000000..657751d7b --- /dev/null +++ b/automation/types/type_labels.gen.go @@ -0,0 +1,57 @@ +package types + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// automation/types/types.yaml + +// SetLabel adds new label to label map +func (m *Trigger) SetLabel(key string, value string) { + if m.Labels == nil { + m.Labels = make(map[string]string) + } + + m.Labels[key] = value +} + +// GetLabels adds new label to label map +func (m Trigger) GetLabels() map[string]string { + return m.Labels +} + +// GetLabels adds new label to label map +func (Trigger) LabelResourceKind() string { + return "trigger" +} + +// GetLabels adds new label to label map +func (m Trigger) LabelResourceID() uint64 { + return m.ID +} + +// SetLabel adds new label to label map +func (m *Workflow) SetLabel(key string, value string) { + if m.Labels == nil { + m.Labels = make(map[string]string) + } + + m.Labels[key] = value +} + +// GetLabels adds new label to label map +func (m Workflow) GetLabels() map[string]string { + return m.Labels +} + +// GetLabels adds new label to label map +func (Workflow) LabelResourceKind() string { + return "workflow" +} + +// GetLabels adds new label to label map +func (m Workflow) LabelResourceID() uint64 { + return m.ID +} diff --git a/automation/types/type_set.gen.go b/automation/types/type_set.gen.go new file mode 100644 index 000000000..6d241bc1b --- /dev/null +++ b/automation/types/type_set.gen.go @@ -0,0 +1,387 @@ +package types + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// automation/types/types.yaml + +type ( + + // SessionSet slice of Session + // + // This type is auto-generated. + SessionSet []*Session + + // StateSet slice of State + // + // This type is auto-generated. + StateSet []*State + + // TriggerSet slice of Trigger + // + // This type is auto-generated. + TriggerSet []*Trigger + + // TriggerConstraintSet slice of TriggerConstraint + // + // This type is auto-generated. + TriggerConstraintSet []*TriggerConstraint + + // WorkflowSet slice of Workflow + // + // This type is auto-generated. + WorkflowSet []*Workflow + + // WorkflowPathSet slice of WorkflowPath + // + // This type is auto-generated. + WorkflowPathSet []*WorkflowPath + + // WorkflowStepSet slice of WorkflowStep + // + // This type is auto-generated. + WorkflowStepSet []*WorkflowStep +) + +// Walk iterates through every slice item and calls w(Session) err +// +// This function is auto-generated. +func (set SessionSet) Walk(w func(*Session) error) (err error) { + for i := range set { + if err = w(set[i]); err != nil { + return + } + } + + return +} + +// Filter iterates through every slice item, calls f(Session) (bool, err) and return filtered slice +// +// This function is auto-generated. +func (set SessionSet) Filter(f func(*Session) (bool, error)) (out SessionSet, err error) { + var ok bool + out = SessionSet{} + for i := range set { + if ok, err = f(set[i]); err != nil { + return + } else if ok { + out = append(out, set[i]) + } + } + + return +} + +// FindByID finds items from slice by its ID property +// +// This function is auto-generated. +func (set SessionSet) FindByID(ID uint64) *Session { + for i := range set { + if set[i].ID == ID { + return set[i] + } + } + + return nil +} + +// IDs returns a slice of uint64s from all items in the set +// +// This function is auto-generated. +func (set SessionSet) IDs() (IDs []uint64) { + IDs = make([]uint64, len(set)) + + for i := range set { + IDs[i] = set[i].ID + } + + return +} + +// Walk iterates through every slice item and calls w(State) err +// +// This function is auto-generated. +func (set StateSet) Walk(w func(*State) error) (err error) { + for i := range set { + if err = w(set[i]); err != nil { + return + } + } + + return +} + +// Filter iterates through every slice item, calls f(State) (bool, err) and return filtered slice +// +// This function is auto-generated. +func (set StateSet) Filter(f func(*State) (bool, error)) (out StateSet, err error) { + var ok bool + out = StateSet{} + for i := range set { + if ok, err = f(set[i]); err != nil { + return + } else if ok { + out = append(out, set[i]) + } + } + + return +} + +// FindByID finds items from slice by its ID property +// +// This function is auto-generated. +func (set StateSet) FindByID(ID uint64) *State { + for i := range set { + if set[i].ID == ID { + return set[i] + } + } + + return nil +} + +// IDs returns a slice of uint64s from all items in the set +// +// This function is auto-generated. +func (set StateSet) IDs() (IDs []uint64) { + IDs = make([]uint64, len(set)) + + for i := range set { + IDs[i] = set[i].ID + } + + return +} + +// Walk iterates through every slice item and calls w(Trigger) err +// +// This function is auto-generated. +func (set TriggerSet) Walk(w func(*Trigger) error) (err error) { + for i := range set { + if err = w(set[i]); err != nil { + return + } + } + + return +} + +// Filter iterates through every slice item, calls f(Trigger) (bool, err) and return filtered slice +// +// This function is auto-generated. +func (set TriggerSet) Filter(f func(*Trigger) (bool, error)) (out TriggerSet, err error) { + var ok bool + out = TriggerSet{} + for i := range set { + if ok, err = f(set[i]); err != nil { + return + } else if ok { + out = append(out, set[i]) + } + } + + return +} + +// FindByID finds items from slice by its ID property +// +// This function is auto-generated. +func (set TriggerSet) FindByID(ID uint64) *Trigger { + for i := range set { + if set[i].ID == ID { + return set[i] + } + } + + return nil +} + +// IDs returns a slice of uint64s from all items in the set +// +// This function is auto-generated. +func (set TriggerSet) IDs() (IDs []uint64) { + IDs = make([]uint64, len(set)) + + for i := range set { + IDs[i] = set[i].ID + } + + return +} + +// Walk iterates through every slice item and calls w(TriggerConstraint) err +// +// This function is auto-generated. +func (set TriggerConstraintSet) Walk(w func(*TriggerConstraint) error) (err error) { + for i := range set { + if err = w(set[i]); err != nil { + return + } + } + + return +} + +// Filter iterates through every slice item, calls f(TriggerConstraint) (bool, err) and return filtered slice +// +// This function is auto-generated. +func (set TriggerConstraintSet) Filter(f func(*TriggerConstraint) (bool, error)) (out TriggerConstraintSet, err error) { + var ok bool + out = TriggerConstraintSet{} + for i := range set { + if ok, err = f(set[i]); err != nil { + return + } else if ok { + out = append(out, set[i]) + } + } + + return +} + +// Walk iterates through every slice item and calls w(Workflow) err +// +// This function is auto-generated. +func (set WorkflowSet) Walk(w func(*Workflow) error) (err error) { + for i := range set { + if err = w(set[i]); err != nil { + return + } + } + + return +} + +// Filter iterates through every slice item, calls f(Workflow) (bool, err) and return filtered slice +// +// This function is auto-generated. +func (set WorkflowSet) Filter(f func(*Workflow) (bool, error)) (out WorkflowSet, err error) { + var ok bool + out = WorkflowSet{} + for i := range set { + if ok, err = f(set[i]); err != nil { + return + } else if ok { + out = append(out, set[i]) + } + } + + return +} + +// FindByID finds items from slice by its ID property +// +// This function is auto-generated. +func (set WorkflowSet) FindByID(ID uint64) *Workflow { + for i := range set { + if set[i].ID == ID { + return set[i] + } + } + + return nil +} + +// IDs returns a slice of uint64s from all items in the set +// +// This function is auto-generated. +func (set WorkflowSet) IDs() (IDs []uint64) { + IDs = make([]uint64, len(set)) + + for i := range set { + IDs[i] = set[i].ID + } + + return +} + +// Walk iterates through every slice item and calls w(WorkflowPath) err +// +// This function is auto-generated. +func (set WorkflowPathSet) Walk(w func(*WorkflowPath) error) (err error) { + for i := range set { + if err = w(set[i]); err != nil { + return + } + } + + return +} + +// Filter iterates through every slice item, calls f(WorkflowPath) (bool, err) and return filtered slice +// +// This function is auto-generated. +func (set WorkflowPathSet) Filter(f func(*WorkflowPath) (bool, error)) (out WorkflowPathSet, err error) { + var ok bool + out = WorkflowPathSet{} + for i := range set { + if ok, err = f(set[i]); err != nil { + return + } else if ok { + out = append(out, set[i]) + } + } + + return +} + +// Walk iterates through every slice item and calls w(WorkflowStep) err +// +// This function is auto-generated. +func (set WorkflowStepSet) Walk(w func(*WorkflowStep) error) (err error) { + for i := range set { + if err = w(set[i]); err != nil { + return + } + } + + return +} + +// Filter iterates through every slice item, calls f(WorkflowStep) (bool, err) and return filtered slice +// +// This function is auto-generated. +func (set WorkflowStepSet) Filter(f func(*WorkflowStep) (bool, error)) (out WorkflowStepSet, err error) { + var ok bool + out = WorkflowStepSet{} + for i := range set { + if ok, err = f(set[i]); err != nil { + return + } else if ok { + out = append(out, set[i]) + } + } + + return +} + +// FindByID finds items from slice by its ID property +// +// This function is auto-generated. +func (set WorkflowStepSet) FindByID(ID uint64) *WorkflowStep { + for i := range set { + if set[i].ID == ID { + return set[i] + } + } + + return nil +} + +// IDs returns a slice of uint64s from all items in the set +// +// This function is auto-generated. +func (set WorkflowStepSet) IDs() (IDs []uint64) { + IDs = make([]uint64, len(set)) + + for i := range set { + IDs[i] = set[i].ID + } + + return +} diff --git a/automation/types/type_set.gen_test.go b/automation/types/type_set.gen_test.go new file mode 100644 index 000000000..592514027 --- /dev/null +++ b/automation/types/type_set.gen_test.go @@ -0,0 +1,577 @@ +package types + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// automation/types/types.yaml + +import ( + "fmt" + "github.com/stretchr/testify/require" + "testing" +) + +func TestSessionSetWalk(t *testing.T) { + var ( + value = make(SessionSet, 3) + req = require.New(t) + ) + + // check walk with no errors + { + err := value.Walk(func(*Session) error { + return nil + }) + req.NoError(err) + } + + // check walk with error + req.Error(value.Walk(func(*Session) error { return fmt.Errorf("walk error") })) +} + +func TestSessionSetFilter(t *testing.T) { + var ( + value = make(SessionSet, 3) + req = require.New(t) + ) + + // filter nothing + { + set, err := value.Filter(func(*Session) (bool, error) { + return true, nil + }) + req.NoError(err) + req.Equal(len(set), len(value)) + } + + // filter one item + { + found := false + set, err := value.Filter(func(*Session) (bool, error) { + if !found { + found = true + return found, nil + } + return false, nil + }) + req.NoError(err) + req.Len(set, 1) + } + + // filter error + { + _, err := value.Filter(func(*Session) (bool, error) { + return false, fmt.Errorf("filter error") + }) + req.Error(err) + } +} + +func TestSessionSetIDs(t *testing.T) { + var ( + value = make(SessionSet, 3) + req = require.New(t) + ) + + // construct objects + value[0] = new(Session) + value[1] = new(Session) + value[2] = new(Session) + // set ids + value[0].ID = 1 + value[1].ID = 2 + value[2].ID = 3 + + // Find existing + { + val := value.FindByID(2) + req.Equal(uint64(2), val.ID) + } + + // Find non-existing + { + val := value.FindByID(4) + req.Nil(val) + } + + // List IDs from set + { + val := value.IDs() + req.Equal(len(val), len(value)) + } +} + +func TestStateSetWalk(t *testing.T) { + var ( + value = make(StateSet, 3) + req = require.New(t) + ) + + // check walk with no errors + { + err := value.Walk(func(*State) error { + return nil + }) + req.NoError(err) + } + + // check walk with error + req.Error(value.Walk(func(*State) error { return fmt.Errorf("walk error") })) +} + +func TestStateSetFilter(t *testing.T) { + var ( + value = make(StateSet, 3) + req = require.New(t) + ) + + // filter nothing + { + set, err := value.Filter(func(*State) (bool, error) { + return true, nil + }) + req.NoError(err) + req.Equal(len(set), len(value)) + } + + // filter one item + { + found := false + set, err := value.Filter(func(*State) (bool, error) { + if !found { + found = true + return found, nil + } + return false, nil + }) + req.NoError(err) + req.Len(set, 1) + } + + // filter error + { + _, err := value.Filter(func(*State) (bool, error) { + return false, fmt.Errorf("filter error") + }) + req.Error(err) + } +} + +func TestStateSetIDs(t *testing.T) { + var ( + value = make(StateSet, 3) + req = require.New(t) + ) + + // construct objects + value[0] = new(State) + value[1] = new(State) + value[2] = new(State) + // set ids + value[0].ID = 1 + value[1].ID = 2 + value[2].ID = 3 + + // Find existing + { + val := value.FindByID(2) + req.Equal(uint64(2), val.ID) + } + + // Find non-existing + { + val := value.FindByID(4) + req.Nil(val) + } + + // List IDs from set + { + val := value.IDs() + req.Equal(len(val), len(value)) + } +} + +func TestTriggerSetWalk(t *testing.T) { + var ( + value = make(TriggerSet, 3) + req = require.New(t) + ) + + // check walk with no errors + { + err := value.Walk(func(*Trigger) error { + return nil + }) + req.NoError(err) + } + + // check walk with error + req.Error(value.Walk(func(*Trigger) error { return fmt.Errorf("walk error") })) +} + +func TestTriggerSetFilter(t *testing.T) { + var ( + value = make(TriggerSet, 3) + req = require.New(t) + ) + + // filter nothing + { + set, err := value.Filter(func(*Trigger) (bool, error) { + return true, nil + }) + req.NoError(err) + req.Equal(len(set), len(value)) + } + + // filter one item + { + found := false + set, err := value.Filter(func(*Trigger) (bool, error) { + if !found { + found = true + return found, nil + } + return false, nil + }) + req.NoError(err) + req.Len(set, 1) + } + + // filter error + { + _, err := value.Filter(func(*Trigger) (bool, error) { + return false, fmt.Errorf("filter error") + }) + req.Error(err) + } +} + +func TestTriggerSetIDs(t *testing.T) { + var ( + value = make(TriggerSet, 3) + req = require.New(t) + ) + + // construct objects + value[0] = new(Trigger) + value[1] = new(Trigger) + value[2] = new(Trigger) + // set ids + value[0].ID = 1 + value[1].ID = 2 + value[2].ID = 3 + + // Find existing + { + val := value.FindByID(2) + req.Equal(uint64(2), val.ID) + } + + // Find non-existing + { + val := value.FindByID(4) + req.Nil(val) + } + + // List IDs from set + { + val := value.IDs() + req.Equal(len(val), len(value)) + } +} + +func TestTriggerConstraintSetWalk(t *testing.T) { + var ( + value = make(TriggerConstraintSet, 3) + req = require.New(t) + ) + + // check walk with no errors + { + err := value.Walk(func(*TriggerConstraint) error { + return nil + }) + req.NoError(err) + } + + // check walk with error + req.Error(value.Walk(func(*TriggerConstraint) error { return fmt.Errorf("walk error") })) +} + +func TestTriggerConstraintSetFilter(t *testing.T) { + var ( + value = make(TriggerConstraintSet, 3) + req = require.New(t) + ) + + // filter nothing + { + set, err := value.Filter(func(*TriggerConstraint) (bool, error) { + return true, nil + }) + req.NoError(err) + req.Equal(len(set), len(value)) + } + + // filter one item + { + found := false + set, err := value.Filter(func(*TriggerConstraint) (bool, error) { + if !found { + found = true + return found, nil + } + return false, nil + }) + req.NoError(err) + req.Len(set, 1) + } + + // filter error + { + _, err := value.Filter(func(*TriggerConstraint) (bool, error) { + return false, fmt.Errorf("filter error") + }) + req.Error(err) + } +} + +func TestWorkflowSetWalk(t *testing.T) { + var ( + value = make(WorkflowSet, 3) + req = require.New(t) + ) + + // check walk with no errors + { + err := value.Walk(func(*Workflow) error { + return nil + }) + req.NoError(err) + } + + // check walk with error + req.Error(value.Walk(func(*Workflow) error { return fmt.Errorf("walk error") })) +} + +func TestWorkflowSetFilter(t *testing.T) { + var ( + value = make(WorkflowSet, 3) + req = require.New(t) + ) + + // filter nothing + { + set, err := value.Filter(func(*Workflow) (bool, error) { + return true, nil + }) + req.NoError(err) + req.Equal(len(set), len(value)) + } + + // filter one item + { + found := false + set, err := value.Filter(func(*Workflow) (bool, error) { + if !found { + found = true + return found, nil + } + return false, nil + }) + req.NoError(err) + req.Len(set, 1) + } + + // filter error + { + _, err := value.Filter(func(*Workflow) (bool, error) { + return false, fmt.Errorf("filter error") + }) + req.Error(err) + } +} + +func TestWorkflowSetIDs(t *testing.T) { + var ( + value = make(WorkflowSet, 3) + req = require.New(t) + ) + + // construct objects + value[0] = new(Workflow) + value[1] = new(Workflow) + value[2] = new(Workflow) + // set ids + value[0].ID = 1 + value[1].ID = 2 + value[2].ID = 3 + + // Find existing + { + val := value.FindByID(2) + req.Equal(uint64(2), val.ID) + } + + // Find non-existing + { + val := value.FindByID(4) + req.Nil(val) + } + + // List IDs from set + { + val := value.IDs() + req.Equal(len(val), len(value)) + } +} + +func TestWorkflowPathSetWalk(t *testing.T) { + var ( + value = make(WorkflowPathSet, 3) + req = require.New(t) + ) + + // check walk with no errors + { + err := value.Walk(func(*WorkflowPath) error { + return nil + }) + req.NoError(err) + } + + // check walk with error + req.Error(value.Walk(func(*WorkflowPath) error { return fmt.Errorf("walk error") })) +} + +func TestWorkflowPathSetFilter(t *testing.T) { + var ( + value = make(WorkflowPathSet, 3) + req = require.New(t) + ) + + // filter nothing + { + set, err := value.Filter(func(*WorkflowPath) (bool, error) { + return true, nil + }) + req.NoError(err) + req.Equal(len(set), len(value)) + } + + // filter one item + { + found := false + set, err := value.Filter(func(*WorkflowPath) (bool, error) { + if !found { + found = true + return found, nil + } + return false, nil + }) + req.NoError(err) + req.Len(set, 1) + } + + // filter error + { + _, err := value.Filter(func(*WorkflowPath) (bool, error) { + return false, fmt.Errorf("filter error") + }) + req.Error(err) + } +} + +func TestWorkflowStepSetWalk(t *testing.T) { + var ( + value = make(WorkflowStepSet, 3) + req = require.New(t) + ) + + // check walk with no errors + { + err := value.Walk(func(*WorkflowStep) error { + return nil + }) + req.NoError(err) + } + + // check walk with error + req.Error(value.Walk(func(*WorkflowStep) error { return fmt.Errorf("walk error") })) +} + +func TestWorkflowStepSetFilter(t *testing.T) { + var ( + value = make(WorkflowStepSet, 3) + req = require.New(t) + ) + + // filter nothing + { + set, err := value.Filter(func(*WorkflowStep) (bool, error) { + return true, nil + }) + req.NoError(err) + req.Equal(len(set), len(value)) + } + + // filter one item + { + found := false + set, err := value.Filter(func(*WorkflowStep) (bool, error) { + if !found { + found = true + return found, nil + } + return false, nil + }) + req.NoError(err) + req.Len(set, 1) + } + + // filter error + { + _, err := value.Filter(func(*WorkflowStep) (bool, error) { + return false, fmt.Errorf("filter error") + }) + req.Error(err) + } +} + +func TestWorkflowStepSetIDs(t *testing.T) { + var ( + value = make(WorkflowStepSet, 3) + req = require.New(t) + ) + + // construct objects + value[0] = new(WorkflowStep) + value[1] = new(WorkflowStep) + value[2] = new(WorkflowStep) + // set ids + value[0].ID = 1 + value[1].ID = 2 + value[2].ID = 3 + + // Find existing + { + val := value.FindByID(2) + req.Equal(uint64(2), val.ID) + } + + // Find non-existing + { + val := value.FindByID(4) + req.Nil(val) + } + + // List IDs from set + { + val := value.IDs() + req.Equal(len(val), len(value)) + } +} diff --git a/automation/types/types.yaml b/automation/types/types.yaml new file mode 100644 index 000000000..a6fdca969 --- /dev/null +++ b/automation/types/types.yaml @@ -0,0 +1,13 @@ +types: + Trigger: + labelResourceType: trigger + TriggerConstraint: + noIdField: true + + Workflow: + labelResourceType: workflow + WorkflowPath: + noIdField: true + WorkflowStep: {} + Session: {} + State: {} diff --git a/automation/types/workflow.go b/automation/types/workflow.go new file mode 100644 index 000000000..5be157f5b --- /dev/null +++ b/automation/types/workflow.go @@ -0,0 +1,207 @@ +package types + +import ( + "context" + "database/sql/driver" + "encoding/json" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/rbac" + "time" +) + +type ( + // Workflow represents entire workflow definition + Workflow struct { + ID uint64 `json:"workflowID,string"` + Handle string `json:"handle"` + Labels map[string]string `json:"labels,omitempty"` + Meta *WorkflowMeta `json:"meta,omitempty"` + Enabled bool `json:"enabled"` + + Trace bool `json:"trace"` + + // how much time do we keep completed sessions (in sec) + KeepSessions int `json:"keepSessions"` + + // Initial input scope + Scope *expr.Vars `json:"scope"` + + Steps WorkflowStepSet `json:"steps"` + Paths WorkflowPathSet `json:"paths"` + + RunAs uint64 `json:"runAs,string"` + + OwnedBy uint64 `json:"ownedBy,string"` + CreatedAt time.Time `json:"createdAt,omitempty"` + CreatedBy uint64 `json:"createdBy,string" ` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + UpdatedBy uint64 `json:"updatedBy,string,omitempty"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + DeletedBy uint64 `json:"deletedBy,string,omitempty"` + } + + WorkflowFilter struct { + WorkflowID []uint64 `json:"workflowID"` + + Query string `json:"query"` + + Deleted filter.State `json:"deleted"` + Disabled filter.State `json:"disabled"` + + LabeledIDs []uint64 `json:"-"` + Labels map[string]string `json:"labels,omitempty"` + + // Check fn is called by store backend for each resource found function can + // modify the resource and return false if store should not return it + // + // Store then loads additional resources to satisfy the paging parameters + Check func(*Workflow) (bool, error) `json:"-"` + + // Standard helpers for paging and sorting + filter.Sorting + filter.Paging + } + + WorkflowMeta struct { + Name string `json:"name"` + Description string `json:"description"` + Visual map[string]interface{} `json:"visual"` + } + + // WorkflowStep describes one workflow step + WorkflowStep struct { + ID uint64 `json:"stepID,string"` + Kind WorkflowStepKind `json:"kind"` + + // reference to function or subprocess (workflow) + Ref string `json:"ref"` + + // set of expressions to evaluate, test or pass to function + // invalid for for kind=~gateway:* + Arguments []*Expr `json:"arguments"` + + // only valid when kind=function + Results []*Expr `json:"results"` + + Meta WorkflowStepMeta `json:"meta,omitempty"` + } + + WorkflowStepMeta struct { + Name string `json:"name"` + Description string `json:"description"` + Visual map[string]interface{} `json:"visual"` + } + + // WorkflowPath defines connection between two workflow steps + WorkflowPath struct { + // Expression to evaluate over the input variables; results will be set to scope under variable Name + Expr string `json:"expr,omitempty"` + + eval expr.Evaluable + + ParentID uint64 `json:"parentID,string"` + ChildID uint64 `json:"childID,string"` + Meta WorkflowPathMeta `json:"meta,omitempty"` + } + + WorkflowPathMeta struct { + Name string `json:"name"` + Description string `json:"description"` + Visual map[string]interface{} `json:"visual"` + } + + WorkflowStepKind string +) + +const ( + WorkflowStepKindExpressions WorkflowStepKind = "expressions" // ref + WorkflowStepKindGateway WorkflowStepKind = "gateway" // ref = join|fork|excl|incl + WorkflowStepKindFunction WorkflowStepKind = "function" // ref = + WorkflowStepKindIterator WorkflowStepKind = "iterator" // ref = + WorkflowStepKindMessage WorkflowStepKind = "message" // ref = error|warning|info, ... + WorkflowStepKindPrompt WorkflowStepKind = "prompt" // ref = + WorkflowStepKindErrHandler WorkflowStepKind = "error-handler" // no ref + WorkflowStepKindVisual WorkflowStepKind = "visual" // ref = <*> + //WorkflowStepKindSubprocess WorkflowStepKind = "subprocess" + //WorkflowStepKindEvent WorkflowStepKind = "event" // ref = ?? +) + +// Resource returns a resource ID for this type +func (r *Workflow) RBACResource() rbac.Resource { + return WorkflowRBACResource.AppendID(r.ID) +} + +func (vv *WorkflowMeta) Scan(value interface{}) error { + //lint:ignore S1034 This typecast is intentional, we need to get []byte out of a []uint8 + switch value.(type) { + case nil: + *vv = WorkflowMeta{} + case []uint8: + b := value.([]byte) + if err := json.Unmarshal(b, vv); err != nil { + return fmt.Errorf("can not scan '%v' into WorkflowMeta: %w", string(b), err) + } + } + + return nil +} + +// Scan on WorkflowMeta gracefully handles conversion from NULL +func (vv *WorkflowMeta) Value() (driver.Value, error) { + if vv == nil { + return []byte("null"), nil + } + + return json.Marshal(vv) +} + +func (t WorkflowPath) GetExpr() string { return t.Expr } +func (t *WorkflowPath) SetEval(eval expr.Evaluable) { t.eval = eval } +func (t WorkflowPath) Eval(ctx context.Context, scope *expr.Vars) (interface{}, error) { + return t.eval.Eval(ctx, scope) +} +func (t WorkflowPath) Test(ctx context.Context, scope *expr.Vars) (bool, error) { + return t.eval.Test(ctx, scope) +} + +func (vv *WorkflowStepSet) Scan(value interface{}) error { + //lint:ignore S1034 This typecast is intentional, we need to get []byte out of a []uint8 + switch value.(type) { + case nil: + *vv = WorkflowStepSet{} + case []uint8: + b := value.([]byte) + if err := json.Unmarshal(b, vv); err != nil { + return fmt.Errorf("can not scan '%v' into WorkflowStepSet: %w", string(b), err) + } + } + + return nil +} + +// Scan on WorkflowStepSet gracefully handles conversion from NULL +func (vv WorkflowStepSet) Value() (driver.Value, error) { + return json.Marshal(vv) +} + +func (vv *WorkflowPathSet) Scan(value interface{}) error { + //lint:ignore S1034 This typecast is intentional, we need to get []byte out of a []uint8 + switch value.(type) { + case nil: + *vv = WorkflowPathSet{} + case []uint8: + b := value.([]byte) + if err := json.Unmarshal(b, vv); err != nil { + return fmt.Errorf("can not scan '%v' into WorkflowPathSet: %w", string(b), err) + } + } + + return nil +} + +// Scan on WorkflowPathSet gracefully handles conversion from NULL +func (vv WorkflowPathSet) Value() (driver.Value, error) { + return json.Marshal(vv) +} diff --git a/compose/automation/modules_handler.gen.go b/compose/automation/modules_handler.gen.go new file mode 100644 index 000000000..0db7ccfac --- /dev/null +++ b/compose/automation/modules_handler.gen.go @@ -0,0 +1,665 @@ +package automation + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// compose/automation/modules_handler.yaml + +import ( + "context" + atypes "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/compose/types" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/label" + "github.com/cortezaproject/corteza-server/pkg/wfexec" +) + +var _ wfexec.ExecResponse + +type ( + modulesHandlerRegistry interface { + AddFunctions(ff ...*atypes.Function) + Type(ref string) expr.Type + } +) + +func (h modulesHandler) register() { + h.reg.AddFunctions( + h.LookupByID(), + h.Save(), + h.Create(), + h.Update(), + h.Delete(), + ) +} + +type ( + modulesLookupByIDArgs struct { + hasRecordID bool + RecordID uint64 + + hasModule bool + Module interface{} + moduleID uint64 + moduleHandle string + moduleRes *types.Module + + hasNamespace bool + Namespace interface{} + namespaceID uint64 + namespaceHandle string + namespaceRes *types.Namespace + } + + modulesLookupByIDResults struct { + Record *types.Record + } +) + +func (a modulesLookupByIDArgs) GetModule() (bool, uint64, string, *types.Module) { + return a.hasModule, a.moduleID, a.moduleHandle, a.moduleRes +} + +func (a modulesLookupByIDArgs) GetNamespace() (bool, uint64, string, *types.Namespace) { + return a.hasNamespace, a.namespaceID, a.namespaceHandle, a.namespaceRes +} + +// LookupByID function Lookup for compose record by ID +// +// expects implementation of lookupByID function: +// func (h modulesHandler) lookupByID(ctx context.Context, args *modulesLookupByIDArgs) (results *modulesLookupByIDResults, err error) { +// return +// } +func (h modulesHandler) LookupByID() *atypes.Function { + return &atypes.Function{ + Ref: "composeModulesLookupByID", + Kind: "function", + Meta: &atypes.FunctionMeta{ + Short: "Lookup for compose record by ID", + }, + + Parameters: []*atypes.Param{ + { + Name: "recordID", + Types: []string{"ID"}, Required: true, + }, + { + Name: "module", + Types: []string{"ID", "String", "ComposeModule"}, Required: true, + Meta: &atypes.ParamMeta{ + Label: "Module to set record type", + Description: "Even with unique record ID across all modules, module needs to be known\nbefore doing any record operations. Mainly because records of different\nmodules can be located in different stores.", + }, + }, + { + Name: "namespace", + Types: []string{"ID", "String", "ComposeNamespace"}, Required: true, + }, + }, + + Results: []*atypes.Param{ + + { + Name: "record", + Types: []string{"ComposeRecord"}, + }, + }, + + Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { + var ( + args = &modulesLookupByIDArgs{ + hasRecordID: in.Has("recordID"), + hasModule: in.Has("module"), + hasNamespace: in.Has("namespace"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + // Converting Module argument + if args.hasModule { + aux := expr.Must(expr.Select(in, "module")) + switch aux.Type() { + case h.reg.Type("ID").Type(): + args.moduleID = aux.Get().(uint64) + case h.reg.Type("String").Type(): + args.moduleHandle = aux.Get().(string) + case h.reg.Type("ComposeModule").Type(): + args.moduleRes = aux.Get().(*types.Module) + } + } + + // Converting Namespace argument + if args.hasNamespace { + aux := expr.Must(expr.Select(in, "namespace")) + switch aux.Type() { + case h.reg.Type("ID").Type(): + args.namespaceID = aux.Get().(uint64) + case h.reg.Type("String").Type(): + args.namespaceHandle = aux.Get().(string) + case h.reg.Type("ComposeNamespace").Type(): + args.namespaceRes = aux.Get().(*types.Namespace) + } + } + + var results *modulesLookupByIDResults + if results, err = h.lookupByID(ctx, args); err != nil { + return + } + + out = &expr.Vars{} + + { + // converting results.Record (*types.Record) to ComposeRecord + var ( + tval expr.TypedValue + ) + + if tval, err = h.reg.Type("ComposeRecord").Cast(results.Record); err != nil { + return + } else if err = expr.Assign(out, "record", tval); err != nil { + return + } + } + + return + }, + } +} + +type ( + modulesSaveArgs struct { + hasRecord bool + Record *types.Record + } + + modulesSaveResults struct { + Record *types.Record + } +) + +// Save function Save record +// +// expects implementation of save function: +// func (h modulesHandler) save(ctx context.Context, args *modulesSaveArgs) (results *modulesSaveResults, err error) { +// return +// } +func (h modulesHandler) Save() *atypes.Function { + return &atypes.Function{ + Ref: "composeModulesSave", + Kind: "function", + Meta: &atypes.FunctionMeta{ + Short: "Save record", + }, + + Parameters: []*atypes.Param{ + { + Name: "record", + Types: []string{"ComposeRecord"}, Required: true, + }, + }, + + Results: []*atypes.Param{ + + { + Name: "record", + Types: []string{"ComposeRecord"}, + }, + }, + + Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { + var ( + args = &modulesSaveArgs{ + hasRecord: in.Has("record"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + var results *modulesSaveResults + if results, err = h.save(ctx, args); err != nil { + return + } + + out = &expr.Vars{} + + { + // converting results.Record (*types.Record) to ComposeRecord + var ( + tval expr.TypedValue + ) + + if tval, err = h.reg.Type("ComposeRecord").Cast(results.Record); err != nil { + return + } else if err = expr.Assign(out, "record", tval); err != nil { + return + } + } + + return + }, + } +} + +type ( + modulesCreateArgs struct { + hasModule bool + Module interface{} + moduleID uint64 + moduleHandle string + moduleRes *types.Module + + hasNamespace bool + Namespace interface{} + namespaceID uint64 + namespaceHandle string + namespaceRes *types.Namespace + + hasValues bool + Values types.RecordValueSet + + hasLabels bool + Labels label.Labels + + hasOwnedBy bool + OwnedBy uint64 + } + + modulesCreateResults struct { + Record *types.Record + } +) + +func (a modulesCreateArgs) GetModule() (bool, uint64, string, *types.Module) { + return a.hasModule, a.moduleID, a.moduleHandle, a.moduleRes +} + +func (a modulesCreateArgs) GetNamespace() (bool, uint64, string, *types.Namespace) { + return a.hasNamespace, a.namespaceID, a.namespaceHandle, a.namespaceRes +} + +// Create function Creates and stores a new record +// +// expects implementation of create function: +// func (h modulesHandler) create(ctx context.Context, args *modulesCreateArgs) (results *modulesCreateResults, err error) { +// return +// } +func (h modulesHandler) Create() *atypes.Function { + return &atypes.Function{ + Ref: "composeModulesCreate", + Kind: "function", + Meta: &atypes.FunctionMeta{ + Short: "Creates and stores a new record", + }, + + Parameters: []*atypes.Param{ + { + Name: "module", + Types: []string{"ID", "String", "ComposeModule"}, Required: true, + Meta: &atypes.ParamMeta{ + Label: "Module to set record type", + Description: "Even with unique record ID across all modules, module needs to be known\nbefore doing any record operations. Mainly because records of different\nmodules can be located in different stores.", + }, + }, + { + Name: "namespace", + Types: []string{"ID", "String", "ComposeNamespace"}, Required: true, + }, + { + Name: "values", + Types: []string{"KV"}, + }, + { + Name: "labels", + Types: []string{"KV"}, + }, + { + Name: "ownedBy", + Types: []string{"ID"}, + Meta: &atypes.ParamMeta{ + Label: "Record owner", + Visual: map[string]interface{}{"ref": "users"}, + }, + }, + }, + + Results: []*atypes.Param{ + + { + Name: "record", + Types: []string{"ComposeRecord"}, + }, + }, + + Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { + var ( + args = &modulesCreateArgs{ + hasModule: in.Has("module"), + hasNamespace: in.Has("namespace"), + hasValues: in.Has("values"), + hasLabels: in.Has("labels"), + hasOwnedBy: in.Has("ownedBy"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + // Converting Module argument + if args.hasModule { + aux := expr.Must(expr.Select(in, "module")) + switch aux.Type() { + case h.reg.Type("ID").Type(): + args.moduleID = aux.Get().(uint64) + case h.reg.Type("String").Type(): + args.moduleHandle = aux.Get().(string) + case h.reg.Type("ComposeModule").Type(): + args.moduleRes = aux.Get().(*types.Module) + } + } + + // Converting Namespace argument + if args.hasNamespace { + aux := expr.Must(expr.Select(in, "namespace")) + switch aux.Type() { + case h.reg.Type("ID").Type(): + args.namespaceID = aux.Get().(uint64) + case h.reg.Type("String").Type(): + args.namespaceHandle = aux.Get().(string) + case h.reg.Type("ComposeNamespace").Type(): + args.namespaceRes = aux.Get().(*types.Namespace) + } + } + + var results *modulesCreateResults + if results, err = h.create(ctx, args); err != nil { + return + } + + out = &expr.Vars{} + + { + // converting results.Record (*types.Record) to ComposeRecord + var ( + tval expr.TypedValue + ) + + if tval, err = h.reg.Type("ComposeRecord").Cast(results.Record); err != nil { + return + } else if err = expr.Assign(out, "record", tval); err != nil { + return + } + } + + return + }, + } +} + +type ( + modulesUpdateArgs struct { + hasModule bool + Module interface{} + moduleID uint64 + moduleHandle string + moduleRes *types.Module + + hasNamespace bool + Namespace interface{} + namespaceID uint64 + namespaceHandle string + namespaceRes *types.Namespace + + hasValues bool + Values types.RecordValueSet + + hasLabels bool + Labels label.Labels + + hasOwnedBy bool + OwnedBy uint64 + } + + modulesUpdateResults struct { + Record *types.Record + } +) + +func (a modulesUpdateArgs) GetModule() (bool, uint64, string, *types.Module) { + return a.hasModule, a.moduleID, a.moduleHandle, a.moduleRes +} + +func (a modulesUpdateArgs) GetNamespace() (bool, uint64, string, *types.Namespace) { + return a.hasNamespace, a.namespaceID, a.namespaceHandle, a.namespaceRes +} + +// Update function Updates an existing record +// +// expects implementation of update function: +// func (h modulesHandler) update(ctx context.Context, args *modulesUpdateArgs) (results *modulesUpdateResults, err error) { +// return +// } +func (h modulesHandler) Update() *atypes.Function { + return &atypes.Function{ + Ref: "composeModulesUpdate", + Kind: "function", + Meta: &atypes.FunctionMeta{ + Short: "Updates an existing record", + }, + + Parameters: []*atypes.Param{ + { + Name: "module", + Types: []string{"ID", "String", "ComposeModule"}, Required: true, + Meta: &atypes.ParamMeta{ + Label: "Module to set record type", + Description: "Even with unique record ID across all modules, module needs to be known\nbefore doing any record operations. Mainly because records of different\nmodules can be located in different stores.", + }, + }, + { + Name: "namespace", + Types: []string{"ID", "String", "ComposeNamespace"}, Required: true, + }, + { + Name: "values", + Types: []string{"KV"}, + }, + { + Name: "labels", + Types: []string{"KV"}, + }, + { + Name: "ownedBy", + Types: []string{"ID"}, + Meta: &atypes.ParamMeta{ + Label: "Record owner", + Visual: map[string]interface{}{"ref": "users"}, + }, + }, + }, + + Results: []*atypes.Param{ + + { + Name: "record", + Types: []string{"ComposeRecord"}, + }, + }, + + Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { + var ( + args = &modulesUpdateArgs{ + hasModule: in.Has("module"), + hasNamespace: in.Has("namespace"), + hasValues: in.Has("values"), + hasLabels: in.Has("labels"), + hasOwnedBy: in.Has("ownedBy"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + // Converting Module argument + if args.hasModule { + aux := expr.Must(expr.Select(in, "module")) + switch aux.Type() { + case h.reg.Type("ID").Type(): + args.moduleID = aux.Get().(uint64) + case h.reg.Type("String").Type(): + args.moduleHandle = aux.Get().(string) + case h.reg.Type("ComposeModule").Type(): + args.moduleRes = aux.Get().(*types.Module) + } + } + + // Converting Namespace argument + if args.hasNamespace { + aux := expr.Must(expr.Select(in, "namespace")) + switch aux.Type() { + case h.reg.Type("ID").Type(): + args.namespaceID = aux.Get().(uint64) + case h.reg.Type("String").Type(): + args.namespaceHandle = aux.Get().(string) + case h.reg.Type("ComposeNamespace").Type(): + args.namespaceRes = aux.Get().(*types.Namespace) + } + } + + var results *modulesUpdateResults + if results, err = h.update(ctx, args); err != nil { + return + } + + out = &expr.Vars{} + + { + // converting results.Record (*types.Record) to ComposeRecord + var ( + tval expr.TypedValue + ) + + if tval, err = h.reg.Type("ComposeRecord").Cast(results.Record); err != nil { + return + } else if err = expr.Assign(out, "record", tval); err != nil { + return + } + } + + return + }, + } +} + +type ( + modulesDeleteArgs struct { + hasRecordID bool + RecordID uint64 + + hasModule bool + Module interface{} + moduleID uint64 + moduleHandle string + moduleRes *types.Module + + hasNamespace bool + Namespace interface{} + namespaceID uint64 + namespaceHandle string + namespaceRes *types.Namespace + } +) + +func (a modulesDeleteArgs) GetModule() (bool, uint64, string, *types.Module) { + return a.hasModule, a.moduleID, a.moduleHandle, a.moduleRes +} + +func (a modulesDeleteArgs) GetNamespace() (bool, uint64, string, *types.Namespace) { + return a.hasNamespace, a.namespaceID, a.namespaceHandle, a.namespaceRes +} + +// Delete function Soft deletes compose record by ID +// +// expects implementation of delete function: +// func (h modulesHandler) delete(ctx context.Context, args *modulesDeleteArgs) (err error) { +// return +// } +func (h modulesHandler) Delete() *atypes.Function { + return &atypes.Function{ + Ref: "composeModulesDelete", + Kind: "function", + Meta: &atypes.FunctionMeta{ + Short: "Soft deletes compose record by ID", + }, + + Parameters: []*atypes.Param{ + { + Name: "recordID", + Types: []string{"ID"}, Required: true, + }, + { + Name: "module", + Types: []string{"ID", "String", "ComposeModule"}, Required: true, + Meta: &atypes.ParamMeta{ + Label: "Module to set record type", + Description: "Even with unique record ID across all modules, module needs to be known\nbefore doing any record operations. Mainly because records of different\nmodules can be located in different stores.", + }, + }, + { + Name: "namespace", + Types: []string{"ID", "String", "ComposeNamespace"}, Required: true, + }, + }, + + Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { + var ( + args = &modulesDeleteArgs{ + hasRecordID: in.Has("recordID"), + hasModule: in.Has("module"), + hasNamespace: in.Has("namespace"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + // Converting Module argument + if args.hasModule { + aux := expr.Must(expr.Select(in, "module")) + switch aux.Type() { + case h.reg.Type("ID").Type(): + args.moduleID = aux.Get().(uint64) + case h.reg.Type("String").Type(): + args.moduleHandle = aux.Get().(string) + case h.reg.Type("ComposeModule").Type(): + args.moduleRes = aux.Get().(*types.Module) + } + } + + // Converting Namespace argument + if args.hasNamespace { + aux := expr.Must(expr.Select(in, "namespace")) + switch aux.Type() { + case h.reg.Type("ID").Type(): + args.namespaceID = aux.Get().(uint64) + case h.reg.Type("String").Type(): + args.namespaceHandle = aux.Get().(string) + case h.reg.Type("ComposeNamespace").Type(): + args.namespaceRes = aux.Get().(*types.Namespace) + } + } + + return out, h.delete(ctx, args) + }, + } +} diff --git a/compose/automation/modules_handler.go b/compose/automation/modules_handler.go new file mode 100644 index 000000000..9f63d74d1 --- /dev/null +++ b/compose/automation/modules_handler.go @@ -0,0 +1,158 @@ +package automation + +import ( + "context" + "github.com/cortezaproject/corteza-server/compose/types" +) + +type ( + recordService interface { + FindByID(ctx context.Context, namespaceID, moduleID, recordID uint64) (*types.Record, error) + Find(ctx context.Context, filter types.RecordFilter) (set types.RecordSet, f types.RecordFilter, err error) + + Create(ctx context.Context, record *types.Record) (*types.Record, error) + Update(ctx context.Context, record *types.Record) (*types.Record, error) + Bulk(ctx context.Context, oo ...*types.RecordBulkOperation) (types.RecordSet, error) + + DeleteByID(ctx context.Context, namespaceID, moduleID uint64, recordID ...uint64) error + } + + moduleService interface { + FindByID(ctx context.Context, namespaceID, moduleID uint64) (*types.Module, error) + FindByHandle(ctx context.Context, namespaceID uint64, handle string) (*types.Module, error) + } + + namespaceService interface { + FindByID(ctx context.Context, namespaceID uint64) (*types.Namespace, error) + FindByHandle(ctx context.Context, handle string) (*types.Namespace, error) + } + + recordsHandlers struct { + reg recordsHandlerRegistry + ns namespaceService + mod moduleService + rec recordService + } +) + +func RecordsHandlers(reg recordsHandlerRegistry, ns namespaceService, mod moduleService, rec recordService) *recordsHandlers { + h := &recordsHandlers{ + reg: reg, + ns: ns, + mod: mod, + rec: rec, + } + + h.register() + return h +} + +func resolveNamespace(ctx context.Context, svc namespaceService, id *uint64, handle string, res *types.Namespace) (err error) { + if *id == 0 { + if len(handle) > 0 { + if res, err = svc.FindByHandle(ctx, handle); err != nil { + return + } + } + + if res != nil { + *id = res.ID + } + } + + return +} + +func resolveModule(ctx context.Context, svc moduleService, namespaceID uint64, id *uint64, handle string, res *types.Module) (err error) { + if *id == 0 { + if len(handle) > 0 { + if res, err = svc.FindByHandle(ctx, namespaceID, handle); err != nil { + return + } + } + + if res != nil { + *id = res.ID + } + } + + return +} + +func (h recordsHandlers) lookupByID(ctx context.Context, args *recordsLookupByIDArgs) (results *recordsLookupByIDResults, err error) { + results = &recordsLookupByIDResults{} + + if err = resolveNamespace(ctx, h.ns, &args.namespaceID, args.namespaceHandle, args.namespaceRes); err != nil { + return nil, err + } + + if err = resolveModule(ctx, h.mod, args.namespaceID, &args.moduleID, args.moduleHandle, args.moduleRes); err != nil { + return nil, err + } + + results.Record, err = h.rec.FindByID(ctx, args.namespaceID, args.moduleID, args.RecordID) + return +} + +func (h recordsHandlers) create(ctx context.Context, args *recordsCreateArgs) (results *recordsCreateResults, err error) { + results = &recordsCreateResults{} + + if err = resolveNamespace(ctx, h.ns, &args.namespaceID, args.namespaceHandle, args.namespaceRes); err != nil { + return nil, err + } + + if err = resolveModule(ctx, h.mod, args.namespaceID, &args.moduleID, args.moduleHandle, args.moduleRes); err != nil { + return nil, err + } + + rec := &types.Record{ + ModuleID: args.moduleID, + NamespaceID: args.namespaceID, + Values: args.Values, + Labels: args.Labels, + OwnedBy: args.OwnedBy, + } + + results.Record, err = h.rec.Create(ctx, rec) + return +} + +func (h recordsHandlers) save(ctx context.Context, args *recordsSaveArgs) (results *recordsSaveResults, err error) { + results = &recordsSaveResults{} + results.Record, err = h.rec.Update(ctx, args.Record) + return +} + +func (h recordsHandlers) update(ctx context.Context, args *recordsUpdateArgs) (results *recordsUpdateResults, err error) { + results = &recordsUpdateResults{} + if err = resolveNamespace(ctx, h.ns, &args.namespaceID, args.namespaceHandle, args.namespaceRes); err != nil { + return nil, err + } + + if err = resolveModule(ctx, h.mod, args.namespaceID, &args.moduleID, args.moduleHandle, args.moduleRes); err != nil { + return nil, err + } + + rec := &types.Record{ + ModuleID: args.moduleID, + NamespaceID: args.namespaceID, + Values: args.Values, + Labels: args.Labels, + OwnedBy: args.OwnedBy, + } + + results.Record, err = h.rec.Update(ctx, rec) + return +} + +func (h recordsHandlers) delete(ctx context.Context, args *recordsDeleteArgs) (err error) { + if err = resolveNamespace(ctx, h.ns, &args.namespaceID, args.namespaceHandle, args.namespaceRes); err != nil { + return err + } + + if err = resolveModule(ctx, h.mod, args.namespaceID, &args.moduleID, args.moduleHandle, args.moduleRes); err != nil { + return err + } + + return h.rec.DeleteByID(ctx, args.namespaceID, args.moduleID, args.RecordID) +} diff --git a/compose/automation/modules_handler.yaml b/compose/automation/modules_handler.yaml new file mode 100644 index 000000000..a22b9dbd4 --- /dev/null +++ b/compose/automation/modules_handler.yaml @@ -0,0 +1,124 @@ +prefix: compose + +imports: + - github.com/cortezaproject/corteza-server/compose/types + - github.com/cortezaproject/corteza-server/pkg/label +# - time + +params: + module: &module + required: true + types: + - { wf: ID, go: uint64, suffix: ID } + - { wf: String, go: string, suffix: handle } + - { wf: ComposeModule, go: "*types.Module", suffix: res } + meta: + label: Module to set record type + description: |- + Even with unique record ID across all modules, module needs to be known + before doing any record operations. Mainly because records of different + modules can be located in different stores. + + namespace: &namespace + required: true + types: + - { wf: ID, go: uint64, suffix: ID } + - { wf: String, go: string, suffix: handle } + - { wf: ComposeNamespace, go: "*types.Namespace", suffix: res } + + record: &record + required: true + types: + - { wf: ComposeRecord, go: "*types.Record" } + + recordID: &recordID + required: true + types: + - { wf: ID, go: uint64 } + + values: &values + types: [ { wf: KV, go: 'types.RecordValueSet' }] + + labels: &labels + types: [ { wf: KV, go: 'label.Labels' }] + + ownedBy: &ownedBy + types: [ { wf: ID, go: uint64 } ] + meta: + label: Record owner + visual: + ref: users + + recordReturnValue: &recordReturnValue + wf: ComposeRecord + go: "*types.Record" + +functions: + lookupByID: + meta: + short: Lookup for compose record by ID + params: + recordID: *recordID + module: *module + namespace: *namespace + results: + record: *recordReturnValue + + save: + meta: + short: Save record + params: + record: *record + results: + record: *recordReturnValue + + # @todo validate + # -> record + # <- errors RVSE + # <- valid (bool) + + # @todo copy + # -> source Record + # -> target Record + # -> include []string list of fields from source record to copy + # -> exclude []string list of fields from source record to copy + + create: + meta: + short: Creates and stores a new record + params: + module: *module + namespace: *namespace + values: *values + labels: *labels + ownedBy: *ownedBy + results: + record: *recordReturnValue + + update: + meta: + short: Updates an existing record + params: + module: *module + namespace: *namespace + values: *values + labels: *labels + ownedBy: *ownedBy + results: + record: *recordReturnValue + + delete: + meta: + short: Soft deletes compose record by ID + params: + recordID: *recordID + module: *module + namespace: *namespace + +# restore: +# meta: +# short: Soft deletes compose record by ID +# params: +# recordID: *recordID +# module: *module +# namespace: *namespace diff --git a/compose/automation/namespaces_handler.gen.go b/compose/automation/namespaces_handler.gen.go new file mode 100644 index 000000000..677604a28 --- /dev/null +++ b/compose/automation/namespaces_handler.gen.go @@ -0,0 +1,153 @@ +package automation + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// compose/automation/namespaces_handler.yaml + +import ( + "context" + atypes "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/compose/types" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/wfexec" +) + +var _ wfexec.ExecResponse + +type ( + namespacesHandlerRegistry interface { + AddFunctions(ff ...*atypes.Function) + Type(ref string) expr.Type + } +) + +func (h namespacesHandler) register() { + h.reg.AddFunctions( + h.Lookup(), + ) +} + +type ( + namespacesLookupArgs struct { + hasModule bool + Module interface{} + moduleID uint64 + moduleHandle string + + hasNamespace bool + Namespace interface{} + namespaceID uint64 + namespaceHandle string + namespaceRes *types.Namespace + } + + namespacesLookupResults struct { + Module *types.Module + } +) + +func (a namespacesLookupArgs) GetModule() (bool, uint64, string) { + return a.hasModule, a.moduleID, a.moduleHandle +} + +func (a namespacesLookupArgs) GetNamespace() (bool, uint64, string, *types.Namespace) { + return a.hasNamespace, a.namespaceID, a.namespaceHandle, a.namespaceRes +} + +// Lookup function Lookup for compose module by ID +// +// expects implementation of lookup function: +// func (h namespacesHandler) lookup(ctx context.Context, args *namespacesLookupArgs) (results *namespacesLookupResults, err error) { +// return +// } +func (h namespacesHandler) Lookup() *atypes.Function { + return &atypes.Function{ + Ref: "composeNamespacesLookup", + Kind: "function", + Meta: &atypes.FunctionMeta{ + Short: "Lookup for compose module by ID", + }, + + Parameters: []*atypes.Param{ + { + Name: "module", + Types: []string{"ID", "String"}, Required: true, + }, + { + Name: "namespace", + Types: []string{"ID", "String", "ComposeNamespace"}, Required: true, + }, + }, + + Results: []*atypes.Param{ + + { + Name: "module", + Types: []string{"ComposeModule"}, + }, + }, + + Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { + var ( + args = &namespacesLookupArgs{ + hasModule: in.Has("module"), + hasNamespace: in.Has("namespace"), + } + ) + + if err = in.Decode(args); err != nil { + return + } + + // Converting Module argument + if args.hasModule { + aux := expr.Must(expr.Select(in, "module")) + switch aux.Type() { + case h.reg.Type("ID").Type(): + args.moduleID = aux.Get().(uint64) + case h.reg.Type("String").Type(): + args.moduleHandle = aux.Get().(string) + } + } + + // Converting Namespace argument + if args.hasNamespace { + aux := expr.Must(expr.Select(in, "namespace")) + switch aux.Type() { + case h.reg.Type("ID").Type(): + args.namespaceID = aux.Get().(uint64) + case h.reg.Type("String").Type(): + args.namespaceHandle = aux.Get().(string) + case h.reg.Type("ComposeNamespace").Type(): + args.namespaceRes = aux.Get().(*types.Namespace) + } + } + + var results *namespacesLookupResults + if results, err = h.lookup(ctx, args); err != nil { + return + } + + out = &expr.Vars{} + + { + // converting results.Module (*types.Module) to ComposeModule + var ( + tval expr.TypedValue + ) + + if tval, err = h.reg.Type("ComposeModule").Cast(results.Module); err != nil { + return + } else if err = expr.Assign(out, "module", tval); err != nil { + return + } + } + + return + }, + } +} diff --git a/compose/automation/namespaces_handler.go b/compose/automation/namespaces_handler.go new file mode 100644 index 000000000..a5c30d1af --- /dev/null +++ b/compose/automation/namespaces_handler.go @@ -0,0 +1,73 @@ +package automation + +import ( + "context" + "github.com/cortezaproject/corteza-server/compose/types" +) + +type ( + moduleService interface { + FindByID(ctx context.Context, namespaceID, moduleID uint64) (*types.Module, error) + FindByHandle(ctx context.Context, namespaceID uint64, handle string) (*types.Module, error) + Find(ctx context.Context, filter types.ModuleFilter) (set types.ModuleSet, f types.ModuleFilter, err error) + + Create(ctx context.Context, module *types.Module) (*types.Module, error) + Update(ctx context.Context, module *types.Module) (*types.Module, error) + + DeleteByID(ctx context.Context, namespaceID uint64, moduleID ...uint64) error + } + + moduleNamespaceService interface { + FindByID(ctx context.Context, namespaceID uint64) (*types.Namespace, error) + FindByHandle(ctx context.Context, handle string) (*types.Namespace, error) + } + + modulesHandlers struct { + reg modulesHandlerRegistry + ns moduleNamespaceService + mod moduleService + } +) + +func ModulesHandlers(reg modulesHandlerRegistry, ns moduleNamespaceService, rec moduleService) *modulesHandlers { + h := &modulesHandlers{ + reg: reg, + ns: ns, + mod: rec, + } + + h.register() + return h +} + +func (h modulesHandlers) resolveNamespace(ctx context.Context, id *uint64, handle string, res *types.Namespace) (err error) { + if *id == 0 { + if len(handle) > 0 { + if res, err = h.ns.FindByHandle(ctx, handle); err != nil { + return + } + } + + if res != nil { + *id = res.ID + } + } + + return +} + +func (h modulesHandlers) lookup(ctx context.Context, args *modulesLookupArgs) (results *modulesLookupResults, err error) { + results = &modulesLookupResults{} + + if err = h.resolveNamespace(ctx, &args.namespaceID, args.namespaceHandle, args.namespaceRes); err != nil { + return nil, err + } + + if args.moduleID > 0 { + results.Module, err = h.mod.FindByID(ctx, args.namespaceID, args.moduleID) + } else { + results.Module, err = h.mod.FindByHandle(ctx, args.namespaceID, args.moduleHandle) + } + + return +} diff --git a/compose/automation/namespaces_handler.yaml b/compose/automation/namespaces_handler.yaml new file mode 100644 index 000000000..76f71108a --- /dev/null +++ b/compose/automation/namespaces_handler.yaml @@ -0,0 +1,32 @@ +prefix: compose + +imports: + - github.com/cortezaproject/corteza-server/compose/types + +params: + module: &module + required: true + types: + - { wf: ID, go: uint64, suffix: ID } + - { wf: String, go: string, suffix: handle } + + namespace: &namespace + required: true + types: + - { wf: ID, go: uint64, suffix: ID } + - { wf: String, go: string, suffix: handle } + - { wf: ComposeNamespace, go: "*types.Namespace", suffix: res } + + moduleReturnValue: &moduleReturnValue + wf: ComposeModule + go: "*types.Module" + +functions: + lookup: + meta: + short: Lookup for compose module by ID + params: + module: *module + namespace: *namespace + results: + module: *moduleReturnValue diff --git a/go.mod b/go.mod index 90868c9a3..00cb34c1c 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig v2.22.0+incompatible github.com/Masterminds/squirrel v1.1.1-0.20191017225151-12f2162c8d8d - github.com/PaesslerAG/gval v1.1.0 + github.com/PaesslerAG/gval v1.1.1-0.20201104175134-7847ed0c7671 github.com/PaesslerAG/jsonpath v0.1.1 // indirect github.com/SentimensRG/ctx v0.0.0-20180729130232-0bfd988c655d github.com/crusttech/go-oidc v0.0.0-20180918092017-982855dad3e1 @@ -65,7 +65,7 @@ require ( github.com/stretchr/testify v1.6.1 github.com/tebeka/strftime v0.1.5 // indirect go.uber.org/atomic v1.6.0 - go.uber.org/zap v1.15.0 + go.uber.org/zap v1.16.0 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a // indirect golang.org/x/sys v0.0.0-20200918174421-af09f7315aff // indirect diff --git a/go.sum b/go.sum index 5f83116e6..6f1ead0cb 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/PaesslerAG/gval v0.1.1/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= github.com/PaesslerAG/gval v1.1.0 h1:k3RuxeZDO3eejD4cMPSt+74tUSvTnbGvLx0df4mdwFc= github.com/PaesslerAG/gval v1.1.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= +github.com/PaesslerAG/gval v1.1.1-0.20201104175134-7847ed0c7671 h1:mXmNWMJr5OPcaA00ryiN+FZphVa1EtUYiVNH8nfWALY= +github.com/PaesslerAG/gval v1.1.1-0.20201104175134-7847ed0c7671/go.mod h1:Fa8gfkCmUsELXgayr8sfL/sw+VzCVoa03dcOcR/if2w= github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk= github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= @@ -407,6 +409,8 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEa go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.15.0 h1:ZZCA22JRF2gQE5FoNmhmrf7jeJJ2uhqDUNRYKm8dvmM= go.uber.org/zap v1.15.0/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= +go.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= diff --git a/pkg/codegen/afunc.go b/pkg/codegen/afunc.go index 563391106..a5ec2828f 100644 --- a/pkg/codegen/afunc.go +++ b/pkg/codegen/afunc.go @@ -3,7 +3,6 @@ package codegen import ( "encoding/json" "fmt" - "github.com/cortezaproject/corteza-server/automation/types" . "github.com/cortezaproject/corteza-server/pkg/y7s" "gopkg.in/yaml.v3" "os" @@ -28,11 +27,12 @@ type ( Functions aFunctionSet } - aFunctionSet []aFuncDef + aFunctionSet []*aFuncDef aFuncDef struct { Name string - Meta *types.FunctionMeta + Kind string + Meta *aFuncMetaDef Params aFuncParamSet Results aFuncResultSet } @@ -43,9 +43,9 @@ type ( aFuncParamDef struct { Name string Required bool - SetOf bool + IsArray bool `yaml:"isArray"` Types []*aFuncParamTypeVarDef - Meta *types.ParamMeta + Meta *aFuncParamMetaDef } aFuncParamTypeVarDef struct { @@ -56,11 +56,22 @@ type ( aFuncResultDef struct { Name string - Required bool - SetOf bool + IsArray bool `yaml:"isArray"` WorkflowType string `yaml:"wf"` GoType string `yaml:"go"` - Meta *types.ParamMeta + Meta *aFuncParamMetaDef + } + + aFuncMetaDef struct { + Short string + Description string + Visual map[string]interface{} + } + + aFuncParamMetaDef struct { + Label string + Description string + Visual map[string]interface{} } ) @@ -85,7 +96,7 @@ func procAutomationFunctions(mm ...string) (dd []*aFuncDefs, err error) { d.Name = d.Name[:len(d.Name)-13] if err := yaml.NewDecoder(f).Decode(d); err != nil { - return nil, err + return nil, fmt.Errorf("could not decode %s: %w", m, err) } dd = append(dd, d) @@ -96,12 +107,16 @@ func procAutomationFunctions(mm ...string) (dd []*aFuncDefs, err error) { func (set *aFunctionSet) UnmarshalYAML(n *yaml.Node) error { return Each(n, func(k *yaml.Node, v *yaml.Node) (err error) { - def := aFuncDef{Name: k.Value} + def := &aFuncDef{Name: k.Value} if err = v.Decode(&def); err != nil { return err } + if def.Kind == "" { + def.Kind = "function" + } + *set = append(*set, def) return nil @@ -132,6 +147,47 @@ func (set *aFuncResultSet) UnmarshalYAML(n *yaml.Node) error { }) } +func expandAutomationFunctionTypes(ff []*aFuncDefs, tt []*exprTypesDef) { + // index of all known types + ti := make(map[string]*exprTypeDef) + + for _, t := range tt { + for typ, d := range t.Types { + ti[t.Prefix+typ] = d + } + } + + for _, f := range ff { + for _, fn := range f.Functions { + for _, p := range fn.Params { + for _, t := range p.Types { + if ti[t.WorkflowType] == nil { + fmt.Printf("%s/%s(): unknown type %q used for param %q\n", f.Prefix, fn.Name, t.WorkflowType, p.Name) + } + + if t.GoType == "" { + t.GoType = ti[t.WorkflowType].As + } + + if t.Suffix == "" && len(p.Types) > 1 { + t.Suffix = t.WorkflowType + } + } + } + + for _, r := range fn.Results { + if ti[r.WorkflowType] == nil { + fmt.Printf("%s/%s(): unknown type %q used for result %q\n", f.Prefix, fn.Name, r.WorkflowType, r.Name) + } + + if r.GoType == "" { + r.GoType = ti[r.WorkflowType].As + } + } + } + } +} + func genAutomationFunctions(tpl *template.Template, dd ...*aFuncDefs) (err error) { var ( // Will only be generated if file does not exist previously diff --git a/pkg/codegen/assets/afunc.gen.go.tpl b/pkg/codegen/assets/afunc.gen.go.tpl index dc70ea2ae..60729af12 100644 --- a/pkg/codegen/assets/afunc.gen.go.tpl +++ b/pkg/codegen/assets/afunc.gen.go.tpl @@ -12,19 +12,27 @@ import ( atypes "github.com/cortezaproject/corteza-server/automation/types" "context" "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/wfexec" {{- range .Imports }} {{ normalizeImport . }} {{- end }} ) -var ( - {{ $.Name }} = &{{ $.Name }}Handler{} +var _ wfexec.ExecResponse + +type ( + {{ $.Name }}HandlerRegistry interface { + AddFunctions(ff ...*atypes.Function) + Type(ref string) expr.Type + } ) -func (h {{ $.Name }}Handler) register(reg func(*atypes.Function)) { -{{- range .Functions }} - reg(h.{{ export .Name }}()) -{{- end }} +func (h {{ $.Name }}Handler) register() { + h.reg.AddFunctions( + {{- range .Functions }} + h.{{ export .Name }}(), + {{- end }} + ) } {{ range .Functions }} @@ -36,16 +44,17 @@ func (h {{ $.Name }}Handler) register(reg func(*atypes.Function)) { type ( {{ $ARGS }} struct { {{ range .Params }} - {{ $NAME := .Name }} + {{ $name := .Name }} + {{ $isArray := .IsArray }} has{{ export .Name }} bool - {{ if gt (len .Types) 1 }} + {{- if gt (len .Types) 1 }} {{ export .Name }} interface{} {{- range .Types }} - {{ $NAME }}{{ export .Suffix }} {{ .GoType }} + {{ $name }}{{ export .Suffix }} {{ if $isArray }}[]{{ end }}{{ .GoType }} {{- end }} {{- else -}} {{ range .Types }} - {{ export $NAME }}{{ export .Suffix }} {{ .GoType }} + {{ export $name }}{{ export .Suffix }} {{ if $isArray }}[]{{ end }}{{ .GoType }} {{- end }} {{- end -}} {{- end }} @@ -54,21 +63,35 @@ type ( {{ if .Results }} {{ unexport $.Name .Name }}Results struct { {{ range .Results }} - {{ export .Name }} {{ .GoType }} + {{ export .Name }} {{ if .IsArray }}[]{{ end }}{{ .GoType }} {{- end }} } {{- end }} ) -// + + + +{{ range .Params }} +{{- if gt (len .Types) 1 }} +{{ $name := .Name }} +func (a {{ $ARGS }}) {{ export "get" $name }}() (bool, {{ range .Types }}{{ .GoType }},{{ end }}) { + return a.has{{ export $name }}{{ range .Types }}, a.{{ $name }}{{ export .Suffix }}{{ end }} +} +{{- end }} +{{- end }} + + +// {{ export .Name }} function {{ .Meta.Short }} // // expects implementation of {{ .Name }} function: -// func (h {{ $.Name }}) {{ .Name }}(ctx context.Context, args *{{ $ARGS }}) (results *{{ $RESULTS }}, err error) { +// func (h {{ $.Name }}Handler) {{ .Name }}(ctx context.Context, args *{{ $ARGS }}) ({{ if .Results }}results *{{ $RESULTS }}, {{ end }}err error) { // return // } func (h {{ $.Name }}Handler) {{ export .Name }}() *atypes.Function { return &atypes.Function{ Ref: {{ printf "%q" ( $REF ) }}, + Kind: {{ printf "%q" .Kind }}, {{- if .Meta }} Meta: &atypes.FunctionMeta{ {{- if .Meta.Short }} @@ -87,9 +110,9 @@ func (h {{ $.Name }}Handler) {{ export .Name }}() *atypes.Function { {{- range .Params }} { Name: {{ printf "%q" .Name }}, - Types: []string{ {{ range .Types }}({{ .WorkflowType }}{}).Type(),{{ end }} }, + Types: []string{ {{ range .Types }}{{ printf "%q" .WorkflowType }},{{ end }} }, {{- if .Required }}Required: true,{{ end }} - {{- if .SetOf }}SetOf: true,{{ end }} + {{- if .IsArray }}IsArray: true,{{ end }} {{- if .Meta }} Meta: &atypes.ParamMeta{ {{- if .Meta.Label }} @@ -107,65 +130,137 @@ func (h {{ $.Name }}Handler) {{ export .Name }}() *atypes.Function { {{- end }} }, + {{ if .Results }} Results: []*atypes.Param{ {{ range .Results }} - atypes.NewParam({{ printf "%q" .Name }}, - atypes.Types(&{{ .WorkflowType }}{}), - ), + { + Name: {{ printf "%q" .Name }}, + Types: []string{ {{ printf "%q" .WorkflowType }} }, + {{ if .IsArray }}IsArray: true,{{ end -}} + {{ if .Meta -}} + Meta: &atypes.ParamMeta{ + {{- if .Meta.Label }} + Label: {{ printf "%#v" .Meta.Label }}, + {{- end }} + {{- if .Meta.Description }} + Description: {{ printf "%#v" .Meta.Description }}, + {{- end }} + {{- if .Meta.Visual }} + Visual: {{ printf "%#v" .Meta.Visual }}, + {{- end }} + }, + {{ end }} + }, {{ end }} }, {{ end }} - Handler: func(ctx context.Context, in expr.Vars) (out expr.Vars, err error) { + {{ if eq .Kind "iterator" }} + Iterator: func(ctx context.Context, in *expr.Vars) (out wfexec.IteratorHandler, err error) { var ( args = &{{ $ARGS }}{ {{- range .Params }} has{{ export .Name }}: in.Has({{ printf "%q" .Name }}), {{- end }} } - - {{ if .Results }} - results *{{ $RESULTS }} - {{ end }} ) - if err = in.Decode(args); err != nil { - return - } + {{ template "params" .Params }} - {{ range .Params }} - {{ $NAME := .Name }} - {{ if gt (len .Types) 1 }} - // Converting {{ export .Name }} to go type - switch casted := args.{{ export .Name }}.(type) { - {{- range .Types }} - case {{ .GoType }}: - args.{{ $NAME }}{{ export .Suffix }} = casted - {{- end -}} - } + return h.{{ .Name }}(ctx, args) + }, + {{ else }} + Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { + var ( + args = &{{ $ARGS }}{ + {{- range .Params }} + has{{ export .Name }}: in.Has({{ printf "%q" .Name }}), {{- end }} - {{ end }} + } + ) + {{ template "params" .Params }} {{ if .Results }} + var results *{{ $RESULTS }} if results, err = h.{{ .Name }}(ctx, args); err != nil { return } - out = expr.Vars{} + out = &expr.Vars{} {{- range .Results }} - if out[{{ printf "%q" .Name }}], err = ({{ .WorkflowType }}{}).Cast(results.{{ export .Name }}); err != nil { - return nil, err - } - {{- end }} + {{ if .IsArray }} + + { + // converting results.{{ export .Name }} ({{ .GoType }}) to Array (of {{ .WorkflowType }}) + var ( + tval expr.TypedValue + tarr = make([]expr.TypedValue, len(results.{{ export .Name }})) + ) + + for i := range results.{{ export .Name }} { + if tarr[i], err = h.reg.Type({{ printf "%q" .WorkflowType }}).Cast(results.{{ export .Name }}[i]); err != nil { + return + } + } + + if tval, err = expr.NewArray(tarr); err != nil { + return + } else if err = expr.Assign(out, {{ printf "%q" .Name }}, tval); err != nil { + return + } + } + + + {{ else }} + + { + // converting results.{{ export .Name }} ({{ .GoType }}) to {{ .WorkflowType }} + var ( + tval expr.TypedValue + ) + + if tval, err = h.reg.Type({{ printf "%q" .WorkflowType }}).Cast(results.{{ export .Name }}); err != nil { + return + } else if err = expr.Assign(out, {{ printf "%q" .Name }}, tval); err != nil { + return + } + } + + {{ end }} + {{ end }} return {{- else }} return out, h.{{ .Name }}(ctx, args) {{- end }} }, + {{ end }} } } {{ end }} + +{{ define "params" }} + if err = in.Decode(args); err != nil { + return + } + + {{ range . }} + {{ $name := .Name }} + {{ if gt (len .Types) 1 }} + // Converting {{ export .Name }} argument + if args.has{{ export .Name }} { + aux := expr.Must(expr.Select(in, {{ printf "%q" .Name }})) + switch aux.Type() { + {{- range .Types }} + case h.reg.Type({{ printf "%q" .WorkflowType }}).Type(): + args.{{ $name }}{{ export .Suffix }} = aux.Get().({{ .GoType }}) + {{- end -}} + } + } + + {{- end }} + {{ end }} +{{ end }} diff --git a/pkg/codegen/assets/expr_types.gen.go.tpl b/pkg/codegen/assets/expr_types.gen.go.tpl index bd5214d6d..0758547a4 100644 --- a/pkg/codegen/assets/expr_types.gen.go.tpl +++ b/pkg/codegen/assets/expr_types.gen.go.tpl @@ -10,38 +10,146 @@ package {{ .Package }} {{ if .Imports }} import ( + "context" + "fmt" {{- range .Imports }} {{ normalizeImport . }} {{- end }} {{- if ne .Package "expr" }} - "github.com/cortezaproject/corteza-server/pkg/expr" + . "github.com/cortezaproject/corteza-server/pkg/expr" {{- end }} ) {{ end }} -{{ $TypedValue := "TypedValue" }} -{{ if ne .Package "expr" }} - {{ $TypedValue = "expr.TypedValue" }} -{{ end }} +var _ = context.Background +var _ = fmt.Errorf -{{ range $exprType, $nativeType := .Types }} -// {{ $exprType }} is an expression type, wrapper for {{ $nativeType }} type -type {{ $exprType }} struct{ value {{ $nativeType }} } + +{{ range $exprType, $def := .Types }} +// {{ $exprType }} is an expression type, wrapper for {{ $def.As }} type +type {{ $exprType }} struct{ value {{ $def.As }} } // New{{ $exprType }} creates new instance of {{ $exprType }} expression type -func New{{ $exprType }}(new interface{}) ({{ $TypedValue }}, error) { - t := &{{ $exprType }}{} - return t, t.Set(new) +func New{{ $exprType }}(val interface{}) (*{{ $exprType }}, error) { + if c, err := {{ export "CastTo" $exprType }}(val); err != nil { + return nil, fmt.Errorf("unable to create {{ $exprType }}: %w", err) + } else { + return &{{ $exprType }}{value: c}, nil + } } -// Returns underlying value on {{ $exprType }} + +// Return underlying value on {{ $exprType }} func (t {{ $exprType }}) Get() interface{} { return t.value } -// Returns type name -func ({{ $exprType }}) Type() string { return "{{ $exprType }}" } +// Return type name +func ({{ $exprType }}) Type() string { return "{{ $.Prefix }}{{ $exprType }}" } -// Casts value to {{ $nativeType }} -func ({{ $exprType }}) Cast(value interface{}) ({{ $TypedValue }}, error) { return New{{ $exprType }}(value) } +// Convert value to {{ $def.As }} +func ({{ $exprType }}) Cast(val interface{}) (TypedValue, error) { + return New{{ $exprType }}(val) +} + +// Assign new value to {{ $exprType }} +// +// value is first passed through {{ export "CastTo" $exprType }} +func (t *{{ $exprType }}) Assign(val interface{}) (error) { + if c, err := {{ export "CastTo" $exprType }}(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + + +{{ if $def.Struct }} +func (t *{{ $exprType }}) AssignFieldValue(key string, val interface{}) error { + return {{ $def.AssignerFn }}(t.value, key, val) +} + +{{ if not $def.CustomGValSelector }} +// SelectGVal implements gval.Selector requirements +// +// It allows gval lib to access {{ $exprType }}'s underlying value ({{ $def.As }}) +// and it's fields +// +func (t {{ $exprType }}) SelectGVal(ctx context.Context, k string) (interface{}, error) { + return {{ unexport $exprType "GValSelector" }}(t.value, k) +} +{{ end }} + +// Select is field accessor for {{ $def.As }} +// +// Similar to SelectGVal but returns typed values +func (t {{ $exprType }}) Select(k string) (TypedValue, error) { + return {{ unexport $exprType "TypedValueSelector" }}(t.value, k) +} + +func (t {{ $exprType }}) Has(k string) bool { + switch k { + {{- range $def.Struct }} + {{- if .ExprType }} + case {{ printf "%q" .Name }}{{ if .Alias }}, {{ printf "%q" .Alias }}{{ end }}: + return true + {{- end }} + {{- end }} + } + return false +} + +// {{ unexport $exprType "GValSelector" }} is field accessor for {{ $def.As }} +func {{ unexport $exprType "GValSelector" }}(res {{ $def.As }}, k string) (interface{}, error) { + switch k { + {{- range $def.Struct }} + {{- if .ExprType }} + case {{ printf "%q" .Name }}{{ if .Alias }}, {{ printf "%q" .Alias }}{{ end }}: + return res.{{ export .Name }}, nil + {{- end }} + {{- end }} + } + + return nil, fmt.Errorf("unknown field '%s'", k)} + +// {{ unexport $exprType "TypedValueSelector" }} is field accessor for {{ $def.As }} +func {{ unexport $exprType "TypedValueSelector" }}(res {{ $def.As }}, k string) (TypedValue, error) { + switch k { + {{- range $def.Struct }} + {{- if .ExprType }} + case {{ printf "%q" .Name }}{{ if .Alias }}, {{ printf "%q" .Alias }}{{ end }}: + return {{ export "New" .ExprType }}(res.{{ export .Name }}) + {{- end }} + {{- end }} + } + + return nil, fmt.Errorf("unknown field '%s'", k) +} + +{{ if $def.BuiltInAssignerFn }} +// {{ $def.AssignerFn }} is field value setter for {{ $def.As }} +func {{ $def.AssignerFn }}(res {{ $def.As }}, k string, val interface{}) (error) { + switch k { +{{- range $def.Struct }} + case {{ printf "%q" .Name }}{{ if .Alias }}, {{ printf "%q" .Alias }}{{ end }}: + {{- if .Readonly }} + return fmt.Errorf("field '%s' is read-only", k) + {{- else }} + aux, err := {{ export "CastTo" .ExprType }}(val) + if err != nil { + return err + } + + res.{{ export .Name }} = aux + return nil + {{- end }} +{{- end }} + } + + return fmt.Errorf("unknown field '%s'", k) +} +{{ end }} +{{ end }} {{ end }} + diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 6b4ddd454..e5a502425 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -75,6 +75,8 @@ func Proc() { "camelCase": camelCase, "export": export, "unexport": unexport, + "removePtr": removePtr, + "hasPtr": hasPtr, "toggleExport": toggleExport, "toLower": strings.ToLower, "toUpper": strings.ToUpper, @@ -275,11 +277,13 @@ func Proc() { if aFuncsDefs, err = procAutomationFunctions(aFuncsSrc...); err == nil { if genCode { + expandAutomationFunctionTypes(aFuncsDefs, exprTypeDefs) + err = genAutomationFunctions(tpls, aFuncsDefs...) } } - if outputErr(err, "failed to process store:\n") { + if outputErr(err, "failed to process automation functions:\n") { return } diff --git a/pkg/codegen/expr_types.go b/pkg/codegen/expr_types.go index e03cde7ff..263928344 100644 --- a/pkg/codegen/expr_types.go +++ b/pkg/codegen/expr_types.go @@ -18,8 +18,32 @@ type ( outputDir string Imports []string - Package string `yaml:"package"` - Types map[string]string `yaml:"types"` + Package string `yaml:"package"` + Prefix string `yaml:"prefix"` + Types map[string]*exprTypeDef `yaml:"types"` + } + + exprTypeDef struct { + As string + RawDefault string `yaml:"default"` + AssignerFn string `yaml:"assignerFn"` + BuiltInCastFn bool + BuiltInAssignerFn bool + CustomGValSelector bool `yaml:"customGValSelector"` + Struct []*exprTypeStructDef + + // @todo custom setters + // @todo custom getters + } + + exprTypeStructDef struct { + Name string + Alias string + ExprType string `yaml:"exprType"` + GoType string `yaml:"goType"` + Mode string + + // @todo custom expr-type-constructor NewExprType } ) @@ -33,7 +57,7 @@ func procExprTypes(mm ...string) (dd []*exprTypesDef, err error) { outputDir: path.Dir(m), Package: "types", - Types: make(map[string]string), + Types: make(map[string]*exprTypeDef), } ) @@ -48,6 +72,13 @@ func procExprTypes(mm ...string) (dd []*exprTypesDef, err error) { return nil, fmt.Errorf("%s decode failed: %w", m, err) } + for tName, tdef := range d.Types { + if tdef.AssignerFn == "" { + tdef.BuiltInAssignerFn = true + tdef.AssignerFn = unexport("assignTo", tName) + } + } + dd = append(dd, d) } @@ -72,3 +103,15 @@ func genExprTypes(tpl *template.Template, dd ...*exprTypesDef) (err error) { return nil } + +func (s exprTypeDef) Default() string { + if s.RawDefault == "" { + return "nil" + } + + return s.RawDefault +} + +func (s exprTypeStructDef) Readonly() bool { + return s.Mode == "ro" +} diff --git a/pkg/codegen/templating.go b/pkg/codegen/templating.go index f063ec696..709388c09 100644 --- a/pkg/codegen/templating.go +++ b/pkg/codegen/templating.go @@ -106,6 +106,14 @@ func unexport(pp ...string) (out string) { return strings.ToLower(out[:1]) + out[1:] } +func removePtr(name string) string { + return strings.TrimLeft(name, "*") +} + +func hasPtr(name string) bool { + return len(name) > 0 && name[0:1] == "*" +} + func toggleExport(e bool, pp ...string) (out string) { if e { return export(pp...) diff --git a/pkg/expr/expr.go b/pkg/expr/expr.go index 42347a247..ea132005d 100644 --- a/pkg/expr/expr.go +++ b/pkg/expr/expr.go @@ -1,31 +1,157 @@ package expr import ( + "bufio" "fmt" - "github.com/PaesslerAG/gval" + "strings" ) -func Parser(ll ...gval.Language) gval.Language { - return gval.Full(append(AllFunctions(), ll...)...) -} +const ( + pathDelimiter = "." +) -func AllFunctions() []gval.Language { - ff := make([]gval.Language, 0, 100) +var ( + invalidPathErr = fmt.Errorf("invalid path format") +) - //ff = append(ff, GenericFunctions()...) - ff = append(ff, StringFunctions()...) - ff = append(ff, NumericFunctions()...) - ff = append(ff, TimeFunctions()...) +func PathSplit(path string) ([]string, error) { + out := make([]string, 0) + s := bufio.NewScanner(strings.NewReader(path)) + s.Split(pathSplitter) - return ff -} - -// utility function for examples -func eval(e string, p interface{}) { - result, err := Parser().Evaluate(e, p) - if err != nil { - fmt.Printf("error: %v", err) - } else { - fmt.Printf("%v", result) + for s.Scan() { + if len(s.Text()) == 0 { + return nil, invalidPathErr + } + out = append(out, s.Text()) } + + if s.Err() != nil { + return nil, s.Err() + } + + return out, nil +} + +func pathSplitter(data []byte, atEOF bool) (advance int, token []byte, err error) { + start := 0 + for i := 0; i < len(data); i += 1 { + switch data[i] { + case '.', '[': + return i + 1, data[start:i], nil + case ']': + // When at closing bracket but not at the end, make sure we properly split the token + if i == len(data)-1 { + return i + 1, data[start:i], nil + } + + if data[i+1] != '.' { + return 0, nil, invalidPathErr + } + + return i + 2, data[start:i], nil + } + } + + // If we're at EOF, we have a final, non-empty, non-terminated word. Return it. + if atEOF && len(data) > start { + return len(data), data[start:], nil + } + // Request more data. + return start, nil, nil +} + +func PathBase(path string) string { + return strings.Split(path, ".")[0] +} + +func Assign(base TypedValue, path string, val interface{}) error { + pp, err := PathSplit(path) + if err != nil { + return err + } + + if len(pp) == 0 { + panic("setting value with empty path") + } + + var ( + key = pp[0] + ) + + // descend lower by the path but + // stop before the last part of the path + for len(pp) > 1 { + switch s := base.(type) { + case DeepFieldAssigner: + return s.AssignFieldValue(pp, val) + + case FieldSelector: + key, pp = pp[0], pp[1:] + if base, err = s.Select(key); err != nil { + return err + } + + default: + return fmt.Errorf("can not set value on %s with path '%s'", base.Type(), path) + + } + } + + key = pp[0] + + // try with field setter first + // if not a FieldSetter it has to be a Selector + // that returns TypedValue that we can set + switch setter := base.(type) { + case DeepFieldAssigner: + return setter.AssignFieldValue(pp, val) + + case FieldAssigner: + return setter.AssignFieldValue(key, val) + + case FieldSelector: + if base, err = setter.Select(key); err != nil { + return err + } + + return base.Assign(val) + + default: + return fmt.Errorf("%T does not support value assigning with '%s'", base, path) + } + +} + +func Select(base TypedValue, path string) (TypedValue, error) { + pp, err := PathSplit(path) + if err != nil { + return nil, err + } + + if len(pp) == 0 { + panic("selecting value with empty path") + } + + var ( + failure = fmt.Errorf("can not get value from %s with path '%s'", base.Type(), path) + key string + ) + + // descend lower by the path but + // stop before the last part of the path + for len(pp) > 0 { + s, is := base.(FieldSelector) + if !is { + return nil, failure + } + + key, pp = pp[0], pp[1:] + if base, err = s.Select(key); err != nil { + return nil, err + } + + } + + return base, nil } diff --git a/pkg/expr/expr_test.go b/pkg/expr/expr_test.go index b78b0c8f5..563b83e44 100644 --- a/pkg/expr/expr_test.go +++ b/pkg/expr/expr_test.go @@ -1,30 +1,69 @@ package expr import ( - "context" "github.com/stretchr/testify/require" "testing" ) -func TestParser(t *testing.T) { - var ( - req = require.New(t) - ctx = context.Background() - p = Parser() - e, err = p.NewEvaluable("0 == 0") +func TestPathSplit(t *testing.T) { + tcc := []struct { + p string + r []string + err string + }{ + {p: "a", r: []string{"a"}}, + {p: "foo.bar", r: []string{"foo", "bar"}}, + {p: "a.b[1]", r: []string{"a", "b", "1"}}, + {p: "a.b[1].baz[0]", r: []string{"a", "b", "1", "baz", "0"}}, + {p: "a.[]", err: invalidPathErr.Error()}, + {p: "a[1]bzz", err: invalidPathErr.Error()}, + } - result bool + for _, tc := range tcc { + t.Run(tc.p, func(t *testing.T) { + req := require.New(t) + pp, err := PathSplit(tc.p) + if len(tc.err) == 0 { + req.NoError(err) + } else { + req.EqualError(err, tc.err) + } + + req.Equal(tc.r, pp) + }) + } + +} + +func TestVars(t *testing.T) { + var ( + req = require.New(t) + + vars = RVars{ + "int": Must(NewInteger(42)), + "sub": RVars{ + "foo": Must(NewString("foo")), + }.Vars(), + "three": RVars{ + "two": RVars{ + "one": RVars{ + "go": Must(NewString("!")), + }.Vars(), + }.Vars(), + }.Vars(), + }.Vars() ) - req.NoError(err) + req.NoError(Assign(vars, "int", 123)) + req.Equal(123, Must(Select(vars, "int")).Get().(int)) - result, err = e.EvalBool(ctx, nil) - req.NoError(err) - req.True(result) -} + req.NoError(Assign(vars, "sub.foo", "bar")) + req.Equal("bar", Must(Select(vars, "sub.foo")).Get().(string)) -func Example_simpleExpresion() { - eval(`40 + 2`, nil) - // output: - // 42 + req.NoError(Assign(vars, "kv", &KV{})) + req.NoError(Assign(vars, "kv.foo", "bar")) + req.Equal("bar", Must(Select(vars, "kv.foo")).Get().(string)) + + req.NoError(Assign(vars, "three.two.one.go", "!!!")) + req.Equal("!!!", Must(Select(vars, "three.two.one.go")).Get().(string)) } diff --git a/pkg/expr/expr_types.gen.go b/pkg/expr/expr_types.gen.go new file mode 100644 index 000000000..f9498b636 --- /dev/null +++ b/pkg/expr/expr_types.gen.go @@ -0,0 +1,544 @@ +package expr + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// pkg/expr/expr_types.yaml + +import ( + "context" + "fmt" + "io" + "time" +) + +var _ = context.Background +var _ = fmt.Errorf + +// Any is an expression type, wrapper for interface{} type +type Any struct{ value interface{} } + +// NewAny creates new instance of Any expression type +func NewAny(val interface{}) (*Any, error) { + if c, err := CastToAny(val); err != nil { + return nil, fmt.Errorf("unable to create Any: %w", err) + } else { + return &Any{value: c}, nil + } +} + +// Return underlying value on Any +func (t Any) Get() interface{} { return t.value } + +// Return type name +func (Any) Type() string { return "Any" } + +// Convert value to interface{} +func (Any) Cast(val interface{}) (TypedValue, error) { + return NewAny(val) +} + +// Assign new value to Any +// +// value is first passed through CastToAny +func (t *Any) Assign(val interface{}) error { + if c, err := CastToAny(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// Array is an expression type, wrapper for []TypedValue type +type Array struct{ value []TypedValue } + +// NewArray creates new instance of Array expression type +func NewArray(val interface{}) (*Array, error) { + if c, err := CastToArray(val); err != nil { + return nil, fmt.Errorf("unable to create Array: %w", err) + } else { + return &Array{value: c}, nil + } +} + +// Return underlying value on Array +func (t Array) Get() interface{} { return t.value } + +// Return type name +func (Array) Type() string { return "Array" } + +// Convert value to []TypedValue +func (Array) Cast(val interface{}) (TypedValue, error) { + return NewArray(val) +} + +// Assign new value to Array +// +// value is first passed through CastToArray +func (t *Array) Assign(val interface{}) error { + if c, err := CastToArray(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// Boolean is an expression type, wrapper for bool type +type Boolean struct{ value bool } + +// NewBoolean creates new instance of Boolean expression type +func NewBoolean(val interface{}) (*Boolean, error) { + if c, err := CastToBoolean(val); err != nil { + return nil, fmt.Errorf("unable to create Boolean: %w", err) + } else { + return &Boolean{value: c}, nil + } +} + +// Return underlying value on Boolean +func (t Boolean) Get() interface{} { return t.value } + +// Return type name +func (Boolean) Type() string { return "Boolean" } + +// Convert value to bool +func (Boolean) Cast(val interface{}) (TypedValue, error) { + return NewBoolean(val) +} + +// Assign new value to Boolean +// +// value is first passed through CastToBoolean +func (t *Boolean) Assign(val interface{}) error { + if c, err := CastToBoolean(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// DateTime is an expression type, wrapper for *time.Time type +type DateTime struct{ value *time.Time } + +// NewDateTime creates new instance of DateTime expression type +func NewDateTime(val interface{}) (*DateTime, error) { + if c, err := CastToDateTime(val); err != nil { + return nil, fmt.Errorf("unable to create DateTime: %w", err) + } else { + return &DateTime{value: c}, nil + } +} + +// Return underlying value on DateTime +func (t DateTime) Get() interface{} { return t.value } + +// Return type name +func (DateTime) Type() string { return "DateTime" } + +// Convert value to *time.Time +func (DateTime) Cast(val interface{}) (TypedValue, error) { + return NewDateTime(val) +} + +// Assign new value to DateTime +// +// value is first passed through CastToDateTime +func (t *DateTime) Assign(val interface{}) error { + if c, err := CastToDateTime(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// Duration is an expression type, wrapper for time.Duration type +type Duration struct{ value time.Duration } + +// NewDuration creates new instance of Duration expression type +func NewDuration(val interface{}) (*Duration, error) { + if c, err := CastToDuration(val); err != nil { + return nil, fmt.Errorf("unable to create Duration: %w", err) + } else { + return &Duration{value: c}, nil + } +} + +// Return underlying value on Duration +func (t Duration) Get() interface{} { return t.value } + +// Return type name +func (Duration) Type() string { return "Duration" } + +// Convert value to time.Duration +func (Duration) Cast(val interface{}) (TypedValue, error) { + return NewDuration(val) +} + +// Assign new value to Duration +// +// value is first passed through CastToDuration +func (t *Duration) Assign(val interface{}) error { + if c, err := CastToDuration(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// Float is an expression type, wrapper for float64 type +type Float struct{ value float64 } + +// NewFloat creates new instance of Float expression type +func NewFloat(val interface{}) (*Float, error) { + if c, err := CastToFloat(val); err != nil { + return nil, fmt.Errorf("unable to create Float: %w", err) + } else { + return &Float{value: c}, nil + } +} + +// Return underlying value on Float +func (t Float) Get() interface{} { return t.value } + +// Return type name +func (Float) Type() string { return "Float" } + +// Convert value to float64 +func (Float) Cast(val interface{}) (TypedValue, error) { + return NewFloat(val) +} + +// Assign new value to Float +// +// value is first passed through CastToFloat +func (t *Float) Assign(val interface{}) error { + if c, err := CastToFloat(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// Handle is an expression type, wrapper for string type +type Handle struct{ value string } + +// NewHandle creates new instance of Handle expression type +func NewHandle(val interface{}) (*Handle, error) { + if c, err := CastToHandle(val); err != nil { + return nil, fmt.Errorf("unable to create Handle: %w", err) + } else { + return &Handle{value: c}, nil + } +} + +// Return underlying value on Handle +func (t Handle) Get() interface{} { return t.value } + +// Return type name +func (Handle) Type() string { return "Handle" } + +// Convert value to string +func (Handle) Cast(val interface{}) (TypedValue, error) { + return NewHandle(val) +} + +// Assign new value to Handle +// +// value is first passed through CastToHandle +func (t *Handle) Assign(val interface{}) error { + if c, err := CastToHandle(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// ID is an expression type, wrapper for uint64 type +type ID struct{ value uint64 } + +// NewID creates new instance of ID expression type +func NewID(val interface{}) (*ID, error) { + if c, err := CastToID(val); err != nil { + return nil, fmt.Errorf("unable to create ID: %w", err) + } else { + return &ID{value: c}, nil + } +} + +// Return underlying value on ID +func (t ID) Get() interface{} { return t.value } + +// Return type name +func (ID) Type() string { return "ID" } + +// Convert value to uint64 +func (ID) Cast(val interface{}) (TypedValue, error) { + return NewID(val) +} + +// Assign new value to ID +// +// value is first passed through CastToID +func (t *ID) Assign(val interface{}) error { + if c, err := CastToID(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// Integer is an expression type, wrapper for int64 type +type Integer struct{ value int64 } + +// NewInteger creates new instance of Integer expression type +func NewInteger(val interface{}) (*Integer, error) { + if c, err := CastToInteger(val); err != nil { + return nil, fmt.Errorf("unable to create Integer: %w", err) + } else { + return &Integer{value: c}, nil + } +} + +// Return underlying value on Integer +func (t Integer) Get() interface{} { return t.value } + +// Return type name +func (Integer) Type() string { return "Integer" } + +// Convert value to int64 +func (Integer) Cast(val interface{}) (TypedValue, error) { + return NewInteger(val) +} + +// Assign new value to Integer +// +// value is first passed through CastToInteger +func (t *Integer) Assign(val interface{}) error { + if c, err := CastToInteger(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// KV is an expression type, wrapper for map[string]string type +type KV struct{ value map[string]string } + +// NewKV creates new instance of KV expression type +func NewKV(val interface{}) (*KV, error) { + if c, err := CastToKV(val); err != nil { + return nil, fmt.Errorf("unable to create KV: %w", err) + } else { + return &KV{value: c}, nil + } +} + +// Return underlying value on KV +func (t KV) Get() interface{} { return t.value } + +// Return type name +func (KV) Type() string { return "KV" } + +// Convert value to map[string]string +func (KV) Cast(val interface{}) (TypedValue, error) { + return NewKV(val) +} + +// Assign new value to KV +// +// value is first passed through CastToKV +func (t *KV) Assign(val interface{}) error { + if c, err := CastToKV(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// KVV is an expression type, wrapper for map[string][]string type +type KVV struct{ value map[string][]string } + +// NewKVV creates new instance of KVV expression type +func NewKVV(val interface{}) (*KVV, error) { + if c, err := CastToKVV(val); err != nil { + return nil, fmt.Errorf("unable to create KVV: %w", err) + } else { + return &KVV{value: c}, nil + } +} + +// Return underlying value on KVV +func (t KVV) Get() interface{} { return t.value } + +// Return type name +func (KVV) Type() string { return "KVV" } + +// Convert value to map[string][]string +func (KVV) Cast(val interface{}) (TypedValue, error) { + return NewKVV(val) +} + +// Assign new value to KVV +// +// value is first passed through CastToKVV +func (t *KVV) Assign(val interface{}) error { + if c, err := CastToKVV(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// Reader is an expression type, wrapper for io.Reader type +type Reader struct{ value io.Reader } + +// NewReader creates new instance of Reader expression type +func NewReader(val interface{}) (*Reader, error) { + if c, err := CastToReader(val); err != nil { + return nil, fmt.Errorf("unable to create Reader: %w", err) + } else { + return &Reader{value: c}, nil + } +} + +// Return underlying value on Reader +func (t Reader) Get() interface{} { return t.value } + +// Return type name +func (Reader) Type() string { return "Reader" } + +// Convert value to io.Reader +func (Reader) Cast(val interface{}) (TypedValue, error) { + return NewReader(val) +} + +// Assign new value to Reader +// +// value is first passed through CastToReader +func (t *Reader) Assign(val interface{}) error { + if c, err := CastToReader(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// String is an expression type, wrapper for string type +type String struct{ value string } + +// NewString creates new instance of String expression type +func NewString(val interface{}) (*String, error) { + if c, err := CastToString(val); err != nil { + return nil, fmt.Errorf("unable to create String: %w", err) + } else { + return &String{value: c}, nil + } +} + +// Return underlying value on String +func (t String) Get() interface{} { return t.value } + +// Return type name +func (String) Type() string { return "String" } + +// Convert value to string +func (String) Cast(val interface{}) (TypedValue, error) { + return NewString(val) +} + +// Assign new value to String +// +// value is first passed through CastToString +func (t *String) Assign(val interface{}) error { + if c, err := CastToString(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// UnsignedInteger is an expression type, wrapper for uint64 type +type UnsignedInteger struct{ value uint64 } + +// NewUnsignedInteger creates new instance of UnsignedInteger expression type +func NewUnsignedInteger(val interface{}) (*UnsignedInteger, error) { + if c, err := CastToUnsignedInteger(val); err != nil { + return nil, fmt.Errorf("unable to create UnsignedInteger: %w", err) + } else { + return &UnsignedInteger{value: c}, nil + } +} + +// Return underlying value on UnsignedInteger +func (t UnsignedInteger) Get() interface{} { return t.value } + +// Return type name +func (UnsignedInteger) Type() string { return "UnsignedInteger" } + +// Convert value to uint64 +func (UnsignedInteger) Cast(val interface{}) (TypedValue, error) { + return NewUnsignedInteger(val) +} + +// Assign new value to UnsignedInteger +// +// value is first passed through CastToUnsignedInteger +func (t *UnsignedInteger) Assign(val interface{}) error { + if c, err := CastToUnsignedInteger(val); err != nil { + return err + } else { + t.value = c + return nil + } +} + +// Vars is an expression type, wrapper for RVars type +type Vars struct{ value RVars } + +// NewVars creates new instance of Vars expression type +func NewVars(val interface{}) (*Vars, error) { + if c, err := CastToVars(val); err != nil { + return nil, fmt.Errorf("unable to create Vars: %w", err) + } else { + return &Vars{value: c}, nil + } +} + +// Return underlying value on Vars +func (t Vars) Get() interface{} { return t.value } + +// Return type name +func (Vars) Type() string { return "Vars" } + +// Convert value to RVars +func (Vars) Cast(val interface{}) (TypedValue, error) { + return NewVars(val) +} + +// Assign new value to Vars +// +// value is first passed through CastToVars +func (t *Vars) Assign(val interface{}) error { + if c, err := CastToVars(val); err != nil { + return err + } else { + t.value = c + return nil + } +} diff --git a/pkg/expr/expr_types.go b/pkg/expr/expr_types.go new file mode 100644 index 000000000..fd60ce6db --- /dev/null +++ b/pkg/expr/expr_types.go @@ -0,0 +1,295 @@ +package expr + +import ( + "bytes" + "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/pkg/handle" + "github.com/spf13/cast" + "io" + "net/http" + "net/url" + "reflect" + "strings" + "time" +) + +type ( + resolvableType interface { + Type + ResolveTypes(func(string) Type) error + } +) + +func ResolveTypes(rt resolvableType, resolver func(typ string) Type) error { + return rt.ResolveTypes(resolver) +} + +// Unresolved is a special type that holds value + type it needs to be resolved to +// +// This solves problem with typed value serialization +type Unresolved struct { + typ string + value interface{} +} + +// NewUnresolved creates new instance of Unresolved expression type +func NewUnresolved(typ string, val interface{}) (TypedValue, error) { + return &Unresolved{ + typ: typ, + value: UntypedValue(val), + }, nil +} + +// Returns underlying value on Unresolved +func (t Unresolved) Get() interface{} { return t.value } + +// Returns type name +func (t Unresolved) Type() string { return t.typ } + +// Casts value to interface{} +func (Unresolved) Cast(interface{}) (TypedValue, error) { + return nil, fmt.Errorf("can not cast to unresolved type") +} + +func (t *Unresolved) Assign(interface{}) (err error) { + return fmt.Errorf("can not set on unresolved type") +} + +func CastToAny(val interface{}) (interface{}, error) { + return val, nil +} + +func CastToArray(val interface{}) ([]TypedValue, error) { + + switch val := val.(type) { + case *Array: + return val.value, nil + } + + ref := reflect.ValueOf(val) + if ref.Kind() == reflect.Slice { + out := make([]TypedValue, ref.Len()) + for i := 0; i < ref.Len(); i++ { + item := ref.Index(i).Interface() + if tVal, is := item.(TypedValue); is { + out[i] = tVal + } else { + out[i] = &Any{value: item} + } + } + return out, nil + } + + return nil, fmt.Errorf("unable to cast %T to []TypedValue", val) +} + +var _ TypeValueDecoder = &Array{} + +func (t *Array) Decode(dst reflect.Value) error { + if dst.Kind() != reflect.Slice { + return fmt.Errorf("failed to decode Array to non-slice") + } + + if reflect.ValueOf(make([]TypedValue, 0)).Type() == dst.Type() { + dst.Set(reflect.ValueOf(t.value)) + return nil + } + + out := reflect.MakeSlice(dst.Type(), len(t.value), len(t.value)) + for i := range t.value { + out.Index(i).Set(reflect.ValueOf(UntypedValue(t.value[i]))) + } + + dst.Set(out) + + return nil +} + +// SelectGVal implements gval.Selector requirements +// +// It allows gval lib to access Record's underlying value (*types.Array) +// and it's fields +// +func (t Array) SelectGVal(ctx context.Context, k string) (interface{}, error) { + if s, err := t.Select(k); err != nil { + return nil, err + } else { + return UntypedValue(s), nil + } +} + +var _ FieldSelector = &Array{} + +// Select is field accessor for *types.Record +// +// Similar to SelectGVal but returns typed values +func (t Array) Has(k string) bool { + if i, err := cast.ToIntE(k); err != nil { + return false + } else { + return i >= 0 && i < len(t.value) + } +} + +// Select is field accessor for *types.Record +// +// Similar to SelectGVal but returns typed values +func (t Array) Select(k string) (TypedValue, error) { + if i, err := cast.ToIntE(k); err != nil { + return nil, err + } else { + return t.value[i], nil + } +} + +func CastToBoolean(val interface{}) (out bool, err error) { + return cast.ToBoolE(UntypedValue(val)) +} + +func CastToString(val interface{}) (out string, err error) { + return cast.ToStringE(UntypedValue(val)) +} + +func CastToHandle(val interface{}) (string, error) { + val = UntypedValue(val) + + h, err := cast.ToStringE(val) + + if !handle.IsValid(h) { + return "", fmt.Errorf("invalid handle format: '%s'", h) + } + + return h, err +} + +func CastToDuration(val interface{}) (out time.Duration, err error) { + return cast.ToDurationE(UntypedValue(val)) +} + +func CastToDateTime(val interface{}) (out *time.Time, err error) { + val = UntypedValue(val) + switch casted := val.(type) { + case *time.Time: + return casted, nil + case time.Time: + return &casted, nil + default: + var c time.Time + if c, err = cast.ToTimeE(casted); err != nil { + return nil, err + } + + return &c, nil + } +} + +func CastToFloat(val interface{}) (out float64, err error) { + return cast.ToFloat64E(UntypedValue(val)) +} + +func CastToID(val interface{}) (out uint64, err error) { + out, err = cast.ToUint64E(UntypedValue(val)) + if out == 0 { + err = fmt.Errorf("invalid ID") + } + + return +} + +func CastToInteger(val interface{}) (out int64, err error) { + return cast.ToInt64E(UntypedValue(val)) +} + +func CastToUnsignedInteger(val interface{}) (out uint64, err error) { + return cast.ToUint64E(UntypedValue(val)) +} + +func (t *KV) Has(k string) bool { + _, has := t.value[k] + return has +} + +func (t *KV) Select(k string) (TypedValue, error) { + if v, has := t.value[k]; has { + return Must(NewString(v)), nil + } else { + return nil, errors.NotFound("no such key '%s'", k) + } +} + +func (t *KV) AssignFieldValue(key string, val interface{}) error { + return assignToKV(t, key, val) +} + +func assignToKV(t *KV, key string, val interface{}) error { + if t.value == nil { + t.value = make(map[string]string) + } + + str, err := cast.ToStringE(UntypedValue(val)) + t.value[key] = str + return err +} + +func CastToKV(val interface{}) (out map[string]string, err error) { + val = UntypedValue(val) + + if val == nil { + return make(map[string]string), nil + } + + switch casted := val.(type) { + case map[string]string: + return casted, nil + default: + return cast.ToStringMapStringE(casted) + } +} + +func (t *KVV) AssignFieldValue(key string, val interface{}) error { + return assignToKVV(t, key, val) +} + +func assignToKVV(t *KVV, key string, val interface{}) error { + if t.value == nil { + t.value = make(map[string][]string) + } + + str, err := cast.ToStringSliceE(val) + t.value[key] = str + return err +} + +func CastToKVV(val interface{}) (out map[string][]string, err error) { + val = UntypedValue(val) + + if val == nil { + return make(map[string][]string), nil + } + + switch casted := val.(type) { + case http.Header: + return casted, nil + case url.Values: + return casted, nil + default: + return cast.ToStringMapStringSliceE(casted) + } +} + +func CastToReader(val interface{}) (out io.Reader, err error) { + val = UntypedValue(val) + + switch casted := val.(type) { + case []byte: + return bytes.NewReader(casted), nil + case string: + return strings.NewReader(casted), nil + case io.Reader: + return casted, nil + default: + return nil, fmt.Errorf("unable to cast %T to io.Reader", val) + } +} diff --git a/pkg/expr/expr_types.yaml b/pkg/expr/expr_types.yaml new file mode 100644 index 000000000..f4d58783f --- /dev/null +++ b/pkg/expr/expr_types.yaml @@ -0,0 +1,61 @@ +package: expr +imports: + - io + - time + +types: + Any: + as: 'interface{}' + + Array: + as: '[]TypedValue' + + Vars: + as: 'RVars' + + Boolean: + as: 'bool' + default: 'false' + + ID: + as: 'uint64' + default: '0' + + Integer: + as: 'int64' + default: '0' + + UnsignedInteger: + as: 'uint64' + default: '0' + + Float: + as: 'float64' + default: '0' + + String: + as: 'string' + default: '""' + + Handle: + as: 'string' + default: '""' + + DateTime: + as: '*time.Time' + + Duration: + as: 'time.Duration' + default: '0' + + KV: + as: 'map[string]string' + assignFn: assignToKV + + KVV: + as: 'map[string][]string' + assignFn: assignToKVV + + Reader: + as: 'io.Reader' + diff --git a/pkg/expr/expr_types_test.go b/pkg/expr/expr_types_test.go new file mode 100644 index 000000000..30f126928 --- /dev/null +++ b/pkg/expr/expr_types_test.go @@ -0,0 +1,89 @@ +package expr + +import ( + "github.com/stretchr/testify/require" + "net/http" + "net/url" + "testing" +) + +func TestKV_Set(t *testing.T) { + var ( + req = require.New(t) + + vars = KV{value: map[string]string{ + "k1": "v1", + "k2": "v2", + }} + ) + + req.NoError(Assign(&vars, "k1", "v11")) + req.Equal("v11", vars.value["k1"]) + req.Equal("v2", vars.value["k2"]) + +} + +func TestKVV_Set(t *testing.T) { + var ( + req = require.New(t) + kvv KVV + ) + + req.NoError(Assign(&kvv, "foo", "bar")) + req.Contains(kvv.value, "foo") + req.Equal([]string{"bar"}, kvv.value["foo"]) + + // Making sure http.Header is properly converted + kvv = KVV{} + req.NoError(kvv.Assign(http.Header{"foo": []string{"bar"}})) + req.Contains(kvv.value, "foo") + req.Equal([]string{"bar"}, kvv.value["foo"]) + + // Making sure url.Values are properly converted + kvv = KVV{} + req.NoError(kvv.Assign(url.Values{"foo": []string{"bar"}})) + req.Contains(kvv.value, "foo") + req.Equal([]string{"bar"}, kvv.value["foo"]) +} + +func TestCastToArray(t *testing.T) { + var ( + req = require.New(t) + ) + + arr, err := CastToArray([]interface{}{ + Must(NewString("abc")), + Must(NewString("123")), + }) + + req.NoError(err) + req.Len(arr, 2) +} + +func TestArrayDecode(t *testing.T) { + var ( + req = require.New(t) + + foo = struct { + Typed TypedValue + Iface interface{} + Strings []string + Values []TypedValue + }{} + ) + + arr, err := CastToArray([]interface{}{ + Must(NewString("abc")), + Must(NewString("123")), + }) + + req.NoError(err) + req.NoError(RVars{ + "strings": &Array{arr}, + "iface": Must(NewString("typed")), + "typed": Must(NewString("typed")), + "values": &Array{arr}, + }.Vars().Decode(&foo)) + req.Len(foo.Strings, 2) + req.Len(foo.Values, 2) +} diff --git a/pkg/expr/parser.go b/pkg/expr/parser.go new file mode 100644 index 000000000..3388a7fed --- /dev/null +++ b/pkg/expr/parser.go @@ -0,0 +1,99 @@ +package expr + +import ( + "context" + "fmt" + "github.com/PaesslerAG/gval" +) + +type ( + Parsable interface { + Parse(string) (Evaluable, error) + ParseEvaluators(ee ...Evaluator) error + } + + Evaluable interface { + Eval(context.Context, *Vars) (interface{}, error) + Test(context.Context, *Vars) (bool, error) + } + + Evaluator interface { + GetExpr() string + SetEval(eval Evaluable) + } + + gvalParser struct { + lang gval.Language + } + + gvalEval struct { + evaluable gval.Evaluable + } +) + +func NewParser() Parsable { + return NewGvalParser() +} + +func NewGvalParser() *gvalParser { + return &gvalParser{lang: gval.Full(AllFunctions()...)} +} + +func (p *gvalParser) Parse(expr string) (Evaluable, error) { + var ( + ge = &gvalEval{} + err error + ) + + if ge.evaluable, err = p.lang.NewEvaluable(expr); err != nil { + return nil, err + } + + return ge, err +} + +func (p *gvalParser) ParseEvaluators(ee ...Evaluator) error { + for _, e := range ee { + evaluable, err := p.Parse(e.GetExpr()) + if err != nil { + return err + } + + e.SetEval(evaluable) + } + + return nil +} + +func (e *gvalEval) Eval(ctx context.Context, scope *Vars) (interface{}, error) { + return e.evaluable(ctx, scope.Dict()) +} + +func (e *gvalEval) Test(ctx context.Context, scope *Vars) (bool, error) { + return e.evaluable.EvalBool(ctx, scope.Dict()) +} + +func Parser(ll ...gval.Language) gval.Language { + return gval.Full(append(AllFunctions(), ll...)...) +} + +func AllFunctions() []gval.Language { + ff := make([]gval.Language, 0, 100) + + //ff = append(ff, GenericFunctions()...) + ff = append(ff, StringFunctions()...) + ff = append(ff, NumericFunctions()...) + ff = append(ff, TimeFunctions()...) + + return ff +} + +// utility function for examples +func eval(e string, p interface{}) { + result, err := Parser().Evaluate(e, p) + if err != nil { + fmt.Printf("error: %v", err) + } else { + fmt.Printf("%v", result) + } +} diff --git a/pkg/expr/parser_test.go b/pkg/expr/parser_test.go new file mode 100644 index 000000000..b78b0c8f5 --- /dev/null +++ b/pkg/expr/parser_test.go @@ -0,0 +1,30 @@ +package expr + +import ( + "context" + "github.com/stretchr/testify/require" + "testing" +) + +func TestParser(t *testing.T) { + var ( + req = require.New(t) + ctx = context.Background() + p = Parser() + e, err = p.NewEvaluable("0 == 0") + + result bool + ) + + req.NoError(err) + + result, err = e.EvalBool(ctx, nil) + req.NoError(err) + req.True(result) +} + +func Example_simpleExpresion() { + eval(`40 + 2`, nil) + // output: + // 42 +} diff --git a/pkg/expr/values.go b/pkg/expr/values.go new file mode 100644 index 000000000..1789887d1 --- /dev/null +++ b/pkg/expr/values.go @@ -0,0 +1,65 @@ +package expr + +import "reflect" + +type ( + Type interface { + Type() string + Cast(interface{}) (TypedValue, error) + } + + TypedValue interface { + Type + Assigner + Get() interface{} + } + + typedValueWrap struct { + Value interface{} `json:"@value"` + Type string `json:"@type"` + } + + Assigner interface { + Assign(interface{}) error + } + + FieldSelector interface { + Has(k string) bool + Select(k string) (TypedValue, error) + } + + FieldAssigner interface { + AssignFieldValue(string, interface{}) error + } + + DeepFieldAssigner interface { + AssignFieldValue([]string, interface{}) error + } + + Iterator interface { + Each(func(k string, v TypedValue) error) error + } + + TypeValueDecoder interface { + Decode(dst reflect.Value) error + } + + Dict interface { + Dict() map[string]interface{} + } +) + +func UntypedValue(val interface{}) interface{} { + if tv, is := val.(TypedValue); is { + return tv.Get() + } + + return val +} + +func Must(v TypedValue, err error) TypedValue { + if err != nil { + panic(err) + } + return v +} diff --git a/pkg/expr/vars.go b/pkg/expr/vars.go new file mode 100644 index 000000000..c21fb5d1b --- /dev/null +++ b/pkg/expr/vars.go @@ -0,0 +1,376 @@ +package expr + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "github.com/PaesslerAG/gval" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/spf13/cast" + "reflect" + "strings" +) + +type ( + // RVars or raw-vars, used as internal type for Vars expr type + RVars map[string]TypedValue +) + +// Vars is a utility func that returns RVars wrapped in Vars +func (v RVars) Vars() *Vars { + return &Vars{value: v} +} + +func (t Vars) Len() int { + return len(t.value) +} + +func (t Vars) Select(k string) (TypedValue, error) { + if v, is := t.value[k]; is { + return v, nil + } else { + return nil, errors.NotFound("no such key '%s'", k) + } +} + +func (t *Vars) AssignFieldValue(key string, val interface{}) (err error) { + if t.value == nil { + t.value = make(RVars) + } + + if tv, is := val.(TypedValue); is { + t.value[key] = tv + } else { + t.value[key] = Must(NewAny(val)) + } + + return err +} + +func (t Vars) ResolveTypes(res func(typ string) Type) (err error) { + for k, v := range t.value { + if u, is := v.(*Unresolved); is { + if res(u.Type()) == nil { + return errors.NotFound("failed to resolve unknown or unregistered type %q on %q", u.Type(), k) + } + + t.value[k], err = res(u.Type()).Cast(t.value[k]) + if err != nil { + return fmt.Errorf("failed to resolve: %w", err) + } + } + + if r, is := t.value[k].(resolvableType); is { + if err = r.ResolveTypes(res); err != nil { + return + } + } + } + + return nil +} + +// Assign takes base variables and assigns all new variables +func (t *Vars) Merge(nn ...Iterator) *Vars { + var ( + out = &Vars{value: make(RVars)} + ) + + nn = append([]Iterator{t}, nn...) + + for _, i := range nn { + _ = i.Each(func(k string, v TypedValue) error { + out.value[k] = v + return nil + }) + } + + return out +} + +// Assign takes base variables and assigns all new variables +func (t *Vars) Copy(dst *Vars, kk ...string) { + if t == nil { + return + } + + if dst.value == nil { + dst.value = make(RVars) + } + + for _, k := range kk { + dst.value[k] = t.value[k] + } +} + +// Returns true key is present +func (t *Vars) Has(key string) bool { + return t.HasAll(key) +} + +// Returns true if all keys are present +func (t *Vars) HasAll(key string, kk ...string) bool { + if t == nil { + return false + } + + for _, key = range append([]string{key}, kk...) { + if _, has := t.value[key]; !has { + return false + } + } + + return true +} + +// Returns true if all keys are present +func (t *Vars) HasAny(key string, kk ...string) bool { + if t == nil { + return false + } + + for _, key = range append([]string{key}, kk...) { + if _, has := t.value[key]; has { + return true + } + } + + return false +} + +func (t *Vars) Dict() map[string]interface{} { + if t == nil { + return nil + } + + dict := make(map[string]interface{}) + for k, v := range t.value { + switch v := v.(type) { + case gval.Selector: + dict[k] = v + + case Dict: + dict[k] = v.Dict() + + case TypedValue: + dict[k] = v.Get() + + default: + dict[k] = v + } + + } + + return dict +} + +func (t *Vars) Decode(dst interface{}) (err error) { + if t == nil { + return nil + } + + dstRef := reflect.ValueOf(dst) + + if dstRef.Kind() != reflect.Ptr { + return fmt.Errorf("expecting a pointer, not a value") + } + + if dstRef.IsNil() { + return fmt.Errorf("nil pointer passed") + } + + dstRef = dstRef.Elem() + + for i := 0; i < dstRef.NumField(); i++ { + var ( + value TypedValue + has bool + ftyp = dstRef.Type().Field(i) + ) + + keyName := ftyp.Tag.Get("var") + if keyName == "" { + keyName = strings.ToLower(ftyp.Name[:1]) + ftyp.Name[1:] + } + + value, has = t.value[keyName] + if !has { + continue + } + + if tvd, is := value.(TypeValueDecoder); is { + if err = tvd.Decode(dstRef.Field(i)); err != nil { + return + } + } else if err = decode(dstRef.Field(i), value); err != nil { + return fmt.Errorf("failed to decode value to field %s: %w", ftyp.Name, err) + } + } + + return +} + +func (t *Vars) Scan(value interface{}) error { + //lint:ignore S1034 This typecast is intentional, we need to get []byte out of a []uint8 + switch value.(type) { + case nil: + *t = Vars{} + case []uint8: + b := value.([]byte) + if err := json.Unmarshal(b, t); err != nil { + return fmt.Errorf("can not scan '%v' into %T: %w", string(b), t, err) + } + } + + return nil +} + +func (t *Vars) Value() (driver.Value, error) { + return json.Marshal(t) +} + +// UnmarshalJSON +func (t *Vars) UnmarshalJSON(in []byte) (err error) { + var ( + aux = make(map[string]*typedValueWrap) + ) + + if t.value == nil { + t.value = make(map[string]TypedValue) + } + + if err = json.Unmarshal(in, &aux); err != nil { + return + } + + for k, v := range aux { + if t.value[k], err = NewUnresolved(v.Type, v.Value); err != nil { + return + } + } + + return nil +} + +func (t *Vars) Each(fn func(k string, v TypedValue) error) (err error) { + if t == nil || t.value == nil { + return + } + + for k, v := range t.value { + if err = fn(k, v); err != nil { + return + } + } + + return +} + +// UnmarshalJSON parses sort expression when passed inside JSON +func (t Vars) MarshalJSON() ([]byte, error) { + aux := make(map[string]*typedValueWrap) + for k, v := range t.value { + aux[k] = &typedValueWrap{Type: v.Type()} + + if _, is := v.(json.Marshaler); is { + aux[k].Value = v + } else { + aux[k].Value = v.Get() + } + } + + return json.Marshal(aux) +} + +func decode(dst reflect.Value, src TypedValue) (err error) { + defer func() { + r := recover() + if r == nil { + return + } + + switch r := r.(type) { + case error: + err = r + default: + err = fmt.Errorf("%v", r) + } + }() + + if dst.Kind() == reflect.Interface && reflect.ValueOf(src).Type().Implements(dst.Type()) { + dst.Set(reflect.ValueOf(src)) + return + } + + raw := UntypedValue(src) + + // Optimistically try to decode source to destination by comparing (internal) value type for destination + if reflect.ValueOf(raw).Type().ConvertibleTo(dst.Type()) { + dst.Set(reflect.ValueOf(raw)) + return + } + + var ( + vBool bool + vInt64 int64 + vUint64 uint64 + vFloat64 float64 + vString string + ) + + switch dst.Kind() { + case reflect.Bool: + if vBool, err = cast.ToBoolE(raw); err == nil { + dst.SetBool(vBool) + } + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if vInt64, err = cast.ToInt64E(raw); err == nil { + dst.SetInt(vInt64) + } + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if vUint64, err = cast.ToUint64E(raw); err == nil { + dst.SetUint(vUint64) + } + + case reflect.Float32, reflect.Float64: + if vFloat64, err = cast.ToFloat64E(raw); err == nil { + dst.SetFloat(vFloat64) + } + + case reflect.String: + if vString, err = cast.ToStringE(raw); err == nil { + dst.SetString(vString) + } + + //case reflect.Interface: + // dst.Set(reflect.ValueOf(raw)) + + default: + return fmt.Errorf("failed to cast %T to %s", raw, dst.Kind()) + } + + if err != nil { + return fmt.Errorf("failed to cast %T to %s: %w", raw, dst.Kind(), err) + } + + return nil +} + +func CastToVars(val interface{}) (out RVars, err error) { + val = UntypedValue(val) + + if val == nil { + return make(RVars), nil + } + + switch c := val.(type) { + case *Vars: + return c.value, nil + case RVars: + return c, nil + } + + return nil, fmt.Errorf("unable to cast type %T to %T", val, out) +} diff --git a/pkg/expr/vars_test.go b/pkg/expr/vars_test.go new file mode 100644 index 000000000..b3dfe6dad --- /dev/null +++ b/pkg/expr/vars_test.go @@ -0,0 +1,82 @@ +package expr + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +// extract typed-value + +func TestVars_Decode(t *testing.T) { + + t.Run("mix", func(t *testing.T) { + var ( + req = require.New(t) + + dst = &struct { + Int int64 + Uint64 uint64 + String string `var:"STRING"` + RawString string `var:"rawString"` + Bool bool + Unexisting byte + }{} + + vars = RVars{ + "int": Must(NewInteger(42)), + "STRING": Must(NewString("foo")), + "bool": Must(NewBoolean(true)), + "missing": Must(NewBoolean(true)), + }.Vars() + ) + + req.NoError(vars.Decode(dst)) + req.Equal(int64(42), dst.Int) + req.Equal("foo", dst.String) + req.Equal(true, dst.Bool) + req.Empty(dst.Unexisting) + }) + + t.Run("interfaces", func(t *testing.T) { + var ( + req = require.New(t) + + dst = &struct { + IString interface{} `var:"iString"` + IInteger interface{} `var:"iInteger"` + IBool interface{} `var:"iBool"` + }{} + + vars = RVars{ + "iString": Must(NewString("foo")), + "iInteger": Must(NewInteger(42)), + "iBool": Must(NewBoolean(true)), + }.Vars() + ) + + req.NoError(vars.Decode(dst)) + }) + + t.Run("int-uint", func(t *testing.T) { + var ( + req = require.New(t) + + dst = &struct { + Int int64 + Uint64 uint64 + }{} + + vars = RVars{ + "uint64": Must(NewAny("42")), + "int": Must(NewAny("42")), + }.Vars() + ) + + dst.Uint64 = 0 + dst.Int = 0 + + req.NoError(vars.Decode(dst)) + req.Equal(uint64(42), dst.Uint64) + req.Equal(int64(42), dst.Int) + }) +} diff --git a/pkg/filter/state.go b/pkg/filter/state.go index 64a4907df..9f5d7cfcd 100644 --- a/pkg/filter/state.go +++ b/pkg/filter/state.go @@ -2,6 +2,7 @@ package filter import ( "github.com/Masterminds/squirrel" + "strconv" ) type ( @@ -22,6 +23,10 @@ const ( StateExclusive State = 2 ) +func (s State) String() string { + return strconv.Itoa(int(s)) +} + // squirrel.SelectBuilder func StateCondition(q squirrel.SelectBuilder, field string, fs State) squirrel.SelectBuilder { switch fs { @@ -30,7 +35,7 @@ func StateCondition(q squirrel.SelectBuilder, field string, fs State) squirrel.S return q.Where(squirrel.NotEq{field: nil}) case StateInclusive: - // mo filter + // no filter return q default: @@ -38,3 +43,19 @@ func StateCondition(q squirrel.SelectBuilder, field string, fs State) squirrel.S return q.Where(squirrel.Eq{field: nil}) } } + +// squirrel.SelectBuilder +func StateConditionNegBool(q squirrel.SelectBuilder, field string, fs State) squirrel.SelectBuilder { + switch fs { + case StateExcluded: + // only true + return q.Where(squirrel.Eq{field: true}) + + case StateExclusive: + // only false + return q.Where(squirrel.Eq{field: false}) + + default: + return q + } +} diff --git a/pkg/wfexec/gateways.go b/pkg/wfexec/gateways.go new file mode 100644 index 000000000..480ac6f7e --- /dev/null +++ b/pkg/wfexec/gateways.go @@ -0,0 +1,175 @@ +package wfexec + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/expr" + "sync" +) + +// GatewayPath structure is used by subset of gateway nodes +// +// It allows to evaluate test Expression to help determine the +// gateway if a certain path should be used or not +type ( + GatewayPaths []*GatewayPath + GatewayPath struct { + test pathTester + to Step + } + + pathTester func(context.Context, *expr.Vars) (bool, error) +) + +// NewGatewayPath validates Expression and returns initialized GatewayPath +func NewGatewayPath(s Step, t pathTester) (gwp *GatewayPath, err error) { + return &GatewayPath{to: s, test: t}, nil +} + +// joinGateway handles merging/joining of multiple paths into +// a single path forward +type joinGateway struct { + stepIdentifier + paths Steps + scopes map[Step]*expr.Vars + l sync.Mutex +} + +// JoinGateway fn initializes join gateway with all paths that are expected to be partial +func JoinGateway(ss ...Step) *joinGateway { + return &joinGateway{ + paths: ss, + scopes: make(map[Step]*expr.Vars), + } +} + +// Exec fn on join gateway can be called multiple times, even multiple times parent the same parent +// +// Func will override the collected parent's *expr.Vars. +// +// Join gateways is ready to continue with the Graph when all configured paths are ready to be partial +// When all paths are merged (ie Exec was called at least once per parent) +func (gw *joinGateway) Exec(_ context.Context, r *ExecRequest) (ExecResponse, error) { + gw.l.Lock() + defer gw.l.Unlock() + + if !gw.paths.Contains(r.Parent) { + return nil, fmt.Errorf("unknown parent for join gateway") + } + + gw.scopes[r.Parent] = r.Scope + if len(gw.scopes) < len(gw.paths) { + return &partial{}, nil + } + + // All collected, merge scope parent all paths in the defined order + var merged *expr.Vars + for _, p := range gw.paths { + if gw.scopes[p] != nil { + merged = merged.Merge(gw.scopes[p]) + } + } + + return merged, nil +} + +// forkGateway handles forking to multiple paths +type forkGateway struct { + stepIdentifier +} + +// ForkGateway fn initializes fork gateway +// No arguments are required; Graph Graph config is used to +// determine all possible fork paths on the fly +func ForkGateway() *forkGateway { + return &forkGateway{} +} + +// Exec fn on fork gateway always returns empty Steps slice +// This signals Graph executor to collect child nodes directly parent Graph +func (gw forkGateway) Exec(context.Context, *ExecRequest) (ExecResponse, error) { + return Steps{}, nil +} + +// inclGateway is an inclusive gateway that can return one or more paths +type inclGateway struct { + stepIdentifier + paths []*GatewayPath +} + +// InclGateway fn initializes inclusive gateway +func InclGateway(pp ...*GatewayPath) (*inclGateway, error) { + if len(pp) < 2 { + return nil, fmt.Errorf("expecting at least two paths for incusive gateway") + } + + for _, p := range pp { + if p.test == nil { + return nil, fmt.Errorf("all inclusve gateway paths must have valid test Expression") + } + } + + return &inclGateway{paths: pp}, nil +} + +// Exec fn on inclGateway uses current scope to test all configured paths +// +// One or more matched paths can be returned! +func (gw inclGateway) Exec(ctx context.Context, r *ExecRequest) (ExecResponse, error) { + var paths Steps + for _, p := range gw.paths { + if result, err := p.test(ctx, r.Scope); err != nil { + return nil, err + } else if result { + paths = append(paths, p.to) + } + } + + if len(paths) == 0 { + return nil, fmt.Errorf("inclusive gateway must match at least one condition") + } + + return paths, nil +} + +// exclGateway is an exclusive gateway that can return exactly one path +type exclGateway struct { + stepIdentifier + paths []*GatewayPath +} + +// ExclGateway fn initializes exclusive gateway +func ExclGateway(pp ...*GatewayPath) (*exclGateway, error) { + t := len(pp) + if t < 2 { + return nil, fmt.Errorf("expecting at least two paths for exclusive gateway") + } + + for i, p := range pp { + if p.test == nil && i != t-1 { + return nil, fmt.Errorf("all exclusive gateway paths must have valid test Expression") + } + } + + return &exclGateway{paths: pp}, nil +} + +// Exec fn on exclGateway uses current scope to test all configured paths +// +// Exactly one matched path can be returned. +func (gw exclGateway) Exec(ctx context.Context, r *ExecRequest) (ExecResponse, error) { + for _, p := range gw.paths { + if p.test == nil { + // empty & last; treat it as else part of the if condition + return p.to, nil + } + + if result, err := p.test(ctx, r.Scope); err != nil { + return nil, err + } else if result { + return p.to, nil + } + } + + return nil, fmt.Errorf("exclusive gateway must match one condition") +} diff --git a/pkg/wfexec/gateways_test.go b/pkg/wfexec/gateways_test.go new file mode 100644 index 000000000..9bc0309fe --- /dev/null +++ b/pkg/wfexec/gateways_test.go @@ -0,0 +1,107 @@ +package wfexec + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/stretchr/testify/require" + "testing" +) + +func gt(key string, val int64) pathTester { + return func(ctx context.Context, variables *expr.Vars) (bool, error) { + return expr.Must(expr.Select(variables, key)).Get().(int64) > val, nil + } +} + +func makeIntVars(k string, num int) *expr.Vars { + return expr.RVars{k: expr.Must(expr.NewInteger(num))}.Vars() +} + +func TestJoinGateway(t *testing.T) { + var ( + req = require.New(t) + p1, p2, p3 = &wfTestStep{name: "p1"}, &wfTestStep{name: "p2"}, &wfTestStep{name: "p3"} + gw = JoinGateway(p1, p2, p3) + + r ExecResponse + err error + ) + + r, err = gw.Exec(context.TODO(), &ExecRequest{Parent: p1}) + req.NoError(err) + req.Equal(&partial{}, r) + + r, err = gw.Exec(context.TODO(), &ExecRequest{Parent: p2}) + req.NoError(err) + req.Equal(&partial{}, r) + + r, err = gw.Exec(context.TODO(), &ExecRequest{Parent: p3}) + req.NoError(err) + req.IsType(&expr.Vars{}, r) +} + +func TestForkGateway(t *testing.T) { + var ( + req = require.New(t) + gw = ForkGateway() + ) + + r, err := gw.Exec(context.TODO(), nil) + req.NoError(err) + req.Equal(Steps{}, r) + req.Empty(r) +} + +func TestInclGateway(t *testing.T) { + var ( + req = require.New(t) + + s1, s2, s3 = &wfTestStep{name: "s1"}, &wfTestStep{name: "s2"}, &wfTestStep{name: "s3"} + gwp1, _ = NewGatewayPath(s1, gt("a", 10)) + gwp2, _ = NewGatewayPath(s2, gt("a", 5)) + gwp3, _ = NewGatewayPath(s3, gt("a", 0)) + + gw, err = InclGateway(gwp1, gwp2, gwp3) + ) + + r, err := gw.Exec(context.Background(), &ExecRequest{Scope: makeIntVars("a", 11)}) + req.NoError(err) + req.Equal(Steps{s1, s2, s3}, r) + + r, err = gw.Exec(context.Background(), &ExecRequest{Scope: makeIntVars("a", 6)}) + req.NoError(err) + req.Equal(Steps{s2, s3}, r) + + r, err = gw.Exec(context.Background(), &ExecRequest{Scope: makeIntVars("a", 1)}) + req.NoError(err) + req.Equal(Steps{s3}, r) + + r, err = gw.Exec(context.Background(), &ExecRequest{Scope: makeIntVars("a", 0)}) + req.Error(err) + req.Nil(r) +} + +func TestExclGateway(t *testing.T) { + var ( + req = require.New(t) + + s1, s2, s3 = &wfTestStep{name: "s1"}, &wfTestStep{name: "s2"}, &wfTestStep{name: "s3"} + gwp1, _ = NewGatewayPath(s1, gt("a", 10)) + gwp2, _ = NewGatewayPath(s2, gt("a", 5)) + gwp3, _ = NewGatewayPath(s3, nil) + + gw, err = ExclGateway(gwp1, gwp2, gwp3) + ) + + r, err := gw.Exec(context.Background(), &ExecRequest{Scope: makeIntVars("a", 11)}) + req.NoError(err) + req.Equal(s1, r) + + r, err = gw.Exec(context.Background(), &ExecRequest{Scope: makeIntVars("a", 6)}) + req.NoError(err) + req.Equal(s2, r) + + r, err = gw.Exec(context.Background(), &ExecRequest{Scope: makeIntVars("a", 1)}) + req.NoError(err) + req.Equal(s3, r) +} diff --git a/pkg/wfexec/graph.go b/pkg/wfexec/graph.go new file mode 100644 index 000000000..8425e2852 --- /dev/null +++ b/pkg/wfexec/graph.go @@ -0,0 +1,90 @@ +package wfexec + +import ( + "context" +) + +type ( + Steps []Step + Step interface { + ID() uint64 + SetID(uint64) + Exec(context.Context, *ExecRequest) (ExecResponse, error) + } + + // list of Graph steps with relations + Graph struct { + steps []Step + children map[Step][]Step + parents map[Step][]Step + index map[uint64]Step + } + + stepIdentifier struct{ id uint64 } +) + +func NewGraph() *Graph { + wf := &Graph{ + steps: make([]Step, 0, 1024), + children: make(map[Step][]Step), + parents: make(map[Step][]Step), + index: make(map[uint64]Step), + } + + return wf +} + +func (i *stepIdentifier) ID() uint64 { return i.id } +func (i *stepIdentifier) SetID(id uint64) { i.id = id } + +func (g *Graph) AddStep(s Step, cc ...Step) { + g.steps = append(g.steps, s) + + if id := s.ID(); id != 0 { + g.index[id] = s + } + + if len(cc) > 0 { + for _, c := range cc { + g.AddParent(c, s) + } + } +} + +func (g *Graph) Len() int { + return len(g.steps) +} + +func (g *Graph) StepByID(ID uint64) Step { + return g.index[ID] +} + +func (g *Graph) AddParent(c, p Step) { + g.parents[c] = append(g.parents[c], p) + g.children[p] = append(g.children[p], c) +} + +func (g *Graph) Children(s Step) Steps { + return g.children[s] +} + +func (g *Graph) Parents(s Step) Steps { + return g.parents[s] +} + +func (g *Graph) Exec(context.Context, *ExecRequest) (ExecResponse, error) { + // @todo + return nil, nil +} + +func (g *Graph) Orphans() (oo Steps) { + for _, step := range g.steps { + if len(g.Parents(step)) > 0 { + continue + } + + oo = append(oo, step) + } + + return +} diff --git a/pkg/wfexec/graph_test.go b/pkg/wfexec/graph_test.go new file mode 100644 index 000000000..dd1ea0f17 --- /dev/null +++ b/pkg/wfexec/graph_test.go @@ -0,0 +1,34 @@ +package wfexec + +import ( + "context" + "github.com/stretchr/testify/require" + "testing" +) + +type ( + wfTestStep struct { + stepIdentifier + name string + } +) + +func (wfTestStep) Exec(context.Context, *ExecRequest) (ExecResponse, error) { + return nil, nil +} + +func TestWorkflow(t *testing.T) { + var ( + req = require.New(t) + wf = NewGraph() + + s = &wfTestStep{name: "s1"} + c1, c2, c3 = &wfTestStep{name: "c1"}, &wfTestStep{name: "c2"}, &wfTestStep{name: "c3"} + ) + + wf.AddStep(s, c1, c2, c3) + req.Equal(wf.Children(s), Steps{c1, c2, c3}) + req.Equal(wf.Parents(c1), Steps{s}) + req.Equal(wf.Parents(c2), Steps{s}) + req.Equal(wf.Parents(c3), Steps{s}) +} diff --git a/pkg/wfexec/iterator.go b/pkg/wfexec/iterator.go new file mode 100644 index 000000000..93af3ac7e --- /dev/null +++ b/pkg/wfexec/iterator.go @@ -0,0 +1,91 @@ +package wfexec + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/expr" +) + +type ( + // Iterator can be returned from Exec fn as ExecResponse + // + // It helps session's exec fn() to properly navigate through graph + // by calling is/break/iterator/next function + Iterator interface { + // Is the given step this iterator step + Is(Step) bool + + // Initialize iterator + Start(context.Context, *expr.Vars) error + + // Break fn is called when loop is forcefully broken + Break() Step + + Iterator() Step + + // Next is called before each iteration and returns + // 1st step of the iteration branch and variables that are added to the scope + Next(context.Context, *expr.Vars) (Step, *expr.Vars, error) + } + + ResultEvaluator interface { + EvalResults(ctx context.Context, results *expr.Vars) (out *expr.Vars, err error) + } + + IteratorHandler interface { + Start(context.Context, *expr.Vars) error + More(context.Context, *expr.Vars) (bool, error) + Next(context.Context, *expr.Vars) (*expr.Vars, error) + } + + // Handles communication between Session's exec() fn and iterator handler + genericIterator struct { + iter, next, exit Step + + h IteratorHandler + } +) + +// GenericIterator creates a wrapper around IteratorHandler and +// returns genericIterator that implements Iterator interface +func GenericIterator(iter, next, exit Step, h IteratorHandler) Iterator { + return &genericIterator{ + iter: iter, + next: next, + exit: exit, + h: h, + } +} + +func (i *genericIterator) Is(s Step) bool { return i.iter == s } +func (i *genericIterator) Start(ctx context.Context, s *expr.Vars) error { return i.h.Start(ctx, s) } +func (i *genericIterator) Break() Step { return i.exit } +func (i *genericIterator) Iterator() Step { return i.iter } + +// Next calls More and Next functions on iterator handler. +// +// If iterator step (iter field) implements ResultEvaluator it calls +// EvalResults on it before returning it. If iterator step does not implement it, +// results are omitted. +func (i *genericIterator) Next(ctx context.Context, scope *expr.Vars) (next Step, out *expr.Vars, err error) { + var ( + more bool + results *expr.Vars + ) + if more, err = i.h.More(ctx, scope); err != nil || !more { + return + } + + if results, err = i.h.Next(ctx, scope); err != nil { + return + } + + if re, is := i.iter.(ResultEvaluator); is { + if out, err = re.EvalResults(ctx, results); err != nil { + return + } + } + + next = i.next + + return +} diff --git a/pkg/wfexec/response.go b/pkg/wfexec/response.go new file mode 100644 index 000000000..8fed69c18 --- /dev/null +++ b/pkg/wfexec/response.go @@ -0,0 +1,34 @@ +package wfexec + +import ( + "time" +) + +type ( + ExecResponse interface{} + partial struct{} + + errHandler struct { + handler Step + } +) + +func DelayExecution(until time.Time) *suspended { + return &suspended{resumeAt: &until} +} + +func WaitForInput() *suspended { + return &suspended{input: true} +} + +func ErrorHandler(h Step) *errHandler { + return &errHandler{handler: h} +} + +type ( + loopBreak struct{} + loopContinue struct{} +) + +func LoopBreak() *loopBreak { return &loopBreak{} } +func LoopContinue() *loopContinue { return &loopContinue{} } diff --git a/pkg/wfexec/session.go b/pkg/wfexec/session.go new file mode 100644 index 000000000..7f786da1d --- /dev/null +++ b/pkg/wfexec/session.go @@ -0,0 +1,662 @@ +package wfexec + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/id" + "github.com/cortezaproject/corteza-server/pkg/logger" + "go.uber.org/zap" + "sync" + "time" +) + +type ( + Session struct { + // Session identifier + id uint64 + + // steps graph + g *Graph + + started time.Time + + // state channel (ie work queue) + qState chan *State + + // error channel + qErr chan error + + // locks concurrent executions + execLock chan struct{} + + // collection of all suspended states + // map key represents state identifier + suspended map[uint64]*suspended + + // how often we check for suspended states and how often idle stat is checked in Wait() + workerInterval time.Duration + + // only one worker routine per session + workerLock chan struct{} + + workerTicker *time.Ticker + + // holds final result + result *expr.Vars + err error + + mux *sync.RWMutex + + // debug logger + log *zap.Logger + + eventHandler StateChangeHandler + } + + StateChangeHandler func(int, *State, *Session) + + suspended struct { + // when not nil, assuming delayed + resumeAt *time.Time + + // when true, assuming waiting for input (resumable through Resume()) + input bool + + // state to be resumed + state *State + } + + sessionOpt func(*Session) + + Frame struct { + Created time.Time `json:"created"` + SessionID uint64 `json:"sessionID"` + StateID uint64 `json:"stateID"` + Input *expr.Vars `json:"input"` + Scope *expr.Vars `json:"scope"` + ParentID uint64 `json:"parentID"` + StepID uint64 `json:"stepID"` + LeadTime time.Duration `json:"leadTime"` + } + + // ExecRequest is passed to Exec() functions and contains all information to + // resume suspended states in a Graph Session + ExecRequest struct { + SessionID uint64 + StateID uint64 + + // Current input received on session resume + Input *expr.Vars + + // Current scope + Scope *expr.Vars + + // Helps with gateway join/merge steps + // that needs info about the step it's currently merging + Parent Step + } +) + +const ( + sessionStateChanBuf = 512 + sessionConcurrentExec = 32 +) + +const ( + SessionActive int = iota + SessionSuspended + SessionStepSuspended + SessionNewMessage + SessionFailed + SessionErrorHandled + SessionCompleted +) + +var ( + // wrapper around nextID that will aid service testing + nextID = func() uint64 { + return id.Next() + } + + // wrapper around time.Now() that will aid service testing + now = func() *time.Time { + c := time.Now() + return &c + } +) + +func NewSession(ctx context.Context, g *Graph, oo ...sessionOpt) *Session { + s := &Session{ + g: g, + id: nextID(), + started: *now(), + qState: make(chan *State, sessionStateChanBuf), + qErr: make(chan error, 1), + execLock: make(chan struct{}, sessionConcurrentExec), + suspended: make(map[uint64]*suspended), + + //workerInterval: time.Millisecond, + workerInterval: time.Millisecond * 250, // debug mode rate + workerLock: make(chan struct{}, 1), + + mux: &sync.RWMutex{}, + + log: zap.NewNop(), + eventHandler: func(int, *State, *Session) { + // noop + }, + } + + for _, o := range oo { + o(s) + } + + s.log = s.log. + WithOptions(zap.AddStacktrace(zap.ErrorLevel)). + With(zap.Uint64("sessionId", s.id)) + + go s.worker(ctx) + + return s +} + +func (s Session) Status() int { + defer s.mux.Unlock() + s.mux.Lock() + + if s.err != nil { + return SessionFailed + } + + if len(s.suspended) > 0 { + return SessionSuspended + } + + if s.result == nil { + // active + return SessionActive + } + + return SessionCompleted +} + +func (s Session) ID() uint64 { return s.id } + +func (s Session) Idle() bool { + return s.Status() != SessionActive +} + +func (s *Session) Error() error { + defer s.mux.RUnlock() + s.mux.RLock() + + return s.err +} + +func (s *Session) Result() *expr.Vars { + defer s.mux.RUnlock() + s.mux.RLock() + + return s.result +} + +func (s *Session) Exec(ctx context.Context, step Step, scope *expr.Vars) error { + if s.g.Len() == 0 { + return fmt.Errorf("refusing to execute without steps") + } + + if len(s.g.Parents(step)) > 0 { + return fmt.Errorf("can not execute step with parents") + } + + if scope == nil { + + scope, _ = expr.NewVars(nil) + } + + return s.enqueue(ctx, NewState(s, auth.GetIdentityFromContext(ctx), nil, step, scope)) +} + +func (s *Session) Resume(ctx context.Context, stateId uint64, input *expr.Vars) error { + defer s.mux.Unlock() + s.mux.Lock() + + resumed, has := s.suspended[stateId] + if !has { + return fmt.Errorf("unexisting state") + } + + if !resumed.input { + return fmt.Errorf("not input state") + } + + delete(s.suspended, stateId) + + // setting received input to state + resumed.state.input = input + + return s.enqueue(ctx, resumed.state) +} + +func (s *Session) enqueue(ctx context.Context, st *State) error { + if st == nil { + return fmt.Errorf("state is nil") + } + + if st.step == nil { + return fmt.Errorf("state step is nil") + } + + if st.stateId == 0 { + st.stateId = nextID() + } + + select { + case <-ctx.Done(): + return ctx.Err() + + case s.qState <- st: + s.log.Debug("add step to queue") + return nil + } +} + +// does not wait for the whole wf to be complete but until: +// - context timeout +// - idle state +// - error in error queue +func (s *Session) Wait(ctx context.Context) error { + waitCheck := time.NewTicker(s.workerInterval) + defer waitCheck.Stop() + +waitLoop: + for { + select { + case <-waitCheck.C: + if s.Idle() { + s.log.Debug("idle", zap.Int("status", s.Status())) + // nothing in the pipeline + break waitLoop + } + + case <-ctx.Done(): + s.log.Debug("wait context done", zap.Error(ctx.Err())) + break waitLoop + } + } + + return s.err +} + +func (s *Session) worker(ctx context.Context) { + defer s.Stop() + + // making sure + defer close(s.workerLock) + s.workerLock <- struct{}{} + + s.workerTicker = time.NewTicker(s.workerInterval) + defer s.workerTicker.Stop() + + for { + select { + case <-ctx.Done(): + s.log.Debug("worker context done", zap.Error(ctx.Err())) + return + + case <-s.workerTicker.C: + s.log.Debug("checking for suspended states") + s.queueScheduledSuspended() + + case st := <-s.qState: + if st == nil { + // stop worker + s.log.Debug("worker done") + break + } + + s.log.Debug("pulled state from queue", zap.Uint64("stateId", st.stateId)) + if st.step == nil { + s.log.Debug("done, stopping and setting results") + defer s.mux.Unlock() + s.mux.Lock() + + // making sure result != nil + s.result = (&expr.Vars{}).Merge(st.scope) + return + } + + // add empty struct to chan to lock and to have control over numver of concurrent go processes + // this will block if number of items in execLock chan reached value of sessionConcurrentExec + s.execLock <- struct{}{} + + go func() { + s.exec(ctx, st) + st.completed = now() + + // remove single + <-s.execLock + + status := s.Status() + s.log.Debug("executed", zap.Uint64("stateId", st.stateId), zap.Int("status", status)) + // after exec lock is released call event handler with (new) session status + s.eventHandler(status, st, s) + }() + + case err := <-s.qErr: + if err == nil { + // stop worker + return + } + + s.log.Warn("worker completed with error", zap.Error(err)) + s.mux.Lock() + s.err = err + s.mux.Unlock() + return + } + } +} + +func (s *Session) Stop() { + s.log.Debug("stopping session") + defer s.workerTicker.Stop() +} + +func (s Session) Suspended() bool { + defer s.mux.RUnlock() + s.mux.RLock() + return len(s.suspended) > 0 +} + +func (s *Session) queueScheduledSuspended() { + defer s.mux.Unlock() + s.mux.Lock() + + for id, sus := range s.suspended { + if sus.resumeAt == nil { + continue + } + + if sus.resumeAt.After(*now()) { + continue + } + + delete(s.suspended, id) + s.log.Debug("resuming suspended state", zap.Uint64("stateId", sus.state.stateId)) + s.qState <- sus.state + } +} + +// executes single step, resolves response and schedule following steps for execution +func (s *Session) exec(ctx context.Context, st *State) { + var ( + result ExecResponse + scope = (&expr.Vars{}).Merge(st.scope) + next Steps + + currLoop = st.loopCurr() + + log = s.log.With(zap.Uint64("stateId", st.stateId)) + ) + + if st.step != nil { + log = log.With(zap.Uint64("step", st.step.ID())) + } + + // @todo enable this when not in debug mode + // - OR - + // find a way to stick stacktrace from panic to log + //defer func() { + // reason := recover() + // if reason == nil { + // return + // } + // + // switch reason := reason.(type) { + // case error: + // log.Error("workflow session crashed", zap.Error(reason)) + // s.qErr <- fmt.Errorf("session %d step %d crashed: %w", s.id, st.step.ID(), reason) + // default: + // s.qErr <- fmt.Errorf("session %d step %d crashed: %v", s.id, st.step.ID(), reason) + // } + //}() + + s.eventHandler(SessionActive, st, s) + + { + if currLoop != nil && currLoop.Is(st.step) { + result = currLoop + } else { + // push logger to context but raise the stacktrace level to panic + // to prevent overly verbose traces + ctx = logger.ContextWithValue(ctx, log.WithOptions(zap.AddStacktrace(zap.PanicLevel))) + + // Context received in exec() wil not have the identity we're expecting + // so we need to pull it from state owner and add it to new context + // that is set to step exec function + ctxWithIdentity := auth.SetIdentityToContext(ctx, st.owner) + + result, st.err = st.step.Exec(ctxWithIdentity, st.MakeRequest()) + + if iterator, isIterator := result.(Iterator); isIterator && st.err == nil { + // Exec fn returned an iterator, adding loop to stack + st.newLoop(iterator) + if err := iterator.Start(ctx, scope); err != nil { + s.qErr <- err + } + } + } + + if st.err != nil { + if st.errHandler != nil { + s.eventHandler(SessionErrorHandled, st, s) + + // handling error with error handling + // step set in one of the previous steps + log.Warn("step execution error handled", + zap.Uint64("errorHandlerStepId", st.errHandler.ID()), + zap.Error(st.err), + ) + + expr.Assign(scope, "error", expr.Must(expr.NewString(st.err.Error()))) + + // copy error handler & disable it on state to prevent inf. loop + // in case of another error in the error-handling branch + eh := st.errHandler + st.errHandler = nil + if err := s.enqueue(ctx, st.Next(eh, scope)); err != nil { + log.Warn("unable to queue", zap.Error(err)) + } + + return + } else { + log.Error("step execution failed", zap.Error(st.err)) + s.qErr <- fmt.Errorf("session %d step %d execution failed: %w", s.id, st.step.ID(), st.err) + return + } + } + + switch l := result.(type) { + case Iterator: + // add looper to state + var ( + err error + n Step + ) + n, result, err = l.Next(ctx, scope) + if err != nil { + s.qErr <- err + return + } + + if n == nil { + next = st.loopEnd() + } else { + next = Steps{n} + } + } + + log.Debug("step executed", zap.String("resultType", fmt.Sprintf("%T", result))) + switch result := result.(type) { + case *expr.Vars: + // most common (successful) result + // session will continue with configured child steps + scope = scope.Merge(result) + + log.Debug("result variables", zap.Any("scope", result)) + result.Each(func(k string, v expr.TypedValue) error { + log.Debug("result variables", zap.String("name", k), zap.Any("value", v)) + return nil + }) + + case *errHandler: + // this step sets error handling step on current state + // and continues on the current path + st.errHandler = result.handler + + // find step that's not error handler and + // use it for the next step + for _, c := range s.g.Children(st.step) { + if c != st.errHandler { + next = Steps{c} + break + } + } + + case *loopBreak: + if currLoop == nil { + s.qErr <- fmt.Errorf("session %d step %d break step not inside a loop", s.id, st.step.ID()) + return + } + + // jump out of the loop + next = st.loopEnd() + s.qErr <- fmt.Errorf("session %d step %d expliciy brake out of the loop", s.id, st.step.ID()) + + case *loopContinue: + if currLoop == nil { + s.qErr <- fmt.Errorf("session %d step %d continue step not inside a loop", s.id, st.step.ID()) + return + } + + // jump back to iterator + next = Steps{currLoop.Iterator()} + s.qErr <- fmt.Errorf("session %d step %d expliciy continue with next iteration", s.id, st.step.ID()) + + case *partial: + // *partial is returned when step needs to be executed again + // it's used mainly for join gateway step that should be called multiple times (one for each parent path) + return + + case *suspended: + // suspend execution because of delay or pending user input + // either way, it breaks execution loop for the current path + + if result == nil { + // @todo properly handle this + log.Warn("suspended with nil") + return + } + + if result.resumeAt != nil { + log.Debug("suspended, temporal", zap.Timep("at", result.resumeAt)) + } else { + log.Debug("suspended, prompt") + + } + + result.state = st + s.mux.Lock() + s.suspended[st.stateId] = result + s.mux.Unlock() + s.eventHandler(SessionStepSuspended, st, s) + return + + case Steps: + // session continues with set of specified steps + // steps MUST be configured in a graph as step's children + next = result + + case Step: + // session continues with a specified step + // step MUST be configured in a graph as step's child + next = Steps{result} + + default: + s.qErr <- fmt.Errorf("session %d step %d unknown exec response type %T", s.id, st.step.ID(), result) + return + } + } + + if len(next) == 0 { + next = s.g.Children(st.step) + } else { + cc := s.g.Children(st.step) + if len(cc) > 0 && !cc.Contains(next...) { + s.qErr <- fmt.Errorf("inconsistent relationship") + return + } + } + + if currLoop != nil && len(next) == 0 { + // gracefully handling last step of iteration branch that does not point back to the iterator step + next = Steps{currLoop.Iterator()} + log.Debug("last step in iteration branch, going back", zap.Uint64("backStepId", next[0].ID())) + } + + if len(next) == 0 { + log.Debug("zero paths, finalizing") + // using state to transport results and complete the worker loop + s.qState <- FinalState(s, scope) + return + } + + for _, step := range next { + log.Debug("next step queued", zap.Uint64("nextStepId", step.ID())) + if err := s.enqueue(ctx, st.Next(step, scope)); err != nil { + log.Error("unable to queue", zap.Error(err)) + } + } + +} + +func SetWorkerInterval(i time.Duration) sessionOpt { + return func(s *Session) { + s.workerInterval = i + } +} + +func SetHandler(fn StateChangeHandler) sessionOpt { + return func(s *Session) { + s.eventHandler = fn + } +} + +func SetLogger(log *zap.Logger) sessionOpt { + return func(s *Session) { + s.log = log + } +} + +func (ss Steps) hash() map[Step]bool { + out := make(map[Step]bool) + for _, s := range ss { + out[s] = true + } + + return out +} + +func (ss Steps) Contains(steps ...Step) bool { + hash := ss.hash() + for _, s1 := range steps { + if !hash[s1] { + return false + } + } + + return true +} diff --git a/pkg/wfexec/session_test.go b/pkg/wfexec/session_test.go new file mode 100644 index 000000000..54b75c6fb --- /dev/null +++ b/pkg/wfexec/session_test.go @@ -0,0 +1,221 @@ +package wfexec + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/stretchr/testify/require" + "go.uber.org/atomic" + "testing" + "time" +) + +type ( + sesTestStep struct { + stepIdentifier + name string + exec func(context.Context, *ExecRequest) (ExecResponse, error) + } + + sesTestTemporal struct { + stepIdentifier + delay time.Duration + until time.Time + } +) + +var ( + // used for testing to produce lower numbers that are easier to inspect and compare + testID = atomic.NewUint64(0) +) + +func (s *sesTestStep) Exec(ctx context.Context, r *ExecRequest) (ExecResponse, error) { + if s.exec != nil { + return s.exec(ctx, r) + } + + var args = &struct { + Path string + Counter int64 + }{} + + if err := r.Scope.Decode(args); err != nil { + return nil, err + } + + return expr.RVars{ + "counter": expr.Must(expr.NewInteger(args.Counter + 1)), + "path": expr.Must(expr.NewString(args.Path + "/" + s.name)), + s.name: expr.Must(expr.NewString("executed")), + }.Vars(), nil +} + +func (s *sesTestTemporal) Exec(ctx context.Context, r *ExecRequest) (ExecResponse, error) { + if s.until.IsZero() { + s.until = now().Add(s.delay) + } + + if now().Before(s.until) { + return DelayExecution(s.until), nil + } + + return expr.RVars{ + "waitForMoment": expr.Must(expr.NewString("executed")), + }.Vars(), nil +} + +func TestSession_TwoStepWorkflow(t *testing.T) { + var ( + ctx = context.Background() + req = require.New(t) + wf = NewGraph() + ses = NewSession(ctx, wf) + + s1 = &sesTestStep{name: "s1"} + s2 = &sesTestStep{name: "s2"} + ) + + wf.AddStep(s1, s2) // 1st execute s1 then s2 + ses.Exec(ctx, s1, expr.RVars{"two": expr.Must(expr.NewInteger(1)), "three": expr.Must(expr.NewInteger(1))}.Vars()) + ses.Wait(ctx) + req.NoError(ses.Error()) + req.NotNil(ses.Result()) + req.Equal("/s1/s2", expr.Must(expr.Select(ses.Result(), "path")).Get()) +} + +func TestSession_SplitAndMerge(t *testing.T) { + var ( + ctx = context.Background() + req = require.New(t) + wf = NewGraph() + ses = NewSession(ctx, wf) + + start = &sesTestStep{name: "start"} + split1 = &sesTestStep{name: "split1"} + split2 = &sesTestStep{name: "split2"} + split3 = &sesTestStep{name: "split3"} + + end = JoinGateway(split1, split2, split3) + ) + + wf.AddStep(start, split1, split2, split3) + wf.AddStep(split1, end) + wf.AddStep(split2, end) + wf.AddStep(split3, end) + ses.Exec(ctx, start, nil) + ses.Wait(ctx) + req.True(ses.Idle()) + req.NoError(ses.Error()) + req.NotNil(ses.Result()) + // split3 only! + req.Equal("/start/split3", expr.Must(expr.Select(ses.Result(), "path")).Get()) + req.Contains(ses.Result().Dict(), "split1") + req.Contains(ses.Result().Dict(), "split2") + req.Contains(ses.Result().Dict(), "split3") +} + +func TestSession_Delays(t *testing.T) { + t.SkipNow() + var ( + // how fast we want to go (lower = faster) + // + unit = time.Millisecond + delay = unit * 3 + + ctx = context.Background() + req = require.New(t) + wf = NewGraph() + ses = NewSession(ctx, wf, + // for testing we need much shorter worker intervals + SetWorkerInterval(unit), + ) + + start = &sesTestStep{name: "start"} + + waitForMoment = &sesTestTemporal{delay: delay} + + waitForInputStateId atomic.Uint64 + waitForInput = &sesTestStep{name: "waitForInput", exec: func(ctx context.Context, r *ExecRequest) (ExecResponse, error) { + if !r.Input.Has("input") { + waitForInputStateId.Store(r.StateID) + return WaitForInput(), nil + } + + out := expr.RVars{ + "waitForInput": expr.Must(expr.NewString("executed")), + }.Vars() + + r.Input.Copy(out, "input") + + return out, nil + }} + ) + + ctx, cancelFn := context.WithTimeout(ctx, time.Second*5) + defer cancelFn() + + wf.AddStep(start, waitForMoment) + wf.AddStep(waitForMoment, waitForInput) + + req.NoError(ses.Exec(ctx, start, nil)) + + // wait-for-moment step needs to be executed before we can resume wait-for-input + ses.Wait(ctx) + time.Sleep(delay + unit) + req.NotZero(waitForInputStateId.Load()) + + // should not be completed yet... + req.True(ses.Idle()) + req.True(ses.Suspended()) + + // push in the input + req.NoError(ses.Resume(ctx, waitForInputStateId.Load(), expr.RVars{"input": expr.Must(expr.NewString("foo"))}.Vars())) + + req.False(ses.Suspended()) + ses.Wait(ctx) + time.Sleep(2 * unit) + + // should not be completed yet... + req.True(ses.Idle()) + req.NoError(ses.Error()) + req.NotNil(ses.Result()) + req.Contains(ses.Result().Dict(), "waitForMoment") + req.Contains(ses.Result().Dict(), "waitForInput") + req.Equal("foo", expr.Must(expr.Select(ses.Result(), "input")).Get()) + +} + +func bmSessionSimpleStepSequence(c uint64, b *testing.B) { + var ( + ctx = context.Background() + g = NewGraph() + err error + ) + + for i := uint64(1); i <= c; i++ { + s := &sesTestStep{name: "start"} + s.SetID(i) + g.AddStep(s) + if i > 1 { + g.AddParent(s, g.StepByID(i-1)) + } + } + + b.StartTimer() + for n := 0; n < b.N; n++ { + ses := NewSession(ctx, g) + if err = ses.Exec(ctx, g.StepByID(1), nil); err != nil { + b.Fatal(err.Error()) + } + + ses.Wait(ctx) + } + b.StopTimer() +} + +func BenchmarkSessionSimple1StepSequence(b *testing.B) { bmSessionSimpleStepSequence(1, b) } +func BenchmarkSessionSimple10StepSequence(b *testing.B) { bmSessionSimpleStepSequence(10, b) } +func BenchmarkSessionSimple100StepSequence(b *testing.B) { bmSessionSimpleStepSequence(100, b) } +func BenchmarkSessionSimple1000StepSequence(b *testing.B) { bmSessionSimpleStepSequence(1000, b) } +func BenchmarkSessionSimple10000StepSequence(b *testing.B) { bmSessionSimpleStepSequence(10000, b) } +func BenchmarkSessionSimple100000StepSequence(b *testing.B) { bmSessionSimpleStepSequence(100000, b) } +func BenchmarkSessionSimple1000000StepSequence(b *testing.B) { bmSessionSimpleStepSequence(1000000, b) } diff --git a/pkg/wfexec/state.go b/pkg/wfexec/state.go new file mode 100644 index 000000000..d183bbb4a --- /dev/null +++ b/pkg/wfexec/state.go @@ -0,0 +1,152 @@ +package wfexec + +import ( + "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/expr" + "time" +) + +type ( + + // state holds information about Session ID + State struct { + created time.Time + completed *time.Time + + // state identifier + stateId uint64 + + // who's running this? + owner auth.Identifiable + + // Session identifier + sessionId uint64 + + // parent, parent step + parent Step + + // current step + step Step + + // step error (if any) + err error + + // input variables that were sent to resume the session + input *expr.Vars + + // scope + scope *expr.Vars + + // error handling step + errHandler Step + + loops []Iterator + } +) + +func NewState(ses *Session, owner auth.Identifiable, caller, current Step, scope *expr.Vars) *State { + return &State{ + stateId: nextID(), + owner: owner, + sessionId: ses.id, + created: *now(), + parent: caller, + step: current, + scope: scope, + + loops: make([]Iterator, 0, 4), + } +} + +func FinalState(ses *Session, scope *expr.Vars) *State { + return &State{ + stateId: nextID(), + sessionId: ses.id, + created: *now(), + completed: now(), + scope: scope, + } +} + +func (s State) Next(current Step, scope *expr.Vars) *State { + return &State{ + stateId: nextID(), + created: *now(), + + owner: s.owner, + sessionId: s.sessionId, + parent: s.step, + errHandler: s.errHandler, + loops: s.loops, + + step: current, + scope: scope, + } +} + +func (s State) MakeRequest() *ExecRequest { + return &ExecRequest{ + SessionID: s.sessionId, + StateID: s.stateId, + Scope: s.scope, + Input: s.input, + Parent: s.parent, + } +} + +func (s *State) newLoop(i Iterator) { + s.loops = append(s.loops, i) +} + +// ends loop and returns step that leads out of the loop +func (s *State) loopEnd() (out Steps) { + l := len(s.loops) - 1 + if l < 0 { + panic("not inside a loop") + } + + out = Steps{s.loops[l].Break()} + s.loops = s.loops[:l] + return +} + +func (s State) loopCurr() Iterator { + l := len(s.loops) + if l > 0 { + return s.loops[l-1] + } + + return nil +} + +func (s State) MakeFrame() *Frame { + f := &Frame{ + Created: s.created, + SessionID: s.sessionId, + StateID: s.stateId, + Input: s.input, + Scope: s.scope, + } + + if s.step != nil { + f.StepID = s.step.ID() + } + + if s.parent != nil { + f.ParentID = s.parent.ID() + } + + if s.completed != nil { + f.LeadTime = s.completed.Sub(s.created) + } + + return f +} + +func (s *State) Error() string { + if s.err == nil { + return "" + } + + return s.err.Error() +} diff --git a/provision/300_automation/1000_roles.yaml b/provision/300_automation/1000_roles.yaml new file mode 100644 index 000000000..adb5e3edf --- /dev/null +++ b/provision/300_automation/1000_roles.yaml @@ -0,0 +1,3 @@ +roles: + developers: + name: Developers diff --git a/provision/300_automation/2000_access_control.yaml b/provision/300_automation/2000_access_control.yaml new file mode 100644 index 000000000..6fb674c8f --- /dev/null +++ b/provision/300_automation/2000_access_control.yaml @@ -0,0 +1,34 @@ +allow: + admins: + automation: + - access + - grant + - workflow.create + - triggers.search + - sessions.search + + automation:workflow: + - read + - update + - delete + - execute + - triggers.manage + - sessions.manage + + developers: + automation: + - access + - grant + - workflow.create + - triggers.search + - sessions.search + + automation:workflow: + - read + - update + - delete + - execute + - triggers.manage + - sessions.manage + + diff --git a/store/automation_sessions.gen.go b/store/automation_sessions.gen.go new file mode 100644 index 000000000..f26b7e157 --- /dev/null +++ b/store/automation_sessions.gen.go @@ -0,0 +1,77 @@ +package store + +// This file is auto-generated. +// +// Template: pkg/codegen/assets/store_base.gen.go.tpl +// Definitions: store/automation_sessions.yaml +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/types" +) + +type ( + AutomationSessions interface { + SearchAutomationSessions(ctx context.Context, f types.SessionFilter) (types.SessionSet, types.SessionFilter, error) + LookupAutomationSessionByID(ctx context.Context, id uint64) (*types.Session, error) + + CreateAutomationSession(ctx context.Context, rr ...*types.Session) error + + UpdateAutomationSession(ctx context.Context, rr ...*types.Session) error + + UpsertAutomationSession(ctx context.Context, rr ...*types.Session) error + + DeleteAutomationSession(ctx context.Context, rr ...*types.Session) error + DeleteAutomationSessionByID(ctx context.Context, ID uint64) error + + TruncateAutomationSessions(ctx context.Context) error + } +) + +var _ *types.Session +var _ context.Context + +// SearchAutomationSessions returns all matching AutomationSessions from store +func SearchAutomationSessions(ctx context.Context, s AutomationSessions, f types.SessionFilter) (types.SessionSet, types.SessionFilter, error) { + return s.SearchAutomationSessions(ctx, f) +} + +// LookupAutomationSessionByID searches for session by ID +// +// It returns session even if deleted +func LookupAutomationSessionByID(ctx context.Context, s AutomationSessions, id uint64) (*types.Session, error) { + return s.LookupAutomationSessionByID(ctx, id) +} + +// CreateAutomationSession creates one or more AutomationSessions in store +func CreateAutomationSession(ctx context.Context, s AutomationSessions, rr ...*types.Session) error { + return s.CreateAutomationSession(ctx, rr...) +} + +// UpdateAutomationSession updates one or more (existing) AutomationSessions in store +func UpdateAutomationSession(ctx context.Context, s AutomationSessions, rr ...*types.Session) error { + return s.UpdateAutomationSession(ctx, rr...) +} + +// UpsertAutomationSession creates new or updates existing one or more AutomationSessions in store +func UpsertAutomationSession(ctx context.Context, s AutomationSessions, rr ...*types.Session) error { + return s.UpsertAutomationSession(ctx, rr...) +} + +// DeleteAutomationSession Deletes one or more AutomationSessions from store +func DeleteAutomationSession(ctx context.Context, s AutomationSessions, rr ...*types.Session) error { + return s.DeleteAutomationSession(ctx, rr...) +} + +// DeleteAutomationSessionByID Deletes AutomationSession from store +func DeleteAutomationSessionByID(ctx context.Context, s AutomationSessions, ID uint64) error { + return s.DeleteAutomationSessionByID(ctx, ID) +} + +// TruncateAutomationSessions Deletes all AutomationSessions from store +func TruncateAutomationSessions(ctx context.Context, s AutomationSessions) error { + return s.TruncateAutomationSessions(ctx) +} diff --git a/store/automation_sessions.yaml b/store/automation_sessions.yaml new file mode 100644 index 000000000..28956bbd2 --- /dev/null +++ b/store/automation_sessions.yaml @@ -0,0 +1,33 @@ +import: + - github.com/cortezaproject/corteza-server/automation/types + +types: + type: types.Session + +fields: + - { field: ID } + - { field: WorkflowID } + - { field: EventType } + - { field: ResourceType } + - { field: Status, type: int } + - { field: Input, type: "expr.Vars" } + - { field: Output, type: "expr.Vars" } + - { field: Stacktrace, type: "types.Stacktrace" } + - { field: CreatedBy } + - { field: CreatedAt } + - { field: PurgeAt } + - { field: CompletedAt } + - { field: SuspendedAt } + - { field: Error } + +rdbms: + alias: atms + table: automation_sessions + customFilterConverter: true + +lookups: + - fields: [ ID ] + description: |- + searches for session by ID + + It returns session even if deleted diff --git a/store/automation_triggers.gen.go b/store/automation_triggers.gen.go new file mode 100644 index 000000000..c32554790 --- /dev/null +++ b/store/automation_triggers.gen.go @@ -0,0 +1,77 @@ +package store + +// This file is auto-generated. +// +// Template: pkg/codegen/assets/store_base.gen.go.tpl +// Definitions: store/automation_triggers.yaml +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/types" +) + +type ( + AutomationTriggers interface { + SearchAutomationTriggers(ctx context.Context, f types.TriggerFilter) (types.TriggerSet, types.TriggerFilter, error) + LookupAutomationTriggerByID(ctx context.Context, id uint64) (*types.Trigger, error) + + CreateAutomationTrigger(ctx context.Context, rr ...*types.Trigger) error + + UpdateAutomationTrigger(ctx context.Context, rr ...*types.Trigger) error + + UpsertAutomationTrigger(ctx context.Context, rr ...*types.Trigger) error + + DeleteAutomationTrigger(ctx context.Context, rr ...*types.Trigger) error + DeleteAutomationTriggerByID(ctx context.Context, ID uint64) error + + TruncateAutomationTriggers(ctx context.Context) error + } +) + +var _ *types.Trigger +var _ context.Context + +// SearchAutomationTriggers returns all matching AutomationTriggers from store +func SearchAutomationTriggers(ctx context.Context, s AutomationTriggers, f types.TriggerFilter) (types.TriggerSet, types.TriggerFilter, error) { + return s.SearchAutomationTriggers(ctx, f) +} + +// LookupAutomationTriggerByID searches for trigger by ID +// +// It returns trigger even if deleted +func LookupAutomationTriggerByID(ctx context.Context, s AutomationTriggers, id uint64) (*types.Trigger, error) { + return s.LookupAutomationTriggerByID(ctx, id) +} + +// CreateAutomationTrigger creates one or more AutomationTriggers in store +func CreateAutomationTrigger(ctx context.Context, s AutomationTriggers, rr ...*types.Trigger) error { + return s.CreateAutomationTrigger(ctx, rr...) +} + +// UpdateAutomationTrigger updates one or more (existing) AutomationTriggers in store +func UpdateAutomationTrigger(ctx context.Context, s AutomationTriggers, rr ...*types.Trigger) error { + return s.UpdateAutomationTrigger(ctx, rr...) +} + +// UpsertAutomationTrigger creates new or updates existing one or more AutomationTriggers in store +func UpsertAutomationTrigger(ctx context.Context, s AutomationTriggers, rr ...*types.Trigger) error { + return s.UpsertAutomationTrigger(ctx, rr...) +} + +// DeleteAutomationTrigger Deletes one or more AutomationTriggers from store +func DeleteAutomationTrigger(ctx context.Context, s AutomationTriggers, rr ...*types.Trigger) error { + return s.DeleteAutomationTrigger(ctx, rr...) +} + +// DeleteAutomationTriggerByID Deletes AutomationTrigger from store +func DeleteAutomationTriggerByID(ctx context.Context, s AutomationTriggers, ID uint64) error { + return s.DeleteAutomationTriggerByID(ctx, ID) +} + +// TruncateAutomationTriggers Deletes all AutomationTriggers from store +func TruncateAutomationTriggers(ctx context.Context, s AutomationTriggers) error { + return s.TruncateAutomationTriggers(ctx) +} diff --git a/store/automation_triggers.yaml b/store/automation_triggers.yaml new file mode 100644 index 000000000..838c6fca5 --- /dev/null +++ b/store/automation_triggers.yaml @@ -0,0 +1,35 @@ +import: + - github.com/cortezaproject/corteza-server/automation/types + +types: + type: types.Trigger + +fields: + - { field: ID } + - { field: WorkflowID } + - { field: StepID } + - { field: Enabled, type: bool } + - { field: ResourceType } + - { field: EventType } + - { field: Meta, type: "types.TriggerMeta" } + - { field: Constraints, type: "json.Text" } + - { field: Input, type: "expr.Vars" } + - { field: OwnedBy } + - { field: CreatedBy } + - { field: UpdatedBy } + - { field: DeletedBy } + - { field: CreatedAt } + - { field: UpdatedAt } + - { field: DeletedAt } + +rdbms: + alias: atmt + table: automation_triggers + customFilterConverter: true + +lookups: + - fields: [ ID ] + description: |- + searches for trigger by ID + + It returns trigger even if deleted diff --git a/store/automation_workflows.gen.go b/store/automation_workflows.gen.go new file mode 100644 index 000000000..753c05c54 --- /dev/null +++ b/store/automation_workflows.gen.go @@ -0,0 +1,85 @@ +package store + +// This file is auto-generated. +// +// Template: pkg/codegen/assets/store_base.gen.go.tpl +// Definitions: store/automation_workflows.yaml +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import ( + "context" + "github.com/cortezaproject/corteza-server/automation/types" +) + +type ( + AutomationWorkflows interface { + SearchAutomationWorkflows(ctx context.Context, f types.WorkflowFilter) (types.WorkflowSet, types.WorkflowFilter, error) + LookupAutomationWorkflowByID(ctx context.Context, id uint64) (*types.Workflow, error) + LookupAutomationWorkflowByHandle(ctx context.Context, handle string) (*types.Workflow, error) + + CreateAutomationWorkflow(ctx context.Context, rr ...*types.Workflow) error + + UpdateAutomationWorkflow(ctx context.Context, rr ...*types.Workflow) error + + UpsertAutomationWorkflow(ctx context.Context, rr ...*types.Workflow) error + + DeleteAutomationWorkflow(ctx context.Context, rr ...*types.Workflow) error + DeleteAutomationWorkflowByID(ctx context.Context, ID uint64) error + + TruncateAutomationWorkflows(ctx context.Context) error + } +) + +var _ *types.Workflow +var _ context.Context + +// SearchAutomationWorkflows returns all matching AutomationWorkflows from store +func SearchAutomationWorkflows(ctx context.Context, s AutomationWorkflows, f types.WorkflowFilter) (types.WorkflowSet, types.WorkflowFilter, error) { + return s.SearchAutomationWorkflows(ctx, f) +} + +// LookupAutomationWorkflowByID searches for workflow by ID +// +// It returns workflow even if deleted +func LookupAutomationWorkflowByID(ctx context.Context, s AutomationWorkflows, id uint64) (*types.Workflow, error) { + return s.LookupAutomationWorkflowByID(ctx, id) +} + +// LookupAutomationWorkflowByHandle searches for workflow by their handle +// +// It returns only valid workflows +func LookupAutomationWorkflowByHandle(ctx context.Context, s AutomationWorkflows, handle string) (*types.Workflow, error) { + return s.LookupAutomationWorkflowByHandle(ctx, handle) +} + +// CreateAutomationWorkflow creates one or more AutomationWorkflows in store +func CreateAutomationWorkflow(ctx context.Context, s AutomationWorkflows, rr ...*types.Workflow) error { + return s.CreateAutomationWorkflow(ctx, rr...) +} + +// UpdateAutomationWorkflow updates one or more (existing) AutomationWorkflows in store +func UpdateAutomationWorkflow(ctx context.Context, s AutomationWorkflows, rr ...*types.Workflow) error { + return s.UpdateAutomationWorkflow(ctx, rr...) +} + +// UpsertAutomationWorkflow creates new or updates existing one or more AutomationWorkflows in store +func UpsertAutomationWorkflow(ctx context.Context, s AutomationWorkflows, rr ...*types.Workflow) error { + return s.UpsertAutomationWorkflow(ctx, rr...) +} + +// DeleteAutomationWorkflow Deletes one or more AutomationWorkflows from store +func DeleteAutomationWorkflow(ctx context.Context, s AutomationWorkflows, rr ...*types.Workflow) error { + return s.DeleteAutomationWorkflow(ctx, rr...) +} + +// DeleteAutomationWorkflowByID Deletes AutomationWorkflow from store +func DeleteAutomationWorkflowByID(ctx context.Context, s AutomationWorkflows, ID uint64) error { + return s.DeleteAutomationWorkflowByID(ctx, ID) +} + +// TruncateAutomationWorkflows Deletes all AutomationWorkflows from store +func TruncateAutomationWorkflows(ctx context.Context, s AutomationWorkflows) error { + return s.TruncateAutomationWorkflows(ctx) +} diff --git a/store/automation_workflows.yaml b/store/automation_workflows.yaml new file mode 100644 index 000000000..10195395a --- /dev/null +++ b/store/automation_workflows.yaml @@ -0,0 +1,43 @@ +import: + - github.com/cortezaproject/corteza-server/automation/types + +types: + type: types.Workflow + +fields: + - { field: ID } + - { field: Handle, unique: true, lookupFilterPreprocessor: lower } + - { field: Meta, type: "types.WorkflowMeta" } + - { field: Enabled, type: bool } + - { field: Trace, type: bool } + - { field: KeepSessions, type: "time.Duration" } + - { field: Scope, type: "expr.Vars" } + - { field: Steps, type: "expr.Vars" } + - { field: Paths, type: "expr.Vars" } + - { field: RunAs, type: "uint64" } + - { field: OwnedBy } + - { field: CreatedBy } + - { field: UpdatedBy } + - { field: DeletedBy } + - { field: CreatedAt } + - { field: UpdatedAt } + - { field: DeletedAt } + +rdbms: + alias: atmwf + table: automation_workflows + customFilterConverter: true + +lookups: + - fields: [ ID ] + description: |- + searches for workflow by ID + + It returns workflow even if deleted + - fields: [ Handle ] + filter: { DeletedAt: nil } + uniqueConstraintCheck: true + description: |- + searches for workflow by their handle + + It returns only valid workflows diff --git a/store/interfaces.gen.go b/store/interfaces.gen.go index face66eec..b89e8981c 100644 --- a/store/interfaces.gen.go +++ b/store/interfaces.gen.go @@ -11,6 +11,9 @@ package store // - store/auth_confirmed_clients.yaml // - store/auth_oa2tokens.yaml // - store/auth_sessions.yaml +// - store/automation_sessions.yaml +// - store/automation_triggers.yaml +// - store/automation_workflows.yaml // - store/compose_attachments.yaml // - store/compose_charts.yaml // - store/compose_module_fields.yaml @@ -49,6 +52,9 @@ type ( AuthConfirmedClients AuthOa2tokens AuthSessions + AutomationSessions + AutomationTriggers + AutomationWorkflows ComposeAttachments ComposeCharts ComposeModuleFields diff --git a/store/rdbms/auth_clients.gen.go b/store/rdbms/auth_clients.gen.go index 7a18347e9..f8734e796 100644 --- a/store/rdbms/auth_clients.gen.go +++ b/store/rdbms/auth_clients.gen.go @@ -469,7 +469,7 @@ func (s Store) internalAuthClientRowScanner(row rowScanner) (res *types.AuthClie } if err != nil { - return nil, errors.Store("could not scan authClient db row").Wrap(err) + return nil, errors.Store("could not scan authClient db row: %s", err).Wrap(err) } else { return res, nil } diff --git a/store/rdbms/auth_confirmed_clients.gen.go b/store/rdbms/auth_confirmed_clients.gen.go index 45d8bd5eb..ae57f6fb7 100644 --- a/store/rdbms/auth_confirmed_clients.gen.go +++ b/store/rdbms/auth_confirmed_clients.gen.go @@ -247,7 +247,7 @@ func (s Store) internalAuthConfirmedClientRowScanner(row rowScanner) (res *types } if err != nil { - return nil, errors.Store("could not scan authConfirmedClient db row").Wrap(err) + return nil, errors.Store("could not scan authConfirmedClient db row: %s", err).Wrap(err) } else { return res, nil } diff --git a/store/rdbms/auth_oa2tokens.gen.go b/store/rdbms/auth_oa2tokens.gen.go index cf4ded11f..8a0146017 100644 --- a/store/rdbms/auth_oa2tokens.gen.go +++ b/store/rdbms/auth_oa2tokens.gen.go @@ -201,7 +201,7 @@ func (s Store) internalAuthOa2tokenRowScanner(row rowScanner) (res *types.AuthOa } if err != nil { - return nil, errors.Store("could not scan authOa2token db row").Wrap(err) + return nil, errors.Store("could not scan authOa2token db row: %s", err).Wrap(err) } else { return res, nil } diff --git a/store/rdbms/auth_sessions.gen.go b/store/rdbms/auth_sessions.gen.go index dd6f1f0dc..5082731ad 100644 --- a/store/rdbms/auth_sessions.gen.go +++ b/store/rdbms/auth_sessions.gen.go @@ -248,7 +248,7 @@ func (s Store) internalAuthSessionRowScanner(row rowScanner) (res *types.AuthSes } if err != nil { - return nil, errors.Store("could not scan authSession db row").Wrap(err) + return nil, errors.Store("could not scan authSession db row: %s", err).Wrap(err) } else { return res, nil } diff --git a/store/rdbms/automation_sessions.gen.go b/store/rdbms/automation_sessions.gen.go new file mode 100644 index 000000000..5cae8ec91 --- /dev/null +++ b/store/rdbms/automation_sessions.gen.go @@ -0,0 +1,596 @@ +package rdbms + +// This file is an auto-generated file +// +// Template: pkg/codegen/assets/store_rdbms.gen.go.tpl +// Definitions: store/automation_sessions.yaml +// +// Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated. + +import ( + "context" + "database/sql" + "github.com/Masterminds/squirrel" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/store/rdbms/builders" +) + +var _ = errors.Is + +// SearchAutomationSessions returns all matching rows +// +// This function calls convertAutomationSessionFilter with the given +// types.SessionFilter and expects to receive a working squirrel.SelectBuilder +func (s Store) SearchAutomationSessions(ctx context.Context, f types.SessionFilter) (types.SessionSet, types.SessionFilter, error) { + var ( + err error + set []*types.Session + q squirrel.SelectBuilder + ) + + return set, f, func() error { + q, err = s.convertAutomationSessionFilter(f) + if err != nil { + return err + } + + // Paging enabled + // {search: {enablePaging:true}} + // Cleanup unwanted cursor values (only relevant is f.PageCursor, next&prev are reset and returned) + f.PrevPage, f.NextPage = nil, nil + + if f.PageCursor != nil { + // Page cursor exists so we need to validate it against used sort + // To cover the case when paging cursor is set but sorting is empty, we collect the sorting instructions + // from the cursor. + // This (extracted sorting info) is then returned as part of response + if f.Sort, err = f.PageCursor.Sort(f.Sort); err != nil { + return err + } + } + + // Make sure results are always sorted at least by primary keys + if f.Sort.Get("id") == nil { + f.Sort = append(f.Sort, &filter.SortExpr{ + Column: "id", + Descending: f.Sort.LastDescending(), + }) + } + + // Cloned sorting instructions for the actual sorting + // Original are passed to the fetchFullPageOfUsers fn used for cursor creation so it MUST keep the initial + // direction information + sort := f.Sort.Clone() + + // When cursor for a previous page is used it's marked as reversed + // This tells us to flip the descending flag on all used sort keys + if f.PageCursor != nil && f.PageCursor.ROrder { + sort.Reverse() + } + + // Apply sorting expr from filter to query + if q, err = setOrderBy(q, sort, s.sortableAutomationSessionColumns()); err != nil { + return err + } + + set, f.PrevPage, f.NextPage, err = s.fetchFullPageOfAutomationSessions( + ctx, + q, f.Sort, f.PageCursor, + f.Limit, + f.Check, + func(cur *filter.PagingCursor) squirrel.Sqlizer { + return builders.CursorCondition(cur, nil) + }, + ) + + if err != nil { + return err + } + + f.PageCursor = nil + return nil + }() +} + +// fetchFullPageOfAutomationSessions collects all requested results. +// +// Function applies: +// - cursor conditions (where ...) +// - limit +// +// Main responsibility of this function is to perform additional sequential queries in case when not enough results +// are collected due to failed check on a specific row (by check fn). +// +// Function then moves cursor to the last item fetched +func (s Store) fetchFullPageOfAutomationSessions( + ctx context.Context, + q squirrel.SelectBuilder, + sort filter.SortExprSet, + cursor *filter.PagingCursor, + reqItems uint, + check func(*types.Session) (bool, error), + cursorCond func(*filter.PagingCursor) squirrel.Sqlizer, +) (set []*types.Session, prev, next *filter.PagingCursor, err error) { + var ( + aux []*types.Session + + // When cursor for a previous page is used it's marked as reversed + // This tells us to flip the descending flag on all used sort keys + reversedOrder = cursor != nil && cursor.ROrder + + // copy of the select builder + tryQuery squirrel.SelectBuilder + + // Copy no. of required items to limit + // Limit will change when doing subsequent queries to fill + // the set with all required items + limit = reqItems + + // cursor to prev. page is only calculated when cursor is used + hasPrev = cursor != nil + + // next cursor is calculated when there are more pages to come + hasNext bool + ) + + set = make([]*types.Session, 0, DefaultSliceCapacity) + + for try := 0; try < MaxRefetches; try++ { + if cursor != nil { + tryQuery = q.Where(cursorCond(cursor)) + } else { + tryQuery = q + } + + if limit > 0 { + // fetching + 1 so we know if there are more items + // we can fetch (next-page cursor) + tryQuery = tryQuery.Limit(uint64(limit + 1)) + } + + if aux, err = s.QueryAutomationSessions(ctx, tryQuery, check); err != nil { + return nil, nil, nil, err + } + + if len(aux) == 0 { + // nothing fetched + break + } + + // append fetched items + set = append(set, aux...) + + if reqItems == 0 { + // no max requested items specified, break out + break + } + + collected := uint(len(set)) + + if reqItems > collected { + // not enough items fetched, try again with adjusted limit + limit = reqItems - collected + + if limit < MinEnsureFetchLimit { + // In case limit is set very low and we've missed records in the first fetch, + // make sure next fetch limit is a bit higher + limit = MinEnsureFetchLimit + } + + // Update cursor so that it points to the last item fetched + cursor = s.collectAutomationSessionCursorValues(set[collected-1], sort...) + + // Copy reverse flag from sorting + cursor.LThen = sort.Reversed() + continue + } + + if reqItems < collected { + set = set[:reqItems] + hasNext = true + } + + break + } + + collected := len(set) + + if collected == 0 { + return nil, nil, nil, nil + } + + if reversedOrder { + // Fetched set needs to be reversed because we've forced a descending order to get the previous page + for i, j := 0, collected-1; i < j; i, j = i+1, j-1 { + set[i], set[j] = set[j], set[i] + } + + // when in reverse-order rules on what cursor to return change + hasPrev, hasNext = hasNext, hasPrev + } + + if hasPrev { + prev = s.collectAutomationSessionCursorValues(set[0], sort...) + prev.ROrder = true + prev.LThen = !sort.Reversed() + } + + if hasNext { + next = s.collectAutomationSessionCursorValues(set[collected-1], sort...) + next.LThen = sort.Reversed() + } + + return set, prev, next, nil +} + +// QueryAutomationSessions queries the database, converts and checks each row and +// returns collected set +// +// Fn also returns total number of fetched items and last fetched item so that the caller can construct cursor +// for next page of results +func (s Store) QueryAutomationSessions( + ctx context.Context, + q squirrel.Sqlizer, + check func(*types.Session) (bool, error), +) ([]*types.Session, error) { + var ( + set = make([]*types.Session, 0, DefaultSliceCapacity) + res *types.Session + + // Query rows with + rows, err = s.Query(ctx, q) + ) + + if err != nil { + return nil, err + } + + defer rows.Close() + for rows.Next() { + if err = rows.Err(); err == nil { + res, err = s.internalAutomationSessionRowScanner(rows) + } + + if err != nil { + return nil, err + } + + // check fn set, call it and see if it passed the test + // if not, skip the item + if check != nil { + if chk, err := check(res); err != nil { + return nil, err + } else if !chk { + continue + } + } + + set = append(set, res) + } + + return set, rows.Err() +} + +// LookupAutomationSessionByID searches for session by ID +// +// It returns session even if deleted +func (s Store) LookupAutomationSessionByID(ctx context.Context, id uint64) (*types.Session, error) { + return s.execLookupAutomationSession(ctx, squirrel.Eq{ + s.preprocessColumn("atms.id", ""): store.PreprocessValue(id, ""), + }) +} + +// CreateAutomationSession creates one or more rows in automation_sessions table +func (s Store) CreateAutomationSession(ctx context.Context, rr ...*types.Session) (err error) { + for _, res := range rr { + err = s.checkAutomationSessionConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execCreateAutomationSessions(ctx, s.internalAutomationSessionEncoder(res)) + if err != nil { + return err + } + } + + return +} + +// UpdateAutomationSession updates one or more existing rows in automation_sessions +func (s Store) UpdateAutomationSession(ctx context.Context, rr ...*types.Session) error { + return s.partialAutomationSessionUpdate(ctx, nil, rr...) +} + +// partialAutomationSessionUpdate updates one or more existing rows in automation_sessions +func (s Store) partialAutomationSessionUpdate(ctx context.Context, onlyColumns []string, rr ...*types.Session) (err error) { + for _, res := range rr { + err = s.checkAutomationSessionConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execUpdateAutomationSessions( + ctx, + squirrel.Eq{ + s.preprocessColumn("atms.id", ""): store.PreprocessValue(res.ID, ""), + }, + s.internalAutomationSessionEncoder(res).Skip("id").Only(onlyColumns...)) + if err != nil { + return err + } + } + + return +} + +// UpsertAutomationSession updates one or more existing rows in automation_sessions +func (s Store) UpsertAutomationSession(ctx context.Context, rr ...*types.Session) (err error) { + for _, res := range rr { + err = s.checkAutomationSessionConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execUpsertAutomationSessions(ctx, s.internalAutomationSessionEncoder(res)) + if err != nil { + return err + } + } + + return nil +} + +// DeleteAutomationSession Deletes one or more rows from automation_sessions table +func (s Store) DeleteAutomationSession(ctx context.Context, rr ...*types.Session) (err error) { + for _, res := range rr { + + err = s.execDeleteAutomationSessions(ctx, squirrel.Eq{ + s.preprocessColumn("atms.id", ""): store.PreprocessValue(res.ID, ""), + }) + if err != nil { + return err + } + } + + return nil +} + +// DeleteAutomationSessionByID Deletes row from the automation_sessions table +func (s Store) DeleteAutomationSessionByID(ctx context.Context, ID uint64) error { + return s.execDeleteAutomationSessions(ctx, squirrel.Eq{ + s.preprocessColumn("atms.id", ""): store.PreprocessValue(ID, ""), + }) +} + +// TruncateAutomationSessions Deletes all rows from the automation_sessions table +func (s Store) TruncateAutomationSessions(ctx context.Context) error { + return s.Truncate(ctx, s.automationSessionTable()) +} + +// execLookupAutomationSession prepares AutomationSession query and executes it, +// returning types.Session (or error) +func (s Store) execLookupAutomationSession(ctx context.Context, cnd squirrel.Sqlizer) (res *types.Session, err error) { + var ( + row rowScanner + ) + + row, err = s.QueryRow(ctx, s.automationSessionsSelectBuilder().Where(cnd)) + if err != nil { + return + } + + res, err = s.internalAutomationSessionRowScanner(row) + if err != nil { + return + } + + return res, nil +} + +// execCreateAutomationSessions updates all matched (by cnd) rows in automation_sessions with given data +func (s Store) execCreateAutomationSessions(ctx context.Context, payload store.Payload) error { + return s.Exec(ctx, s.InsertBuilder(s.automationSessionTable()).SetMap(payload)) +} + +// execUpdateAutomationSessions updates all matched (by cnd) rows in automation_sessions with given data +func (s Store) execUpdateAutomationSessions(ctx context.Context, cnd squirrel.Sqlizer, set store.Payload) error { + return s.Exec(ctx, s.UpdateBuilder(s.automationSessionTable("atms")).Where(cnd).SetMap(set)) +} + +// execUpsertAutomationSessions inserts new or updates matching (by-primary-key) rows in automation_sessions with given data +func (s Store) execUpsertAutomationSessions(ctx context.Context, set store.Payload) error { + upsert, err := s.config.UpsertBuilder( + s.config, + s.automationSessionTable(), + set, + s.preprocessColumn("id", ""), + ) + + if err != nil { + return err + } + + return s.Exec(ctx, upsert) +} + +// execDeleteAutomationSessions Deletes all matched (by cnd) rows in automation_sessions with given data +func (s Store) execDeleteAutomationSessions(ctx context.Context, cnd squirrel.Sqlizer) error { + return s.Exec(ctx, s.DeleteBuilder(s.automationSessionTable("atms")).Where(cnd)) +} + +func (s Store) internalAutomationSessionRowScanner(row rowScanner) (res *types.Session, err error) { + res = &types.Session{} + + if _, has := s.config.RowScanners["automationSession"]; has { + scanner := s.config.RowScanners["automationSession"].(func(_ rowScanner, _ *types.Session) error) + err = scanner(row, res) + } else { + err = row.Scan( + &res.ID, + &res.WorkflowID, + &res.EventType, + &res.ResourceType, + &res.Status, + &res.Input, + &res.Output, + &res.Stacktrace, + &res.CreatedBy, + &res.CreatedAt, + &res.PurgeAt, + &res.CompletedAt, + &res.SuspendedAt, + &res.Error, + ) + } + + if err == sql.ErrNoRows { + return nil, store.ErrNotFound.Stack(1) + } + + if err != nil { + return nil, errors.Store("could not scan automationSession db row: %s", err).Wrap(err) + } else { + return res, nil + } +} + +// QueryAutomationSessions returns squirrel.SelectBuilder with set table and all columns +func (s Store) automationSessionsSelectBuilder() squirrel.SelectBuilder { + return s.SelectBuilder(s.automationSessionTable("atms"), s.automationSessionColumns("atms")...) +} + +// automationSessionTable name of the db table +func (Store) automationSessionTable(aa ...string) string { + var alias string + if len(aa) > 0 { + alias = " AS " + aa[0] + } + + return "automation_sessions" + alias +} + +// AutomationSessionColumns returns all defined table columns +// +// With optional string arg, all columns are returned aliased +func (Store) automationSessionColumns(aa ...string) []string { + var alias string + if len(aa) > 0 { + alias = aa[0] + "." + } + + return []string{ + alias + "id", + alias + "rel_workflow", + alias + "event_type", + alias + "resource_type", + alias + "status", + alias + "input", + alias + "output", + alias + "stacktrace", + alias + "created_by", + alias + "created_at", + alias + "purge_at", + alias + "completed_at", + alias + "suspended_at", + alias + "error", + } +} + +// {true true false true true true} + +// sortableAutomationSessionColumns returns all AutomationSession columns flagged as sortable +// +// With optional string arg, all columns are returned aliased +func (Store) sortableAutomationSessionColumns() map[string]string { + return map[string]string{ + "id": "id", + } +} + +// internalAutomationSessionEncoder encodes fields from types.Session to store.Payload (map) +// +// Encoding is done by using generic approach or by calling encodeAutomationSession +// func when rdbms.customEncoder=true +func (s Store) internalAutomationSessionEncoder(res *types.Session) store.Payload { + return store.Payload{ + "id": res.ID, + "rel_workflow": res.WorkflowID, + "event_type": res.EventType, + "resource_type": res.ResourceType, + "status": res.Status, + "input": res.Input, + "output": res.Output, + "stacktrace": res.Stacktrace, + "created_by": res.CreatedBy, + "created_at": res.CreatedAt, + "purge_at": res.PurgeAt, + "completed_at": res.CompletedAt, + "suspended_at": res.SuspendedAt, + "error": res.Error, + } +} + +// collectAutomationSessionCursorValues collects values from the given resource that and sets them to the cursor +// to be used for pagination +// +// Values that are collected must come from sortable, unique or primary columns/fields +// At least one of the collected columns must be flagged as unique, otherwise fn appends primary keys at the end +// +// Known issue: +// when collecting cursor values for query that sorts by unique column with partial index (ie: unique handle on +// undeleted items) +func (s Store) collectAutomationSessionCursorValues(res *types.Session, cc ...*filter.SortExpr) *filter.PagingCursor { + var ( + cursor = &filter.PagingCursor{LThen: filter.SortExprSet(cc).Reversed()} + + hasUnique bool + + // All known primary key columns + + pkId bool + + collect = func(cc ...*filter.SortExpr) { + for _, c := range cc { + switch c.Column { + case "id": + cursor.Set(c.Column, res.ID, c.Descending) + + pkId = true + + } + } + } + ) + + collect(cc...) + if !hasUnique || !(pkId && true) { + collect(&filter.SortExpr{Column: "id", Descending: false}) + } + + return cursor +} + +// checkAutomationSessionConstraints performs lookups (on valid) resource to check if any of the values on unique fields +// already exists in the store +// +// Using built-in constraint checking would be more performant but unfortunately we can not rely +// on the full support (MySQL does not support conditional indexes) +func (s *Store) checkAutomationSessionConstraints(ctx context.Context, res *types.Session) error { + // Consider resource valid when all fields in unique constraint check lookups + // have valid (non-empty) value + // + // Only string and uint64 are supported for now + // feel free to add additional types if needed + var valid = true + + if !valid { + return nil + } + + return nil +} diff --git a/store/rdbms/automation_sessions.go b/store/rdbms/automation_sessions.go new file mode 100644 index 000000000..1d82b001c --- /dev/null +++ b/store/rdbms/automation_sessions.go @@ -0,0 +1,32 @@ +package rdbms + +import ( + "github.com/Masterminds/squirrel" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/filter" +) + +func (s Store) convertAutomationSessionFilter(f types.SessionFilter) (query squirrel.SelectBuilder, err error) { + query = s.automationSessionsSelectBuilder() + + query = filter.StateCondition(query, "atms.suspended_at", f.Suspended) + query = filter.StateCondition(query, "atms.completed_at", f.Completed) + + if len(f.SessionID) > 0 { + query = query.Where(squirrel.Eq{"atms.id": f.SessionID}) + } + + if len(f.WorkflowID) > 0 { + query = query.Where(squirrel.Eq{"atms.id": f.WorkflowID}) + } + + if len(f.EventType) > 0 { + query = query.Where(squirrel.Eq{"atms.event_type": f.EventType}) + } + + if len(f.ResourceType) > 0 { + query = query.Where(squirrel.Eq{"atms.resource_type": f.ResourceType}) + } + + return +} diff --git a/store/rdbms/automation_triggers.gen.go b/store/rdbms/automation_triggers.gen.go new file mode 100644 index 000000000..c3c290a2b --- /dev/null +++ b/store/rdbms/automation_triggers.gen.go @@ -0,0 +1,602 @@ +package rdbms + +// This file is an auto-generated file +// +// Template: pkg/codegen/assets/store_rdbms.gen.go.tpl +// Definitions: store/automation_triggers.yaml +// +// Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated. + +import ( + "context" + "database/sql" + "github.com/Masterminds/squirrel" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/store/rdbms/builders" +) + +var _ = errors.Is + +// SearchAutomationTriggers returns all matching rows +// +// This function calls convertAutomationTriggerFilter with the given +// types.TriggerFilter and expects to receive a working squirrel.SelectBuilder +func (s Store) SearchAutomationTriggers(ctx context.Context, f types.TriggerFilter) (types.TriggerSet, types.TriggerFilter, error) { + var ( + err error + set []*types.Trigger + q squirrel.SelectBuilder + ) + + return set, f, func() error { + q, err = s.convertAutomationTriggerFilter(f) + if err != nil { + return err + } + + // Paging enabled + // {search: {enablePaging:true}} + // Cleanup unwanted cursor values (only relevant is f.PageCursor, next&prev are reset and returned) + f.PrevPage, f.NextPage = nil, nil + + if f.PageCursor != nil { + // Page cursor exists so we need to validate it against used sort + // To cover the case when paging cursor is set but sorting is empty, we collect the sorting instructions + // from the cursor. + // This (extracted sorting info) is then returned as part of response + if f.Sort, err = f.PageCursor.Sort(f.Sort); err != nil { + return err + } + } + + // Make sure results are always sorted at least by primary keys + if f.Sort.Get("id") == nil { + f.Sort = append(f.Sort, &filter.SortExpr{ + Column: "id", + Descending: f.Sort.LastDescending(), + }) + } + + // Cloned sorting instructions for the actual sorting + // Original are passed to the fetchFullPageOfUsers fn used for cursor creation so it MUST keep the initial + // direction information + sort := f.Sort.Clone() + + // When cursor for a previous page is used it's marked as reversed + // This tells us to flip the descending flag on all used sort keys + if f.PageCursor != nil && f.PageCursor.ROrder { + sort.Reverse() + } + + // Apply sorting expr from filter to query + if q, err = setOrderBy(q, sort, s.sortableAutomationTriggerColumns()); err != nil { + return err + } + + set, f.PrevPage, f.NextPage, err = s.fetchFullPageOfAutomationTriggers( + ctx, + q, f.Sort, f.PageCursor, + f.Limit, + f.Check, + func(cur *filter.PagingCursor) squirrel.Sqlizer { + return builders.CursorCondition(cur, nil) + }, + ) + + if err != nil { + return err + } + + f.PageCursor = nil + return nil + }() +} + +// fetchFullPageOfAutomationTriggers collects all requested results. +// +// Function applies: +// - cursor conditions (where ...) +// - limit +// +// Main responsibility of this function is to perform additional sequential queries in case when not enough results +// are collected due to failed check on a specific row (by check fn). +// +// Function then moves cursor to the last item fetched +func (s Store) fetchFullPageOfAutomationTriggers( + ctx context.Context, + q squirrel.SelectBuilder, + sort filter.SortExprSet, + cursor *filter.PagingCursor, + reqItems uint, + check func(*types.Trigger) (bool, error), + cursorCond func(*filter.PagingCursor) squirrel.Sqlizer, +) (set []*types.Trigger, prev, next *filter.PagingCursor, err error) { + var ( + aux []*types.Trigger + + // When cursor for a previous page is used it's marked as reversed + // This tells us to flip the descending flag on all used sort keys + reversedOrder = cursor != nil && cursor.ROrder + + // copy of the select builder + tryQuery squirrel.SelectBuilder + + // Copy no. of required items to limit + // Limit will change when doing subsequent queries to fill + // the set with all required items + limit = reqItems + + // cursor to prev. page is only calculated when cursor is used + hasPrev = cursor != nil + + // next cursor is calculated when there are more pages to come + hasNext bool + ) + + set = make([]*types.Trigger, 0, DefaultSliceCapacity) + + for try := 0; try < MaxRefetches; try++ { + if cursor != nil { + tryQuery = q.Where(cursorCond(cursor)) + } else { + tryQuery = q + } + + if limit > 0 { + // fetching + 1 so we know if there are more items + // we can fetch (next-page cursor) + tryQuery = tryQuery.Limit(uint64(limit + 1)) + } + + if aux, err = s.QueryAutomationTriggers(ctx, tryQuery, check); err != nil { + return nil, nil, nil, err + } + + if len(aux) == 0 { + // nothing fetched + break + } + + // append fetched items + set = append(set, aux...) + + if reqItems == 0 { + // no max requested items specified, break out + break + } + + collected := uint(len(set)) + + if reqItems > collected { + // not enough items fetched, try again with adjusted limit + limit = reqItems - collected + + if limit < MinEnsureFetchLimit { + // In case limit is set very low and we've missed records in the first fetch, + // make sure next fetch limit is a bit higher + limit = MinEnsureFetchLimit + } + + // Update cursor so that it points to the last item fetched + cursor = s.collectAutomationTriggerCursorValues(set[collected-1], sort...) + + // Copy reverse flag from sorting + cursor.LThen = sort.Reversed() + continue + } + + if reqItems < collected { + set = set[:reqItems] + hasNext = true + } + + break + } + + collected := len(set) + + if collected == 0 { + return nil, nil, nil, nil + } + + if reversedOrder { + // Fetched set needs to be reversed because we've forced a descending order to get the previous page + for i, j := 0, collected-1; i < j; i, j = i+1, j-1 { + set[i], set[j] = set[j], set[i] + } + + // when in reverse-order rules on what cursor to return change + hasPrev, hasNext = hasNext, hasPrev + } + + if hasPrev { + prev = s.collectAutomationTriggerCursorValues(set[0], sort...) + prev.ROrder = true + prev.LThen = !sort.Reversed() + } + + if hasNext { + next = s.collectAutomationTriggerCursorValues(set[collected-1], sort...) + next.LThen = sort.Reversed() + } + + return set, prev, next, nil +} + +// QueryAutomationTriggers queries the database, converts and checks each row and +// returns collected set +// +// Fn also returns total number of fetched items and last fetched item so that the caller can construct cursor +// for next page of results +func (s Store) QueryAutomationTriggers( + ctx context.Context, + q squirrel.Sqlizer, + check func(*types.Trigger) (bool, error), +) ([]*types.Trigger, error) { + var ( + set = make([]*types.Trigger, 0, DefaultSliceCapacity) + res *types.Trigger + + // Query rows with + rows, err = s.Query(ctx, q) + ) + + if err != nil { + return nil, err + } + + defer rows.Close() + for rows.Next() { + if err = rows.Err(); err == nil { + res, err = s.internalAutomationTriggerRowScanner(rows) + } + + if err != nil { + return nil, err + } + + // check fn set, call it and see if it passed the test + // if not, skip the item + if check != nil { + if chk, err := check(res); err != nil { + return nil, err + } else if !chk { + continue + } + } + + set = append(set, res) + } + + return set, rows.Err() +} + +// LookupAutomationTriggerByID searches for trigger by ID +// +// It returns trigger even if deleted +func (s Store) LookupAutomationTriggerByID(ctx context.Context, id uint64) (*types.Trigger, error) { + return s.execLookupAutomationTrigger(ctx, squirrel.Eq{ + s.preprocessColumn("atmt.id", ""): store.PreprocessValue(id, ""), + }) +} + +// CreateAutomationTrigger creates one or more rows in automation_triggers table +func (s Store) CreateAutomationTrigger(ctx context.Context, rr ...*types.Trigger) (err error) { + for _, res := range rr { + err = s.checkAutomationTriggerConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execCreateAutomationTriggers(ctx, s.internalAutomationTriggerEncoder(res)) + if err != nil { + return err + } + } + + return +} + +// UpdateAutomationTrigger updates one or more existing rows in automation_triggers +func (s Store) UpdateAutomationTrigger(ctx context.Context, rr ...*types.Trigger) error { + return s.partialAutomationTriggerUpdate(ctx, nil, rr...) +} + +// partialAutomationTriggerUpdate updates one or more existing rows in automation_triggers +func (s Store) partialAutomationTriggerUpdate(ctx context.Context, onlyColumns []string, rr ...*types.Trigger) (err error) { + for _, res := range rr { + err = s.checkAutomationTriggerConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execUpdateAutomationTriggers( + ctx, + squirrel.Eq{ + s.preprocessColumn("atmt.id", ""): store.PreprocessValue(res.ID, ""), + }, + s.internalAutomationTriggerEncoder(res).Skip("id").Only(onlyColumns...)) + if err != nil { + return err + } + } + + return +} + +// UpsertAutomationTrigger updates one or more existing rows in automation_triggers +func (s Store) UpsertAutomationTrigger(ctx context.Context, rr ...*types.Trigger) (err error) { + for _, res := range rr { + err = s.checkAutomationTriggerConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execUpsertAutomationTriggers(ctx, s.internalAutomationTriggerEncoder(res)) + if err != nil { + return err + } + } + + return nil +} + +// DeleteAutomationTrigger Deletes one or more rows from automation_triggers table +func (s Store) DeleteAutomationTrigger(ctx context.Context, rr ...*types.Trigger) (err error) { + for _, res := range rr { + + err = s.execDeleteAutomationTriggers(ctx, squirrel.Eq{ + s.preprocessColumn("atmt.id", ""): store.PreprocessValue(res.ID, ""), + }) + if err != nil { + return err + } + } + + return nil +} + +// DeleteAutomationTriggerByID Deletes row from the automation_triggers table +func (s Store) DeleteAutomationTriggerByID(ctx context.Context, ID uint64) error { + return s.execDeleteAutomationTriggers(ctx, squirrel.Eq{ + s.preprocessColumn("atmt.id", ""): store.PreprocessValue(ID, ""), + }) +} + +// TruncateAutomationTriggers Deletes all rows from the automation_triggers table +func (s Store) TruncateAutomationTriggers(ctx context.Context) error { + return s.Truncate(ctx, s.automationTriggerTable()) +} + +// execLookupAutomationTrigger prepares AutomationTrigger query and executes it, +// returning types.Trigger (or error) +func (s Store) execLookupAutomationTrigger(ctx context.Context, cnd squirrel.Sqlizer) (res *types.Trigger, err error) { + var ( + row rowScanner + ) + + row, err = s.QueryRow(ctx, s.automationTriggersSelectBuilder().Where(cnd)) + if err != nil { + return + } + + res, err = s.internalAutomationTriggerRowScanner(row) + if err != nil { + return + } + + return res, nil +} + +// execCreateAutomationTriggers updates all matched (by cnd) rows in automation_triggers with given data +func (s Store) execCreateAutomationTriggers(ctx context.Context, payload store.Payload) error { + return s.Exec(ctx, s.InsertBuilder(s.automationTriggerTable()).SetMap(payload)) +} + +// execUpdateAutomationTriggers updates all matched (by cnd) rows in automation_triggers with given data +func (s Store) execUpdateAutomationTriggers(ctx context.Context, cnd squirrel.Sqlizer, set store.Payload) error { + return s.Exec(ctx, s.UpdateBuilder(s.automationTriggerTable("atmt")).Where(cnd).SetMap(set)) +} + +// execUpsertAutomationTriggers inserts new or updates matching (by-primary-key) rows in automation_triggers with given data +func (s Store) execUpsertAutomationTriggers(ctx context.Context, set store.Payload) error { + upsert, err := s.config.UpsertBuilder( + s.config, + s.automationTriggerTable(), + set, + s.preprocessColumn("id", ""), + ) + + if err != nil { + return err + } + + return s.Exec(ctx, upsert) +} + +// execDeleteAutomationTriggers Deletes all matched (by cnd) rows in automation_triggers with given data +func (s Store) execDeleteAutomationTriggers(ctx context.Context, cnd squirrel.Sqlizer) error { + return s.Exec(ctx, s.DeleteBuilder(s.automationTriggerTable("atmt")).Where(cnd)) +} + +func (s Store) internalAutomationTriggerRowScanner(row rowScanner) (res *types.Trigger, err error) { + res = &types.Trigger{} + + if _, has := s.config.RowScanners["automationTrigger"]; has { + scanner := s.config.RowScanners["automationTrigger"].(func(_ rowScanner, _ *types.Trigger) error) + err = scanner(row, res) + } else { + err = row.Scan( + &res.ID, + &res.WorkflowID, + &res.StepID, + &res.Enabled, + &res.ResourceType, + &res.EventType, + &res.Meta, + &res.Constraints, + &res.Input, + &res.OwnedBy, + &res.CreatedBy, + &res.UpdatedBy, + &res.DeletedBy, + &res.CreatedAt, + &res.UpdatedAt, + &res.DeletedAt, + ) + } + + if err == sql.ErrNoRows { + return nil, store.ErrNotFound.Stack(1) + } + + if err != nil { + return nil, errors.Store("could not scan automationTrigger db row: %s", err).Wrap(err) + } else { + return res, nil + } +} + +// QueryAutomationTriggers returns squirrel.SelectBuilder with set table and all columns +func (s Store) automationTriggersSelectBuilder() squirrel.SelectBuilder { + return s.SelectBuilder(s.automationTriggerTable("atmt"), s.automationTriggerColumns("atmt")...) +} + +// automationTriggerTable name of the db table +func (Store) automationTriggerTable(aa ...string) string { + var alias string + if len(aa) > 0 { + alias = " AS " + aa[0] + } + + return "automation_triggers" + alias +} + +// AutomationTriggerColumns returns all defined table columns +// +// With optional string arg, all columns are returned aliased +func (Store) automationTriggerColumns(aa ...string) []string { + var alias string + if len(aa) > 0 { + alias = aa[0] + "." + } + + return []string{ + alias + "id", + alias + "rel_workflow", + alias + "rel_step", + alias + "enabled", + alias + "resource_type", + alias + "event_type", + alias + "meta", + alias + "constraints", + alias + "input", + alias + "owned_by", + alias + "created_by", + alias + "updated_by", + alias + "deleted_by", + alias + "created_at", + alias + "updated_at", + alias + "deleted_at", + } +} + +// {true true false true true true} + +// sortableAutomationTriggerColumns returns all AutomationTrigger columns flagged as sortable +// +// With optional string arg, all columns are returned aliased +func (Store) sortableAutomationTriggerColumns() map[string]string { + return map[string]string{ + "id": "id", + } +} + +// internalAutomationTriggerEncoder encodes fields from types.Trigger to store.Payload (map) +// +// Encoding is done by using generic approach or by calling encodeAutomationTrigger +// func when rdbms.customEncoder=true +func (s Store) internalAutomationTriggerEncoder(res *types.Trigger) store.Payload { + return store.Payload{ + "id": res.ID, + "rel_workflow": res.WorkflowID, + "rel_step": res.StepID, + "enabled": res.Enabled, + "resource_type": res.ResourceType, + "event_type": res.EventType, + "meta": res.Meta, + "constraints": res.Constraints, + "input": res.Input, + "owned_by": res.OwnedBy, + "created_by": res.CreatedBy, + "updated_by": res.UpdatedBy, + "deleted_by": res.DeletedBy, + "created_at": res.CreatedAt, + "updated_at": res.UpdatedAt, + "deleted_at": res.DeletedAt, + } +} + +// collectAutomationTriggerCursorValues collects values from the given resource that and sets them to the cursor +// to be used for pagination +// +// Values that are collected must come from sortable, unique or primary columns/fields +// At least one of the collected columns must be flagged as unique, otherwise fn appends primary keys at the end +// +// Known issue: +// when collecting cursor values for query that sorts by unique column with partial index (ie: unique handle on +// undeleted items) +func (s Store) collectAutomationTriggerCursorValues(res *types.Trigger, cc ...*filter.SortExpr) *filter.PagingCursor { + var ( + cursor = &filter.PagingCursor{LThen: filter.SortExprSet(cc).Reversed()} + + hasUnique bool + + // All known primary key columns + + pkId bool + + collect = func(cc ...*filter.SortExpr) { + for _, c := range cc { + switch c.Column { + case "id": + cursor.Set(c.Column, res.ID, c.Descending) + + pkId = true + + } + } + } + ) + + collect(cc...) + if !hasUnique || !(pkId && true) { + collect(&filter.SortExpr{Column: "id", Descending: false}) + } + + return cursor +} + +// checkAutomationTriggerConstraints performs lookups (on valid) resource to check if any of the values on unique fields +// already exists in the store +// +// Using built-in constraint checking would be more performant but unfortunately we can not rely +// on the full support (MySQL does not support conditional indexes) +func (s *Store) checkAutomationTriggerConstraints(ctx context.Context, res *types.Trigger) error { + // Consider resource valid when all fields in unique constraint check lookups + // have valid (non-empty) value + // + // Only string and uint64 are supported for now + // feel free to add additional types if needed + var valid = true + + if !valid { + return nil + } + + return nil +} diff --git a/store/rdbms/automation_triggers.go b/store/rdbms/automation_triggers.go new file mode 100644 index 000000000..46f717d40 --- /dev/null +++ b/store/rdbms/automation_triggers.go @@ -0,0 +1,36 @@ +package rdbms + +import ( + "github.com/Masterminds/squirrel" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/filter" +) + +func (s Store) convertAutomationTriggerFilter(f types.TriggerFilter) (query squirrel.SelectBuilder, err error) { + query = s.automationTriggersSelectBuilder() + + query = filter.StateCondition(query, "atmt.deleted_at", f.Deleted) + query = filter.StateConditionNegBool(query, "atmt.enabled", f.Disabled) + + if len(f.TriggerID) > 0 { + query = query.Where(squirrel.Eq{"atmt.id": f.TriggerID}) + } + + if len(f.WorkflowID) > 0 { + query = query.Where(squirrel.Eq{"atmt.rel_workflow": f.WorkflowID}) + } + + if len(f.LabeledIDs) > 0 { + query = query.Where(squirrel.Eq{"atmt.id": f.LabeledIDs}) + } + + if len(f.EventType) > 0 { + query = query.Where(squirrel.Eq{"atmt.event_type": f.EventType}) + } + + if len(f.ResourceType) > 0 { + query = query.Where(squirrel.Eq{"atmt.resource_type": f.ResourceType}) + } + + return +} diff --git a/store/rdbms/automation_workflows.gen.go b/store/rdbms/automation_workflows.gen.go new file mode 100644 index 000000000..05b85c9aa --- /dev/null +++ b/store/rdbms/automation_workflows.gen.go @@ -0,0 +1,630 @@ +package rdbms + +// This file is an auto-generated file +// +// Template: pkg/codegen/assets/store_rdbms.gen.go.tpl +// Definitions: store/automation_workflows.yaml +// +// Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated. + +import ( + "context" + "database/sql" + "github.com/Masterminds/squirrel" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/store/rdbms/builders" +) + +var _ = errors.Is + +// SearchAutomationWorkflows returns all matching rows +// +// This function calls convertAutomationWorkflowFilter with the given +// types.WorkflowFilter and expects to receive a working squirrel.SelectBuilder +func (s Store) SearchAutomationWorkflows(ctx context.Context, f types.WorkflowFilter) (types.WorkflowSet, types.WorkflowFilter, error) { + var ( + err error + set []*types.Workflow + q squirrel.SelectBuilder + ) + + return set, f, func() error { + q, err = s.convertAutomationWorkflowFilter(f) + if err != nil { + return err + } + + // Paging enabled + // {search: {enablePaging:true}} + // Cleanup unwanted cursor values (only relevant is f.PageCursor, next&prev are reset and returned) + f.PrevPage, f.NextPage = nil, nil + + if f.PageCursor != nil { + // Page cursor exists so we need to validate it against used sort + // To cover the case when paging cursor is set but sorting is empty, we collect the sorting instructions + // from the cursor. + // This (extracted sorting info) is then returned as part of response + if f.Sort, err = f.PageCursor.Sort(f.Sort); err != nil { + return err + } + } + + // Make sure results are always sorted at least by primary keys + if f.Sort.Get("id") == nil { + f.Sort = append(f.Sort, &filter.SortExpr{ + Column: "id", + Descending: f.Sort.LastDescending(), + }) + } + + // Cloned sorting instructions for the actual sorting + // Original are passed to the fetchFullPageOfUsers fn used for cursor creation so it MUST keep the initial + // direction information + sort := f.Sort.Clone() + + // When cursor for a previous page is used it's marked as reversed + // This tells us to flip the descending flag on all used sort keys + if f.PageCursor != nil && f.PageCursor.ROrder { + sort.Reverse() + } + + // Apply sorting expr from filter to query + if q, err = setOrderBy(q, sort, s.sortableAutomationWorkflowColumns()); err != nil { + return err + } + + set, f.PrevPage, f.NextPage, err = s.fetchFullPageOfAutomationWorkflows( + ctx, + q, f.Sort, f.PageCursor, + f.Limit, + f.Check, + func(cur *filter.PagingCursor) squirrel.Sqlizer { + return builders.CursorCondition(cur, nil) + }, + ) + + if err != nil { + return err + } + + f.PageCursor = nil + return nil + }() +} + +// fetchFullPageOfAutomationWorkflows collects all requested results. +// +// Function applies: +// - cursor conditions (where ...) +// - limit +// +// Main responsibility of this function is to perform additional sequential queries in case when not enough results +// are collected due to failed check on a specific row (by check fn). +// +// Function then moves cursor to the last item fetched +func (s Store) fetchFullPageOfAutomationWorkflows( + ctx context.Context, + q squirrel.SelectBuilder, + sort filter.SortExprSet, + cursor *filter.PagingCursor, + reqItems uint, + check func(*types.Workflow) (bool, error), + cursorCond func(*filter.PagingCursor) squirrel.Sqlizer, +) (set []*types.Workflow, prev, next *filter.PagingCursor, err error) { + var ( + aux []*types.Workflow + + // When cursor for a previous page is used it's marked as reversed + // This tells us to flip the descending flag on all used sort keys + reversedOrder = cursor != nil && cursor.ROrder + + // copy of the select builder + tryQuery squirrel.SelectBuilder + + // Copy no. of required items to limit + // Limit will change when doing subsequent queries to fill + // the set with all required items + limit = reqItems + + // cursor to prev. page is only calculated when cursor is used + hasPrev = cursor != nil + + // next cursor is calculated when there are more pages to come + hasNext bool + ) + + set = make([]*types.Workflow, 0, DefaultSliceCapacity) + + for try := 0; try < MaxRefetches; try++ { + if cursor != nil { + tryQuery = q.Where(cursorCond(cursor)) + } else { + tryQuery = q + } + + if limit > 0 { + // fetching + 1 so we know if there are more items + // we can fetch (next-page cursor) + tryQuery = tryQuery.Limit(uint64(limit + 1)) + } + + if aux, err = s.QueryAutomationWorkflows(ctx, tryQuery, check); err != nil { + return nil, nil, nil, err + } + + if len(aux) == 0 { + // nothing fetched + break + } + + // append fetched items + set = append(set, aux...) + + if reqItems == 0 { + // no max requested items specified, break out + break + } + + collected := uint(len(set)) + + if reqItems > collected { + // not enough items fetched, try again with adjusted limit + limit = reqItems - collected + + if limit < MinEnsureFetchLimit { + // In case limit is set very low and we've missed records in the first fetch, + // make sure next fetch limit is a bit higher + limit = MinEnsureFetchLimit + } + + // Update cursor so that it points to the last item fetched + cursor = s.collectAutomationWorkflowCursorValues(set[collected-1], sort...) + + // Copy reverse flag from sorting + cursor.LThen = sort.Reversed() + continue + } + + if reqItems < collected { + set = set[:reqItems] + hasNext = true + } + + break + } + + collected := len(set) + + if collected == 0 { + return nil, nil, nil, nil + } + + if reversedOrder { + // Fetched set needs to be reversed because we've forced a descending order to get the previous page + for i, j := 0, collected-1; i < j; i, j = i+1, j-1 { + set[i], set[j] = set[j], set[i] + } + + // when in reverse-order rules on what cursor to return change + hasPrev, hasNext = hasNext, hasPrev + } + + if hasPrev { + prev = s.collectAutomationWorkflowCursorValues(set[0], sort...) + prev.ROrder = true + prev.LThen = !sort.Reversed() + } + + if hasNext { + next = s.collectAutomationWorkflowCursorValues(set[collected-1], sort...) + next.LThen = sort.Reversed() + } + + return set, prev, next, nil +} + +// QueryAutomationWorkflows queries the database, converts and checks each row and +// returns collected set +// +// Fn also returns total number of fetched items and last fetched item so that the caller can construct cursor +// for next page of results +func (s Store) QueryAutomationWorkflows( + ctx context.Context, + q squirrel.Sqlizer, + check func(*types.Workflow) (bool, error), +) ([]*types.Workflow, error) { + var ( + set = make([]*types.Workflow, 0, DefaultSliceCapacity) + res *types.Workflow + + // Query rows with + rows, err = s.Query(ctx, q) + ) + + if err != nil { + return nil, err + } + + defer rows.Close() + for rows.Next() { + if err = rows.Err(); err == nil { + res, err = s.internalAutomationWorkflowRowScanner(rows) + } + + if err != nil { + return nil, err + } + + // check fn set, call it and see if it passed the test + // if not, skip the item + if check != nil { + if chk, err := check(res); err != nil { + return nil, err + } else if !chk { + continue + } + } + + set = append(set, res) + } + + return set, rows.Err() +} + +// LookupAutomationWorkflowByID searches for workflow by ID +// +// It returns workflow even if deleted +func (s Store) LookupAutomationWorkflowByID(ctx context.Context, id uint64) (*types.Workflow, error) { + return s.execLookupAutomationWorkflow(ctx, squirrel.Eq{ + s.preprocessColumn("atmwf.id", ""): store.PreprocessValue(id, ""), + }) +} + +// LookupAutomationWorkflowByHandle searches for workflow by their handle +// +// It returns only valid workflows +func (s Store) LookupAutomationWorkflowByHandle(ctx context.Context, handle string) (*types.Workflow, error) { + return s.execLookupAutomationWorkflow(ctx, squirrel.Eq{ + s.preprocessColumn("atmwf.handle", "lower"): store.PreprocessValue(handle, "lower"), + + "atmwf.deleted_at": nil, + }) +} + +// CreateAutomationWorkflow creates one or more rows in automation_workflows table +func (s Store) CreateAutomationWorkflow(ctx context.Context, rr ...*types.Workflow) (err error) { + for _, res := range rr { + err = s.checkAutomationWorkflowConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execCreateAutomationWorkflows(ctx, s.internalAutomationWorkflowEncoder(res)) + if err != nil { + return err + } + } + + return +} + +// UpdateAutomationWorkflow updates one or more existing rows in automation_workflows +func (s Store) UpdateAutomationWorkflow(ctx context.Context, rr ...*types.Workflow) error { + return s.partialAutomationWorkflowUpdate(ctx, nil, rr...) +} + +// partialAutomationWorkflowUpdate updates one or more existing rows in automation_workflows +func (s Store) partialAutomationWorkflowUpdate(ctx context.Context, onlyColumns []string, rr ...*types.Workflow) (err error) { + for _, res := range rr { + err = s.checkAutomationWorkflowConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execUpdateAutomationWorkflows( + ctx, + squirrel.Eq{ + s.preprocessColumn("atmwf.id", ""): store.PreprocessValue(res.ID, ""), + }, + s.internalAutomationWorkflowEncoder(res).Skip("id").Only(onlyColumns...)) + if err != nil { + return err + } + } + + return +} + +// UpsertAutomationWorkflow updates one or more existing rows in automation_workflows +func (s Store) UpsertAutomationWorkflow(ctx context.Context, rr ...*types.Workflow) (err error) { + for _, res := range rr { + err = s.checkAutomationWorkflowConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execUpsertAutomationWorkflows(ctx, s.internalAutomationWorkflowEncoder(res)) + if err != nil { + return err + } + } + + return nil +} + +// DeleteAutomationWorkflow Deletes one or more rows from automation_workflows table +func (s Store) DeleteAutomationWorkflow(ctx context.Context, rr ...*types.Workflow) (err error) { + for _, res := range rr { + + err = s.execDeleteAutomationWorkflows(ctx, squirrel.Eq{ + s.preprocessColumn("atmwf.id", ""): store.PreprocessValue(res.ID, ""), + }) + if err != nil { + return err + } + } + + return nil +} + +// DeleteAutomationWorkflowByID Deletes row from the automation_workflows table +func (s Store) DeleteAutomationWorkflowByID(ctx context.Context, ID uint64) error { + return s.execDeleteAutomationWorkflows(ctx, squirrel.Eq{ + s.preprocessColumn("atmwf.id", ""): store.PreprocessValue(ID, ""), + }) +} + +// TruncateAutomationWorkflows Deletes all rows from the automation_workflows table +func (s Store) TruncateAutomationWorkflows(ctx context.Context) error { + return s.Truncate(ctx, s.automationWorkflowTable()) +} + +// execLookupAutomationWorkflow prepares AutomationWorkflow query and executes it, +// returning types.Workflow (or error) +func (s Store) execLookupAutomationWorkflow(ctx context.Context, cnd squirrel.Sqlizer) (res *types.Workflow, err error) { + var ( + row rowScanner + ) + + row, err = s.QueryRow(ctx, s.automationWorkflowsSelectBuilder().Where(cnd)) + if err != nil { + return + } + + res, err = s.internalAutomationWorkflowRowScanner(row) + if err != nil { + return + } + + return res, nil +} + +// execCreateAutomationWorkflows updates all matched (by cnd) rows in automation_workflows with given data +func (s Store) execCreateAutomationWorkflows(ctx context.Context, payload store.Payload) error { + return s.Exec(ctx, s.InsertBuilder(s.automationWorkflowTable()).SetMap(payload)) +} + +// execUpdateAutomationWorkflows updates all matched (by cnd) rows in automation_workflows with given data +func (s Store) execUpdateAutomationWorkflows(ctx context.Context, cnd squirrel.Sqlizer, set store.Payload) error { + return s.Exec(ctx, s.UpdateBuilder(s.automationWorkflowTable("atmwf")).Where(cnd).SetMap(set)) +} + +// execUpsertAutomationWorkflows inserts new or updates matching (by-primary-key) rows in automation_workflows with given data +func (s Store) execUpsertAutomationWorkflows(ctx context.Context, set store.Payload) error { + upsert, err := s.config.UpsertBuilder( + s.config, + s.automationWorkflowTable(), + set, + s.preprocessColumn("id", ""), + ) + + if err != nil { + return err + } + + return s.Exec(ctx, upsert) +} + +// execDeleteAutomationWorkflows Deletes all matched (by cnd) rows in automation_workflows with given data +func (s Store) execDeleteAutomationWorkflows(ctx context.Context, cnd squirrel.Sqlizer) error { + return s.Exec(ctx, s.DeleteBuilder(s.automationWorkflowTable("atmwf")).Where(cnd)) +} + +func (s Store) internalAutomationWorkflowRowScanner(row rowScanner) (res *types.Workflow, err error) { + res = &types.Workflow{} + + if _, has := s.config.RowScanners["automationWorkflow"]; has { + scanner := s.config.RowScanners["automationWorkflow"].(func(_ rowScanner, _ *types.Workflow) error) + err = scanner(row, res) + } else { + err = row.Scan( + &res.ID, + &res.Handle, + &res.Meta, + &res.Enabled, + &res.Trace, + &res.KeepSessions, + &res.Scope, + &res.Steps, + &res.Paths, + &res.RunAs, + &res.OwnedBy, + &res.CreatedBy, + &res.UpdatedBy, + &res.DeletedBy, + &res.CreatedAt, + &res.UpdatedAt, + &res.DeletedAt, + ) + } + + if err == sql.ErrNoRows { + return nil, store.ErrNotFound.Stack(1) + } + + if err != nil { + return nil, errors.Store("could not scan automationWorkflow db row: %s", err).Wrap(err) + } else { + return res, nil + } +} + +// QueryAutomationWorkflows returns squirrel.SelectBuilder with set table and all columns +func (s Store) automationWorkflowsSelectBuilder() squirrel.SelectBuilder { + return s.SelectBuilder(s.automationWorkflowTable("atmwf"), s.automationWorkflowColumns("atmwf")...) +} + +// automationWorkflowTable name of the db table +func (Store) automationWorkflowTable(aa ...string) string { + var alias string + if len(aa) > 0 { + alias = " AS " + aa[0] + } + + return "automation_workflows" + alias +} + +// AutomationWorkflowColumns returns all defined table columns +// +// With optional string arg, all columns are returned aliased +func (Store) automationWorkflowColumns(aa ...string) []string { + var alias string + if len(aa) > 0 { + alias = aa[0] + "." + } + + return []string{ + alias + "id", + alias + "handle", + alias + "meta", + alias + "enabled", + alias + "trace", + alias + "keep_sessions", + alias + "scope", + alias + "steps", + alias + "paths", + alias + "run_as", + alias + "owned_by", + alias + "created_by", + alias + "updated_by", + alias + "deleted_by", + alias + "created_at", + alias + "updated_at", + alias + "deleted_at", + } +} + +// {true true false true true true} + +// sortableAutomationWorkflowColumns returns all AutomationWorkflow columns flagged as sortable +// +// With optional string arg, all columns are returned aliased +func (Store) sortableAutomationWorkflowColumns() map[string]string { + return map[string]string{ + "id": "id", + } +} + +// internalAutomationWorkflowEncoder encodes fields from types.Workflow to store.Payload (map) +// +// Encoding is done by using generic approach or by calling encodeAutomationWorkflow +// func when rdbms.customEncoder=true +func (s Store) internalAutomationWorkflowEncoder(res *types.Workflow) store.Payload { + return store.Payload{ + "id": res.ID, + "handle": res.Handle, + "meta": res.Meta, + "enabled": res.Enabled, + "trace": res.Trace, + "keep_sessions": res.KeepSessions, + "scope": res.Scope, + "steps": res.Steps, + "paths": res.Paths, + "run_as": res.RunAs, + "owned_by": res.OwnedBy, + "created_by": res.CreatedBy, + "updated_by": res.UpdatedBy, + "deleted_by": res.DeletedBy, + "created_at": res.CreatedAt, + "updated_at": res.UpdatedAt, + "deleted_at": res.DeletedAt, + } +} + +// collectAutomationWorkflowCursorValues collects values from the given resource that and sets them to the cursor +// to be used for pagination +// +// Values that are collected must come from sortable, unique or primary columns/fields +// At least one of the collected columns must be flagged as unique, otherwise fn appends primary keys at the end +// +// Known issue: +// when collecting cursor values for query that sorts by unique column with partial index (ie: unique handle on +// undeleted items) +func (s Store) collectAutomationWorkflowCursorValues(res *types.Workflow, cc ...*filter.SortExpr) *filter.PagingCursor { + var ( + cursor = &filter.PagingCursor{LThen: filter.SortExprSet(cc).Reversed()} + + hasUnique bool + + // All known primary key columns + + pkId bool + + collect = func(cc ...*filter.SortExpr) { + for _, c := range cc { + switch c.Column { + case "id": + cursor.Set(c.Column, res.ID, c.Descending) + + pkId = true + case "handle": + cursor.Set(c.Column, res.Handle, c.Descending) + hasUnique = true + + } + } + } + ) + + collect(cc...) + if !hasUnique || !(pkId && true) { + collect(&filter.SortExpr{Column: "id", Descending: false}) + } + + return cursor +} + +// checkAutomationWorkflowConstraints performs lookups (on valid) resource to check if any of the values on unique fields +// already exists in the store +// +// Using built-in constraint checking would be more performant but unfortunately we can not rely +// on the full support (MySQL does not support conditional indexes) +func (s *Store) checkAutomationWorkflowConstraints(ctx context.Context, res *types.Workflow) error { + // Consider resource valid when all fields in unique constraint check lookups + // have valid (non-empty) value + // + // Only string and uint64 are supported for now + // feel free to add additional types if needed + var valid = true + + valid = valid && len(res.Handle) > 0 + + if !valid { + return nil + } + + { + ex, err := s.LookupAutomationWorkflowByHandle(ctx, res.Handle) + if err == nil && ex != nil && ex.ID != res.ID { + return store.ErrNotUnique.Stack(1) + } else if !errors.IsNotFound(err) { + return err + } + } + + return nil +} diff --git a/store/rdbms/automation_workflows.go b/store/rdbms/automation_workflows.go new file mode 100644 index 000000000..c9d7e6268 --- /dev/null +++ b/store/rdbms/automation_workflows.go @@ -0,0 +1,31 @@ +package rdbms + +import ( + "github.com/Masterminds/squirrel" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/filter" +) + +func (s Store) convertAutomationWorkflowFilter(f types.WorkflowFilter) (query squirrel.SelectBuilder, err error) { + query = s.automationWorkflowsSelectBuilder() + + query = filter.StateCondition(query, "atmwf.deleted_at", f.Deleted) + query = filter.StateConditionNegBool(query, "atmwf.enabled", f.Disabled) + + if len(f.WorkflowID) > 0 { + query = query.Where(squirrel.Eq{"atmwf.id": f.WorkflowID}) + } + + if len(f.LabeledIDs) > 0 { + query = query.Where(squirrel.Eq{"atmwf.id": f.LabeledIDs}) + } + + if f.Query != "" { + qs := f.Query + "%" + query = query.Where(squirrel.Or{ + squirrel.Like{"atmwf.handle": qs}, + }) + } + + return +} diff --git a/store/rdbms/rdbms_schema.go b/store/rdbms/rdbms_schema.go index a933da1d0..04817387b 100644 --- a/store/rdbms/rdbms_schema.go +++ b/store/rdbms/rdbms_schema.go @@ -617,6 +617,7 @@ func (Schema) AutomationTriggers() *Table { ColumnDef("rel_workflow", ColumnTypeIdentifier), ColumnDef("rel_step", ColumnTypeIdentifier), ColumnDef("enabled", ColumnTypeBoolean), + ColumnDef("meta", ColumnTypeJson), ColumnDef("resource_type", ColumnTypeText, ColumnTypeLength(handleLength)), ColumnDef("event_type", ColumnTypeText, ColumnTypeLength(handleLength)), ColumnDef("constraints", ColumnTypeJson), diff --git a/store/rdbms/templates.gen.go b/store/rdbms/templates.gen.go index 786566464..08c864f09 100644 --- a/store/rdbms/templates.gen.go +++ b/store/rdbms/templates.gen.go @@ -462,7 +462,7 @@ func (s Store) internalTemplateRowScanner(row rowScanner) (res *types.Template, } if err != nil { - return nil, errors.Store("could not scan template db row").Wrap(err) + return nil, errors.Store("could not scan template db row: %s", err).Wrap(err) } else { return res, nil } diff --git a/store/tests/gen_test.go b/store/tests/gen_test.go index aa974210b..2821edda3 100644 --- a/store/tests/gen_test.go +++ b/store/tests/gen_test.go @@ -11,6 +11,9 @@ package tests // - store/auth_confirmed_clients.yaml // - store/auth_oa2tokens.yaml // - store/auth_sessions.yaml +// - store/automation_sessions.yaml +// - store/automation_triggers.yaml +// - store/automation_workflows.yaml // - store/compose_attachments.yaml // - store/compose_charts.yaml // - store/compose_module_fields.yaml @@ -79,6 +82,21 @@ func testAllGenerated(t *testing.T, s store.Storer) { testAuthSessions(t, s) }) + // Run generated tests for AutomationSessions + t.Run("AutomationSessions", func(t *testing.T) { + testAutomationSessions(t, s) + }) + + // Run generated tests for AutomationTriggers + t.Run("AutomationTriggers", func(t *testing.T) { + testAutomationTriggers(t, s) + }) + + // Run generated tests for AutomationWorkflows + t.Run("AutomationWorkflows", func(t *testing.T) { + testAutomationWorkflows(t, s) + }) + // Run generated tests for ComposeAttachments t.Run("ComposeAttachments", func(t *testing.T) { testComposeAttachments(t, s) diff --git a/tests/automation/main_test.go b/tests/automation/main_test.go new file mode 100644 index 000000000..f49d86d15 --- /dev/null +++ b/tests/automation/main_test.go @@ -0,0 +1,174 @@ +package automation + +import ( + "context" + "errors" + "github.com/cortezaproject/corteza-server/app" + "github.com/cortezaproject/corteza-server/automation/rest" + "github.com/cortezaproject/corteza-server/automation/service" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/api/server" + "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/cli" + "github.com/cortezaproject/corteza-server/pkg/eventbus" + "github.com/cortezaproject/corteza-server/pkg/id" + "github.com/cortezaproject/corteza-server/pkg/label" + ltype "github.com/cortezaproject/corteza-server/pkg/label/types" + "github.com/cortezaproject/corteza-server/pkg/logger" + "github.com/cortezaproject/corteza-server/pkg/rand" + "github.com/cortezaproject/corteza-server/pkg/rbac" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/store/sqlite3" + sysTypes "github.com/cortezaproject/corteza-server/system/types" + "github.com/cortezaproject/corteza-server/tests/helpers" + "github.com/go-chi/chi" + _ "github.com/joho/godotenv/autoload" + "github.com/steinfletcher/apitest" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "os" + "testing" +) + +type ( + helper struct { + t *testing.T + a *require.Assertions + + cUser *sysTypes.User + roleID uint64 + } +) + +var ( + testApp *app.CortezaApp + r chi.Router + + eventBus = eventbus.New() +) + +func init() { + helpers.RecursiveDotEnvLoad() +} + +// random string, 10 chars long by default +func rs(a ...int) string { + var l = 10 + if len(a) > 0 { + l = a[0] + } + + return string(rand.Bytes(l)) +} + +func InitTestApp() { + if testApp == nil { + ctx := cli.Context() + + testApp = helpers.NewIntegrationTestApp(ctx, func(app *app.CortezaApp) (err error) { + rbac.SetGlobal(rbac.NewTestService(zap.NewNop(), app.Store)) + + service.DefaultStore, err = sqlite3.ConnectInMemory(ctx) + if err != nil { + return err + } + + eventbus.Set(eventBus) + return nil + }) + } + + if r == nil { + r = chi.NewRouter() + r.Use(server.BaseMiddleware(false, logger.Default())...) + helpers.BindAuthMiddleware(r) + rest.MountRoutes(r) + } +} + +func TestMain(m *testing.M) { + InitTestApp() + os.Exit(m.Run()) +} + +func newHelper(t *testing.T) helper { + h := helper{ + t: t, + a: require.New(t), + roleID: id.Next(), + cUser: &sysTypes.User{ + ID: id.Next(), + }, + } + + h.cUser.SetRoles([]uint64{h.roleID}) + + rbac.Global().(*rbac.TestService).ClearGrants() + h.mockPermissionsWithAccess() + + return h +} + +// Returns context w/ security details +func (h helper) secCtx() context.Context { + return auth.SetIdentityToContext(context.Background(), h.cUser) +} + +// apitest basics, initialize, set handler, add auth +func (h helper) apiInit() *apitest.APITest { + InitTestApp() + + return apitest. + New(). + Handler(r). + Intercept(helpers.ReqHeaderAuthBearer(h.cUser)) + +} + +func (h helper) mockPermissions(rules ...*rbac.Rule) { + h.a.NoError(rbac.Global().Grant( + // TestService we use does not have any backend storage, + context.Background(), + // We want to make sure we did not make a mistake with any of the mocked resources or actions + service.DefaultAccessControl.Whitelist(), + rules..., + )) +} + +// Prepends allow access rule for messaging service for everyone +func (h helper) mockPermissionsWithAccess(rules ...*rbac.Rule) { + rules = append( + rules, + rbac.AllowRule(rbac.EveryoneRoleID, types.AutomationRBACResource, "access"), + ) + + h.mockPermissions(rules...) +} + +// Set allow permision for test role +func (h helper) allow(r rbac.Resource, o rbac.Operation) { + h.mockPermissions(rbac.AllowRule(h.roleID, r, o)) +} + +// set deny permission for test role +func (h helper) deny(r rbac.Resource, o rbac.Operation) { + h.mockPermissions(rbac.DenyRule(h.roleID, r, o)) +} + +// Unwraps error before it passes it to the tester +func (h helper) noError(err error) { + for errors.Unwrap(err) != nil { + err = errors.Unwrap(err) + } + + h.a.NoError(err) +} + +func (h helper) setLabel(res label.LabeledResource, name, value string) { + h.a.NoError(store.UpsertLabel(h.secCtx(), service.DefaultStore, <ype.Label{ + Kind: res.LabelResourceKind(), + ResourceID: res.LabelResourceID(), + Name: name, + Value: value, + })) +} diff --git a/tests/automation/permissions_delete_test.go b/tests/automation/permissions_delete_test.go new file mode 100644 index 000000000..af14abcd1 --- /dev/null +++ b/tests/automation/permissions_delete_test.go @@ -0,0 +1,45 @@ +package automation + +import ( + "fmt" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/rbac" + "github.com/cortezaproject/corteza-server/tests/helpers" + "net/http" + "testing" +) + +func TestPermissionsDelete(t *testing.T) { + h := newHelper(t) + p := rbac.Global() + + // Make sure our user can grant + h.allow(types.AutomationRBACResource, "grant") + + // New role. + permDelRole := h.roleID + 1 + + h.a.Len(rbac.Global().FindRulesByRoleID(permDelRole), 0) + + // Setup a few fake rules for new roke + h.mockPermissions( + rbac.AllowRule(permDelRole, types.AutomationRBACResource, "access"), + rbac.DenyRule(permDelRole, types.AutomationRBACResource, "workflow.create"), + ) + + h.a.Len(p.FindRulesByRoleID(permDelRole), 2) + + h.apiInit(). + Delete(fmt.Sprintf("/permissions/%d/rules", permDelRole)). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + // Make sure everything is deleted + rr, _ := p.FindRulesByRoleID(permDelRole).Filter(func(r *rbac.Rule) (b bool, e error) { + return r.Access != rbac.Inherit, nil + }) + + h.a.Empty(rr) +} diff --git a/tests/automation/permissions_effective_test.go b/tests/automation/permissions_effective_test.go new file mode 100644 index 000000000..df39de400 --- /dev/null +++ b/tests/automation/permissions_effective_test.go @@ -0,0 +1,21 @@ +package automation + +import ( + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/tests/helpers" + "net/http" + "testing" +) + +func TestPermissionsEffective(t *testing.T) { + h := newHelper(t) + h.allow(types.AutomationRBACResource, "access") + h.deny(types.AutomationRBACResource, "workflow.create") + + h.apiInit(). + Get("/permissions/effective"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() +} diff --git a/tests/automation/permissions_list_test.go b/tests/automation/permissions_list_test.go new file mode 100644 index 000000000..a68333d4b --- /dev/null +++ b/tests/automation/permissions_list_test.go @@ -0,0 +1,20 @@ +package automation + +import ( + "github.com/cortezaproject/corteza-server/tests/helpers" + "github.com/steinfletcher/apitest-jsonpath" + "net/http" + "testing" +) + +func TestPermissionsList(t *testing.T) { + h := newHelper(t) + + h.apiInit(). + Get("/permissions/"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Present(`$.response[? @.resource=="automation"]`)). + End() +} diff --git a/tests/automation/permissions_read_test.go b/tests/automation/permissions_read_test.go new file mode 100644 index 000000000..63140abd1 --- /dev/null +++ b/tests/automation/permissions_read_test.go @@ -0,0 +1,23 @@ +package automation + +import ( + "fmt" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/tests/helpers" + "net/http" + "testing" +) + +func TestPermissionsRead(t *testing.T) { + h := newHelper(t) + h.allow(types.AutomationRBACResource, "access") + h.allow(types.AutomationRBACResource, "grant") + h.deny(types.AutomationRBACResource, "workflow.create") + + h.apiInit(). + Get(fmt.Sprintf("/permissions/%d/rules", h.roleID)). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() +} diff --git a/tests/automation/permissions_update_test.go b/tests/automation/permissions_update_test.go new file mode 100644 index 000000000..b427d84f1 --- /dev/null +++ b/tests/automation/permissions_update_test.go @@ -0,0 +1,21 @@ +package automation + +import ( + "fmt" + "github.com/cortezaproject/corteza-server/tests/helpers" + "net/http" + "testing" +) + +func TestPermissionsUpdate(t *testing.T) { + h := newHelper(t) + h.allow("automation", "grant") + + h.apiInit(). + Patch(fmt.Sprintf("/permissions/%d/rules", h.roleID)). + JSON(`{"rules":[{"resource":"automation","operation":"workflow.create","access":"allow"}]}`). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() +} diff --git a/tests/automation/provision_test.go b/tests/automation/provision_test.go new file mode 100644 index 000000000..d666a61a8 --- /dev/null +++ b/tests/automation/provision_test.go @@ -0,0 +1,15 @@ +package automation + +import ( + "testing" +) + +func TestProvisioning(t *testing.T) { + t.SkipNow() + //h := newHelper(t) + //ctx := auth.SetSuperUserContext(h.secCtx()) + // + //readers, err := impAux.ReadStatic(provision.Asset) + //h.noError(err) + //h.noError(importer.Import(ctx, nil, readers...)) +} diff --git a/tests/automation/trigger_test.go b/tests/automation/trigger_test.go new file mode 100644 index 000000000..c6633e9f9 --- /dev/null +++ b/tests/automation/trigger_test.go @@ -0,0 +1,366 @@ +package automation + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/automation/service" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/id" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/tests/helpers" + "github.com/steinfletcher/apitest" + "github.com/steinfletcher/apitest-jsonpath" + "github.com/stretchr/testify/require" + "net/http" + "net/url" + "testing" + "time" +) + +func (h helper) clearTriggers() { + h.noError(store.TruncateAutomationTriggers(context.Background(), service.DefaultStore)) +} + +func (h helper) createTrigger(res *types.Trigger) *types.Trigger { + if res.ID == 0 { + res.ID = id.Next() + } + + if res.CreatedAt.IsZero() { + res.CreatedAt = time.Now() + } + + h.a.NoError(service.DefaultStore.CreateAutomationTrigger(context.Background(), res)) + return res +} + +func (h helper) repoMakeTrigger(wf *types.Workflow, ss ...string) *types.Trigger { + var r = &types.Trigger{ + ID: id.Next(), + CreatedAt: time.Now(), + WorkflowID: wf.ID, + } + + if len(ss) > 1 { + r.ResourceType = ss[1] + } else { + r.ResourceType = "h_" + rs() + + } + + r.Enabled = true + + h.a.NoError(store.CreateAutomationTrigger(context.Background(), service.DefaultStore, r)) + + return r +} + +func (h helper) lookupTriggerByID(ID uint64) *types.Trigger { + res, err := store.LookupAutomationTriggerByID(context.Background(), service.DefaultStore, ID) + h.noError(err) + return res +} + +func TestTriggerRead(t *testing.T) { + h := newHelper(t) + h.clearTriggers() + + wf := h.repoMakeWorkflow() + tg := h.repoMakeTrigger(wf) + + h.allow(types.AutomationRBACResource, "triggers.search") + + h.apiInit(). + Get(fmt.Sprintf("/triggers/%d", tg.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Equal(`$.response.resourceType`, tg.ResourceType)). + Assert(jsonpath.Equal(`$.response.triggerID`, fmt.Sprintf("%d", tg.ID))). + End() +} + +func TestTriggerList(t *testing.T) { + h := newHelper(t) + h.clearTriggers() + + h.allow(types.AutomationRBACResource, "triggers.search") + + wf := h.repoMakeWorkflow() + h.repoMakeTrigger(wf) + h.repoMakeTrigger(wf) + + h.apiInit(). + Get("/triggers/"). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Len(`$.response.set`, 2)). + End() +} + +func TestTriggerCreate(t *testing.T) { + h := newHelper(t) + h.clearTriggers() + h.clearWorkflows() + + var ( + wf = h.repoMakeWorkflow() + req = func() *apitest.Request { + return h.apiInit(). + Post("/triggers/"). + Header("Accept", "application/json"). + FormData("name", rs()). + FormData("workflowID", fmt.Sprintf("%d", wf.ID)) + + } + ) + + t.Run("allowed", func(t *testing.T) { + h.allow(types.WorkflowRBACResource.AppendID(wf.ID), "triggers.manage") + req().Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + }) + + t.Run("denied", func(t *testing.T) { + h.deny(types.WorkflowRBACResource.AppendID(wf.ID), "triggers.manage") + req().Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("not allowed to create triggers")). + End() + }) +} + +func TestTriggerCreateFull(t *testing.T) { + h := newHelper(t) + + h.allow(types.WorkflowRBACResource.AppendWildcard(), "triggers.manage") + + h.clearTriggers() + var ( + wf = h.repoMakeWorkflow() + output = &types.Trigger{} + stored = &types.Trigger{} + input = &types.Trigger{ + WorkflowID: wf.ID, + ResourceType: "wf-full-test", + Enabled: true, + Input: expr.RVars{}.Vars(), + OwnedBy: 42, + } + ) + + h.apiInit(). + Post("/triggers/"). + Header("Accept", "application/json"). + JSON(helpers.JSON(input)). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End(). + JSON(&struct{ Response *types.Trigger }{output}) + + h.a.NotZero(output.ID) + h.a.NotZero(output.OwnedBy) + h.a.NotNil(output.CreatedAt) + h.a.NotZero(output.CreatedBy) + + input.ID = output.ID + input.OwnedBy = output.OwnedBy + input.CreatedBy = output.CreatedBy + input.CreatedAt = output.CreatedAt + h.a.NoError(output.Input.ResolveTypes(service.Registry().Type)) + + input.Meta = output.Meta + + h.a.Equal(input, output) + + h.allow(types.AutomationRBACResource, "triggers.search") + + h.apiInit(). + Debug(). + Get(fmt.Sprintf("/triggers/%d", output.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End(). + JSON(&struct{ Response *types.Trigger }{stored}) + + h.a.NoError(stored.Input.ResolveTypes(service.Registry().Type)) + input.Meta = stored.Meta + + h.a.Equal(input, stored) +} + +func TestTriggerUpdate(t *testing.T) { + h := newHelper(t) + h.clearTriggers() + h.clearWorkflows() + + var ( + wf = h.repoMakeWorkflow() + tg1 = h.createTrigger(&types.Trigger{WorkflowID: wf.ID, ResourceType: "as-created"}) + tg2 = h.createTrigger(&types.Trigger{WorkflowID: wf.ID, ResourceType: "as-created"}) + + req = func(id uint64, resType string) *apitest.Request { + return h.apiInit(). + Put(fmt.Sprintf("/triggers/%d", id)). + Header("Accept", "application/json"). + FormData("resourceType", resType) + + } + ) + + t.Run("allowed", func(t *testing.T) { + h.allow(types.WorkflowRBACResource.AppendID(wf.ID), "triggers.manage") + req(tg1.ID, "foo").Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + res := h.lookupTriggerByID(tg1.ID) + h.a.NotNil(res) + h.a.Equal("foo", res.ResourceType) + }) + + t.Run("denied", func(t *testing.T) { + h.deny(types.WorkflowRBACResource.AppendID(wf.ID), "triggers.manage") + req(tg2.ID, "bar").Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("not allowed to update this trigger")). + End() + + res := h.lookupTriggerByID(tg2.ID) + h.a.NotNil(res) + h.a.NotEqual("bar", res.ResourceType) + }) +} + +func TestTriggerDeleteForbidden(t *testing.T) { + h := newHelper(t) + + wf := h.repoMakeWorkflow() + res := h.repoMakeTrigger(wf) + + h.apiInit(). + Delete(fmt.Sprintf("/triggers/%d", res.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("not allowed to delete this trigger")). + End() + + res = h.lookupTriggerByID(res.ID) + h.a.NotNil(res) + h.a.Nil(res.DeletedAt) +} + +func TestTriggerDelete(t *testing.T) { + h := newHelper(t) + h.allow(types.WorkflowRBACResource.AppendWildcard(), "triggers.manage") + + wf := h.repoMakeWorkflow() + res := h.repoMakeTrigger(wf) + + h.apiInit(). + Delete(fmt.Sprintf("/triggers/%d", res.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + res = h.lookupTriggerByID(res.ID) + h.a.NotNil(res) + h.a.NotNil(res.DeletedAt) +} + +func TestTriggerLabels(t *testing.T) { + h := newHelper(t) + h.clearTriggers() + + h.allow(types.WorkflowRBACResource.AppendWildcard(), "triggers.manage") + h.allow(types.AutomationRBACResource, "triggers.search") + + var ( + ID uint64 + wf = h.repoMakeWorkflow() + ) + + t.Run("create", func(t *testing.T) { + var ( + req = require.New(t) + payload = &types.Trigger{} + ) + + helpers.SetLabelsViaAPI(h.apiInit(), t, + "/triggers/", + types.Trigger{Labels: map[string]string{"foo": "bar", "bar": "42"}, WorkflowID: wf.ID}, + payload, + ) + req.NotZero(payload.ID) + + h.a.Equal(payload.Labels["foo"], "bar", + "labels must contain foo with value bar") + h.a.Equal(payload.Labels["bar"], "42", + "labels must contain bar with value 42") + req.Equal(payload.Labels, helpers.LoadLabelsFromStore(t, service.DefaultStore, payload.LabelResourceKind(), payload.ID), + "response must match stored labels") + + ID = payload.ID + }) + + t.Run("update", func(t *testing.T) { + if ID == 0 { + t.Skip("label/create test not ran") + } + + var ( + req = require.New(t) + payload = &types.Trigger{} + ) + + helpers.SetLabelsViaAPI(h.apiInit(), t, + fmt.Sprintf("PUT /triggers/%d", ID), + &types.Trigger{Labels: map[string]string{"foo": "baz", "baz": "123"}, WorkflowID: wf.ID}, + payload, + ) + req.NotZero(payload.ID) + //req.Nil(payload.UpdatedAt, "updatedAt must not change after changing labels") + + req.Equal(payload.Labels["foo"], "baz", + "labels must contain foo with value baz") + req.NotContains(payload.Labels, "bar", + "labels must not contain bar") + req.Equal(payload.Labels["baz"], "123", + "labels must contain baz with value 123") + req.Equal(payload.Labels, helpers.LoadLabelsFromStore(t, service.DefaultStore, payload.LabelResourceKind(), payload.ID), + "response must match stored labels") + }) + + t.Run("search", func(t *testing.T) { + if ID == 0 { + t.Skip("label/create test not ran") + } + + var ( + req = require.New(t) + set = types.TriggerSet{} + params = url.Values{} + ) + params.Add("labels", "baz=123") + params.Add("disabled", filter.StateInclusive.String()) + + helpers.SearchWithLabelsViaAPI(h.apiInit(), t, "/triggers/", &set, params) + req.NotEmpty(set) + req.NotNil(set.FindByID(ID)) + req.NotNil(set.FindByID(ID).Labels) + }) +} diff --git a/tests/automation/workflow_test.go b/tests/automation/workflow_test.go new file mode 100644 index 000000000..088d1f1e8 --- /dev/null +++ b/tests/automation/workflow_test.go @@ -0,0 +1,481 @@ +package automation + +import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/automation/service" + "github.com/cortezaproject/corteza-server/automation/types" + "github.com/cortezaproject/corteza-server/pkg/expr" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/id" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/tests/helpers" + "github.com/steinfletcher/apitest-jsonpath" + "github.com/stretchr/testify/require" + "net/http" + "net/url" + "testing" + "time" +) + +func (h helper) clearWorkflows() { + h.noError(store.TruncateAutomationWorkflows(context.Background(), service.DefaultStore)) +} + +func (h helper) createWorkflow(res *types.Workflow) *types.Workflow { + if res.ID == 0 { + res.ID = id.Next() + } + + if res.CreatedAt.IsZero() { + res.CreatedAt = time.Now() + } + + h.a.NoError(service.DefaultStore.CreateAutomationWorkflow(context.Background(), res)) + return res +} + +func (h helper) repoMakeWorkflow(ss ...string) *types.Workflow { + var r = &types.Workflow{ + ID: id.Next(), + CreatedAt: time.Now(), + Enabled: true, + } + + if len(ss) > 1 { + r.Handle = ss[1] + } else { + r.Handle = "h_" + rs() + + } + + h.a.NoError(store.CreateAutomationWorkflow(context.Background(), service.DefaultStore, r)) + + return r +} + +func (h helper) lookupWorkflowByID(ID uint64) *types.Workflow { + res, err := store.LookupAutomationWorkflowByID(context.Background(), service.DefaultStore, ID) + h.noError(err) + return res +} + +func TestWorkflowRead(t *testing.T) { + h := newHelper(t) + h.clearWorkflows() + + wf := h.repoMakeWorkflow() + h.allow(types.WorkflowRBACResource.AppendID(wf.ID), "read") + + h.apiInit(). + Get(fmt.Sprintf("/workflows/%d", wf.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Equal(`$.response.handle`, wf.Handle)). + Assert(jsonpath.Equal(`$.response.workflowID`, fmt.Sprintf("%d", wf.ID))). + End() +} + +func TestWorkflowList(t *testing.T) { + h := newHelper(t) + h.clearWorkflows() + + h.allow(types.WorkflowRBACResource.AppendWildcard(), "read") + + h.repoMakeWorkflow() + h.repoMakeWorkflow() + + h.apiInit(). + Get("/workflows/"). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.Len(`$.response.set`, 2)). + End() +} + +func TestWorkflowList_filterForbidden(t *testing.T) { + h := newHelper(t) + + // @todo this can be a problematic test because it leaves + // behind workflows that are not denied this context + // db purge might be needed + + h.repoMakeWorkflow("workflow") + f := h.repoMakeWorkflow() + + h.deny(types.WorkflowRBACResource.AppendID(f.ID), "read") + + h.apiInit(). + Get("/workflows/"). + Query("handle", f.Handle). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + Assert(jsonpath.NotPresent(fmt.Sprintf(`$.response.set[? @.handle=="%s"]`, f.Handle))). + End() +} + +func TestWorkflowCreateForbidden(t *testing.T) { + h := newHelper(t) + + h.apiInit(). + Post("/workflows/"). + Header("Accept", "application/json"). + FormData("name", rs()). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("not allowed to create workflows")). + End() +} + +func TestWorkflowCreateNotUnique(t *testing.T) { + h := newHelper(t) + h.allow(types.AutomationRBACResource, "workflow.create") + + workflow := h.repoMakeWorkflow() + h.apiInit(). + Post("/workflows/"). + Header("Accept", "application/json"). + FormData("name", rs()). + FormData("handle", workflow.Handle). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("workflow handle not unique")). + End() +} + +func TestWorkflowCreate(t *testing.T) { + h := newHelper(t) + h.allow(types.AutomationRBACResource, "workflow.create") + + h.apiInit(). + Post("/workflows/"). + FormData("name", rs()). + FormData("handle", "handle_"+rs()). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() +} + +func TestWorkflowCreateFull(t *testing.T) { + h := newHelper(t) + + h.allow(types.AutomationRBACResource, "workflow.create") + + h.clearWorkflows() + var ( + output = &types.Workflow{} + stored = &types.Workflow{} + input = &types.Workflow{ + Handle: "wf-full-test", + Meta: &types.WorkflowMeta{ + Name: "name", + Description: "desc", + Visual: map[string]interface{}{"foo": "bar"}, + }, + Enabled: true, + Trace: true, + KeepSessions: 10000, + Scope: expr.RVars{"foo": expr.Must(expr.NewString("bar"))}.Vars(), + Steps: types.WorkflowStepSet{ + {ID: 1, Kind: types.WorkflowStepKindVisual, Meta: types.WorkflowStepMeta{Visual: map[string]interface{}{"foo": "bar"}}}, + {ID: 2, Kind: types.WorkflowStepKindVisual}, + }, + Paths: types.WorkflowPathSet{ + {ParentID: 1, ChildID: 2, Meta: types.WorkflowPathMeta{Visual: map[string]interface{}{"foo": "bar"}}}, + }, + RunAs: 42, + OwnedBy: 42, + } + ) + + h.apiInit(). + Post("/workflows/"). + Header("Accept", "application/json"). + JSON(helpers.JSON(input)). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End(). + JSON(&struct{ Response *types.Workflow }{output}) + + h.a.NotZero(output.ID) + h.a.NotZero(output.OwnedBy) + h.a.NotNil(output.CreatedAt) + h.a.NotZero(output.CreatedBy) + + // Make comparing input&output a bit easier + input.ID = output.ID + input.OwnedBy = output.OwnedBy + input.CreatedBy = output.CreatedBy + input.CreatedAt = output.CreatedAt + h.a.NoError(output.Scope.ResolveTypes(service.Registry().Type)) + + h.a.Equal(input, output) + + h.allow(types.WorkflowRBACResource.AppendID(output.ID), "read") + + h.apiInit(). + Get(fmt.Sprintf("/workflows/%d", output.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End(). + JSON(&struct{ Response *types.Workflow }{stored}) + + h.a.NoError(stored.Scope.ResolveTypes(service.Registry().Type)) + + h.a.Equal(input, stored) +} + +func TestWorkflowUpdateForbidden(t *testing.T) { + h := newHelper(t) + u := h.repoMakeWorkflow() + + h.apiInit(). + Put(fmt.Sprintf("/workflows/%d", u.ID)). + Header("Accept", "application/json"). + FormData("email", rs()). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("not allowed to update this workflow")). + End() +} + +func TestWorkflowUpdate(t *testing.T) { + h := newHelper(t) + res := h.repoMakeWorkflow() + h.allow(types.WorkflowRBACResource.AppendWildcard(), "update") + + newName := "updated-" + rs() + newHandle := "updated-" + rs() + + h.apiInit(). + Put(fmt.Sprintf("/workflows/%d", res.ID)). + FormData("name", newName). + FormData("handle", newHandle). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + res = h.lookupWorkflowByID(res.ID) + h.a.NotNil(res) + h.a.Equal(newHandle, res.Handle) +} + +func TestWorkflowDeleteForbidden(t *testing.T) { + h := newHelper(t) + u := h.repoMakeWorkflow() + + h.apiInit(). + Delete(fmt.Sprintf("/workflows/%d", u.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertError("not allowed to delete this workflow")). + End() +} + +func TestWorkflowDelete(t *testing.T) { + h := newHelper(t) + h.allow(types.WorkflowRBACResource.AppendWildcard(), "delete") + + res := h.repoMakeWorkflow() + + h.apiInit(). + Delete(fmt.Sprintf("/workflows/%d", res.ID)). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + res = h.lookupWorkflowByID(res.ID) + h.a.NotNil(res) + h.a.NotNil(res.DeletedAt) +} + +func TestWorkflowLabels(t *testing.T) { + h := newHelper(t) + h.clearWorkflows() + + h.allow(types.AutomationRBACResource, "workflow.create") + h.allow(types.WorkflowRBACResource.AppendWildcard(), "read") + h.allow(types.WorkflowRBACResource.AppendWildcard(), "update") + h.allow(types.WorkflowRBACResource.AppendWildcard(), "delete") + + var ( + ID uint64 + ) + + t.Run("create", func(t *testing.T) { + var ( + req = require.New(t) + payload = &types.Workflow{} + ) + + helpers.SetLabelsViaAPI(h.apiInit(), t, + "/workflows/", + types.Workflow{Labels: map[string]string{"foo": "bar", "bar": "42"}}, + payload, + ) + req.NotZero(payload.ID) + + h.a.Equal(payload.Labels["foo"], "bar", + "labels must contain foo with value bar") + h.a.Equal(payload.Labels["bar"], "42", + "labels must contain bar with value 42") + req.Equal(payload.Labels, helpers.LoadLabelsFromStore(t, service.DefaultStore, payload.LabelResourceKind(), payload.ID), + "response must match stored labels") + + ID = payload.ID + }) + + t.Run("update", func(t *testing.T) { + if ID == 0 { + t.Skip("label/create test not ran") + } + + var ( + req = require.New(t) + payload = &types.Workflow{} + ) + + helpers.SetLabelsViaAPI(h.apiInit(), t, + fmt.Sprintf("PUT /workflows/%d", ID), + &types.Workflow{Labels: map[string]string{"foo": "baz", "baz": "123"}}, + payload, + ) + req.NotZero(payload.ID) + //req.Nil(payload.UpdatedAt, "updatedAt must not change after changing labels") + + req.Equal(payload.Labels["foo"], "baz", + "labels must contain foo with value baz") + req.NotContains(payload.Labels, "bar", + "labels must not contain bar") + req.Equal(payload.Labels["baz"], "123", + "labels must contain baz with value 123") + req.Equal(payload.Labels, helpers.LoadLabelsFromStore(t, service.DefaultStore, payload.LabelResourceKind(), payload.ID), + "response must match stored labels") + }) + + t.Run("search", func(t *testing.T) { + if ID == 0 { + t.Skip("label/create test not ran") + } + + var ( + req = require.New(t) + set = types.WorkflowSet{} + params = url.Values{} + ) + + params.Add("labels", "baz=123") + params.Add("disabled", filter.StateInclusive.String()) + + helpers.SearchWithLabelsViaAPI(h.apiInit(), t, "/workflows/", &set, params) + req.NotEmpty(set) + req.NotNil(set.FindByID(ID)) + req.NotNil(set.FindByID(ID).Labels) + }) +} + +func TestWorkflowStepsPayload(t *testing.T) { + wf := &types.Workflow{} + h := newHelper(t) + h.allow(types.AutomationRBACResource, "workflow.create") + + h.apiInit(). + Post("/workflows/"). + JSON(`{ + "scope": { + "foo": { "@type": "String", "@value": "" }, + "bar": { "@type": "String", "@value": "" }, + "baz": { "@type": "String", "@value": "" }, + "hgtg": { "@type": "Integer", "@value": 42 } + }, + + "meta": { + "name": "name of the workdflow", + "description": "lorem ipsum dolor sit...", + "visual": { "arbitrary key": "arbitrary value" } + }, + + "steps": [ + { "stepID": "100", + "kind": "expressions", + "arguments": [ + { "name": "foo", "expr": "\"foo\"" }, + { "name": "bar", "expr": "\"bar\"" } + ], + "meta": { "name": "step name text", "description": "step description text", "visual": { "arbitrary key": "arbitrary value" }} + }, + { "stepID": "101", + "kind": "expressions", + + "arguments": [ + { "name": "foo", "expr": "\"foo\"" }, + { "name": "bar", "expr": "\"bar\"" } + ] + }, + + { "stepID": "200", "kind": "gateway", "ref": "join" }, + { "stepID": "201", "kind": "gateway", "ref": "fork" }, + { "stepID": "202", "kind": "gateway", "ref": "excl" }, + { "stepID": "203", "kind": "gateway", "ref": "incl" }, + + { "stepID": "300", + "kind": "function", + "ref": "serverSideFuncRef", + "meta": { "description": "ref needs to come from a predefined list of registered functions; arguments pick values from scope and need to be compatible with registered function's parameters; same goes for results" }, + + "arguments": [ + { "name": "arg1", "expr": "foo" }, + { "name": "arg2", "expr": "hgtg * 2" } + ], + + "results": [ + { "name": "scopeVar1", "expr": "nameOfVarFromFunctionsResult1" }, + { "name": "scopeVar2", "expr": "nameOfVarFromFunctionsResult2" } + ] + }, + { "stepID": "400", + "kind": "prompt", + "ref": "clientPromptRef", + "meta": { "description": "backed does not need to know about ref, it only cares that input data (when resuming session) is compatible with arguments (and tests there)" }, + + "arguments": [ + { "name": "foo", "expr": "inputVar1", + "tests": [ + { "expr": "inputVar1 != \"\"", "error": "expecting inputVar1 to be non-empty string" } + ] + }, + { "name": "bar", "expr": "inputVar2" } + ] + } + ], + + "paths": [ + { "parentID": "201", "childID": "300", "expr": "hgtg == 42", + "meta": { "name": "...", "description": "only if equals 42" } }, + { "parentID": "201", "childID": "400", + "meta": { "name": "...", "description": "else" } } + ] +}`). + Header("Accept", "application/json"). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End(). + JSON(&struct{ Response *types.Workflow }{Response: wf}) +} diff --git a/tests/helpers/labels.go b/tests/helpers/labels.go index 26baa647c..41f3173b9 100644 --- a/tests/helpers/labels.go +++ b/tests/helpers/labels.go @@ -30,6 +30,7 @@ func SetLabelsViaAPI(api *apitest.APITest, t *testing.T, endpoint string, in, ou } req.JSON(JSON(in)). + Header("Accept", "application/json"). Expect(t). Status(http.StatusOK). Assert(AssertNoErrors). diff --git a/vendor/github.com/PaesslerAG/gval/README.md b/vendor/github.com/PaesslerAG/gval/README.md index b88fe455d..1600e3e34 100644 --- a/vendor/github.com/PaesslerAG/gval/README.md +++ b/vendor/github.com/PaesslerAG/gval/README.md @@ -1,6 +1,6 @@ # Gval -[![Godoc](https://godoc.org/github.com/PaesslerAG/gval?status.png)](https://godoc.org/github.com/PaesslerAG/gval) +[![Godoc](https://pkg.go.dev/github.com/PaesslerAG/gval?status.png)](https://godoc.org/github.com/PaesslerAG/gval) [![Build Status](https://api.travis-ci.org/PaesslerAG/gval.svg?branch=master)](https://travis-ci.org/PaesslerAG/gval) [![Coverage Status](https://coveralls.io/repos/github/PaesslerAG/gval/badge.svg?branch=master)](https://coveralls.io/github/PaesslerAG/gval?branch=master) [![Go Report Card](https://goreportcard.com/badge/github.com/PaesslerAG/gval)](https://goreportcard.com/report/github.com/PaesslerAG/gval) @@ -101,6 +101,13 @@ Gval is completly customizable. Every constant, function or operator can be defi For details see [Godoc](https://godoc.org/github.com/PaesslerAG/gval). +### Implementing custom selector + +In a case you want to provide custom logic for selectors you can implement `SelectGVal(ctx context.Context, k string) (interface{}, error)` on your struct. +Function receives next part of the path and can return any type of var that is again evaluated through standard gval procedures. + +[Example Custom Selector](https://godoc.org/github.com/PaesslerAG/gval/#example-custom-selector) + ### External gval Languages A list of external libraries for gval. Feel free to add your own library. diff --git a/vendor/github.com/PaesslerAG/gval/evaluable.go b/vendor/github.com/PaesslerAG/gval/evaluable.go index 5da168ec5..c3b339884 100644 --- a/vendor/github.com/PaesslerAG/gval/evaluable.go +++ b/vendor/github.com/PaesslerAG/gval/evaluable.go @@ -9,6 +9,13 @@ import ( "strings" ) +// Selector allows for custom variable selection from structs +// +// Return value is again handled with variable() until end of the given path +type Selector interface { + SelectGVal(c context.Context, key string) (interface{}, error) +} + // Evaluable evaluates given parameter type Evaluable func(c context.Context, parameter interface{}) (interface{}, error) @@ -26,7 +33,7 @@ func (e Evaluable) EvalInt(c context.Context, parameter interface{}) (int, error return int(f), nil } -//EvalFloat64 evaluates given parameter to an int +//EvalFloat64 evaluates given parameter to a float64 func (e Evaluable) EvalFloat64(c context.Context, parameter interface{}) (float64, error) { v, err := e(c, parameter) if err != nil { @@ -114,6 +121,12 @@ func variable(path Evaluables) Evaluable { } for i, k := range keys { switch o := v.(type) { + case Selector: + v, err = o.SelectGVal(c, k) + if err != nil { + return nil, fmt.Errorf("failed to select '%s' on %T: %w", k, o, err) + } + continue case map[interface{}]interface{}: v = o[k] continue diff --git a/vendor/github.com/PaesslerAG/gval/go.mod b/vendor/github.com/PaesslerAG/gval/go.mod index 23e13508d..baf50b73c 100644 --- a/vendor/github.com/PaesslerAG/gval/go.mod +++ b/vendor/github.com/PaesslerAG/gval/go.mod @@ -1,3 +1,5 @@ module github.com/PaesslerAG/gval +go 1.15 + require github.com/PaesslerAG/jsonpath v0.1.0 diff --git a/vendor/go.uber.org/zap/CHANGELOG.md b/vendor/go.uber.org/zap/CHANGELOG.md index aeff90e4e..fa817e6a1 100644 --- a/vendor/go.uber.org/zap/CHANGELOG.md +++ b/vendor/go.uber.org/zap/CHANGELOG.md @@ -1,16 +1,37 @@ # Changelog +## 1.16.0 (1 Sep 2020) + +Bugfixes: +* [#828][]: Fix missing newline in IncreaseLevel error messages. +* [#835][]: Fix panic in JSON encoder when encoding times or durations + without specifying a time or duration encoder. +* [#843][]: Honor CallerSkip when taking stack traces. +* [#862][]: Fix the default file permissions to use `0666` and rely on the umask instead. +* [#854][]: Encode `` for nil `Stringer` instead of a panic error log. + +Enhancements: +* [#629][]: Added `zapcore.TimeEncoderOfLayout` to easily create time encoders + for custom layouts. +* [#697][]: Added support for a configurable delimiter in the console encoder. +* [#852][]: Optimize console encoder by pooling the underlying JSON encoder. +* [#844][]: Add ability to include the calling function as part of logs. +* [#843][]: Add `StackSkip` for including truncated stacks as a field. +* [#861][]: Add options to customize Fatal behaviour for better testability. + +Thanks to @SteelPhase, @tmshn, @lixingwang, @wyxloading, @moul, @segevfiner, @andy-retailnext and @jcorbin for their contributions to this release. + ## 1.15.0 (23 Apr 2020) Bugfixes: * [#804][]: Fix handling of `Time` values out of `UnixNano` range. -* [#812][]: Fix `IncreaseLevel` being reset after a call to `With`. +* [#812][]: Fix `IncreaseLevel` being reset after a call to `With`. Enhancements: * [#806][]: Add `WithCaller` option to supersede the `AddCaller` option. This allows disabling annotation of log entries with caller information if previously enabled with `AddCaller`. -* [#813][]: Deprecate `NewSampler` constructor in favor of +* [#813][]: Deprecate `NewSampler` constructor in favor of `NewSamplerWithOptions` which supports a `SamplerHook` option. This option adds support for monitoring sampling decisions through a hook. @@ -399,3 +420,13 @@ upgrade to the upcoming stable release. [#812]: https://github.com/uber-go/zap/pull/812 [#806]: https://github.com/uber-go/zap/pull/806 [#813]: https://github.com/uber-go/zap/pull/813 +[#629]: https://github.com/uber-go/zap/pull/629 +[#697]: https://github.com/uber-go/zap/pull/697 +[#828]: https://github.com/uber-go/zap/pull/828 +[#835]: https://github.com/uber-go/zap/pull/835 +[#843]: https://github.com/uber-go/zap/pull/843 +[#844]: https://github.com/uber-go/zap/pull/844 +[#852]: https://github.com/uber-go/zap/pull/852 +[#854]: https://github.com/uber-go/zap/pull/854 +[#861]: https://github.com/uber-go/zap/pull/861 +[#862]: https://github.com/uber-go/zap/pull/862 diff --git a/vendor/go.uber.org/zap/FAQ.md b/vendor/go.uber.org/zap/FAQ.md index 4256d35c7..5ec728875 100644 --- a/vendor/go.uber.org/zap/FAQ.md +++ b/vendor/go.uber.org/zap/FAQ.md @@ -149,6 +149,7 @@ We're aware of the following extensions, but haven't used them ourselves: | `github.com/tchap/zapext` | Sentry, syslog | | `github.com/fgrosse/zaptest` | Ginkgo | | `github.com/blendle/zapdriver` | Stackdriver | +| `github.com/moul/zapgorm` | Gorm | [go-proverbs]: https://go-proverbs.github.io/ [import-path]: https://golang.org/cmd/go/#hdr-Remote_import_paths diff --git a/vendor/go.uber.org/zap/config.go b/vendor/go.uber.org/zap/config.go index 192fd1a94..55637fb0b 100644 --- a/vendor/go.uber.org/zap/config.go +++ b/vendor/go.uber.org/zap/config.go @@ -101,6 +101,7 @@ func NewProductionEncoderConfig() zapcore.EncoderConfig { LevelKey: "level", NameKey: "logger", CallerKey: "caller", + FunctionKey: zapcore.OmitKey, MessageKey: "msg", StacktraceKey: "stacktrace", LineEnding: zapcore.DefaultLineEnding, @@ -140,6 +141,7 @@ func NewDevelopmentEncoderConfig() zapcore.EncoderConfig { LevelKey: "L", NameKey: "N", CallerKey: "C", + FunctionKey: zapcore.OmitKey, MessageKey: "M", StacktraceKey: "S", LineEnding: zapcore.DefaultLineEnding, diff --git a/vendor/go.uber.org/zap/field.go b/vendor/go.uber.org/zap/field.go index dd558fc23..3c0d7d957 100644 --- a/vendor/go.uber.org/zap/field.go +++ b/vendor/go.uber.org/zap/field.go @@ -364,11 +364,17 @@ func Timep(key string, val *time.Time) Field { // expensive (relatively speaking); this function both makes an allocation and // takes about two microseconds. func Stack(key string) Field { + return StackSkip(key, 1) // skip Stack +} + +// StackSkip constructs a field similarly to Stack, but also skips the given +// number of frames from the top of the stacktrace. +func StackSkip(key string, skip int) Field { // Returning the stacktrace as a string costs an allocation, but saves us // from expanding the zapcore.Field union struct to include a byte slice. Since // taking a stacktrace is already so expensive (~10us), the extra allocation // is okay. - return String(key, takeStacktrace()) + return String(key, takeStacktrace(skip+1)) // skip StackSkip } // Duration constructs a field with the given key and value. The encoder diff --git a/vendor/go.uber.org/zap/go.mod b/vendor/go.uber.org/zap/go.mod index 118abda15..6ef4db70e 100644 --- a/vendor/go.uber.org/zap/go.mod +++ b/vendor/go.uber.org/zap/go.mod @@ -8,5 +8,6 @@ require ( go.uber.org/atomic v1.6.0 go.uber.org/multierr v1.5.0 golang.org/x/lint v0.0.0-20190930215403-16217165b5de + gopkg.in/yaml.v2 v2.2.2 honnef.co/go/tools v0.0.1-2019.2.3 ) diff --git a/vendor/go.uber.org/zap/logger.go b/vendor/go.uber.org/zap/logger.go index cd6e19551..ea484aed1 100644 --- a/vendor/go.uber.org/zap/logger.go +++ b/vendor/go.uber.org/zap/logger.go @@ -49,6 +49,7 @@ type Logger struct { addStack zapcore.LevelEnabler callerSkip int + onFatal zapcore.CheckWriteAction // default is WriteThenFatal } // New constructs a new Logger from the provided zapcore.Core and Options. If @@ -280,7 +281,13 @@ func (log *Logger) check(lvl zapcore.Level, msg string) *zapcore.CheckedEntry { case zapcore.PanicLevel: ce = ce.Should(ent, zapcore.WriteThenPanic) case zapcore.FatalLevel: - ce = ce.Should(ent, zapcore.WriteThenFatal) + onFatal := log.onFatal + // Noop is the default value for CheckWriteAction, and it leads to + // continued execution after a Fatal which is unexpected. + if onFatal == zapcore.WriteThenNoop { + onFatal = zapcore.WriteThenFatal + } + ce = ce.Should(ent, onFatal) case zapcore.DPanicLevel: if log.development { ce = ce.Should(ent, zapcore.WriteThenPanic) @@ -297,15 +304,41 @@ func (log *Logger) check(lvl zapcore.Level, msg string) *zapcore.CheckedEntry { // Thread the error output through to the CheckedEntry. ce.ErrorOutput = log.errorOutput if log.addCaller { - ce.Entry.Caller = zapcore.NewEntryCaller(runtime.Caller(log.callerSkip + callerSkipOffset)) - if !ce.Entry.Caller.Defined { + frame, defined := getCallerFrame(log.callerSkip + callerSkipOffset) + if !defined { fmt.Fprintf(log.errorOutput, "%v Logger.check error: failed to get caller\n", time.Now().UTC()) log.errorOutput.Sync() } + + ce.Entry.Caller = zapcore.EntryCaller{ + Defined: defined, + PC: frame.PC, + File: frame.File, + Line: frame.Line, + Function: frame.Function, + } } if log.addStack.Enabled(ce.Entry.Level) { - ce.Entry.Stack = Stack("").String + ce.Entry.Stack = StackSkip("", log.callerSkip+callerSkipOffset).String } return ce } + +// getCallerFrame gets caller frame. The argument skip is the number of stack +// frames to ascend, with 0 identifying the caller of getCallerFrame. The +// boolean ok is false if it was not possible to recover the information. +// +// Note: This implementation is similar to runtime.Caller, but it returns the whole frame. +func getCallerFrame(skip int) (frame runtime.Frame, ok bool) { + const skipOffset = 2 // skip getCallerFrame and Callers + + pc := make([]uintptr, 1) + numFrames := runtime.Callers(skip+skipOffset, pc[:]) + if numFrames < 1 { + return + } + + frame, _ = runtime.CallersFrames(pc).Next() + return frame, frame.PC != 0 +} diff --git a/vendor/go.uber.org/zap/options.go b/vendor/go.uber.org/zap/options.go index 59f1b54a0..0135c2092 100644 --- a/vendor/go.uber.org/zap/options.go +++ b/vendor/go.uber.org/zap/options.go @@ -86,15 +86,15 @@ func Development() Option { }) } -// AddCaller configures the Logger to annotate each message with the filename -// and line number of zap's caller. See also WithCaller. +// AddCaller configures the Logger to annotate each message with the filename, +// line number, and function name of zap's caller. See also WithCaller. func AddCaller() Option { return WithCaller(true) } -// WithCaller configures the Logger to annotate each message with the filename -// and line number of zap's caller, or not, depending on the value of enabled. -// This is a generalized form of AddCaller. +// WithCaller configures the Logger to annotate each message with the filename, +// line number, and function name of zap's caller, or not, depending on the +// value of enabled. This is a generalized form of AddCaller. func WithCaller(enabled bool) Option { return optionFunc(func(log *Logger) { log.addCaller = enabled @@ -125,9 +125,16 @@ func IncreaseLevel(lvl zapcore.LevelEnabler) Option { return optionFunc(func(log *Logger) { core, err := zapcore.NewIncreaseLevelCore(log.core, lvl) if err != nil { - fmt.Fprintf(log.errorOutput, "failed to IncreaseLevel: %v", err) + fmt.Fprintf(log.errorOutput, "failed to IncreaseLevel: %v\n", err) } else { log.core = core } }) } + +// OnFatal sets the action to take on fatal logs. +func OnFatal(action zapcore.CheckWriteAction) Option { + return optionFunc(func(log *Logger) { + log.onFatal = action + }) +} diff --git a/vendor/go.uber.org/zap/sink.go b/vendor/go.uber.org/zap/sink.go index ff0becfe5..df46fa87a 100644 --- a/vendor/go.uber.org/zap/sink.go +++ b/vendor/go.uber.org/zap/sink.go @@ -136,7 +136,7 @@ func newFileSink(u *url.URL) (Sink, error) { case "stderr": return nopCloserSink{os.Stderr}, nil } - return os.OpenFile(u.Path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) + return os.OpenFile(u.Path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666) } func normalizeScheme(s string) (string, error) { diff --git a/vendor/go.uber.org/zap/stacktrace.go b/vendor/go.uber.org/zap/stacktrace.go index 100fac216..0cf8c1ddf 100644 --- a/vendor/go.uber.org/zap/stacktrace.go +++ b/vendor/go.uber.org/zap/stacktrace.go @@ -22,28 +22,20 @@ package zap import ( "runtime" - "strings" "sync" "go.uber.org/zap/internal/bufferpool" ) -const _zapPackage = "go.uber.org/zap" - var ( _stacktracePool = sync.Pool{ New: func() interface{} { return newProgramCounters(64) }, } - - // We add "." and "/" suffixes to the package name to ensure we only match - // the exact package and not any package with the same prefix. - _zapStacktracePrefixes = addPrefix(_zapPackage, ".", "/") - _zapStacktraceVendorContains = addPrefix("/vendor/", _zapStacktracePrefixes...) ) -func takeStacktrace() string { +func takeStacktrace(skip int) string { buffer := bufferpool.Get() defer buffer.Free() programCounters := _stacktracePool.Get().(*programCounters) @@ -51,9 +43,9 @@ func takeStacktrace() string { var numFrames int for { - // Skip the call to runtime.Counters and takeStacktrace so that the + // Skip the call to runtime.Callers and takeStacktrace so that the // program counters start at the caller of takeStacktrace. - numFrames = runtime.Callers(2, programCounters.pcs) + numFrames = runtime.Callers(skip+2, programCounters.pcs) if numFrames < len(programCounters.pcs) { break } @@ -63,19 +55,12 @@ func takeStacktrace() string { } i := 0 - skipZapFrames := true // skip all consecutive zap frames at the beginning. frames := runtime.CallersFrames(programCounters.pcs[:numFrames]) // Note: On the last iteration, frames.Next() returns false, with a valid // frame, but we ignore this frame. The last frame is a a runtime frame which // adds noise, since it's only either runtime.main or runtime.goexit. for frame, more := frames.Next(); more; frame, more = frames.Next() { - if skipZapFrames && isZapFrame(frame.Function) { - continue - } else { - skipZapFrames = false - } - if i != 0 { buffer.AppendByte('\n') } @@ -91,24 +76,6 @@ func takeStacktrace() string { return buffer.String() } -func isZapFrame(function string) bool { - for _, prefix := range _zapStacktracePrefixes { - if strings.HasPrefix(function, prefix) { - return true - } - } - - // We can't use a prefix match here since the location of the vendor - // directory affects the prefix. Instead we do a contains match. - for _, contains := range _zapStacktraceVendorContains { - if strings.Contains(function, contains) { - return true - } - } - - return false -} - type programCounters struct { pcs []uintptr } @@ -116,11 +83,3 @@ type programCounters struct { func newProgramCounters(size int) *programCounters { return &programCounters{make([]uintptr, size)} } - -func addPrefix(prefix string, ss ...string) []string { - withPrefix := make([]string, len(ss)) - for i, s := range ss { - withPrefix[i] = prefix + s - } - return withPrefix -} diff --git a/vendor/go.uber.org/zap/zapcore/console_encoder.go b/vendor/go.uber.org/zap/zapcore/console_encoder.go index b7875966f..3b68f8c0c 100644 --- a/vendor/go.uber.org/zap/zapcore/console_encoder.go +++ b/vendor/go.uber.org/zap/zapcore/console_encoder.go @@ -56,6 +56,10 @@ type consoleEncoder struct { // encoder configuration, it will omit any element whose key is set to the empty // string. func NewConsoleEncoder(cfg EncoderConfig) Encoder { + if len(cfg.ConsoleSeparator) == 0 { + // Use a default delimiter of '\t' for backwards compatibility + cfg.ConsoleSeparator = "\t" + } return consoleEncoder{newJSONEncoder(cfg, true)} } @@ -89,12 +93,17 @@ func (c consoleEncoder) EncodeEntry(ent Entry, fields []Field) (*buffer.Buffer, nameEncoder(ent.LoggerName, arr) } - if ent.Caller.Defined && c.CallerKey != "" && c.EncodeCaller != nil { - c.EncodeCaller(ent.Caller, arr) + if ent.Caller.Defined { + if c.CallerKey != "" && c.EncodeCaller != nil { + c.EncodeCaller(ent.Caller, arr) + } + if c.FunctionKey != "" { + arr.AppendString(ent.Caller.Function) + } } for i := range arr.elems { if i > 0 { - line.AppendByte('\t') + line.AppendString(c.ConsoleSeparator) } fmt.Fprint(line, arr.elems[i]) } @@ -102,7 +111,7 @@ func (c consoleEncoder) EncodeEntry(ent Entry, fields []Field) (*buffer.Buffer, // Add the message itself. if c.MessageKey != "" { - c.addTabIfNecessary(line) + c.addSeparatorIfNecessary(line) line.AppendString(ent.Message) } @@ -126,7 +135,12 @@ func (c consoleEncoder) EncodeEntry(ent Entry, fields []Field) (*buffer.Buffer, func (c consoleEncoder) writeContext(line *buffer.Buffer, extra []Field) { context := c.jsonEncoder.Clone().(*jsonEncoder) - defer context.buf.Free() + defer func() { + // putJSONEncoder assumes the buffer is still used, but we write out the buffer so + // we can free it. + context.buf.Free() + putJSONEncoder(context) + }() addFields(context, extra) context.closeOpenNamespaces() @@ -134,14 +148,14 @@ func (c consoleEncoder) writeContext(line *buffer.Buffer, extra []Field) { return } - c.addTabIfNecessary(line) + c.addSeparatorIfNecessary(line) line.AppendByte('{') line.Write(context.buf.Bytes()) line.AppendByte('}') } -func (c consoleEncoder) addTabIfNecessary(line *buffer.Buffer) { +func (c consoleEncoder) addSeparatorIfNecessary(line *buffer.Buffer) { if line.Len() > 0 { - line.AppendByte('\t') + line.AppendString(c.ConsoleSeparator) } } diff --git a/vendor/go.uber.org/zap/zapcore/encoder.go b/vendor/go.uber.org/zap/zapcore/encoder.go index 6c78f7e49..6601ca166 100644 --- a/vendor/go.uber.org/zap/zapcore/encoder.go +++ b/vendor/go.uber.org/zap/zapcore/encoder.go @@ -21,6 +21,7 @@ package zapcore import ( + "encoding/json" "time" "go.uber.org/zap/buffer" @@ -151,6 +152,14 @@ func RFC3339NanoTimeEncoder(t time.Time, enc PrimitiveArrayEncoder) { encodeTimeLayout(t, time.RFC3339Nano, enc) } +// TimeEncoderOfLayout returns TimeEncoder which serializes a time.Time using +// given layout. +func TimeEncoderOfLayout(layout string) TimeEncoder { + return func(t time.Time, enc PrimitiveArrayEncoder) { + encodeTimeLayout(t, layout, enc) + } +} + // UnmarshalText unmarshals text to a TimeEncoder. // "rfc3339nano" and "RFC3339Nano" are unmarshaled to RFC3339NanoTimeEncoder. // "rfc3339" and "RFC3339" are unmarshaled to RFC3339TimeEncoder. @@ -176,6 +185,35 @@ func (e *TimeEncoder) UnmarshalText(text []byte) error { return nil } +// UnmarshalYAML unmarshals YAML to a TimeEncoder. +// If value is an object with a "layout" field, it will be unmarshaled to TimeEncoder with given layout. +// timeEncoder: +// layout: 06/01/02 03:04pm +// If value is string, it uses UnmarshalText. +// timeEncoder: iso8601 +func (e *TimeEncoder) UnmarshalYAML(unmarshal func(interface{}) error) error { + var o struct { + Layout string `json:"layout" yaml:"layout"` + } + if err := unmarshal(&o); err == nil { + *e = TimeEncoderOfLayout(o.Layout) + return nil + } + + var s string + if err := unmarshal(&s); err != nil { + return err + } + return e.UnmarshalText([]byte(s)) +} + +// UnmarshalJSON unmarshals JSON to a TimeEncoder as same way UnmarshalYAML does. +func (e *TimeEncoder) UnmarshalJSON(data []byte) error { + return e.UnmarshalYAML(func(v interface{}) error { + return json.Unmarshal(data, v) + }) +} + // A DurationEncoder serializes a time.Duration to a primitive type. type DurationEncoder func(time.Duration, PrimitiveArrayEncoder) @@ -279,6 +317,7 @@ type EncoderConfig struct { TimeKey string `json:"timeKey" yaml:"timeKey"` NameKey string `json:"nameKey" yaml:"nameKey"` CallerKey string `json:"callerKey" yaml:"callerKey"` + FunctionKey string `json:"functionKey" yaml:"functionKey"` StacktraceKey string `json:"stacktraceKey" yaml:"stacktraceKey"` LineEnding string `json:"lineEnding" yaml:"lineEnding"` // Configure the primitive representations of common complex types. For @@ -291,6 +330,9 @@ type EncoderConfig struct { // Unlike the other primitive type encoders, EncodeName is optional. The // zero value falls back to FullNameEncoder. EncodeName NameEncoder `json:"nameEncoder" yaml:"nameEncoder"` + // Configures the field separator used by the console encoder. Defaults + // to tab. + ConsoleSeparator string `json:"consoleSeparator" yaml:"consoleSeparator"` } // ObjectEncoder is a strongly-typed, encoding-agnostic interface for adding a diff --git a/vendor/go.uber.org/zap/zapcore/entry.go b/vendor/go.uber.org/zap/zapcore/entry.go index 8273abdf0..4aa8b4f90 100644 --- a/vendor/go.uber.org/zap/zapcore/entry.go +++ b/vendor/go.uber.org/zap/zapcore/entry.go @@ -22,6 +22,7 @@ package zapcore import ( "fmt" + "runtime" "strings" "sync" "time" @@ -70,10 +71,11 @@ func NewEntryCaller(pc uintptr, file string, line int, ok bool) EntryCaller { // EntryCaller represents the caller of a logging function. type EntryCaller struct { - Defined bool - PC uintptr - File string - Line int + Defined bool + PC uintptr + File string + Line int + Function string } // String returns the full path and line number of the caller. @@ -158,6 +160,8 @@ const ( // WriteThenNoop indicates that nothing special needs to be done. It's the // default behavior. WriteThenNoop CheckWriteAction = iota + // WriteThenGoexit runs runtime.Goexit after Write. + WriteThenGoexit // WriteThenPanic causes a panic after Write. WriteThenPanic // WriteThenFatal causes a fatal os.Exit after Write. @@ -230,6 +234,8 @@ func (ce *CheckedEntry) Write(fields ...Field) { panic(msg) case WriteThenFatal: exit.Exit() + case WriteThenGoexit: + runtime.Goexit() } } diff --git a/vendor/go.uber.org/zap/zapcore/field.go b/vendor/go.uber.org/zap/zapcore/field.go index 6e05f831f..7e255d63e 100644 --- a/vendor/go.uber.org/zap/zapcore/field.go +++ b/vendor/go.uber.org/zap/zapcore/field.go @@ -205,13 +205,23 @@ func addFields(enc ObjectEncoder, fields []Field) { } } -func encodeStringer(key string, stringer interface{}, enc ObjectEncoder) (err error) { +func encodeStringer(key string, stringer interface{}, enc ObjectEncoder) (retErr error) { + // Try to capture panics (from nil references or otherwise) when calling + // the String() method, similar to https://golang.org/src/fmt/print.go#L540 defer func() { - if v := recover(); v != nil { - err = fmt.Errorf("PANIC=%v", v) + if err := recover(); err != nil { + // If it's a nil pointer, just say "". The likeliest causes are a + // Stringer that fails to guard against nil or a nil pointer for a + // value receiver, and in either case, "" is a nice result. + if v := reflect.ValueOf(stringer); v.Kind() == reflect.Ptr && v.IsNil() { + enc.AddString(key, "") + return + } + + retErr = fmt.Errorf("PANIC=%v", err) } }() enc.AddString(key, stringer.(fmt.Stringer).String()) - return + return nil } diff --git a/vendor/go.uber.org/zap/zapcore/json_encoder.go b/vendor/go.uber.org/zap/zapcore/json_encoder.go index 7facc1b36..5cf7d917e 100644 --- a/vendor/go.uber.org/zap/zapcore/json_encoder.go +++ b/vendor/go.uber.org/zap/zapcore/json_encoder.go @@ -236,7 +236,9 @@ func (enc *jsonEncoder) AppendComplex128(val complex128) { func (enc *jsonEncoder) AppendDuration(val time.Duration) { cur := enc.buf.Len() - enc.EncodeDuration(val, enc) + if e := enc.EncodeDuration; e != nil { + e(val, enc) + } if cur == enc.buf.Len() { // User-supplied EncodeDuration is a no-op. Fall back to nanoseconds to keep // JSON valid. @@ -275,7 +277,9 @@ func (enc *jsonEncoder) AppendTimeLayout(time time.Time, layout string) { func (enc *jsonEncoder) AppendTime(val time.Time) { cur := enc.buf.Len() - enc.EncodeTime(val, enc) + if e := enc.EncodeTime; e != nil { + e(val, enc) + } if cur == enc.buf.Len() { // User-supplied EncodeTime is a no-op. Fall back to nanos since epoch to keep // output JSON valid. @@ -362,14 +366,20 @@ func (enc *jsonEncoder) EncodeEntry(ent Entry, fields []Field) (*buffer.Buffer, final.AppendString(ent.LoggerName) } } - if ent.Caller.Defined && final.CallerKey != "" { - final.addKey(final.CallerKey) - cur := final.buf.Len() - final.EncodeCaller(ent.Caller, final) - if cur == final.buf.Len() { - // User-supplied EncodeCaller was a no-op. Fall back to strings to - // keep output JSON valid. - final.AppendString(ent.Caller.String()) + if ent.Caller.Defined { + if final.CallerKey != "" { + final.addKey(final.CallerKey) + cur := final.buf.Len() + final.EncodeCaller(ent.Caller, final) + if cur == final.buf.Len() { + // User-supplied EncodeCaller was a no-op. Fall back to strings to + // keep output JSON valid. + final.AppendString(ent.Caller.String()) + } + } + if final.FunctionKey != "" { + final.addKey(final.FunctionKey) + final.AppendString(ent.Caller.Function) } } if final.MessageKey != "" { diff --git a/vendor/go.uber.org/zap/zapcore/marshaler.go b/vendor/go.uber.org/zap/zapcore/marshaler.go index 2627a653d..c3c55ba0d 100644 --- a/vendor/go.uber.org/zap/zapcore/marshaler.go +++ b/vendor/go.uber.org/zap/zapcore/marshaler.go @@ -23,6 +23,10 @@ package zapcore // ObjectMarshaler allows user-defined types to efficiently add themselves to the // logging context, and to selectively omit information which shouldn't be // included in logs (e.g., passwords). +// +// Note: ObjectMarshaler is only used when zap.Object is used or when +// passed directly to zap.Any. It is not used when reflection-based +// encoding is used. type ObjectMarshaler interface { MarshalLogObject(ObjectEncoder) error } @@ -39,6 +43,10 @@ func (f ObjectMarshalerFunc) MarshalLogObject(enc ObjectEncoder) error { // ArrayMarshaler allows user-defined types to efficiently add themselves to the // logging context, and to selectively omit information which shouldn't be // included in logs (e.g., passwords). +// +// Note: ArrayMarshaler is only used when zap.Array is used or when +// passed directly to zap.Any. It is not used when reflection-based +// encoding is used. type ArrayMarshaler interface { MarshalLogArray(ArrayEncoder) error } diff --git a/vendor/go.uber.org/zap/zaptest/observer/logged_entry.go b/vendor/go.uber.org/zap/zaptest/observer/logged_entry.go new file mode 100644 index 000000000..a4ea7ec36 --- /dev/null +++ b/vendor/go.uber.org/zap/zaptest/observer/logged_entry.go @@ -0,0 +1,39 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package observer + +import "go.uber.org/zap/zapcore" + +// An LoggedEntry is an encoding-agnostic representation of a log message. +// Field availability is context dependant. +type LoggedEntry struct { + zapcore.Entry + Context []zapcore.Field +} + +// ContextMap returns a map for all fields in Context. +func (e LoggedEntry) ContextMap() map[string]interface{} { + encoder := zapcore.NewMapObjectEncoder() + for _, f := range e.Context { + f.AddTo(encoder) + } + return encoder.Fields +} diff --git a/vendor/go.uber.org/zap/zaptest/observer/observer.go b/vendor/go.uber.org/zap/zaptest/observer/observer.go new file mode 100644 index 000000000..78f5be45d --- /dev/null +++ b/vendor/go.uber.org/zap/zaptest/observer/observer.go @@ -0,0 +1,167 @@ +// Copyright (c) 2016 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Package observer provides a zapcore.Core that keeps an in-memory, +// encoding-agnostic repesentation of log entries. It's useful for +// applications that want to unit test their log output without tying their +// tests to a particular output encoding. +package observer // import "go.uber.org/zap/zaptest/observer" + +import ( + "strings" + "sync" + "time" + + "go.uber.org/zap/zapcore" +) + +// ObservedLogs is a concurrency-safe, ordered collection of observed logs. +type ObservedLogs struct { + mu sync.RWMutex + logs []LoggedEntry +} + +// Len returns the number of items in the collection. +func (o *ObservedLogs) Len() int { + o.mu.RLock() + n := len(o.logs) + o.mu.RUnlock() + return n +} + +// All returns a copy of all the observed logs. +func (o *ObservedLogs) All() []LoggedEntry { + o.mu.RLock() + ret := make([]LoggedEntry, len(o.logs)) + for i := range o.logs { + ret[i] = o.logs[i] + } + o.mu.RUnlock() + return ret +} + +// TakeAll returns a copy of all the observed logs, and truncates the observed +// slice. +func (o *ObservedLogs) TakeAll() []LoggedEntry { + o.mu.Lock() + ret := o.logs + o.logs = nil + o.mu.Unlock() + return ret +} + +// AllUntimed returns a copy of all the observed logs, but overwrites the +// observed timestamps with time.Time's zero value. This is useful when making +// assertions in tests. +func (o *ObservedLogs) AllUntimed() []LoggedEntry { + ret := o.All() + for i := range ret { + ret[i].Time = time.Time{} + } + return ret +} + +// FilterMessage filters entries to those that have the specified message. +func (o *ObservedLogs) FilterMessage(msg string) *ObservedLogs { + return o.filter(func(e LoggedEntry) bool { + return e.Message == msg + }) +} + +// FilterMessageSnippet filters entries to those that have a message containing the specified snippet. +func (o *ObservedLogs) FilterMessageSnippet(snippet string) *ObservedLogs { + return o.filter(func(e LoggedEntry) bool { + return strings.Contains(e.Message, snippet) + }) +} + +// FilterField filters entries to those that have the specified field. +func (o *ObservedLogs) FilterField(field zapcore.Field) *ObservedLogs { + return o.filter(func(e LoggedEntry) bool { + for _, ctxField := range e.Context { + if ctxField.Equals(field) { + return true + } + } + return false + }) +} + +func (o *ObservedLogs) filter(match func(LoggedEntry) bool) *ObservedLogs { + o.mu.RLock() + defer o.mu.RUnlock() + + var filtered []LoggedEntry + for _, entry := range o.logs { + if match(entry) { + filtered = append(filtered, entry) + } + } + return &ObservedLogs{logs: filtered} +} + +func (o *ObservedLogs) add(log LoggedEntry) { + o.mu.Lock() + o.logs = append(o.logs, log) + o.mu.Unlock() +} + +// New creates a new Core that buffers logs in memory (without any encoding). +// It's particularly useful in tests. +func New(enab zapcore.LevelEnabler) (zapcore.Core, *ObservedLogs) { + ol := &ObservedLogs{} + return &contextObserver{ + LevelEnabler: enab, + logs: ol, + }, ol +} + +type contextObserver struct { + zapcore.LevelEnabler + logs *ObservedLogs + context []zapcore.Field +} + +func (co *contextObserver) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if co.Enabled(ent.Level) { + return ce.AddCore(ent, co) + } + return ce +} + +func (co *contextObserver) With(fields []zapcore.Field) zapcore.Core { + return &contextObserver{ + LevelEnabler: co.LevelEnabler, + logs: co.logs, + context: append(co.context[:len(co.context):len(co.context)], fields...), + } +} + +func (co *contextObserver) Write(ent zapcore.Entry, fields []zapcore.Field) error { + all := make([]zapcore.Field, 0, len(fields)+len(co.context)) + all = append(all, co.context...) + all = append(all, fields...) + co.logs.add(LoggedEntry{ent, all}) + return nil +} + +func (co *contextObserver) Sync() error { + return nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index f9885d0d7..2afd94d54 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -18,7 +18,7 @@ github.com/Masterminds/sprig # github.com/Masterminds/squirrel v1.1.1-0.20191017225151-12f2162c8d8d ## explicit github.com/Masterminds/squirrel -# github.com/PaesslerAG/gval v1.1.0 +# github.com/PaesslerAG/gval v1.1.1-0.20201104175134-7847ed0c7671 ## explicit github.com/PaesslerAG/gval # github.com/PaesslerAG/jsonpath v0.1.1 @@ -246,7 +246,7 @@ github.com/stretchr/testify/require go.uber.org/atomic # go.uber.org/multierr v1.5.0 go.uber.org/multierr -# go.uber.org/zap v1.15.0 +# go.uber.org/zap v1.16.0 ## explicit go.uber.org/zap go.uber.org/zap/buffer @@ -254,6 +254,7 @@ go.uber.org/zap/internal/bufferpool go.uber.org/zap/internal/color go.uber.org/zap/internal/exit go.uber.org/zap/zapcore +go.uber.org/zap/zaptest/observer # golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 ## explicit golang.org/x/crypto/argon2