diff --git a/main.go b/main.go index f374023..3b317cf 100644 --- a/main.go +++ b/main.go @@ -28,6 +28,8 @@ var ( //go:embed static staticFS embed.FS + + scriptScheduler ScriptScheduler ) func initCmdline() { @@ -63,6 +65,9 @@ func main() { os.Exit(1) } + scriptScheduler = NewScriptScheduler() + go scriptScheduler.Loop() + err = initWebserver() if err != nil { logger.Error("webserver", "error", err) diff --git a/script.go b/script.go index fa24d38..13fe84f 100644 --- a/script.go +++ b/script.go @@ -231,7 +231,7 @@ func ScheduleHook(hookID int) (err error) { // {{{ // The node tree data is retrieved and sent as input to the script. var topNode *Node - topNode, err = GetNodeTree(hook.Node.ID, 0, true) + topNode, err = GetNodeTree(hook.Node.ID, 8, true) if err != nil { err = werr.Wrap(err) return @@ -248,6 +248,11 @@ func ScheduleHook(hookID int) (err error) { // {{{ err = werr.Wrap(err) return } + + go func() { + scriptScheduler.EventQueue <- "SCRIPT_SCHEDULED" + }() + return } // }}} diff --git a/script_scheduler.go b/script_scheduler.go new file mode 100644 index 0000000..808548e --- /dev/null +++ b/script_scheduler.go @@ -0,0 +1,200 @@ +package main + +import ( + // External + werr "git.gibonuddevalla.se/go/wrappederror" + + // Standard + "bytes" + "database/sql" + "fmt" + "os/exec" + "strings" + "time" +) + +type ScriptScheduler struct { + EventQueue chan string +} + +type ScriptExecution struct { + ID int + TimeStart sql.NullTime `db:"time_start"` + TimeEnd sql.NullTime `db:"time_end"` + Source []byte + Data string + SSH string + OutputStdout sql.NullString `db:"output_stdout"` + OutputStderr sql.NullString `db:"output_stderr"` + ExitCode sql.NullInt16 +} + +func NewScriptScheduler() (sched ScriptScheduler) { + sched.EventQueue = make(chan string, 64) + return +} + +func (self ScriptScheduler) Loop() { // {{{ + + // Lets check for somehow missed executions every minute. + // An event SHOULD be received for each new created, but let's be sure. + tick := time.NewTicker(time.Second * 60) + + var event string + for { + select { + case <-tick.C: + self.HandleNextExecution() + + case event = <-self.EventQueue: + if event == "SCRIPT_SCHEDULED" { + self.HandleNextExecution() + } + } + } +} // }}} +func (self ScriptScheduler) HandleNextExecution() { // {{{ + se, err := self.GetNextExecution() + if err != nil { + logger.Error("script_scheduler", "error", err) + return + } + + if se.ID == 0 { + return + } + + // Setting the time_start value on the database row makes sure it doesn't get handled again. + se.TimeStart.Time = time.Now() + se.TimeStart.Valid = true + se.Update() + + logger.Info("script_scheduler", "op", "execute", "id", se.ID) + fname, err := se.GetScriptTempFilename() + if err != nil { + err = werr.Wrap(err) + logger.Error("script_execution", "op", "get_script_temp_filename", "id", se.ID, "error", err) + return + } + + err = se.UploadScript(fname) + if err != nil { + err = werr.Wrap(err) + logger.Error("script_execution", "op", "upload_script", "id", se.ID, "error", err) + return + } + + se.SSHCommand([]byte{}, false, fmt.Sprintf("rm %s", fname)) + + logger.Info("script_scheduler", "op", "handled", "script", fname) +} // }}} +func (self ScriptScheduler) GetNextExecution() (e ScriptExecution, err error) { // {{{ + row := db.QueryRowx(` + SELECT + e.id, + time_start, + time_end, + data, + ssh, + output_stdout, + output_stderr, + exitcode, + sl.source + FROM execution e + INNER JOIN script_log sl ON e.script_log_id = sl.id + WHERE + time_start IS NULL + ORDER BY + id ASC + `) + err = row.StructScan(&e) + + // Returned ScriptExecution is having an ID of 0 if none was returned + if err == sql.ErrNoRows { + err = nil + return + } + + if err != nil { + err = werr.Wrap(err) + return + } + + return +} // }}} + +func (se *ScriptExecution) Update() (err error) { // {{{ + _, err = db.Exec(` + UPDATE public.execution + SET + time_start = $2, + time_end = $3, + output_stdout = $4, + output_stderr = $5, + exitcode = $6 + + WHERE + id=$1`, + se.ID, + se.TimeStart, + se.TimeEnd, + se.OutputStdout, + se.OutputStderr, + se.ExitCode, + ) + if err != nil { + err = werr.Wrap(err) + logger.Error("script_execution", "op", "execute", "id", se.ID, "error", err) + return + } + return +} // }}} +func (se *ScriptExecution) SSHCommand(stdin []byte, log bool, args ...string) (stdoutString string, err error) { // {{{ + params := []string{se.SSH} + params = append(params, args...) + cmd := exec.Command("ssh", params...) + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + cmd.Stdin = bytes.NewReader(stdin) + cmd.Stdout = stdout + cmd.Stderr = stderr + err = cmd.Run() + + // A cleanup command is run after the script. This shouldn't overwrite the output from the actual script. + if log { + se.OutputStdout.String = stdout.String() + se.OutputStderr.String = stderr.String() + se.ExitCode.Int16 = int16(cmd.ProcessState.ExitCode()) + se.OutputStdout.Valid = true + se.OutputStderr.Valid = true + se.ExitCode.Valid = true + } + + se.TimeEnd.Time = time.Now() + se.TimeEnd.Valid = true + se.Update() + + if err != nil { + err = werr.Wrap(err) + return + } + + return stdout.String(), nil +} // }}} +func (se *ScriptExecution) GetScriptTempFilename() (fname string, err error) { // {{{ + fname, err = se.SSHCommand([]byte{}, true, "mktemp -t datagraph.XXXXXX") + if err != nil { + err = werr.Wrap(err) + return + } + fname = strings.TrimSpace(fname) + return +} // }}} +func (se *ScriptExecution) UploadScript(fname string) (err error) { // {{{ + _, err = se.SSHCommand(se.Source, true, fmt.Sprintf("sh -c 'touch %s && chmod 700 %s && cat >%s && %s'", fname, fname, fname, fname)) + if err != nil { + err = werr.Wrap(err) + } + return +} // }}} diff --git a/sql/0014.sql b/sql/0014.sql new file mode 100644 index 0000000..43af267 --- /dev/null +++ b/sql/0014.sql @@ -0,0 +1 @@ +CREATE INDEX execution_time_start_idx ON public.execution (time_start); diff --git a/static/css/main.css b/static/css/main.css index 3c45bcf..06ebd6f 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -245,6 +245,8 @@ select:focus { grid-template-columns: 24px 1fr; grid-gap: 8px; align-items: center; + background-color: #f0f0f0; + padding: 16px; } #connected-nodes .connected-nodes .type-group .type-name { font-weight: bold; @@ -259,10 +261,10 @@ select:focus { height: 24px; } #script-hooks .scripts-grid { - display: grid; - grid-template-columns: repeat(4, min-content); - align-items: center; - grid-gap: 2px 0px; + display: flex; + align-items: start; + flex-flow: row wrap; + gap: 32px; } #script-hooks .scripts-grid .header { font-weight: bold; @@ -272,12 +274,19 @@ select:focus { white-space: nowrap; } #script-hooks .scripts-grid .script-group { + display: grid; + grid-template-columns: repeat(4, min-content); + align-items: center; + grid-gap: 2px 0px; + padding: 16px; + background-color: #f0f0f0; +} +#script-hooks .scripts-grid .script-group-title { grid-column: 1 / -1; font-weight: bold; - margin-top: 8px; } #script-hooks .scripts-grid .script-unhook img, -#script-hooks .scripts-grid .script-run img { +#script-hooks .scripts-grid .script-schedule img { display: block; height: 24px; cursor: pointer; @@ -288,6 +297,10 @@ select:focus { } #script-hooks .scripts-grid .script-ssh { cursor: pointer; + color: #555; +} +#script-hooks .scripts-grid .script-schedule.disabled { + filter: invert(50%); } #script-hooks > .label { display: grid; diff --git a/static/js/app.mjs b/static/js/app.mjs index d6d5294..9eaf8a7 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -1048,19 +1048,29 @@ class ScriptHooks extends Component { renderHooks() {// {{{ this.scriptGrid.innerHTML = '' - let prevGroup = null + let curGroupName = null + let group = document.createElement('div') + group.classList.add('script-group') + for (const hook of this.hooks) { - if (hook.Script.Group !== prevGroup) { + if (hook.Script.Group !== curGroupName) { const g = document.createElement('div') - g.classList.add('script-group') + g.classList.add('script-group-title') g.innerText = hook.Script.Group - this.scriptGrid.append(g) - prevGroup = hook.Script.Group + + group = document.createElement('div') + group.classList.add('script-group') + group.append(g) + this.scriptGrid.append(group) + curGroupName = hook.Script.Group } const h = new ScriptHook(hook, this) - this.scriptGrid.append(h.render()) + group.append(h.render()) } + + if (group.children.length > 1) + this.scriptGrid.append(group) }// }}} hookScript(script) {// {{{ _app.query(`/nodes/hook`, { @@ -1077,7 +1087,9 @@ class ScriptHook extends Component { super() this.hook = hook this.parentList = parentList - this.element_ssh = null + this.elementSSH = null + this.elementSchedule = null + this.scheduleDisable = false }// }}} renderComponent() {// {{{ const tmpl = document.createElement('template') @@ -1085,14 +1097,15 @@ class ScriptHook extends Component {