diff --git a/cmd/OliveTin/main.go b/cmd/OliveTin/main.go index f2af0fe..92641a1 100644 --- a/cmd/OliveTin/main.go +++ b/cmd/OliveTin/main.go @@ -9,6 +9,7 @@ import ( "github.com/OliveTin/OliveTin/internal/executor" grpcapi "github.com/OliveTin/OliveTin/internal/grpcapi" "github.com/OliveTin/OliveTin/internal/installationinfo" + "github.com/OliveTin/OliveTin/internal/oncalendarfile" "github.com/OliveTin/OliveTin/internal/oncron" "github.com/OliveTin/OliveTin/internal/onfileindir" "github.com/OliveTin/OliveTin/internal/onstartup" @@ -153,6 +154,7 @@ func main() { go onstartup.Execute(cfg, executor) go oncron.Schedule(cfg, executor) go onfileindir.WatchFilesInDirectory(cfg, executor) + go oncalendarfile.Schedule(cfg, executor) go entityfiles.SetupEntityFileWatchers(cfg) diff --git a/internal/config/config.go b/internal/config/config.go index 8babebb..97a1040 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Action struct { ExecOnCron []string ExecOnFileCreatedInDir []string ExecOnFileChangedInDir []string + ExecOnCalendarFile string Trigger string MaxConcurrent int Arguments []ActionArgument diff --git a/internal/executor/arguments.go b/internal/executor/arguments.go index 1a7cab1..4d3fbd5 100644 --- a/internal/executor/arguments.go +++ b/internal/executor/arguments.go @@ -9,6 +9,7 @@ import ( "net/url" "regexp" "strings" + "time" ) var ( @@ -94,6 +95,16 @@ func TypeSafetyCheck(name string, value string, argumentType string) error { return typeSafetyCheckUrl(name, value) } + if argumentType == "datetime" { + _, err := time.Parse("2006-01-02T15:04:05", value) + + if err != nil { + return err + } + + return nil + } + return typeSafetyCheckRegex(name, value, argumentType) } diff --git a/internal/filehelper/file_change_notify.go b/internal/filehelper/file_change_notify.go new file mode 100644 index 0000000..06e940f --- /dev/null +++ b/internal/filehelper/file_change_notify.go @@ -0,0 +1,75 @@ +package filehelper + +import ( + "github.com/fsnotify/fsnotify" + log "github.com/sirupsen/logrus" + "path/filepath" +) + +func WatchFile(fullpath string, callback func()) { + watcher, err := fsnotify.NewWatcher() + + if err != nil { + log.Errorf("Could not watch for files being created: %v", err) + return + } + + defer watcher.Close() + + done := make(chan bool) + + filename := filepath.Base(fullpath) + filedir := filepath.Dir(fullpath) + + go func() { + for { + processEvent(filename, watcher, fsnotify.Write, callback) + } + }() + + err = watcher.Add(filedir) + + if err != nil { + log.Errorf("Could not create watcher: %v", err) + } + + <-done +} + +func processEvent(filename string, watcher *fsnotify.Watcher, eventType fsnotify.Op, callback func()) { + select { + case event, ok := <-watcher.Events: + if !consumeEvent(ok, filename, &event, callback) { + return + } + + break + case err := <-watcher.Errors: + log.Errorf("Error in fsnotify: %v", err) + return + } +} + +func consumeEvent(ok bool, filename string, event *fsnotify.Event, callback func()) bool { + if !ok { + return false + } + + if filepath.Base(event.Name) != filename { + log.Tracef("fsnotify irreleventa event different file %+v", event) + return true + } + + consumeWriteEvents(event, callback) + + return true +} + +func consumeWriteEvents(event *fsnotify.Event, callback func()) { + if event.Has(fsnotify.Write) { + log.Debugf("fsnotify write event: %v", event) + callback() + } else { + log.Debugf("fsnotify irrelevant event on file %v", event) + } +} diff --git a/internal/filehelper/file_touch.go b/internal/filehelper/file_touch.go new file mode 100644 index 0000000..d1a9d51 --- /dev/null +++ b/internal/filehelper/file_touch.go @@ -0,0 +1,20 @@ +package filehelper + +import ( + log "github.com/sirupsen/logrus" + "os" +) + +func Touch(filename string, description string) { + _, err := os.Stat(filename) + + if os.IsNotExist(err) { + _, err := os.Create(filename) + + if err != nil { + log.Warnf("Could not create %v: %v", description, filename) + } else { + log.Infof("Created %v: %v", description, filename) + } + } +} diff --git a/internal/oncalendarfile/calendar.go b/internal/oncalendarfile/calendar.go new file mode 100644 index 0000000..e522a30 --- /dev/null +++ b/internal/oncalendarfile/calendar.go @@ -0,0 +1,118 @@ +package oncalendarfile + +import ( + "context" + "github.com/OliveTin/OliveTin/internal/acl" + "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/executor" + "github.com/OliveTin/OliveTin/internal/filehelper" + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + "io/ioutil" + "time" +) + +var calendar map[*config.Action][]*time.Timer + +func Schedule(cfg *config.Config, ex *executor.Executor) { + calendar = make(map[*config.Action][]*time.Timer) + + for _, action := range cfg.Actions { + if action.ExecOnCalendarFile != "" { + x := func() { + parseCalendarFile(action, cfg, ex) + } + + go filehelper.WatchFile(action.ExecOnCalendarFile, x) + + x() + } + } +} + +func parseCalendarFile(action *config.Action, cfg *config.Config, ex *executor.Executor) { + filehelper.Touch(action.ExecOnCalendarFile, "calendar file") + + log.WithFields(log.Fields{ + "actionTitle": action.Title, + }).Infof("Parsing calendar file") + + yfile, err := ioutil.ReadFile(action.ExecOnCalendarFile) + + if err != nil { + log.Errorf("ReadIn: %v", err) + return + } + + data := make([]string, 1) + + err = yaml.Unmarshal(yfile, &data) + + if err != nil { + log.Errorf("Unmarshal: %v", err) + } + + scheduleCalendarActions(data, action, cfg, ex) +} + +func scheduleCalendarActions(entries []string, action *config.Action, cfg *config.Config, ex *executor.Executor) { + ctx := context.Background() + + for _, instant := range entries { + if instant == "" { + continue + } + + until, _ := time.Parse(time.RFC3339, instant) + + go sleepUntil(ctx, until, action, cfg, ex) + } +} + +func sleepUntil(ctx context.Context, instant time.Time, action *config.Action, cfg *config.Config, ex *executor.Executor) { + if time.Now().After(instant) { + log.WithFields(log.Fields{ + "instant": instant, + "actionTitle": action.Title, + }).Warnf("Not scheduling stale calendar action") + + return + } + + log.WithFields(log.Fields{ + "instant": instant, + "actionTitle": action.Title, + }).Infof("Scheduling action on calendar") + + timer := time.NewTimer(time.Until(instant)) + + defer timer.Stop() + + select { + case <-timer.C: + exec(instant, action, cfg, ex) + return + case <-ctx.Done(): + log.Infof("Cancelled scheduled action") + return + } +} + +func exec(instant time.Time, action *config.Action, cfg *config.Config, ex *executor.Executor) { + // calendar[action] = append(calendar[action], timer) + log.WithFields(log.Fields{ + "instant": instant, + "actionTitle": action.Title, + }).Infof("Executing action from calendar") + + req := &executor.ExecutionRequest{ + Action: action, + Cfg: cfg, + Tags: []string{"calendar"}, + AuthenticatedUser: &acl.AuthenticatedUser{ + Username: "calendar", + }, + } + + ex.ExecRequest(req) +} diff --git a/webui.dev/js/ArgumentForm.js b/webui.dev/js/ArgumentForm.js index af14572..1728084 100644 --- a/webui.dev/js/ArgumentForm.js +++ b/webui.dev/js/ArgumentForm.js @@ -102,6 +102,10 @@ class ArgumentForm extends window.HTMLElement { this.domBtnStart.disabled = false domEl.disabled = true } + } else if (arg.type === 'datetime') { + domEl = document.createElement('input') + domEl.setAttribute('type', 'datetime-local') + domEl.setAttribute('step', '1') } else { domEl = document.createElement('input') domEl.onchange = () => {