Compare commits

...

4 Commits

Author SHA1 Message Date
Jonas Plum
a2bdeecb0d feat: scheduler (#1108) 2024-11-04 22:30:20 +01:00
Jonas Plum
42797509f7 fix: set-app-url (#1107) 2024-11-04 20:50:18 +00:00
Jonas Plum
70ba16a6bd feat: docker healthcheck (#1106) 2024-11-04 20:47:55 +00:00
Jonas Plum
f42de34780 fix: ci docker tags 2024-09-30 03:55:39 +02:00
14 changed files with 240 additions and 50 deletions

View File

@@ -13,12 +13,11 @@ builds:
dockers:
- ids: [ catalyst ]
dockerfile: docker/goreleaser.Dockerfile
dockerfile: docker/Dockerfile
image_templates:
- "ghcr.io/securitybrewery/catalyst:main"
- "ghcr.io/securitybrewery/catalyst:latest"
- "ghcr.io/securitybrewery/catalyst:{{.Tag}}"
- "ghcr.io/securitybrewery/catalyst:v{{.Major}}"
- "ghcr.io/securitybrewery/catalyst:v{{.Major}}.{{.Minor}}"
archives:
- format: tar.gz

View File

@@ -39,6 +39,22 @@ build-ui:
cd ui && bun install
cd ui && bun build-only
.PHONY: build
build: build-ui
@echo "Building..."
go build -o catalyst .
.PHONY: build-linux
build-linux: build-ui
@echo "Building..."
GOOS=linux GOARCH=amd64 go build -o catalyst .
.PHONY: docker
docker: build-linux
@echo "Building Docker image..."
docker build -f docker/Dockerfile -t catalyst .
.PHONY: dev
dev:
@echo "Running..."

View File

@@ -107,12 +107,7 @@ func setAppURL(app core.App) *cobra.Command {
return
}
settings, err := app.Settings().Clone()
if err != nil {
app.Logger().Error(err.Error())
return
}
settings := app.Settings()
settings.Meta.AppUrl = args[0]

View File

@@ -18,6 +18,13 @@ func addRoutes() func(*core.ServeEvent) error {
return c.Redirect(http.StatusFound, "/ui/")
})
e.Router.GET("/ui/*", staticFiles())
e.Router.GET("/health", func(c echo.Context) error {
if _, err := Flags(e.App); err != nil {
return err
}
return c.String(http.StatusOK, "OK")
})
e.Router.GET("/api/config", func(c echo.Context) error {
flags, err := Flags(e.App)

View File

@@ -1,24 +1,14 @@
FROM oven/bun:debian
RUN apt-get update && apt-get install -y make
COPY .. /tmp/catalyst
WORKDIR /tmp/catalyst
RUN make build-ui
FROM golang:1.23
COPY --from=0 /tmp/catalyst /tmp/catalyst
WORKDIR /tmp/catalyst
RUN go build -o /usr/local/bin/catalyst
FROM ubuntu:24.04
COPY --from=1 /usr/local/bin/catalyst /usr/local/bin/catalyst
RUN apt-get update && apt-get install -y curl python3 python3-pip python3-venv
COPY catalyst /usr/local/bin/catalyst
EXPOSE 8080
VOLUME /usr/local/bin/catalyst_data
HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
CMD ["/usr/local/bin/catalyst", "serve", "--http", "0.0.0.0:8080"]

View File

@@ -1,9 +0,0 @@
FROM ubuntu:24.04
COPY catalyst /usr/local/bin/catalyst
EXPOSE 8080
VOLUME /usr/local/bin/catalyst_data
CMD ["/usr/local/bin/catalyst", "serve", "--http", "0.0.0.0:8080"]

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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(() => {
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="schedule">Schedule</SelectItem>
<SelectItem value="webhook">HTTP / Webhook</SelectItem>
<SelectItem value="hook">Collection Hook</SelectItem>
</SelectGroup>
@@ -277,7 +299,8 @@ const curlExample = computed(() => {
</FormItem>
</FormField>
<TriggerWebhookFormFields v-if="values.trigger === 'webhook'" />
<TriggerScheduleFormFields v-if="values.trigger === 'schedule'" />
<TriggerWebhookFormFields v-else-if="values.trigger === 'webhook'" />
<TriggerHookFormFields v-else-if="values.trigger === 'hook'" />
<div v-if="values.trigger === 'webhook'">

View File

@@ -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'

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
</script>
<template>
<FormField name="triggerdata.expression" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="expression" class="text-right"> Cron Expression </FormLabel>
<FormControl>
<Input
id="expression"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a cron expression"
/>
</FormControl>
<FormDescription>
<p class="text-sm text-gray-500">
A cron expression or macro to schedule the trigger. Example: <code>0 * * * *</code> (every
hour) or <code>@daily</code> (every day).
<a
href="https://en.wikipedia.org/wiki/Cron#CRON_expression"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 underline"
>
Learn more
</a>
</p>
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -15,15 +15,11 @@ import { Input } from '@/components/ui/input'
<FormItem>
<FormLabel for="token" class="text-right">Token</FormLabel>
<FormControl>
<Input
id="token"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a token (e.g. 'xyz...')"
/>
<Input id="token" class="col-span-3" v-bind="componentField" placeholder="Enter a token" />
</FormControl>
<FormDescription>
Optional. Include an authorization token in the request headers.
Optional. Include an authorization token in the request headers. Example:
<code>Bearer YOUR_TOKEN</code>
</FormDescription>
<FormMessage />
</FormItem>
@@ -33,14 +29,11 @@ import { Input } from '@/components/ui/input'
<FormItem>
<FormLabel for="path" class="text-right">Path</FormLabel>
<FormControl>
<Input
id="path"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a path (e.g. 'action1')"
/>
<Input id="path" class="col-span-3" v-bind="componentField" placeholder="Enter a path" />
</FormControl>
<FormDescription> Specify the path to trigger the reaction. </FormDescription>
<FormDescription>
Specify the path to trigger the reaction. Example: <code>action1</code>
</FormDescription>
<FormMessage />
</FormItem>
</FormField>