Files
upstream-corteza/server/automation/service/workflow.go
upstream-mirror 7e385fc126
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:22:23 +05:00

843 lines
20 KiB
Go

package service
import (
"context"
"fmt"
"reflect"
"sync"
"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/id"
"github.com/cortezaproject/corteza/server/pkg/label"
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/cortezaproject/corteza/server/pkg/rbac"
"github.com/cortezaproject/corteza/server/pkg/wfexec"
"github.com/cortezaproject/corteza/server/store"
"go.uber.org/zap"
)
type (
workflow struct {
eventbus workflowEventTriggerHandler
store store.Storer
actionlog actionlog.Recorder
ac workflowAccessController
triggers *trigger
session *session
opt options.WorkflowOpt
log *zap.Logger
// cache of workflows, graphs to workflow ID (key, uint64)
cache map[uint64]*wfCacheItem
muxCache *sync.RWMutex
// handle to workflow index
wIndex map[string]uint64
muxWIndex *sync.RWMutex
// workflow function registry
reg *registry
corredorOpt options.CorredorOpt
parser expr.Parsable
}
wfCacheItem struct {
wf *types.Workflow
// caching exec graph
g *wfexec.Graph
// caching user we'll executing workflow with
runAs intAuth.Identifiable
}
workflowAccessController interface {
CanSearchWorkflows(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
CanManageSessionsOnWorkflow(context.Context, *types.Workflow) bool
Grant(ctx context.Context, rr ...*rbac.Rule) error
workflowExecController
}
workflowExecController interface {
CanExecuteWorkflow(context.Context, *types.Workflow) bool
}
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
workflowInvokerCtxKey struct{}
)
const (
workflowUnchanged workflowChanges = 0
workflowChanged workflowChanges = 1
workflowLabelsChanged workflowChanges = 2
workflowDefChanged workflowChanges = 4
)
func Workflow(log *zap.Logger, corredorOpt options.CorredorOpt, opt options.WorkflowOpt) *workflow {
return &workflow{
log: log,
opt: opt,
actionlog: DefaultActionlog,
store: DefaultStore,
ac: DefaultAccessControl,
triggers: DefaultTrigger,
session: DefaultSession,
eventbus: eventbus.Service(),
cache: make(map[uint64]*wfCacheItem),
wIndex: make(map[string]uint64),
muxCache: &sync.RWMutex{},
muxWIndex: &sync.RWMutex{},
parser: expr.NewParser(),
reg: Registry(),
corredorOpt: corredorOpt,
}
}
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 !svc.ac.CanSearchWorkflows(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, f, 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{workflow: new}
cUser = intAuth.GetIdentityFromContext(ctx).Identity()
g *wfexec.Graph
runAs intAuth.Identifiable
)
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
if !svc.ac.CanCreateWorkflow(ctx) {
return WorkflowErrNotAllowedToCreate()
}
if new.Meta.Name == "" {
return WorkflowErrMissingName()
}
if !handle.IsValid(new.Handle) {
return WorkflowErrInvalidHandle()
}
if err = svc.uniqueCheck(ctx, new); err != nil {
return err
}
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,
}
wap.workflow = wf
if g, runAs, err = svc.validateWorkflow(ctx, wf); err != nil {
return
}
svc.updateCache(wf, runAs, g)
if len(wf.Issues) == 0 {
if err = svc.triggers.registerWorkflows(ctx, wf); err != nil {
return err
}
}
if err = store.CreateAutomationWorkflow(ctx, s, wf); err != nil {
return
}
if err = label.Create(ctx, s, wf); err != nil {
return
}
wap.setNew(wf)
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 upd.Meta.Name == "" {
return workflowUnchanged, WorkflowErrMissingName()
}
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, func(ctx context.Context, res *types.Workflow) (workflowChanges, error) {
changes, err := svc.handleDelete(ctx, res)
if err != nil {
return workflowUnchanged, err
}
return changes, err
}))
}
func (svc *workflow) UndeleteByID(ctx context.Context, workflowID uint64) error {
return trim1st(svc.updater(ctx, workflowID, WorkflowActionUndelete, func(ctx context.Context, res *types.Workflow) (workflowChanges, error) {
var (
changes, err = svc.handleUndelete(ctx, res)
)
if err != nil {
return workflowUnchanged, err
}
return changes, err
}))
}
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
g *wfexec.Graph
runAs intAuth.Identifiable
)
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 g, runAs, err = svc.validateWorkflow(ctx, res); err != nil {
return
}
svc.updateCache(res, runAs, g)
if len(res.Issues) == 0 {
if err = svc.triggers.registerWorkflows(ctx, res); err != nil {
return err
}
}
if changes&workflowChanged > 0 || len(res.Issues) > 0 {
if err = store.UpdateAutomationWorkflow(ctx, svc.store, res); err != nil {
return err
}
}
if changes&workflowLabelsChanged > 0 {
if err = label.Update(ctx, s, res); err != nil {
return
}
}
return
})
return res, svc.recordAction(ctx, aProps, action, err)
}
func (svc *workflow) handleToID(h string) uint64 {
svc.muxWIndex.RLock()
defer svc.muxWIndex.RUnlock()
return svc.wIndex[h]
}
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) {
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.CanUndeleteWorkflow(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 {
var (
set, _, err = store.SearchAutomationWorkflows(ctx, svc.store, types.WorkflowFilter{
Deleted: filter.StateInclusive,
Disabled: filter.StateExcluded,
SubWorkflow: filter.StateInclusive,
})
g *wfexec.Graph
runAs intAuth.Identifiable
)
if err != nil {
return err
}
svc.muxWIndex.Lock()
defer svc.muxWIndex.Unlock()
for _, wf := range set {
svc.wIndex[wf.Handle] = wf.ID
if g, runAs, err = svc.validateWorkflow(ctx, wf); err != nil {
continue
}
svc.updateCache(wf, runAs, g)
}
return svc.triggers.registerWorkflows(ctx, set...)
}
// updateCache
func (svc *workflow) updateCache(wf *types.Workflow, runAs intAuth.Identifiable, g *wfexec.Graph) {
defer svc.muxCache.Unlock()
svc.muxCache.Lock()
if wf.Executable() {
svc.wIndex[wf.Handle] = wf.ID
svc.cache[wf.ID] = &wfCacheItem{g: g, wf: wf, runAs: runAs}
} else {
// remove deleted
delete(svc.cache, wf.ID)
delete(svc.wIndex, wf.Handle)
}
return
}
func (svc *workflow) Exec(ctx context.Context, workflowID uint64, p types.WorkflowExecParams) (*expr.Vars, uint64, types.Stacktrace, error) {
var (
wap = &workflowActionProps{}
t *types.Trigger
results *expr.Vars
wait WaitFn
stacktrace types.Stacktrace
sessionID uint64
)
err := func() (err error) {
svc.muxCache.Lock()
if nil == svc.cache[workflowID] || nil == svc.cache[workflowID].wf {
svc.muxCache.Unlock()
return WorkflowErrNotFound()
}
wf := svc.cache[workflowID].wf
svc.muxCache.Unlock()
wap.setWorkflow(wf)
if !svc.ac.CanExecuteWorkflow(ctx, wf) {
return WorkflowErrNotAllowedToExecute()
}
if !wf.Enabled && !p.Trace {
return WorkflowErrDisabled()
}
// Find the trigger.
// @todo can we cache this as well?
t, err = func() (*types.Trigger, error) {
if p.CallerWorkflowID > 0 {
// skip triggers checking when executed as sub-workflow
// @todo be more strict and allow this ONLY when workflow is flagged as a sub-workflow
return nil, nil
}
var tt types.TriggerSet
// Load triggers directly from the store. At this point we do not care
// about trigger search or read permissions
tt, err = loadWorkflowTriggers(ctx, svc.store, workflowID)
if err != nil {
return nil, err
}
if len(tt) == 0 {
return nil, nil
}
if p.StepID == 0 && len(tt) > 0 {
return tt[0], nil
} else {
for _, tMatch := range tt {
if tMatch.StepID == p.StepID {
return tMatch, nil
}
}
}
if !p.Trace {
// when not doing a trace (designing the workflow)
// we need to be more strict and disallow execution of
// the misconfigured workflows and use of disabled triggers
if t == nil {
return nil, WorkflowErrUnknownWorkflowStep()
} else if !t.Enabled {
return nil, WorkflowErrDisabled()
}
}
if t != nil {
wap.setTrigger(t)
p.StepID = t.StepID
p.EventType = t.EventType
p.ResourceType = t.ResourceType
// merge with input from trigger
// with trigger input vars are overwritten by input vars
p.Input = t.Input.MustMerge(p.Input)
} else {
p.EventType = "onTrace"
}
return nil, nil
}()
if err != nil {
return
}
wait, sessionID, err = svc.exec(ctx, wf, p)
if err != nil {
return err
}
if p.Async && !p.Wait && wf.CheckDeferred() {
// deferred workflow, return right away and keep the workflow session
// running without waiting for the execution
return nil
}
// wait for the workflow to complete
// reuse scope for results
// this will be decoded back to event properties
results, sessionID, _, stacktrace, err = wait(ctx)
return err
}()
return results, sessionID, stacktrace, svc.recordAction(ctx, wap, WorkflowActionExecute, err)
}
// validates workflow by trying to convert it to graph and checking assigned triggers
func (svc *workflow) validateWorkflow(ctx context.Context, wf *types.Workflow) (g *wfexec.Graph, runAs intAuth.Identifiable, err error) {
var (
tt []*types.Trigger
)
g, wf.Issues = Convert(svc, wf)
tt, _, err = store.SearchAutomationTriggers(ctx, svc.store, types.TriggerFilter{
WorkflowID: id.Strings(types.WorkflowSet{wf}.IDs()...),
Deleted: filter.StateExcluded,
Disabled: filter.StateExcluded,
})
if err != nil {
return
}
wf.Issues = append(wf.Issues, validateWorkflowTriggers(wf, tt...)...)
// Returns context with identity set to service user
//
// Current user (identity in the context) might not have
// sufficient privileges to load info about invoker and runner
sysUserCtx := func() context.Context {
return intAuth.SetIdentityToContext(ctx, intAuth.ServiceUser())
}
// @todo this might not be the smartest thing, users might get invalidated after
// we add cache them as workflow runners
if wf.RunAs > 0 {
if runAs, err = DefaultUser.FindByAny(sysUserCtx(), wf.RunAs); err != nil {
wf.Issues = wf.Issues.Append(fmt.Errorf("failed to load run-as user %d: %w", wf.RunAs, err), nil)
} else if !runAs.Valid() {
wf.Issues = wf.Issues.Append(fmt.Errorf("invalid user %d used for workflow run-as", wf.RunAs), nil)
}
}
return
}
func (svc *workflow) exec(ctx context.Context, wf *types.Workflow, p types.WorkflowExecParams) (WaitFn, uint64, error) {
if wf.Issues != nil {
return nil, 0, wf.Issues
}
defer svc.muxCache.Unlock()
svc.muxCache.Lock()
if svc.cache[wf.ID] == nil {
return nil, 0, WorkflowErrInvalidID()
}
var (
g = svc.cache[wf.ID].g
runAs = svc.cache[wf.ID].runAs
scope *expr.Vars
)
// merge workflow scope with the input
scope = wf.Scope.MustMerge(p.Input)
return svc.session.Start(ctx, g, types.SessionStartParams{
Invoker: intAuth.GetIdentityFromContext(ctx),
Runner: runAs,
WorkflowID: wf.ID,
KeepFor: wf.KeepSessions,
Trace: wf.Trace || p.Trace,
Input: scope,
StepID: p.StepID,
EventType: p.EventType,
ResourceType: p.ResourceType,
CallStack: wfexec.GetContextCallStack(ctx),
})
}
func makeWorkflowHandler(svc *workflow, wf *types.Workflow, t *types.Trigger) eventbus.HandlerFn {
return func(ctx context.Context, ev eventbus.Event) (err error) {
var (
scope *expr.Vars
)
if dec, is := ev.(varsEncoder); is {
scope, err = dec.EncodeVars()
if err != nil {
return
}
}
wait, _, err := svc.exec(ctx, wf, types.WorkflowExecParams{
StepID: t.StepID,
EventType: t.EventType,
ResourceType: t.ResourceType,
Input: t.Input.MustMerge(scope),
Trace: wf.Trace,
Async: false,
})
if err != nil {
return
}
if wf.CheckDeferred() {
// deferred workflow, return right away and keep the workflow session
// running without waiting for the execution
return
}
// wait for the workflow to complete
// reuse scope for results
// this will be decoded back to event properties
scope, _, _, _, err = wait(ctx)
if err != nil {
return
}
if dec, is := ev.(varsDecoder); is {
return dec.DecodeVars(scope)
}
return
}
}
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,
)
}
}
}