From a2bdeecb0d70b48a3365d2ff7f7098ee673ccbd4 Mon Sep 17 00:00:00 2001 From: Jonas Plum Date: Mon, 4 Nov 2024 22:30:20 +0100 Subject: [PATCH] feat: scheduler (#1108) --- migrations/9_reactions_update.go | 28 +++++ migrations/migrations.go | 1 + reaction/schedule/schedule.go | 101 ++++++++++++++++++ reaction/trigger.go | 2 + ui/src/components/reaction/ReactionForm.vue | 25 ++++- ui/src/components/reaction/ReactionList.vue | 4 +- .../reaction/TriggerScheduleFormFields.vue | 42 ++++++++ .../reaction/TriggerWebhookFormFields.vue | 21 ++-- 8 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 migrations/9_reactions_update.go create mode 100644 reaction/schedule/schedule.go create mode 100644 ui/src/components/reaction/TriggerScheduleFormFields.vue diff --git a/migrations/9_reactions_update.go b/migrations/9_reactions_update.go new file mode 100644 index 0000000..9987fe6 --- /dev/null +++ b/migrations/9_reactions_update.go @@ -0,0 +1,28 @@ +package migrations + +import ( + "fmt" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/models/schema" +) + +func reactionsUpdateUp(db dbx.Builder) error { + dao := daos.New(db) + + triggers := []string{"webhook", "hook", "schedule"} + + col, err := dao.FindCollectionByNameOrId(ReactionCollectionName) + if err != nil { + return fmt.Errorf("failed to find collection %s: %w", ReactionCollectionName, err) + } + + field := col.Schema.GetFieldByName("trigger") + + field.Options = &schema.SelectOptions{MaxSelect: 1, Values: triggers} + + col.Schema.AddField(field) + + return dao.SaveCollection(col) +} diff --git a/migrations/migrations.go b/migrations/migrations.go index f541468..97ae54b 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -13,4 +13,5 @@ func Register() { migrations.Register(systemuserUp, systemuserDown, "1700000006_systemuser.go") migrations.Register(searchViewUp, searchViewDown, "1700000007_search_view.go") migrations.Register(dashboardCountsViewUpdateUp, dashboardCountsViewUpdateDown, "1700000008_dashboardview.go") + migrations.Register(reactionsUpdateUp, nil, "1700000009_reactions_update.go") } diff --git a/reaction/schedule/schedule.go b/reaction/schedule/schedule.go new file mode 100644 index 0000000..5668130 --- /dev/null +++ b/reaction/schedule/schedule.go @@ -0,0 +1,101 @@ +package schedule + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/cron" + "go.uber.org/multierr" + + "github.com/SecurityBrewery/catalyst/migrations" + "github.com/SecurityBrewery/catalyst/reaction/action" +) + +type Schedule struct { + Expression string `json:"expression"` +} + +func Start(pb *pocketbase.PocketBase) { + scheduler := cron.New() + + if err := scheduler.Add("reactions", "* * * * *", func() { + ctx := context.Background() + + moment := cron.NewMoment(time.Now()) + + if err := runSchedule(ctx, pb.App, moment); err != nil { + slog.ErrorContext(ctx, fmt.Sprintf("failed to run hook reaction: %v", err)) + } + }); err != nil { + slog.Error(fmt.Sprintf("failed to add cron job: %v", err)) + } + + scheduler.Start() +} + +func runSchedule(ctx context.Context, app core.App, moment *cron.Moment) error { + var errs error + + records, err := findByScheduleTrigger(app.Dao(), moment) + if err != nil { + errs = multierr.Append(errs, fmt.Errorf("failed to find schedule reaction: %w", err)) + } + + if len(records) == 0 { + return nil + } + + for _, hook := range records { + _, err = action.Run(ctx, app, hook.GetString("action"), hook.GetString("actiondata"), "{}") + if err != nil { + errs = multierr.Append(errs, fmt.Errorf("failed to run hook reaction: %w", err)) + } + } + + return errs +} + +func findByScheduleTrigger(dao *daos.Dao, moment *cron.Moment) ([]*models.Record, error) { + records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "schedule"}) + if err != nil { + return nil, fmt.Errorf("failed to find schedule reaction: %w", err) + } + + if len(records) == 0 { + return nil, nil + } + + var errs error + + var matchedRecords []*models.Record + + for _, record := range records { + var schedule Schedule + if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &schedule); err != nil { + errs = multierr.Append(errs, err) + + continue + } + + s, err := cron.NewSchedule(schedule.Expression) + if err != nil { + errs = multierr.Append(errs, err) + + continue + } + + if s.IsDue(moment) { + matchedRecords = append(matchedRecords, record) + } + } + + return matchedRecords, errs +} diff --git a/reaction/trigger.go b/reaction/trigger.go index 11d1c66..38c2bbb 100644 --- a/reaction/trigger.go +++ b/reaction/trigger.go @@ -3,11 +3,13 @@ package reaction import ( "github.com/pocketbase/pocketbase" + "github.com/SecurityBrewery/catalyst/reaction/schedule" "github.com/SecurityBrewery/catalyst/reaction/trigger/hook" "github.com/SecurityBrewery/catalyst/reaction/trigger/webhook" ) func BindHooks(pb *pocketbase.PocketBase, test bool) { + schedule.Start(pb) hook.BindHooks(pb, test) webhook.BindHooks(pb) } diff --git a/ui/src/components/reaction/ReactionForm.vue b/ui/src/components/reaction/ReactionForm.vue index 87c2fb0..eb78958 100644 --- a/ui/src/components/reaction/ReactionForm.vue +++ b/ui/src/components/reaction/ReactionForm.vue @@ -2,6 +2,7 @@ import ActionPythonFormFields from '@/components/reaction/ActionPythonFormFields.vue' import ActionWebhookFormFields from '@/components/reaction/ActionWebhookFormFields.vue' import TriggerHookFormFields from '@/components/reaction/TriggerHookFormFields.vue' +import TriggerScheduleFormFields from '@/components/reaction/TriggerScheduleFormFields.vue' import TriggerWebhookFormFields from '@/components/reaction/TriggerWebhookFormFields.vue' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' @@ -67,6 +68,25 @@ defineRule('required', (value: string) => { return true }) +defineRule('triggerdata.expression', (value: string) => { + if (values.trigger !== 'schedule') { + return true + } + if (!value) { + return 'This field is required' + } + const macros = ['@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly'] + if (macros.includes(value)) { + return true + } + const expression = + /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/ + if (value.match(expression)) { + return true + } + return 'Invalid cron expression' +}) + defineRule('triggerdata.token', (value: string) => { return true }) @@ -160,6 +180,7 @@ const { handleSubmit, validate, values } = useForm({ validationSchema: { name: 'required', trigger: 'required', + 'triggerdata.expression': 'triggerdata.expression', 'triggerdata.token': 'triggerdata.token', 'triggerdata.path': 'triggerdata.path', 'triggerdata.collections': 'triggerdata.collections', @@ -263,6 +284,7 @@ const curlExample = computed(() => { + Schedule HTTP / Webhook Collection Hook @@ -277,7 +299,8 @@ const curlExample = computed(() => { - + +
diff --git a/ui/src/components/reaction/ReactionList.vue b/ui/src/components/reaction/ReactionList.vue index 4f3c4bb..8b33113 100644 --- a/ui/src/components/reaction/ReactionList.vue +++ b/ui/src/components/reaction/ReactionList.vue @@ -32,7 +32,9 @@ const subtitle = (reaction: Reaction) => triggerNiceName(reaction) + ' to ' + reactionNiceName(reaction) const triggerNiceName = (reaction: Reaction) => { - if (reaction.trigger === 'hook') { + if (reaction.trigger === 'schedule') { + return 'Schedule' + } else if (reaction.trigger === 'hook') { return 'Collection Hook' } else if (reaction.trigger === 'webhook') { return 'HTTP / Webhook' diff --git a/ui/src/components/reaction/TriggerScheduleFormFields.vue b/ui/src/components/reaction/TriggerScheduleFormFields.vue new file mode 100644 index 0000000..2cce470 --- /dev/null +++ b/ui/src/components/reaction/TriggerScheduleFormFields.vue @@ -0,0 +1,42 @@ + + + diff --git a/ui/src/components/reaction/TriggerWebhookFormFields.vue b/ui/src/components/reaction/TriggerWebhookFormFields.vue index 2415a50..aa4fc4a 100644 --- a/ui/src/components/reaction/TriggerWebhookFormFields.vue +++ b/ui/src/components/reaction/TriggerWebhookFormFields.vue @@ -15,15 +15,11 @@ import { Input } from '@/components/ui/input' Token - + - Optional. Include an authorization token in the request headers. + Optional. Include an authorization token in the request headers. Example: + Bearer YOUR_TOKEN @@ -33,14 +29,11 @@ import { Input } from '@/components/ui/input' Path - + - Specify the path to trigger the reaction. + + Specify the path to trigger the reaction. Example: action1 +