Compare commits

..

6 Commits

Author SHA1 Message Date
6868bde5e6 Reject unknown DB versions
All checks were successful
Alpine 3.21 Success
2025-09-06 09:12:27 +02:00
d3a046d85d Avoid disaster with DB migrations
All checks were successful
Alpine 3.21 Success
2025-09-04 10:38:31 +02:00
6622ea0e1c Improve formatting of durations
All checks were successful
Alpine 3.20 Success
Since "m" could stand for both "minute" and "month",
and months vary in length, let's stop at days.
2025-01-02 00:36:03 +01:00
a492b3b668 Clean up 2024-12-28 00:27:46 +01:00
280114a5d3 Unify our usage of the local shell 2024-12-27 02:16:14 +01:00
d83517f67b Refresh task view dynamically with Javascript
All checks were successful
Alpine 3.20 Success
This is more efficient, responsive, and user friendly.
2024-12-27 00:25:49 +01:00
4 changed files with 256 additions and 77 deletions

272
acid.go
View File

@@ -30,7 +30,6 @@ import (
"syscall"
ttemplate "text/template"
"time"
"unicode"
_ "github.com/mattn/go-sqlite3"
"github.com/pkg/sftp"
@@ -154,6 +153,14 @@ var shellFuncs = ttemplate.FuncMap{
// --- Utilities ---------------------------------------------------------------
func localShell() string {
if shell := os.Getenv("SHELL"); shell != "" {
return shell
}
// The os/user package doesn't store the parsed out shell field.
return "/bin/sh"
}
func giteaSign(b []byte) string {
payloadHmac := hmac.New(sha256.New, []byte(getConfig().Secret))
payloadHmac.Write(b)
@@ -263,53 +270,179 @@ var templateTask = template.Must(template.New("tasks").Parse(`
<head>
<title>Task {{.ID}}</title>
<meta charset="utf-8">
{{if .IsRunning}}
<meta http-equiv="refresh" content="5">
{{end}}
</head>
<body>
<h1><a href="..">Tasks</a> &raquo; {{.ID}}</h1>
<dl>
{{if .Created}}
<!-- Remember to synchronise these lists with Javascript updates. -->
{{if .Created -}}
<dt>Created</dt>
<dd><span title="{{.Created}}">{{.CreatedAgo}} ago</span></dd>
{{end}}
{{if .Changed}}
<dd><span id="created" title="{{.Created}}">{{.CreatedAgo}} ago</span></dd>
{{end -}}
{{if .Changed -}}
<dt>Changed</dt>
<dd><span title="{{.Changed}}">{{.ChangedAgo}} ago</span></dd>
{{end}}
<dd><span id="changed" title="{{.Changed}}">{{.ChangedAgo}} ago</span></dd>
{{end -}}
<dt>Project</dt>
<dd><a href="{{.RepoURL}}">{{.FullName}}</a></dd>
<dd id="project"><a href="{{.RepoURL}}">{{.FullName}}</a></dd>
<dt>Commit</dt>
<dd><a href="{{.CommitURL}}">{{.Hash}}</a></dd>
<dd id="commit"><a href="{{.CommitURL}}">{{.Hash}}</a></dd>
<dt>Runner</dt>
<dd>{{.RunnerName}}</dd>
<dd id="runner">{{.RunnerName}}</dd>
<dt>State</dt>
<dd>{{.State}}{{if .Detail}} ({{.Detail}}){{end}}</dd>
<dd id="state">{{.State}}{{if .Detail}} ({{.Detail}}){{end}}</dd>
<dt>Notified</dt>
<dd>{{.Notified}}</dd>
{{if .Duration}}
<dd id="notified">{{.Notified}}</dd>
<dt>Duration</dt>
<dd>{{.Duration}}</dd>
{{end}}
<dd id="duration">{{if .Duration}}{{.Duration}}{{else}}&mdash;{{end}}</dd>
</dl>
{{if .RunLog}}
<h2>Runner log</h2>
<pre>{{printf "%s" .RunLog}}</pre>
{{end}}
{{if .TaskLog}}
<h2>Task log</h2>
<pre>{{printf "%s" .TaskLog}}</pre>
{{end}}
{{if .DeployLog}}
<h2>Deploy log</h2>
<pre>{{printf "%s" .DeployLog}}</pre>
{{end}}
</table>
<h2 id="run"{{if not .RunLog}} hidden{{end}}>Runner log</h2>
<pre id="runlog"{{if not .RunLog}} hidden{{
end}}>{{printf "%s" .RunLog}}</pre>
<h2 id="task"{{if not .TaskLog}} hidden{{end}}>Task log</h2>
<pre id="tasklog"{{if not .TaskLog}} hidden{{
end}}>{{printf "%s" .TaskLog}}</pre>
<h2 id="deploy"{{if not .DeployLog}} hidden{{end}}>Deploy log</h2>
<pre id="deploylog"{{if not .DeployLog}} hidden{{
end}}>{{printf "%s" .DeployLog}}</pre>
{{if .IsRunning -}}
<script>
function get(id) {
return document.getElementById(id)
}
function getLog(id) {
const header = get(id), log = get(id + 'log'), text = log.textContent
// lines[-1] is an implementation detail of terminalWriter.Serialize,
// lines[-2] is the actual last line.
const last = Math.max(0, text.split('\n').length - 2)
return {header, log, text, last}
}
function refreshLog(log, top, changed) {
if (top <= 0)
log.log.textContent = changed
else
log.log.textContent =
log.text.split('\n').slice(0, top).join('\n') + '\n' + changed
const empty = log.log.textContent === ''
log.header.hidden = empty
log.log.hidden = empty
}
let refresher = setInterval(() => {
const run = getLog('run'), task = getLog('task'), deploy = getLog('deploy')
const url = new URL(window.location.href)
url.search = ''
url.searchParams.set('json', '')
url.searchParams.set('run', run.last)
url.searchParams.set('task', task.last)
url.searchParams.set('deploy', deploy.last)
fetch(url.toString()).then(response => {
if (!response.ok)
throw response.statusText
return response.json()
}).then(data => {
const scroll = window.scrollY + window.innerHeight
>= document.documentElement.scrollHeight
if (data.Created) {
get('created').title = data.Created
get('created').textContent = data.CreatedAgo + " ago"
}
if (data.Changed) {
get('changed').title = data.Changed
get('changed').textContent = data.ChangedAgo + " ago"
}
get('state').textContent = data.State
if (data.Detail !== '')
get('state').textContent += " (" + data.Detail + ")"
get('notified').textContent = String(data.Notified)
if (data.Duration)
get('duration').textContent = data.Duration
refreshLog(run, data.RunLogTop, data.RunLog)
refreshLog(task, data.TaskLogTop, data.TaskLog)
refreshLog(deploy, data.DeployLogTop, data.DeployLog)
if (scroll)
document.documentElement.scrollTop =
document.documentElement.scrollHeight
if (!data.IsRunning)
clearInterval(refresher)
}).catch(error => {
clearInterval(refresher)
alert(error)
})
}, 1000 /* For faster updates than this, we should use WebSockets. */)
</script>
{{end -}}
</body>
</html>
`))
// handlerTask serves as the data for JSON encoding and the task HTML template.
// It needs to explicitly include many convenience method results.
type handlerTask struct {
Task
IsRunning bool
Created *string // Task.Created?.String()
Changed *string // Task.Changed?.String()
CreatedAgo string // Task.CreatedAgo()
ChangedAgo string // Task.ChangedAgo()
Duration *string // Task.Duration?.String()
State string // Task.State.String()
RunLog string
RunLogTop int
TaskLog string
TaskLogTop int
DeployLog string
DeployLogTop int
}
func toNilableString[T fmt.Stringer](stringer *T) *string {
if stringer == nil {
return nil
}
s := (*stringer).String()
return &s
}
func newHandlerTask(task Task) handlerTask {
return handlerTask{
Task: task,
RunLog: string(task.RunLog),
TaskLog: string(task.TaskLog),
DeployLog: string(task.DeployLog),
Created: toNilableString(task.Created()),
Changed: toNilableString(task.Changed()),
CreatedAgo: task.CreatedAgo(),
ChangedAgo: task.ChangedAgo(),
Duration: toNilableString(task.Duration()),
State: task.State.String(),
}
}
func (ht *handlerTask) updateFromRunning(
rt *RunningTask, lastRun, lastTask, lastDeploy int) {
ht.IsRunning = true
ht.Task.DurationSeconds = rt.elapsed()
ht.Duration = toNilableString(ht.Task.Duration())
rt.RunLog.Lock()
defer rt.RunLog.Unlock()
rt.TaskLog.Lock()
defer rt.TaskLog.Unlock()
rt.DeployLog.Lock()
defer rt.DeployLog.Unlock()
ht.RunLog, ht.RunLogTop = rt.RunLog.SerializeUpdates(lastRun)
ht.TaskLog, ht.TaskLogTop = rt.TaskLog.SerializeUpdates(lastTask)
ht.DeployLog, ht.DeployLogTop = rt.DeployLog.SerializeUpdates(lastDeploy)
}
func handleTask(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
@@ -330,36 +463,32 @@ func handleTask(w http.ResponseWriter, r *http.Request) {
return
}
task := struct {
Task
IsRunning bool
}{Task: tasks[0]}
// These are intended for running tasks,
// so don't reprocess DB logs, which would only help the last update.
q := r.URL.Query()
lastRun, _ := strconv.Atoi(q.Get("run"))
lastTask, _ := strconv.Atoi(q.Get("task"))
lastDeploy, _ := strconv.Atoi(q.Get("deploy"))
task := newHandlerTask(tasks[0])
func() {
gRunningMutex.Lock()
defer gRunningMutex.Unlock()
rt, ok := gRunning[task.ID]
task.IsRunning = ok
if !ok {
return
if rt, ok := gRunning[task.ID]; ok {
task.updateFromRunning(
rt, int(lastRun), int(lastTask), int(lastDeploy))
}
task.DurationSeconds = rt.elapsed()
rt.RunLog.Lock()
defer rt.RunLog.Unlock()
rt.TaskLog.Lock()
defer rt.TaskLog.Unlock()
rt.DeployLog.Lock()
defer rt.DeployLog.Unlock()
task.RunLog = rt.RunLog.Serialize(0)
task.TaskLog = rt.TaskLog.Serialize(0)
task.DeployLog = rt.DeployLog.Serialize(0)
}()
if err := templateTask.Execute(w, &task); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
if q.Has("json") {
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(&task)
} else {
err = templateTask.Execute(w, &task)
}
if err != nil {
log.Println(err)
}
}
@@ -717,7 +846,7 @@ func notifierRunCommand(ctx context.Context, task Task) {
return
}
cmd := exec.CommandContext(ctx, "sh")
cmd := exec.CommandContext(ctx, localShell())
cmd.Stdin = script
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -1041,14 +1170,6 @@ func executorDownload(client *ssh.Client, remoteRoot, localRoot string) error {
return nil
}
func executorLocalShell() string {
if shell := os.Getenv("SHELL"); shell != "" {
return shell
}
// The os/user package doesn't store the parsed out shell field.
return "/bin/sh"
}
func executorTmpDir(fallback string) string {
// See also: https://systemd.io/TEMPORARY_DIRECTORIES/
if tmp := os.Getenv("TMPDIR"); tmp != "" {
@@ -1084,9 +1205,10 @@ func executorDeploy(
return err
}
cmd := exec.CommandContext(ctx, executorLocalShell(), "-c", script.String())
cmd := exec.CommandContext(ctx, localShell())
cmd.Env = rt.localEnv()
cmd.Dir = dir
cmd.Stdin = script
cmd.Stdout = &rt.DeployLog
cmd.Stderr = &rt.DeployLog
return cmd.Run()
@@ -1480,18 +1602,15 @@ func (t *Task) CloneURL() string {
}
func shortDurationString(d time.Duration) string {
rs := []rune(d.Truncate(time.Second).String())
for i, r := range rs {
if !unicode.IsLetter(r) {
continue
}
i++
for i < len(rs) && unicode.IsLetter(rs[i]) {
i++
}
return string(rs[:i])
if d.Abs() >= 24*time.Hour {
return strconv.FormatInt(int64(d/time.Hour/24), 10) + "d"
} else if d.Abs() >= time.Hour {
return strconv.FormatInt(int64(d/time.Hour), 10) + "h"
} else if d.Abs() >= time.Minute {
return strconv.FormatInt(int64(d/time.Minute), 10) + "m"
} else {
return strconv.FormatInt(int64(d/time.Second), 10) + "s"
}
return string(rs)
}
func (t *Task) Created() *time.Time {
@@ -1632,8 +1751,11 @@ func dbOpen(path string) error {
`task`, `duration`, `INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
fallthrough
case 2:
// The next migration goes here, remember to increment the number below.
default:
return fmt.Errorf("unsupported database version: %d", version)
}
if _, err = tx.Exec(

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"testing"
ttemplate "text/template"
"time"
)
func TestTemplateQuote(t *testing.T) {
@@ -30,3 +31,19 @@ func TestTemplateQuote(t *testing.T) {
}
}
}
func TestShortDurationString(t *testing.T) {
for _, test := range []struct {
d time.Duration
expect string
}{
{72 * time.Hour, "3d"},
{-3 * time.Hour, "-3h"},
{12 * time.Minute, "12m"},
{time.Millisecond, "0s"},
} {
if sd := shortDurationString(test.d); sd != test.expect {
t.Errorf("%s = %s; want %s\n", test.d, sd, test.expect)
}
}
}

View File

@@ -47,6 +47,16 @@ func (tw *terminalWriter) log(format string, v ...interface{}) {
}
}
// SerializeUpdates returns an update block for a client with a given last line,
// and the index of the first line in the update block.
func (tw *terminalWriter) SerializeUpdates(last int) (string, int) {
if last < 0 || last >= len(tw.lines) {
return "", last
}
top := tw.lines[last].updateGroup
return string(tw.Serialize(top)), top
}
func (tw *terminalWriter) Serialize(top int) []byte {
var b bytes.Buffer
for i := top; i < len(tw.lines); i++ {
@@ -104,7 +114,7 @@ func (tw *terminalWriter) processPrint(r rune) {
// Refresh update trackers, if necessary.
if tw.lines[len(tw.lines)-1].updateGroup > tw.line {
for i := tw.line; i < len(tw.lines); i++ {
tw.lines[i].updateGroup = tw.line
tw.lines[i].updateGroup = min(tw.lines[i].updateGroup, tw.line)
}
}

View File

@@ -1,6 +1,9 @@
package main
import "testing"
import (
"slices"
"testing"
)
// This could be way more extensive, but we're not aiming for perfection.
var tests = []struct {
@@ -68,3 +71,30 @@ Loop:
}
}
}
func TestTerminalUpdateGroups(t *testing.T) {
tw := terminalWriter{}
collect := func() (have []int) {
for _, line := range tw.lines {
have = append(have, line.updateGroup)
}
return
}
// 0: A 0 0 0
// 1: B X 1 1 1
// 2: C Y 1 2 1 1
// 3: Z 2 3 2
// 4: 3 4
tw.Write([]byte("A\nB\nC\x1b[FX\nY\nZ"))
have, want := collect(), []int{0, 1, 1, 3}
if !slices.Equal(want, have) {
t.Errorf("update groups: %+v; want: %+v", have, want)
}
tw.Write([]byte("\x1b[F1\n2\n3"))
have, want = collect(), []int{0, 1, 1, 2, 4}
if !slices.Equal(want, have) {
t.Errorf("update groups: %+v; want: %+v", have, want)
}
}