Extreact & refactor automation scripts & triggets
Automation (scripts and triggers) is now a standalone package and can be used in other services.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapgrpc"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/grpclog"
|
||||
|
||||
"github.com/cortezaproject/corteza-server/pkg/cli/options"
|
||||
)
|
||||
|
||||
// Corredor standard connector to Corredor service via gRPC
|
||||
func Corredor(ctx context.Context, opt options.ScriptRunnerOpt, logger *zap.Logger) (c *grpc.ClientConn, err error) {
|
||||
if !opt.Enabled {
|
||||
// Do not connect when script runner is not enabled
|
||||
return
|
||||
}
|
||||
|
||||
if opt.Log {
|
||||
// Send logs to zap
|
||||
//
|
||||
// waiting for https://github.com/uber-go/zap/pull/538
|
||||
grpclog.SetLogger(zapgrpc.NewLogger(logger.Named("grpc")))
|
||||
}
|
||||
|
||||
var dopts = []grpc.DialOption{
|
||||
// @todo insecure?
|
||||
grpc.WithInsecure(),
|
||||
}
|
||||
|
||||
if opt.MaxBackoffDelay > 0 {
|
||||
dopts = append(dopts, grpc.WithBackoffMaxDelay(opt.MaxBackoffDelay))
|
||||
}
|
||||
|
||||
return grpc.DialContext(ctx, opt.Addr, dopts...)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package automation
|
||||
|
||||
// Hello! This file is auto-generated.
|
||||
|
||||
type (
|
||||
|
||||
// ScriptSet slice of Script
|
||||
//
|
||||
// This type is auto-generated.
|
||||
ScriptSet []*Script
|
||||
)
|
||||
|
||||
// Walk iterates through every slice item and calls w(Script) err
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (set ScriptSet) Walk(w func(*Script) error) (err error) {
|
||||
for i := range set {
|
||||
if err = w(set[i]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Filter iterates through every slice item, calls f(Script) (bool, err) and return filtered slice
|
||||
//
|
||||
// This function is auto-generated.
|
||||
func (set ScriptSet) Filter(f func(*Script) (bool, error)) (out ScriptSet, err error) {
|
||||
var ok bool
|
||||
out = ScriptSet{}
|
||||
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 ScriptSet) FindByID(ID uint64) *Script {
|
||||
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 ScriptSet) IDs() (IDs []uint64) {
|
||||
IDs = make([]uint64, len(set))
|
||||
|
||||
for i := range set {
|
||||
IDs[i] = set[i].ID
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"errors"
|
||||
|
||||
"github.com/cortezaproject/corteza-server/internal/test"
|
||||
)
|
||||
|
||||
// Hello! This file is auto-generated.
|
||||
|
||||
func TestScriptSetWalk(t *testing.T) {
|
||||
value := make(ScriptSet, 3)
|
||||
|
||||
// check walk with no errors
|
||||
{
|
||||
err := value.Walk(func(*Script) error {
|
||||
return nil
|
||||
})
|
||||
test.NoError(t, err, "Expected no returned error from Walk, got %+v", err)
|
||||
}
|
||||
|
||||
// check walk with error
|
||||
test.Error(t, value.Walk(func(*Script) error { return errors.New("Walk error") }), "Expected error from walk, got nil")
|
||||
}
|
||||
|
||||
func TestScriptSetFilter(t *testing.T) {
|
||||
value := make(ScriptSet, 3)
|
||||
|
||||
// filter nothing
|
||||
{
|
||||
set, err := value.Filter(func(*Script) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
test.NoError(t, err, "Didn't expect error when filtering set: %+v", err)
|
||||
test.Assert(t, len(set) == len(value), "Expected equal length filter: %d != %d", len(value), len(set))
|
||||
}
|
||||
|
||||
// filter one item
|
||||
{
|
||||
found := false
|
||||
set, err := value.Filter(func(*Script) (bool, error) {
|
||||
if !found {
|
||||
found = true
|
||||
return found, nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
test.NoError(t, err, "Didn't expect error when filtering set: %+v", err)
|
||||
test.Assert(t, len(set) == 1, "Expected single item, got %d", len(value))
|
||||
}
|
||||
|
||||
// filter error
|
||||
{
|
||||
_, err := value.Filter(func(*Script) (bool, error) {
|
||||
return false, errors.New("Filter error")
|
||||
})
|
||||
test.Error(t, err, "Expected error, got %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptSetIDs(t *testing.T) {
|
||||
value := make(ScriptSet, 3)
|
||||
// construct objects
|
||||
value[0] = new(Script)
|
||||
value[1] = new(Script)
|
||||
value[2] = new(Script)
|
||||
// set ids
|
||||
value[0].ID = 1
|
||||
value[1].ID = 2
|
||||
value[2].ID = 3
|
||||
|
||||
// Find existing
|
||||
{
|
||||
val := value.FindByID(2)
|
||||
test.Assert(t, val.ID == 2, "Expected ID 2, got %d", val.ID)
|
||||
}
|
||||
|
||||
// Find non-existing
|
||||
{
|
||||
val := value.FindByID(4)
|
||||
test.Assert(t, val == nil, "Expected no value, got %#v", val)
|
||||
}
|
||||
|
||||
// List IDs from set
|
||||
{
|
||||
val := value.IDs()
|
||||
test.Assert(t, len(val) == len(value), "Expected ID count mismatch, %d != %d", len(val), len(value))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/cortezaproject/corteza-server/pkg/rh"
|
||||
)
|
||||
|
||||
type (
|
||||
Script struct {
|
||||
ID uint64 `json:"scriptID,string" db:"id"`
|
||||
|
||||
Name string `json:"name" db:"name"`
|
||||
|
||||
// (URL) Where did we get the source from?
|
||||
SourceRef string `json:"sourceRef" db:"source_ref"`
|
||||
|
||||
// Code
|
||||
Source string `json:"source" db:"source"`
|
||||
|
||||
// No need to wait for script to return the value
|
||||
Async bool `json:"async" db:"async"`
|
||||
|
||||
// Who is running this script?
|
||||
// Leave it at 0 for the current user (security invoker) or
|
||||
// set ID of specific user (security definer)
|
||||
RunAs uint64 `json:"runAs,string" db:"rel_runner"`
|
||||
|
||||
// Where can we run this script? user-agent? corredor service?
|
||||
RunInUA bool `json:"runInUA" db:"run_in_ua"`
|
||||
|
||||
// Are you doing something that can take more time?
|
||||
// specify timeout (in milliseconds)
|
||||
Timeout uint `json:"timeout" db:"timeout"`
|
||||
|
||||
// Is it critical to run this script successfully?
|
||||
Critical bool `json:"critical" db:"critical"`
|
||||
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
|
||||
CreatedAt time.Time `db:"created_at" json:"createdAt"`
|
||||
CreatedBy uint64 `db:"created_by" json:"createdBy,string" `
|
||||
UpdatedAt *time.Time `db:"updated_at" json:"updatedAt,omitempty"`
|
||||
UpdatedBy uint64 `db:"updated_by" json:"updatedBy,string,omitempty" `
|
||||
DeletedAt *time.Time `db:"deleted_at" json:"deletedAt,omitempty"`
|
||||
DeletedBy uint64 `db:"deleted_by" json:"deletedBy,string,omitempty" `
|
||||
|
||||
triggers TriggerSet
|
||||
}
|
||||
|
||||
ScriptFilter struct {
|
||||
Query string
|
||||
Resource string
|
||||
IncDeleted bool `json:"incDeleted"`
|
||||
|
||||
// Standard paging fields & helpers
|
||||
rh.PageFilter
|
||||
}
|
||||
)
|
||||
|
||||
// IsValid - enabled, deleted?
|
||||
func (s *Script) IsValid() bool {
|
||||
return s != nil && s.Enabled && s.DeletedAt == nil
|
||||
}
|
||||
|
||||
// Verify - sanity check of script's properties
|
||||
func (s Script) Verify() error {
|
||||
if s.RunAsDefined() && s.RunInUA {
|
||||
return errors.New("user-agent engine does not support run-as-defined scripts")
|
||||
}
|
||||
|
||||
if s.Critical && s.RunInUA {
|
||||
return errors.New("user-agent engine scripts can not be critical")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsCompatible verifies if trigger can be added to a script
|
||||
func (s *Script) CheckCompatibility(t *Trigger) error {
|
||||
if s == nil {
|
||||
return errors.New("not compatible with nil script")
|
||||
}
|
||||
if s == nil || t == nil {
|
||||
return errors.New("not compatible with nil trigger")
|
||||
}
|
||||
|
||||
if t.IsDeferred() {
|
||||
if s.RunInUA {
|
||||
return errors.New("deferred triggers are not compatible with user-agent scripts")
|
||||
}
|
||||
|
||||
if s.RunAsInvoker() {
|
||||
return errors.New("deferred triggers are not compatible with run-as-invoker scripts")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilterByEvent
|
||||
//
|
||||
// we will use the Trigger struct as a holder for conditions
|
||||
func (set ScriptSet) FilterByEvent(event, resource string, cc ...TriggerConditionChecker) (out ScriptSet) {
|
||||
out, _ = set.Filter(func(s *Script) (bool, error) {
|
||||
return s.triggers.HasMatch(Trigger{Event: event, Resource: resource}, cc...), nil
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// RunAsDefined - script should be run with pre-defined privileges (user)
|
||||
func (s Script) RunAsDefined() bool {
|
||||
return s.RunAs > 0
|
||||
}
|
||||
|
||||
// RunAsInvoker - this script should run with invoker's privileges (user)
|
||||
func (s Script) RunAsInvoker() bool {
|
||||
return s.RunAs == 0
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/titpetric/factory"
|
||||
"gopkg.in/Masterminds/squirrel.v1"
|
||||
|
||||
"github.com/cortezaproject/corteza-server/pkg/rh"
|
||||
)
|
||||
|
||||
type (
|
||||
// repository servs as a db storage layer for permission rules
|
||||
scriptRepository struct {
|
||||
dbh *factory.DB
|
||||
|
||||
// sql table reference
|
||||
dbTablePrefix string
|
||||
}
|
||||
)
|
||||
|
||||
func ScriptRepository(db *factory.DB, dbTablePrefix string) *scriptRepository {
|
||||
return &scriptRepository{
|
||||
dbTablePrefix: dbTablePrefix,
|
||||
dbh: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *scriptRepository) With(ctx context.Context) *scriptRepository {
|
||||
return &scriptRepository{
|
||||
dbTablePrefix: r.dbTablePrefix,
|
||||
dbh: r.db().With(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *scriptRepository) db() *factory.DB {
|
||||
return r.dbh
|
||||
}
|
||||
|
||||
func (r scriptRepository) table() string {
|
||||
return r.dbTablePrefix + "_automation_script"
|
||||
}
|
||||
|
||||
func (r scriptRepository) columns() []string {
|
||||
return []string{
|
||||
"id",
|
||||
"name",
|
||||
"source_ref",
|
||||
"source",
|
||||
"async",
|
||||
"rel_runner",
|
||||
"run_in_ua",
|
||||
"timeout",
|
||||
"critical",
|
||||
"enabled",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
"deleted_at",
|
||||
"deleted_by",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *scriptRepository) query() squirrel.SelectBuilder {
|
||||
return squirrel.
|
||||
Select(r.columns()...).
|
||||
From(r.table())
|
||||
}
|
||||
|
||||
// FindByID finds specific script
|
||||
func (r *scriptRepository) FindByID(ctx context.Context, scriptID uint64) (*Script, error) {
|
||||
var (
|
||||
rval = &Script{}
|
||||
|
||||
query = r.query().
|
||||
Columns(r.columns()...).
|
||||
Where("id = ?", scriptID)
|
||||
)
|
||||
|
||||
return rval, rh.IsFound(rh.FetchOne(r.db(), query, rval), rval.ID > 0, errors.New("script not found"))
|
||||
}
|
||||
|
||||
// Find - finds scripts using given filter
|
||||
func (r *scriptRepository) Find(ctx context.Context, filter ScriptFilter) (set ScriptSet, f ScriptFilter, err error) {
|
||||
f = filter
|
||||
|
||||
query := r.query()
|
||||
|
||||
if !filter.IncDeleted {
|
||||
query = query.Where("deleted_at IS NULL")
|
||||
}
|
||||
|
||||
if f.Query != "" {
|
||||
q := "%" + f.Query + "%"
|
||||
query = query.Where("name like ?", q)
|
||||
}
|
||||
|
||||
if f.Resource != "" {
|
||||
// Making partial trigger repo struct on the fly to help us calculate the name of the triggers table
|
||||
ttable := (triggerRepository{dbTablePrefix: r.dbTablePrefix}).table()
|
||||
query = query.Where(
|
||||
fmt.Sprintf("id IN (SELECT rel_script FROM `%s` WHERE resource = ?", ttable),
|
||||
f.Resource,
|
||||
)
|
||||
}
|
||||
|
||||
if f.Count, err = rh.Count(r.db(), query); err != nil || f.Count == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
query = query.OrderBy("id ASC")
|
||||
|
||||
return set, f, rh.FetchPaged(r.db(), query, f.Page, f.PerPage, &set)
|
||||
}
|
||||
|
||||
// FindAllRunnable - loads and returns all runnable scripts
|
||||
func (r *scriptRepository) FindAllRunnable() (ScriptSet, error) {
|
||||
rr := make([]*Script, 0)
|
||||
|
||||
return rr, errors.Wrap(rh.FetchAll(
|
||||
r.db(),
|
||||
r.query().Where("enabled AND deleted_at IS NULL"),
|
||||
&rr,
|
||||
), "could not load runnable scripts")
|
||||
}
|
||||
|
||||
func (r *scriptRepository) Create(s *Script) (err error) {
|
||||
return r.dbh.Transaction(func() error {
|
||||
return r.dbh.Insert(r.table(), s)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *scriptRepository) Update(s *Script) (err error) {
|
||||
return r.dbh.Transaction(func() error {
|
||||
return r.dbh.Update(r.table(), s, "id")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScript_CheckCompatibility(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
s *Script
|
||||
t *Trigger
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "both nil",
|
||||
s: nil,
|
||||
t: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{name: "both vanilla",
|
||||
s: &Script{},
|
||||
t: &Trigger{},
|
||||
wantErr: false,
|
||||
},
|
||||
{name: "deferred trigger with UA script",
|
||||
s: &Script{RunInUA: true},
|
||||
t: &Trigger{Event: EVENT_TYPE_INTERVAL},
|
||||
wantErr: true,
|
||||
},
|
||||
{name: "deferred trigger with invoker security",
|
||||
s: &Script{RunAs: 0},
|
||||
t: &Trigger{Event: EVENT_TYPE_INTERVAL},
|
||||
wantErr: true,
|
||||
},
|
||||
{name: "deferred trigger with invoker security",
|
||||
s: &Script{RunAs: 1, RunInUA: false},
|
||||
t: &Trigger{Event: EVENT_TYPE_INTERVAL},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.s.CheckCompatibility(tt.t); (err != nil) != tt.wantErr {
|
||||
t.Errorf("CheckCompatibility() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/titpetric/factory"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/cortezaproject/corteza-server/internal/auth"
|
||||
"github.com/cortezaproject/corteza-server/pkg/sentry"
|
||||
)
|
||||
|
||||
type (
|
||||
service struct {
|
||||
l sync.Mutex
|
||||
logger *zap.Logger
|
||||
|
||||
c AutomationServiceConfig
|
||||
|
||||
// service will flush values on TRUE or just reload on FALSE
|
||||
f chan bool
|
||||
|
||||
// internal list of runnable scripts (and their accompanying triggers)
|
||||
runnables ScriptSet
|
||||
|
||||
srepo *scriptRepository
|
||||
trepo *triggerRepository
|
||||
}
|
||||
|
||||
ScriptsProvider interface {
|
||||
FilterByEvent(event, resource string, cc ...TriggerConditionChecker) ScriptSet
|
||||
}
|
||||
|
||||
WatcherService interface {
|
||||
Watch(ctx context.Context)
|
||||
}
|
||||
|
||||
AutomationServiceConfig struct {
|
||||
DB *factory.DB
|
||||
DbTablePrefix string
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
watchInterval = time.Hour
|
||||
)
|
||||
|
||||
// Service initializes service{} struct
|
||||
//
|
||||
// service{} struct handles scripts & triggers. It acts as a caching layer and
|
||||
// proxy to repository where it verifies and enriches payloads
|
||||
//
|
||||
func Service(ctx context.Context, logger *zap.Logger, c AutomationServiceConfig) (svc *service) {
|
||||
svc = &service{
|
||||
logger: logger.Named("automation"),
|
||||
|
||||
c: c,
|
||||
|
||||
f: make(chan bool),
|
||||
}
|
||||
|
||||
if c.DB != nil {
|
||||
svc.srepo = ScriptRepository(c.DB, c.DbTablePrefix)
|
||||
svc.trepo = TriggerRepository(c.DB, c.DbTablePrefix)
|
||||
}
|
||||
|
||||
svc.Reload(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
// Watch() Watches for changes
|
||||
func (svc service) Watch(ctx context.Context) {
|
||||
go func() {
|
||||
defer sentry.Recover()
|
||||
|
||||
var ticker = time.NewTicker(watchInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
svc.Reload(ctx)
|
||||
case <-svc.f:
|
||||
svc.Reload(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
svc.logger.Debug("watcher initialized")
|
||||
}
|
||||
|
||||
func (svc *service) Reload(ctx context.Context) {
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
if svc.c.DB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
err error
|
||||
ss ScriptSet
|
||||
tt TriggerSet
|
||||
)
|
||||
|
||||
ss, err = svc.srepo.With(ctx).FindAllRunnable()
|
||||
svc.logger.Info("scripts loaded", zap.Error(err), zap.Int("count", len(tt)))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only interested in valid scritps
|
||||
ss, _ = ss.Filter(func(s *Script) (b bool, e error) {
|
||||
return s.IsValid(), nil
|
||||
})
|
||||
|
||||
tt, err = svc.trepo.With(ctx).FindAllRunnable()
|
||||
svc.logger.Info("triggers loaded", zap.Error(err), zap.Int("count", len(tt)))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = tt.Walk(func(t *Trigger) error {
|
||||
s := ss.FindByID(t.ScriptID)
|
||||
if t.IsValid() && s.CheckCompatibility(t) != nil {
|
||||
// Add only compatible triggers
|
||||
s.triggers = append(s.triggers, t)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// FindRunnableScripts scans internal list of runnable scripts and filters them by (trigger's) event and origin
|
||||
func (svc service) FindRunnableScripts(event, origin string, cc ...TriggerConditionChecker) ScriptSet {
|
||||
return svc.runnables.FilterByEvent(event, origin, cc...)
|
||||
}
|
||||
|
||||
// updateRunnableScripts - updates script set (internal runnable scripts list)
|
||||
func (svc service) updateRunnableScripts(n *Script) {
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
ss := svc.runnables
|
||||
|
||||
for i := range svc.runnables {
|
||||
if ss[i].ID != n.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
if n.IsValid() {
|
||||
// Valid, replace
|
||||
ss[i] = n
|
||||
}
|
||||
|
||||
// Invalid, remove
|
||||
ss = append(ss[:i], ss[i+1:]...)
|
||||
return
|
||||
}
|
||||
|
||||
if n.IsValid() {
|
||||
ss = append(ss, n)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// updateScriptsWithTrigger - finds the referenced script and updates its trigger set
|
||||
func (svc service) updateScriptWithTrigger(n *Trigger) {
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
ss := svc.runnables
|
||||
|
||||
for i := range ss {
|
||||
if ss[i].ID != n.ScriptID {
|
||||
continue
|
||||
}
|
||||
|
||||
tt := ss[i].triggers
|
||||
|
||||
for i = range tt {
|
||||
if n.IsValid() {
|
||||
// Valid, replace
|
||||
tt[i] = n
|
||||
}
|
||||
|
||||
// Invalid, remove
|
||||
tt = append(tt[:i], tt[i+i:]...)
|
||||
return
|
||||
}
|
||||
|
||||
if n.IsValid() {
|
||||
tt = append(tt, n)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (svc service) FindScriptByID(ctx context.Context, scriptID uint64) (*Script, error) {
|
||||
return svc.srepo.FindByID(ctx, scriptID)
|
||||
}
|
||||
|
||||
func (svc service) FindScripts(ctx context.Context, f ScriptFilter) (ScriptSet, ScriptFilter, error) {
|
||||
return svc.srepo.Find(ctx, f)
|
||||
}
|
||||
|
||||
// CreateScript - modifies script's props, pushes to repo & updates scripts cache
|
||||
func (svc service) CreateScript(ctx context.Context, s *Script) (err error) {
|
||||
s.ID = factory.Sonyflake.NextID()
|
||||
s.CreatedAt = time.Now()
|
||||
s.CreatedBy = auth.GetIdentityFromContext(ctx).Identity()
|
||||
|
||||
if err = svc.srepo.Create(s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc.updateRunnableScripts(s)
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateScript - modifies script's props, pushes to repo & updates scripts cache
|
||||
func (svc service) UpdateScript(ctx context.Context, s *Script) (err error) {
|
||||
s.UpdatedAt = &time.Time{}
|
||||
*s.UpdatedAt = time.Now()
|
||||
s.UpdatedBy = auth.GetIdentityFromContext(ctx).Identity()
|
||||
|
||||
// Ensure sanity
|
||||
s.UpdatedAt, s.UpdatedBy = nil, 0
|
||||
s.DeletedAt, s.DeletedBy = nil, 0
|
||||
|
||||
if err = svc.srepo.Update(s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc.updateRunnableScripts(s)
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteScript - modifies script's props, pushes to repo & updates scripts cache
|
||||
func (svc service) DeleteScript(ctx context.Context, s *Script) (err error) {
|
||||
s.DeletedAt = &time.Time{}
|
||||
*s.DeletedAt = time.Now()
|
||||
s.DeletedBy = auth.GetIdentityFromContext(ctx).Identity()
|
||||
|
||||
// We're doing soft delete in the repo
|
||||
if err = svc.srepo.Update(s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc.updateRunnableScripts(s)
|
||||
return
|
||||
}
|
||||
|
||||
func (svc service) FindTriggerByID(ctx context.Context, scriptID uint64) (*Trigger, error) {
|
||||
return svc.trepo.FindByID(ctx, scriptID)
|
||||
}
|
||||
|
||||
func (svc service) FindTriggers(ctx context.Context, f TriggerFilter) (TriggerSet, TriggerFilter, error) {
|
||||
return svc.trepo.Find(ctx, f)
|
||||
}
|
||||
|
||||
// CreateScript - modifies script's props, pushes to repo & updates scripts cache
|
||||
func (svc service) CreateTrigger(ctx context.Context, s *Script, t *Trigger) (err error) {
|
||||
if err = s.CheckCompatibility(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.ID = factory.Sonyflake.NextID()
|
||||
t.CreatedAt = time.Now()
|
||||
t.CreatedBy = auth.GetIdentityFromContext(ctx).Identity()
|
||||
t.ScriptID = s.ID
|
||||
|
||||
if err = svc.trepo.Create(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc.updateScriptWithTrigger(t)
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateTrigger - modifies script's props, pushes to repo & updates scripts cache
|
||||
func (svc service) UpdateTrigger(ctx context.Context, s *Script, t *Trigger) (err error) {
|
||||
if s.ID != t.ScriptID {
|
||||
return errors.New("invalid script-trigger reference")
|
||||
}
|
||||
|
||||
if err = s.CheckCompatibility(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.UpdatedAt = &time.Time{}
|
||||
*t.UpdatedAt = time.Now()
|
||||
t.UpdatedBy = auth.GetIdentityFromContext(ctx).Identity()
|
||||
|
||||
if err = svc.trepo.Update(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc.updateScriptWithTrigger(t)
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteTrigger - modifies script's props, pushes to repo & updates scripts cache
|
||||
func (svc service) DeleteTrigger(ctx context.Context, t *Trigger) (err error) {
|
||||
t.DeletedAt = &time.Time{}
|
||||
*t.DeletedAt = time.Now()
|
||||
t.DeletedBy = auth.GetIdentityFromContext(ctx).Identity()
|
||||
|
||||
// We're doing soft delete in the repo
|
||||
if err = svc.trepo.Update(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc.updateScriptWithTrigger(t)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package automation
|
||||
|
||||
// Hello! This file is auto-generated.
|
||||
|
||||
type (
|
||||
|
||||
// TriggerSet slice of Trigger
|
||||
//
|
||||
// This type is auto-generated.
|
||||
TriggerSet []*Trigger
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"errors"
|
||||
|
||||
"github.com/cortezaproject/corteza-server/internal/test"
|
||||
)
|
||||
|
||||
// Hello! This file is auto-generated.
|
||||
|
||||
func TestTriggerSetWalk(t *testing.T) {
|
||||
value := make(TriggerSet, 3)
|
||||
|
||||
// check walk with no errors
|
||||
{
|
||||
err := value.Walk(func(*Trigger) error {
|
||||
return nil
|
||||
})
|
||||
test.NoError(t, err, "Expected no returned error from Walk, got %+v", err)
|
||||
}
|
||||
|
||||
// check walk with error
|
||||
test.Error(t, value.Walk(func(*Trigger) error { return errors.New("Walk error") }), "Expected error from walk, got nil")
|
||||
}
|
||||
|
||||
func TestTriggerSetFilter(t *testing.T) {
|
||||
value := make(TriggerSet, 3)
|
||||
|
||||
// filter nothing
|
||||
{
|
||||
set, err := value.Filter(func(*Trigger) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
test.NoError(t, err, "Didn't expect error when filtering set: %+v", err)
|
||||
test.Assert(t, len(set) == len(value), "Expected equal length filter: %d != %d", len(value), len(set))
|
||||
}
|
||||
|
||||
// filter one item
|
||||
{
|
||||
found := false
|
||||
set, err := value.Filter(func(*Trigger) (bool, error) {
|
||||
if !found {
|
||||
found = true
|
||||
return found, nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
test.NoError(t, err, "Didn't expect error when filtering set: %+v", err)
|
||||
test.Assert(t, len(set) == 1, "Expected single item, got %d", len(value))
|
||||
}
|
||||
|
||||
// filter error
|
||||
{
|
||||
_, err := value.Filter(func(*Trigger) (bool, error) {
|
||||
return false, errors.New("Filter error")
|
||||
})
|
||||
test.Error(t, err, "Expected error, got %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerSetIDs(t *testing.T) {
|
||||
value := make(TriggerSet, 3)
|
||||
// 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)
|
||||
test.Assert(t, val.ID == 2, "Expected ID 2, got %d", val.ID)
|
||||
}
|
||||
|
||||
// Find non-existing
|
||||
{
|
||||
val := value.FindByID(4)
|
||||
test.Assert(t, val == nil, "Expected no value, got %#v", val)
|
||||
}
|
||||
|
||||
// List IDs from set
|
||||
{
|
||||
val := value.IDs()
|
||||
test.Assert(t, len(val) == len(value), "Expected ID count mismatch, %d != %d", len(val), len(value))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza-server/pkg/rh"
|
||||
)
|
||||
|
||||
type (
|
||||
Event string
|
||||
|
||||
Trigger struct {
|
||||
ID uint64 `json:"triggerID,string" db:"id"`
|
||||
|
||||
// Resource that triggered the event
|
||||
// - "compose:" (unspec, general)
|
||||
// - "compose:record"
|
||||
// - "compose:namespace"
|
||||
Resource string `json:"resource" db:"resource"`
|
||||
|
||||
// Event name, arbitrary string
|
||||
// - "before"
|
||||
// - "after"
|
||||
// - "on"
|
||||
// - "at"
|
||||
Event string `json:"event" db:"event"`
|
||||
|
||||
// Arbitrary data for trigger condition
|
||||
//
|
||||
// It is caller's responsibility to encode, decode and verify conditions
|
||||
Condition string `json:"condition" db:"condition"`
|
||||
|
||||
ScriptID uint64 `json:"scriptID,string" db:"rel_script"`
|
||||
|
||||
// Is trigger enabled or disabled?
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
|
||||
CreatedAt time.Time `db:"created_at" json:"createdAt"`
|
||||
CreatedBy uint64 `db:"created_by" json:"createdBy,string" `
|
||||
UpdatedAt *time.Time `db:"updated_at" json:"updatedAt,omitempty"`
|
||||
UpdatedBy uint64 `db:"updated_by" json:"updatedBy,string,omitempty" `
|
||||
DeletedAt *time.Time `db:"deleted_at" json:"deletedAt,omitempty"`
|
||||
DeletedBy uint64 `db:"deleted_by" json:"deletedBy,string,omitempty" `
|
||||
}
|
||||
|
||||
TriggerFilter struct {
|
||||
Resource string
|
||||
Event string
|
||||
ScriptID uint64
|
||||
|
||||
IncDeleted bool
|
||||
|
||||
// Standard paging fields & helpers
|
||||
rh.PageFilter
|
||||
}
|
||||
|
||||
TriggerConditionChecker func(string) bool
|
||||
)
|
||||
|
||||
const (
|
||||
EVENT_TYPE_INTERVAL = "interval"
|
||||
EVENT_TYPE_TIMESTAMP = "at"
|
||||
)
|
||||
|
||||
// IsValid checks if trigger is enabled and not deleted
|
||||
func (t *Trigger) IsValid() bool {
|
||||
return t != nil && t.Enabled && t.DeletedAt == nil
|
||||
}
|
||||
|
||||
// IsDeferred - not called as consequence of a user's action (create, delete, update)
|
||||
func (t Trigger) IsDeferred() bool {
|
||||
return t.Event == EVENT_TYPE_INTERVAL || t.Event == EVENT_TYPE_TIMESTAMP
|
||||
}
|
||||
|
||||
// HasMatch checks if any og the triggers in a set matches the given parameters
|
||||
func (set TriggerSet) HasMatch(m Trigger, ff ...TriggerConditionChecker) bool {
|
||||
withTriggers:
|
||||
for _, t := range set {
|
||||
if !t.IsValid() {
|
||||
// only valid can match
|
||||
continue withTriggers
|
||||
}
|
||||
|
||||
if m.ID > 0 && m.ID != t.ID {
|
||||
// Are we looking for a particular trigger?
|
||||
continue withTriggers
|
||||
}
|
||||
|
||||
if m.Resource != t.Resource {
|
||||
// event should match
|
||||
continue withTriggers
|
||||
}
|
||||
|
||||
if m.Event != t.Event {
|
||||
// event should match
|
||||
continue withTriggers
|
||||
}
|
||||
|
||||
// Go through all condition checking functions
|
||||
// All of them should return true for trigger to match
|
||||
for _, fn := range ff {
|
||||
if !fn(t.Condition) {
|
||||
continue withTriggers
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/titpetric/factory"
|
||||
"gopkg.in/Masterminds/squirrel.v1"
|
||||
|
||||
"github.com/cortezaproject/corteza-server/pkg/rh"
|
||||
)
|
||||
|
||||
type (
|
||||
// repository servs as a db storage layer for permission rules
|
||||
triggerRepository struct {
|
||||
dbh *factory.DB
|
||||
|
||||
// sql table reference
|
||||
dbTablePrefix string
|
||||
}
|
||||
)
|
||||
|
||||
func TriggerRepository(db *factory.DB, dbTablePrefix string) *triggerRepository {
|
||||
return &triggerRepository{
|
||||
dbTablePrefix: dbTablePrefix,
|
||||
dbh: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *triggerRepository) With(ctx context.Context) *triggerRepository {
|
||||
return &triggerRepository{
|
||||
dbTablePrefix: r.dbTablePrefix,
|
||||
dbh: r.db().With(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *triggerRepository) db() *factory.DB {
|
||||
return r.dbh
|
||||
}
|
||||
|
||||
func (r triggerRepository) table() string {
|
||||
return r.dbTablePrefix + "_automation_trigger"
|
||||
}
|
||||
|
||||
func (r triggerRepository) columns() []string {
|
||||
return []string{
|
||||
"id",
|
||||
"event",
|
||||
"resource",
|
||||
"`condition`",
|
||||
"rel_script",
|
||||
"enabled",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
"deleted_at",
|
||||
"deleted_by",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *triggerRepository) query() squirrel.SelectBuilder {
|
||||
return squirrel.
|
||||
Select(r.columns()...).
|
||||
From(r.table())
|
||||
}
|
||||
|
||||
// FindByID finds specific trigger
|
||||
func (r *triggerRepository) FindByID(ctx context.Context, triggerID uint64) (*Trigger, error) {
|
||||
var (
|
||||
rval = &Trigger{}
|
||||
|
||||
query = r.query().
|
||||
Columns(r.columns()...).
|
||||
Where("id = ?", triggerID)
|
||||
)
|
||||
|
||||
return rval, rh.IsFound(rh.FetchOne(r.db(), query, rval), rval.ID > 0, errors.New("trigger not found"))
|
||||
}
|
||||
|
||||
// Find - finds triggers using given filter
|
||||
func (r *triggerRepository) Find(ctx context.Context, filter TriggerFilter) (set TriggerSet, f TriggerFilter, err error) {
|
||||
f = filter
|
||||
|
||||
query := r.query()
|
||||
|
||||
if f.ScriptID > 0 {
|
||||
query = query.Where("rel_script = ?", f.ScriptID)
|
||||
}
|
||||
|
||||
if f.Event != "" {
|
||||
query = query.Where("resource = ?", f.Event)
|
||||
}
|
||||
|
||||
if f.Resource != "" {
|
||||
query = query.Where("resource = ?", f.Resource)
|
||||
}
|
||||
|
||||
if !filter.IncDeleted {
|
||||
query = query.Where("deleted_at IS NULL")
|
||||
}
|
||||
|
||||
if f.Count, err = rh.Count(r.db(), query); err != nil || f.Count == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
query = query.OrderBy("id ASC")
|
||||
|
||||
return set, f, rh.FetchPaged(r.db(), query, f.Page, f.PerPage, &set)
|
||||
}
|
||||
|
||||
// FindAllRunnable - loads and returns all runnable triggers
|
||||
func (r *triggerRepository) FindAllRunnable() (TriggerSet, error) {
|
||||
rr := make([]*Trigger, 0)
|
||||
|
||||
return rr, errors.Wrap(rh.FetchAll(
|
||||
r.db(),
|
||||
r.query().Where("enabled AND deleted_at IS NULL"),
|
||||
&rr,
|
||||
), "could not load runnable triggers")
|
||||
}
|
||||
|
||||
func (r *triggerRepository) Create(s *Trigger) (err error) {
|
||||
return r.dbh.Transaction(func() error {
|
||||
// Generate ID
|
||||
s.ID = factory.Sonyflake.NextID()
|
||||
|
||||
if s.CreatedAt.IsZero() {
|
||||
// Make sure time of creation is set
|
||||
s.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
// Ensure sanity
|
||||
s.UpdatedAt, s.UpdatedBy = nil, 0
|
||||
s.DeletedAt, s.DeletedBy = nil, 0
|
||||
|
||||
return r.dbh.Insert(r.table(), s)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *triggerRepository) Update(s *Trigger) (err error) {
|
||||
return r.dbh.Transaction(func() error {
|
||||
s.UpdatedAt = &time.Time{}
|
||||
*s.UpdatedAt = time.Now()
|
||||
|
||||
return r.dbh.Update(r.table(), s, "id")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTriggerSet_HasMatch(t *testing.T) {
|
||||
type args struct {
|
||||
m Trigger
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
set TriggerSet
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "simple match",
|
||||
set: TriggerSet{nil, &Trigger{}, &Trigger{Event: "e", Enabled: true}, nil, &Trigger{}},
|
||||
args: args{m: Trigger{Event: "e"}},
|
||||
want: true,
|
||||
}, {
|
||||
name: "simple miss",
|
||||
set: TriggerSet{nil, &Trigger{}, &Trigger{Event: "e", Enabled: true}, nil, &Trigger{}},
|
||||
args: args{m: Trigger{}},
|
||||
want: false,
|
||||
}, {
|
||||
name: "specific",
|
||||
set: TriggerSet{nil, &Trigger{}, &Trigger{ID: 2, Enabled: true}, nil, &Trigger{}},
|
||||
args: args{m: Trigger{ID: 2}},
|
||||
want: true,
|
||||
}, {
|
||||
name: "invalid",
|
||||
set: TriggerSet{nil, &Trigger{}, &Trigger{Event: "e", Enabled: false}, nil, &Trigger{}},
|
||||
args: args{m: Trigger{Event: "e"}},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.set.HasMatch(tt.args.m); got != tt.want {
|
||||
t.Errorf("HasMatch() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -32,3 +32,7 @@ func (pf *PageFilter) NormalizePerPage(min, max, def uint) {
|
||||
func (pf *PageFilter) NormalizePerPageWithDefaults() {
|
||||
pf.PerPage = NormalizePerPage(pf.PerPage, PER_PAGE_MIN, PER_PAGE_MAX, PER_PAGE_DEFAULT)
|
||||
}
|
||||
|
||||
func (pf *PageFilter) NormalizePerPageNoMax() {
|
||||
pf.PerPage = NormalizePerPage(pf.PerPage, PER_PAGE_MIN, 0, PER_PAGE_DEFAULT)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user