Run scheduled scripts
This commit is contained in:
parent
6d05152ab2
commit
ef0a20ffe0
7 changed files with 294 additions and 28 deletions
5
main.go
5
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
} // }}}
|
||||
|
||||
|
|
|
|||
200
script_scheduler.go
Normal file
200
script_scheduler.go
Normal file
|
|
@ -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
|
||||
} // }}}
|
||||
1
sql/0014.sql
Normal file
1
sql/0014.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE INDEX execution_time_start_idx ON public.execution (time_start);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
<div class="script-name">${this.hook.Script.Name}</div>
|
||||
<div class="script-ssh"></div>
|
||||
<div class="script-unhook"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/trash-can.svg" /></div>
|
||||
<div class="script-run"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/play-box.svg" /></div>
|
||||
<div class="script-schedule"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/play-box.svg" /></div>
|
||||
`
|
||||
this.element_ssh = tmpl.content.querySelector('.script-ssh')
|
||||
this.element_ssh.innerText = this.hook.SSH
|
||||
this.elementSchedule = tmpl.content.querySelector('.script-schedule')
|
||||
this.elementSSH = tmpl.content.querySelector('.script-ssh')
|
||||
this.elementSSH.innerText = `[ ${this.hook.SSH} ]`
|
||||
|
||||
tmpl.content.querySelector('.script-ssh').addEventListener('click', () => this.update())
|
||||
tmpl.content.querySelector('.script-unhook').addEventListener('click', () => this.delete())
|
||||
tmpl.content.querySelector('.script-run').addEventListener('click', () => this.run())
|
||||
tmpl.content.querySelector('.script-schedule').addEventListener('click', () => this.run())
|
||||
|
||||
return tmpl.content
|
||||
}// }}}
|
||||
|
|
@ -1121,7 +1134,7 @@ class ScriptHook extends Component {
|
|||
return
|
||||
}
|
||||
this.hook.SSH = ssh
|
||||
this.element_ssh.innerText = this.hook.SSH
|
||||
this.elementSSH.innerText = this.hook.SSH
|
||||
})
|
||||
.catch(err => showError(err))
|
||||
}// }}}
|
||||
|
|
@ -1141,8 +1154,22 @@ class ScriptHook extends Component {
|
|||
.catch(err => showError(err))
|
||||
}// }}}
|
||||
run() {// {{{
|
||||
if (this.scheduleDisable)
|
||||
return
|
||||
|
||||
this.scheduleDisable = true
|
||||
this.elementSchedule.classList.add('disabled')
|
||||
const start = Date.now()
|
||||
|
||||
_app.query(`/hooks/schedule/${this.hook.ID}`)
|
||||
.catch(err => showError(err))
|
||||
.finally(() => {
|
||||
const duration = Date.now() - start
|
||||
setTimeout(() => {
|
||||
this.scheduleDisable = false
|
||||
this.elementSchedule.classList.remove('disabled')
|
||||
}, 250 - duration)
|
||||
})
|
||||
}// }}}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -324,6 +324,8 @@ select:focus {
|
|||
grid-template-columns: 24px 1fr;
|
||||
grid-gap: 8px;
|
||||
align-items: center;
|
||||
background-color: #f0f0f0;
|
||||
padding: 16px;
|
||||
|
||||
.type-name {
|
||||
font-weight: bold;
|
||||
|
|
@ -346,27 +348,35 @@ select:focus {
|
|||
|
||||
#script-hooks {
|
||||
.scripts-grid {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-flow: row wrap;
|
||||
gap: 32px;
|
||||
|
||||
.header {
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, min-content);
|
||||
align-items: center;
|
||||
grid-gap: 2px 0px;
|
||||
|
||||
div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.script-group {
|
||||
grid-column: 1 / -1;
|
||||
font-weight: bold;
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, min-content);
|
||||
align-items: center;
|
||||
grid-gap: 2px 0px;
|
||||
padding: 16px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.script-unhook, .script-run {
|
||||
.script-group-title {
|
||||
grid-column: 1 / -1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.script-unhook, .script-schedule {
|
||||
img {
|
||||
display: block;
|
||||
height: 24px;
|
||||
|
|
@ -380,6 +390,11 @@ select:focus {
|
|||
|
||||
.script-ssh {
|
||||
cursor: pointer;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.script-schedule.disabled {
|
||||
filter: invert(50%);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue