Compare commits

...

3 commits

Author SHA1 Message Date
Magnus Åhall
5145830f65 Added env to scripts 2025-08-08 14:44:19 +02:00
Magnus Åhall
9514628710 Send data to script 2025-08-08 10:39:04 +02:00
Magnus Åhall
ef0a20ffe0 Run scheduled scripts 2025-08-08 09:52:32 +02:00
8 changed files with 355 additions and 32 deletions

View file

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

View file

@ -27,6 +27,7 @@ type Hook struct {
Node Node
Script Script
SSH string
Env map[string]string
}
func GetScript(scriptID int) (script Script, err error) { // {{{
@ -161,7 +162,7 @@ func SearchScripts(search string) (scripts []Script, err error) { // {{{
return
} // }}}
func HookScript(nodeID, scriptID int) (err error) { // {{{
_, err = db.Exec(`INSERT INTO hook(node_id, script_id, ssh) VALUES($1, $2, '<host>')`, nodeID, scriptID)
_, err = db.Exec(`INSERT INTO hook(node_id, script_id, ssh, env) VALUES($1, $2, '<host>')`, nodeID, scriptID)
return
} // }}}
@ -173,6 +174,7 @@ func GetHook(hookID int) (hook Hook, err error) { // {{{
SELECT
h.id,
h.ssh,
h.env,
(SELECT to_json(node) FROM node WHERE id = h.node_id) AS node,
(SELECT to_json(script) FROM script WHERE id = h.script_id) AS script
FROM hook h
@ -231,7 +233,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
@ -243,11 +245,17 @@ func ScheduleHook(hookID int) (err error) { // {{{
return
}
_, err = db.Exec(`INSERT INTO execution(script_log_id, data, ssh) VALUES($1, $2, $3)`, scriptLogID, nodeData, hook.SSH)
j, _ := json.Marshal(hook.Env)
_, err = db.Exec(`INSERT INTO execution(script_log_id, data, ssh, env) VALUES($1, $2, $3, $4)`, scriptLogID, nodeData, hook.SSH, j)
if err != nil {
err = werr.Wrap(err)
return
}
go func() {
scriptScheduler.EventQueue <- "SCRIPT_SCHEDULED"
}()
return
} // }}}

251
script_scheduler.go Normal file
View file

@ -0,0 +1,251 @@
package main
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
"bytes"
"database/sql"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
const ENV_NAME = 0
const SCRIPT_NAME = 1
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 []byte
SSH string
Env []byte
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)
var fnames []string
fnames, err = se.UploadScript()
if err != nil {
err = werr.Wrap(err)
logger.Error("script_execution", "op", "upload_script", "id", se.ID, "error", err)
return
}
err = se.UploadEnv(fnames[ENV_NAME], fnames[SCRIPT_NAME])
if err != nil {
err = werr.Wrap(err)
logger.Error("script_execution", "op", "upload_env", "id", se.ID, "error", err)
return
}
err = se.RunScript(fnames[ENV_NAME])
if err != nil {
err = werr.Wrap(err)
logger.Error("script_execution", "op", "run_script", "id", se.ID, "error", err)
return
}
se.SSHCommand([]byte{}, false, fmt.Sprintf("rm %s %s", fnames[ENV_NAME], fnames[SCRIPT_NAME]))
logger.Info("script_scheduler", "op", "handled", "script", fnames[SCRIPT_NAME])
} // }}}
func (self ScriptScheduler) GetNextExecution() (e ScriptExecution, err error) { // {{{
row := db.QueryRowx(`
SELECT
e.id,
time_start,
time_end,
data,
ssh,
env,
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) UploadScript() (fnames []string, err error) { // {{{
var filenames string
filenames, err = se.SSHCommand(
se.Source,
true,
`sh -c 'RUNENV=$(mktemp -t datagraph.XXXXXX) && SCRIPT=$(mktemp -t datagraph.XXXXXX) && touch $RUNENV $SCRIPT && chmod 700 $RUNENV $SCRIPT && cat >$SCRIPT && echo $RUNENV $SCRIPT'`,
)
if err != nil {
err = werr.Wrap(err)
}
fnames = strings.Split(strings.TrimSpace(filenames), " ")
if len(fnames) != 2 {
err = werr.New("Invalid temp filename count: %d", len(fnames))
return
}
return fnames[:2], nil
} // }}}
func (se *ScriptExecution) UploadEnv(envFname, scriptFname string) (err error) { // {{{
env := make(map[string]string)
err = json.Unmarshal(se.Env, &env)
if err != nil {
err = werr.Wrap(err)
return
}
var script = "#!/bin/sh\n\n"
for key, val := range env {
script = script + fmt.Sprintf("export %s=\"%s\"\n", key, strings.ReplaceAll(val, `"`, `\"`))
}
script = script + "\n" + scriptFname + "\n"
_, err = se.SSHCommand(
[]byte(script),
true,
fmt.Sprintf(`sh -c 'cat >%s'`, envFname),
)
if err != nil {
err = werr.Wrap(err)
}
return
} // }}}
func (se *ScriptExecution) RunScript(fname string) (err error) { // {{{
_, err = se.SSHCommand(se.Data, true, fname)
if err != nil {
err = werr.Wrap(err)
}
return
} // }}}

1
sql/0014.sql Normal file
View file

@ -0,0 +1 @@
CREATE INDEX execution_time_start_idx ON public.execution (time_start);

1
sql/0015.sql Normal file
View file

@ -0,0 +1 @@
ALTER TABLE public.hook ADD env jsonb DEFAULT '{}' NOT NULL;

View file

@ -62,13 +62,14 @@ button {
padding: 16px 32px;
}
#menu .item {
font-size: 1.1em;
cursor: pointer;
}
#menu .item.selected {
font-weight: bold;
}
#logo img {
height: 96px;
height: 64px;
margin-right: 32px;
}
#nodes {
@ -245,6 +246,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 +262,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 +275,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 +298,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;

View file

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

View file

@ -79,6 +79,7 @@ button {
}
.item {
font-size: 1.1em;
cursor: pointer;
&.selected {
@ -89,7 +90,7 @@ button {
#logo {
img {
height: 96px;
height: 64px;
margin-right: 32px;
}
}
@ -324,6 +325,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 +349,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 +391,11 @@ select:focus {
.script-ssh {
cursor: pointer;
color: #555;
}
.script-schedule.disabled {
filter: invert(50%);
}
}