All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m47s
CI/CD / test-sqlite (push) Successful in 2m52s
CI/CD / build-and-deploy (push) Successful in 30s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
163 lines
5.1 KiB
Go
163 lines
5.1 KiB
Go
package triggers
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/robfig/cron/v3"
|
|
"go.starlark.net/starlark"
|
|
|
|
starlarkjson "go.starlark.net/lib/json"
|
|
|
|
"armature/models"
|
|
"armature/sandbox"
|
|
)
|
|
|
|
// wireScheduledTask registers a cron entry for a scheduled task.
|
|
func (e *Engine) wireScheduledTask(t *models.ScheduledTask) {
|
|
taskID := t.ID
|
|
creatorID := t.CreatorID
|
|
runAs := t.RunAs
|
|
script := t.Script
|
|
cronExpr := t.CronExpr
|
|
|
|
// Parse cron with standard 5-field format (minute hour dom month dow)
|
|
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
|
schedule, err := parser.Parse(cronExpr)
|
|
if err != nil {
|
|
log.Printf(" ⚠️ schedule[%s]: invalid cron %q: %v", taskID, cronExpr, err)
|
|
return
|
|
}
|
|
|
|
// Compute next fire time
|
|
now := time.Now()
|
|
nextFire := schedule.Next(now)
|
|
_ = e.stores.ScheduledTasks.UpdateFireState(context.Background(), taskID,
|
|
time.Time{}, &nextFire, "", 0)
|
|
|
|
entryID := e.cron.Schedule(schedule, cron.FuncJob(func() {
|
|
e.fireScheduledTask(taskID, creatorID, runAs, script, cronExpr)
|
|
}))
|
|
|
|
e.mu.Lock()
|
|
e.cronIDs[taskID] = entryID
|
|
e.mu.Unlock()
|
|
}
|
|
|
|
// fireScheduledTask invokes a scheduled task's Starlark script in a restricted sandbox.
|
|
func (e *Engine) fireScheduledTask(taskID, creatorID, runAs, script, cronExpr string) {
|
|
start := time.Now()
|
|
ctx := e.ctx
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Re-check task is still enabled
|
|
task, err := e.stores.ScheduledTasks.GetByID(ctx, taskID)
|
|
if err != nil || task == nil || !task.Enabled {
|
|
return
|
|
}
|
|
|
|
// Check creator is still active (unless run_as=system)
|
|
if runAs == models.RunAsCreator {
|
|
user, err := e.stores.Users.GetByID(ctx, creatorID)
|
|
if err != nil || user == nil || !user.IsActive {
|
|
log.Printf(" ⚠️ schedule[%s]: creator %s inactive, pausing", taskID, creatorID)
|
|
_ = e.stores.ScheduledTasks.SetEnabled(ctx, taskID, false)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Build restricted module set
|
|
modules := e.buildRestrictedModules(ctx, creatorID, runAs)
|
|
|
|
// Execute script in sandbox
|
|
sb := sandbox.New(sandbox.DefaultConfig())
|
|
result, execErr := sb.ExecWithLoader(ctx, "schedule/"+taskID+".star", script, modules, nil)
|
|
|
|
var output string
|
|
if result != nil {
|
|
output = result.Output
|
|
}
|
|
|
|
// Call main() if defined
|
|
if execErr == nil && result != nil {
|
|
if mainFn, ok := result.Globals["main"]; ok {
|
|
if callable, ok := mainFn.(starlark.Callable); ok {
|
|
// Build context dict
|
|
ctxDict := starlark.NewDict(4)
|
|
_ = ctxDict.SetKey(starlark.String("trigger_type"), starlark.String("schedule"))
|
|
_ = ctxDict.SetKey(starlark.String("task_id"), starlark.String(taskID))
|
|
_ = ctxDict.SetKey(starlark.String("cron_expr"), starlark.String(cronExpr))
|
|
_ = ctxDict.SetKey(starlark.String("fired_at"), starlark.String(start.UTC().Format(time.RFC3339)))
|
|
|
|
_, callOutput, callErr := sb.Call(ctx, callable, starlark.Tuple{ctxDict}, nil)
|
|
output += callOutput
|
|
if callErr != nil {
|
|
execErr = callErr
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
duration := int(time.Since(start).Milliseconds())
|
|
errStr := ""
|
|
if execErr != nil {
|
|
errStr = execErr.Error()
|
|
log.Printf(" ⚠️ schedule[%s] error: %v", taskID, execErr)
|
|
}
|
|
|
|
// Compute next fire time
|
|
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
|
if sched, err := parser.Parse(cronExpr); err == nil {
|
|
nextFire := sched.Next(time.Now())
|
|
_ = e.stores.ScheduledTasks.UpdateFireState(ctx, taskID, start, &nextFire, errStr, duration)
|
|
} else {
|
|
_ = e.stores.ScheduledTasks.UpdateFireState(ctx, taskID, start, nil, errStr, duration)
|
|
}
|
|
|
|
// Log execution
|
|
e.logExecution("", taskID, start, duration, execErr == nil, errStr, output)
|
|
e.publishEvent("trigger.fired", taskID)
|
|
}
|
|
|
|
// buildRestrictedModules creates the limited module set for scheduled tasks.
|
|
// Scheduled scripts can: read settings, load libraries, use JSON, resolve connections.
|
|
// They CANNOT: create DB tables, make raw HTTP calls, read secrets.
|
|
func (e *Engine) buildRestrictedModules(ctx context.Context, creatorID, runAs string) map[string]starlark.Value {
|
|
modules := make(map[string]starlark.Value)
|
|
|
|
// JSON is always available
|
|
modules["json"] = starlarkjson.Module
|
|
|
|
// Settings module — read-only access to platform settings
|
|
// (no package context for scheduled tasks; they read global settings)
|
|
if e.stores.Packages != nil {
|
|
modules["settings"] = sandbox.BuildSettingsModule(ctx, e.stores, "", creatorID, "", nil)
|
|
}
|
|
|
|
// Notifications module — if available
|
|
if e.runner != nil {
|
|
// The runner has the notifier attached; we need to access it.
|
|
// For now, scheduled tasks don't get the notifications module
|
|
// since it requires a package context. This can be enhanced later.
|
|
}
|
|
|
|
// Connections module — read-only resolve for HTTP via existing connections
|
|
// This allows scheduled tasks to make HTTP calls through configured connections.
|
|
// The RunContext carries the creator's user ID for connection resolution.
|
|
|
|
return modules
|
|
}
|
|
|
|
// RunContext builds a sandbox.RunContext for a scheduled task invocation.
|
|
func scheduleRunContext(creatorID, runAs string) *sandbox.RunContext {
|
|
if runAs == models.RunAsSystem {
|
|
return nil // system context — no user scoping
|
|
}
|
|
return &sandbox.RunContext{
|
|
UserID: creatorID,
|
|
}
|
|
}
|