Compare commits
16 Commits
a09b11256b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
b766c9ef20
|
|||
|
6868bde5e6
|
|||
|
d3a046d85d
|
|||
|
6622ea0e1c
|
|||
|
a492b3b668
|
|||
|
280114a5d3
|
|||
|
d83517f67b
|
|||
|
4f2c2dc8da
|
|||
|
55a6693942
|
|||
|
d5981249b1
|
|||
|
4a7fc55c92
|
|||
|
2bd231b84f
|
|||
|
fb291b6def
|
|||
|
14a15e8b59
|
|||
|
0746797c73
|
|||
|
ec656d8b2a
|
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2024, Přemysl Eric Janouch <p@janouch.name>
|
Copyright (c) 2024 - 2025, Přemysl Eric Janouch <p@janouch.name>
|
||||||
|
|
||||||
Permission to use, copy, modify, and/or distribute this software for any
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
purpose with or without fee is hereby granted.
|
purpose with or without fee is hereby granted.
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -5,10 +5,11 @@ version = dev
|
|||||||
outputs = acid acid.1
|
outputs = acid acid.1
|
||||||
all: $(outputs)
|
all: $(outputs)
|
||||||
|
|
||||||
acid: acid.go
|
acid: acid.go terminal.go
|
||||||
go build -ldflags "-X 'main.projectVersion=$(version)'" -o $@
|
go build -ldflags "-X 'main.projectVersion=$(version)'" -o $@
|
||||||
acid.1: acid.adoc
|
acid.1: acid.adoc
|
||||||
asciidoctor -b manpage -a release-version=$(version) -o $@ acid.adoc
|
asciidoctor -b manpage -a release-version=$(version) -o $@ acid.adoc || \
|
||||||
|
a2x -d manpage -f manpage -a release-version=$(version) acid.adoc
|
||||||
test: all
|
test: all
|
||||||
go test
|
go test
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
13
acid.adoc
13
acid.adoc
@@ -37,6 +37,8 @@ Commands
|
|||||||
*restart* [_ID_]...::
|
*restart* [_ID_]...::
|
||||||
Schedule tasks with the given IDs to be rerun.
|
Schedule tasks with the given IDs to be rerun.
|
||||||
Run this command without arguments to pick up external database changes.
|
Run this command without arguments to pick up external database changes.
|
||||||
|
*reload*::
|
||||||
|
Reload configuration.
|
||||||
|
|
||||||
Configuration
|
Configuration
|
||||||
-------------
|
-------------
|
||||||
@@ -65,6 +67,17 @@ which has the following fields:
|
|||||||
*RunnerName*::
|
*RunnerName*::
|
||||||
Descriptive name of the runner.
|
Descriptive name of the runner.
|
||||||
|
|
||||||
|
// Intentionally not documenting CreatedUnix, ChangedUnix, DurationSeconds,
|
||||||
|
// which can be derived from the objects.
|
||||||
|
*Created*, *Changed*::
|
||||||
|
`*time.Time` of task creation and last task state change respectively,
|
||||||
|
or nil if not known.
|
||||||
|
*CreatedAgo*, *ChangedAgo*::
|
||||||
|
Abbreviated human-friendly relative elapsed time duration
|
||||||
|
since *Created* and *Changed* respectively.
|
||||||
|
*Duration*::
|
||||||
|
`*time.Duration` of the last run in seconds, or nil if not known.
|
||||||
|
|
||||||
*URL*::
|
*URL*::
|
||||||
*acid* link to the task, where its log output can be seen.
|
*acid* link to the task, where its log output can be seen.
|
||||||
*RepoURL*::
|
*RepoURL*::
|
||||||
|
|||||||
572
acid.go
572
acid.go
@@ -26,6 +26,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
ttemplate "text/template"
|
ttemplate "text/template"
|
||||||
"time"
|
"time"
|
||||||
@@ -40,9 +41,9 @@ var (
|
|||||||
projectName = "acid"
|
projectName = "acid"
|
||||||
projectVersion = "?"
|
projectVersion = "?"
|
||||||
|
|
||||||
gConfig Config = Config{Listen: ":http"}
|
gConfigPath string
|
||||||
gNotifyScript *ttemplate.Template
|
gConfig atomic.Pointer[Config]
|
||||||
gDB *sql.DB
|
gDB *sql.DB
|
||||||
|
|
||||||
gNotifierSignal = make(chan struct{}, 1)
|
gNotifierSignal = make(chan struct{}, 1)
|
||||||
gExecutorSignal = make(chan struct{}, 1)
|
gExecutorSignal = make(chan struct{}, 1)
|
||||||
@@ -52,6 +53,8 @@ var (
|
|||||||
gRunning = make(map[int64]*RunningTask)
|
gRunning = make(map[int64]*RunningTask)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func getConfig() *Config { return gConfig.Load() }
|
||||||
|
|
||||||
// --- Config ------------------------------------------------------------------
|
// --- Config ------------------------------------------------------------------
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -65,6 +68,8 @@ type Config struct {
|
|||||||
|
|
||||||
Runners map[string]ConfigRunner `yaml:"runners"` // script runners
|
Runners map[string]ConfigRunner `yaml:"runners"` // script runners
|
||||||
Projects map[string]ConfigProject `yaml:"projects"` // configured projects
|
Projects map[string]ConfigProject `yaml:"projects"` // configured projects
|
||||||
|
|
||||||
|
notifyTemplate *ttemplate.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigRunner struct {
|
type ConfigRunner struct {
|
||||||
@@ -86,8 +91,9 @@ type ConfigProject struct {
|
|||||||
func (cf *ConfigProject) AutomaticRunners() (runners []string) {
|
func (cf *ConfigProject) AutomaticRunners() (runners []string) {
|
||||||
// We pass through unknown runner names,
|
// We pass through unknown runner names,
|
||||||
// so that they can cause reference errors later.
|
// so that they can cause reference errors later.
|
||||||
|
config := getConfig()
|
||||||
for runner := range cf.Runners {
|
for runner := range cf.Runners {
|
||||||
if r, _ := gConfig.Runners[runner]; !r.Manual {
|
if r, _ := config.Runners[runner]; !r.Manual {
|
||||||
runners = append(runners, runner)
|
runners = append(runners, runner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,17 +108,28 @@ type ConfigProjectRunner struct {
|
|||||||
Timeout string `yaml:"timeout"` // timeout duration
|
Timeout string `yaml:"timeout"` // timeout duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseConfig(path string) error {
|
// loadConfig reloads configuration.
|
||||||
if f, err := os.Open(path); err != nil {
|
// Beware that changes do not get applied globally at the same moment.
|
||||||
|
func loadConfig() error {
|
||||||
|
new := &Config{}
|
||||||
|
if f, err := os.Open(gConfigPath); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if err = yaml.NewDecoder(f).Decode(&gConfig); err != nil {
|
} else if err = yaml.NewDecoder(f).Decode(new); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if old := getConfig(); old != nil && old.DB != new.DB {
|
||||||
|
return fmt.Errorf("the database file cannot be changed in runtime")
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
gNotifyScript, err =
|
new.notifyTemplate, err =
|
||||||
ttemplate.New("notify").Funcs(shellFuncs).Parse(gConfig.Notify)
|
ttemplate.New("notify").Funcs(shellFuncs).Parse(new.Notify)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
gConfig.Store(new)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var shellFuncs = ttemplate.FuncMap{
|
var shellFuncs = ttemplate.FuncMap{
|
||||||
@@ -136,8 +153,16 @@ var shellFuncs = ttemplate.FuncMap{
|
|||||||
|
|
||||||
// --- Utilities ---------------------------------------------------------------
|
// --- 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 {
|
func giteaSign(b []byte) string {
|
||||||
payloadHmac := hmac.New(sha256.New, []byte(gConfig.Secret))
|
payloadHmac := hmac.New(sha256.New, []byte(getConfig().Secret))
|
||||||
payloadHmac.Write(b)
|
payloadHmac.Write(b)
|
||||||
return hex.EncodeToString(payloadHmac.Sum(nil))
|
return hex.EncodeToString(payloadHmac.Sum(nil))
|
||||||
}
|
}
|
||||||
@@ -145,9 +170,9 @@ func giteaSign(b []byte) string {
|
|||||||
func giteaNewRequest(ctx context.Context, method, path string, body io.Reader) (
|
func giteaNewRequest(ctx context.Context, method, path string, body io.Reader) (
|
||||||
*http.Request, error) {
|
*http.Request, error) {
|
||||||
req, err := http.NewRequestWithContext(
|
req, err := http.NewRequestWithContext(
|
||||||
ctx, method, gConfig.Gitea+path, body)
|
ctx, method, getConfig().Gitea+path, body)
|
||||||
if req != nil {
|
if req != nil {
|
||||||
req.Header.Set("Authorization", "token "+gConfig.Token)
|
req.Header.Set("Authorization", "token "+getConfig().Token)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
}
|
}
|
||||||
return req, err
|
return req, err
|
||||||
@@ -156,6 +181,7 @@ func giteaNewRequest(ctx context.Context, method, path string, body io.Reader) (
|
|||||||
func getTasks(ctx context.Context, query string, args ...any) ([]Task, error) {
|
func getTasks(ctx context.Context, query string, args ...any) ([]Task, error) {
|
||||||
rows, err := gDB.QueryContext(ctx, `
|
rows, err := gDB.QueryContext(ctx, `
|
||||||
SELECT id, owner, repo, hash, runner,
|
SELECT id, owner, repo, hash, runner,
|
||||||
|
created, changed, duration,
|
||||||
state, detail, notified,
|
state, detail, notified,
|
||||||
runlog, tasklog, deploylog FROM task `+query, args...)
|
runlog, tasklog, deploylog FROM task `+query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,11 +193,13 @@ func getTasks(ctx context.Context, query string, args ...any) ([]Task, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t Task
|
var t Task
|
||||||
err := rows.Scan(&t.ID, &t.Owner, &t.Repo, &t.Hash, &t.Runner,
|
err := rows.Scan(&t.ID, &t.Owner, &t.Repo, &t.Hash, &t.Runner,
|
||||||
|
&t.CreatedUnix, &t.ChangedUnix, &t.DurationSeconds,
|
||||||
&t.State, &t.Detail, &t.Notified,
|
&t.State, &t.Detail, &t.Notified,
|
||||||
&t.RunLog, &t.TaskLog, &t.DeployLog)
|
&t.RunLog, &t.TaskLog, &t.DeployLog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// We could also update some fields from gRunning.
|
||||||
tasks = append(tasks, t)
|
tasks = append(tasks, t)
|
||||||
}
|
}
|
||||||
return tasks, rows.Err()
|
return tasks, rows.Err()
|
||||||
@@ -192,6 +220,8 @@ var templateTasks = template.Must(template.New("tasks").Parse(`
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Changed</th>
|
||||||
<th>Repository</th>
|
<th>Repository</th>
|
||||||
<th>Hash</th>
|
<th>Hash</th>
|
||||||
<th>Runner</th>
|
<th>Runner</th>
|
||||||
@@ -204,6 +234,8 @@ var templateTasks = template.Must(template.New("tasks").Parse(`
|
|||||||
{{range .}}
|
{{range .}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="task/{{.ID}}">{{.ID}}</a></td>
|
<td><a href="task/{{.ID}}">{{.ID}}</a></td>
|
||||||
|
<td align="right"><span title="{{.Created}}">{{.CreatedAgo}}</span></td>
|
||||||
|
<td align="right"><span title="{{.Changed}}">{{.ChangedAgo}}</span></td>
|
||||||
<td><a href="{{.RepoURL}}">{{.FullName}}</a></td>
|
<td><a href="{{.RepoURL}}">{{.FullName}}</a></td>
|
||||||
<td><a href="{{.CommitURL}}">{{.Hash}}</a></td>
|
<td><a href="{{.CommitURL}}">{{.Hash}}</a></td>
|
||||||
<td>{{.RunnerName}}</td>
|
<td>{{.RunnerName}}</td>
|
||||||
@@ -219,7 +251,7 @@ var templateTasks = template.Must(template.New("tasks").Parse(`
|
|||||||
`))
|
`))
|
||||||
|
|
||||||
func handleTasks(w http.ResponseWriter, r *http.Request) {
|
func handleTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
tasks, err := getTasks(r.Context(), `ORDER BY id DESC`)
|
tasks, err := getTasks(r.Context(), `ORDER BY changed DESC, id DESC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w,
|
http.Error(w,
|
||||||
"Error retrieving tasks: "+err.Error(),
|
"Error retrieving tasks: "+err.Error(),
|
||||||
@@ -238,41 +270,179 @@ var templateTask = template.Must(template.New("tasks").Parse(`
|
|||||||
<head>
|
<head>
|
||||||
<title>Task {{.ID}}</title>
|
<title>Task {{.ID}}</title>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
{{if .IsRunning}}
|
|
||||||
<meta http-equiv="refresh" content="5">
|
|
||||||
{{end}}
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1><a href="..">Tasks</a> » {{.ID}}</h1>
|
<h1><a href="..">Tasks</a> » {{.ID}}</h1>
|
||||||
<dl>
|
<dl>
|
||||||
|
<!-- Remember to synchronise these lists with Javascript updates. -->
|
||||||
|
{{if .Created -}}
|
||||||
|
<dt>Created</dt>
|
||||||
|
<dd><span id="created" title="{{.Created}}">{{.CreatedAgo}} ago</span></dd>
|
||||||
|
{{end -}}
|
||||||
|
{{if .Changed -}}
|
||||||
|
<dt>Changed</dt>
|
||||||
|
<dd><span id="changed" title="{{.Changed}}">{{.ChangedAgo}} ago</span></dd>
|
||||||
|
{{end -}}
|
||||||
<dt>Project</dt>
|
<dt>Project</dt>
|
||||||
<dd><a href="{{.RepoURL}}">{{.FullName}}</a></dd>
|
<dd id="project"><a href="{{.RepoURL}}">{{.FullName}}</a></dd>
|
||||||
<dt>Commit</dt>
|
<dt>Commit</dt>
|
||||||
<dd><a href="{{.CommitURL}}">{{.Hash}}</a></dd>
|
<dd id="commit"><a href="{{.CommitURL}}">{{.Hash}}</a></dd>
|
||||||
<dt>Runner</dt>
|
<dt>Runner</dt>
|
||||||
<dd>{{.RunnerName}}</dd>
|
<dd id="runner">{{.RunnerName}}</dd>
|
||||||
<dt>State</dt>
|
<dt>State</dt>
|
||||||
<dd>{{.State}}{{if .Detail}} ({{.Detail}}){{end}}</dd>
|
<dd id="state">{{.State}}{{if .Detail}} ({{.Detail}}){{end}}</dd>
|
||||||
<dt>Notified</dt>
|
<dt>Notified</dt>
|
||||||
<dd>{{.Notified}}</dd>
|
<dd id="notified">{{.Notified}}</dd>
|
||||||
|
<dt>Duration</dt>
|
||||||
|
<dd id="duration">{{if .Duration}}{{.Duration}}{{else}}—{{end}}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
{{if .RunLog}}
|
|
||||||
<h2>Runner log</h2>
|
<h2 id="run"{{if not .RunLog}} hidden{{end}}>Runner log</h2>
|
||||||
<pre>{{printf "%s" .RunLog}}</pre>
|
<pre id="runlog"{{if not .RunLog}} hidden{{
|
||||||
{{end}}
|
end}}>{{printf "%s" .RunLog}}</pre>
|
||||||
{{if .TaskLog}}
|
<h2 id="task"{{if not .TaskLog}} hidden{{end}}>Task log</h2>
|
||||||
<h2>Task log</h2>
|
<pre id="tasklog"{{if not .TaskLog}} hidden{{
|
||||||
<pre>{{printf "%s" .TaskLog}}</pre>
|
end}}>{{printf "%s" .TaskLog}}</pre>
|
||||||
{{end}}
|
<h2 id="deploy"{{if not .DeployLog}} hidden{{end}}>Deploy log</h2>
|
||||||
{{if .DeployLog}}
|
<pre id="deploylog"{{if not .DeployLog}} hidden{{
|
||||||
<h2>Deploy log</h2>
|
end}}>{{printf "%s" .DeployLog}}</pre>
|
||||||
<pre>{{printf "%s" .DeployLog}}</pre>
|
|
||||||
{{end}}
|
{{if .IsRunning -}}
|
||||||
</table>
|
<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>
|
</body>
|
||||||
</html>
|
</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) {
|
func handleTask(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.Atoi(r.PathValue("id"))
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -293,34 +463,32 @@ func handleTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
task := struct {
|
// These are intended for running tasks,
|
||||||
Task
|
// so don't reprocess DB logs, which would only help the last update.
|
||||||
IsRunning bool
|
q := r.URL.Query()
|
||||||
}{Task: tasks[0]}
|
lastRun, _ := strconv.Atoi(q.Get("run"))
|
||||||
|
lastTask, _ := strconv.Atoi(q.Get("task"))
|
||||||
|
lastDeploy, _ := strconv.Atoi(q.Get("deploy"))
|
||||||
|
|
||||||
|
task := newHandlerTask(tasks[0])
|
||||||
func() {
|
func() {
|
||||||
gRunningMutex.Lock()
|
gRunningMutex.Lock()
|
||||||
defer gRunningMutex.Unlock()
|
defer gRunningMutex.Unlock()
|
||||||
|
|
||||||
rt, ok := gRunning[task.ID]
|
if rt, ok := gRunning[task.ID]; ok {
|
||||||
task.IsRunning = ok
|
task.updateFromRunning(
|
||||||
if !ok {
|
rt, int(lastRun), int(lastTask), int(lastDeploy))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rt.RunLog.mu.Lock()
|
|
||||||
defer rt.RunLog.mu.Unlock()
|
|
||||||
rt.TaskLog.mu.Lock()
|
|
||||||
defer rt.TaskLog.mu.Unlock()
|
|
||||||
rt.DeployLog.mu.Lock()
|
|
||||||
defer rt.DeployLog.mu.Unlock()
|
|
||||||
|
|
||||||
task.RunLog = rt.RunLog.b
|
|
||||||
task.TaskLog = rt.TaskLog.b
|
|
||||||
task.DeployLog = rt.DeployLog.b
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := templateTask.Execute(w, &task); err != nil {
|
if q.Has("json") {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,8 +515,9 @@ func createTasks(ctx context.Context,
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
stmt, err := tx.Prepare(`INSERT INTO task(owner, repo, hash, runner)
|
stmt, err := tx.Prepare(
|
||||||
VALUES (?, ?, ?, ?)`)
|
`INSERT INTO task(owner, repo, hash, runner, created, changed)
|
||||||
|
VALUES (?, ?, ?, ?, unixepoch('now'), unixepoch('now'))`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -397,7 +566,7 @@ func handlePush(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("received push: %s %s\n",
|
log.Printf("received push: %s %s\n",
|
||||||
event.Repository.FullName, event.HeadCommit.ID)
|
event.Repository.FullName, event.HeadCommit.ID)
|
||||||
|
|
||||||
project, ok := gConfig.Projects[event.Repository.FullName]
|
project, ok := getConfig().Projects[event.Repository.FullName]
|
||||||
if !ok {
|
if !ok {
|
||||||
// This is okay, don't set any commit statuses.
|
// This is okay, don't set any commit statuses.
|
||||||
fmt.Fprintf(w, "The project is not configured.")
|
fmt.Fprintf(w, "The project is not configured.")
|
||||||
@@ -429,8 +598,11 @@ func rpcRestartOne(ctx context.Context, id int64) error {
|
|||||||
|
|
||||||
// The executor bumps to "running" after inserting into gRunning,
|
// The executor bumps to "running" after inserting into gRunning,
|
||||||
// so we should not need to exclude that state here.
|
// so we should not need to exclude that state here.
|
||||||
result, err := gDB.ExecContext(ctx, `UPDATE task
|
//
|
||||||
SET state = ?, detail = '', notified = 0 WHERE id = ?`,
|
// We deliberately do not clear previous run data (duration, *log).
|
||||||
|
result, err := gDB.ExecContext(ctx,
|
||||||
|
`UPDATE task SET changed = unixepoch('now'),
|
||||||
|
state = ?, detail = '', notified = 0 WHERE id = ?`,
|
||||||
taskStateNew, id)
|
taskStateNew, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%d: %w", id, err)
|
return fmt.Errorf("%d: %w", id, err)
|
||||||
@@ -506,7 +678,7 @@ func rpcEnqueue(ctx context.Context,
|
|||||||
return fmt.Errorf("%s: %w", ref, err)
|
return fmt.Errorf("%s: %w", ref, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
project, ok := gConfig.Projects[owner+"/"+repo]
|
project, ok := getConfig().Projects[owner+"/"+repo]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("project configuration not found")
|
return fmt.Errorf("project configuration not found")
|
||||||
}
|
}
|
||||||
@@ -558,6 +730,17 @@ func rpcRestart(ctx context.Context,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcReload(ctx context.Context,
|
||||||
|
w io.Writer, fs *flag.FlagSet, args []string) error {
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fs.NArg() > 0 {
|
||||||
|
return errWrongUsage
|
||||||
|
}
|
||||||
|
return loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
var rpcCommands = map[string]struct {
|
var rpcCommands = map[string]struct {
|
||||||
@@ -570,6 +753,8 @@ var rpcCommands = map[string]struct {
|
|||||||
"Create or restart tasks for the given reference."},
|
"Create or restart tasks for the given reference."},
|
||||||
"restart": {rpcRestart, "[ID]...",
|
"restart": {rpcRestart, "[ID]...",
|
||||||
"Schedule tasks with the given IDs to be rerun."},
|
"Schedule tasks with the given IDs to be rerun."},
|
||||||
|
"reload": {rpcReload, "",
|
||||||
|
"Reload configuration."},
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcPrintCommands(w io.Writer) {
|
func rpcPrintCommands(w io.Writer) {
|
||||||
@@ -656,12 +841,12 @@ func handleRPC(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func notifierRunCommand(ctx context.Context, task Task) {
|
func notifierRunCommand(ctx context.Context, task Task) {
|
||||||
script := bytes.NewBuffer(nil)
|
script := bytes.NewBuffer(nil)
|
||||||
if err := gNotifyScript.Execute(script, &task); err != nil {
|
if err := getConfig().notifyTemplate.Execute(script, &task); err != nil {
|
||||||
log.Printf("error: notify: %s", err)
|
log.Printf("error: notify: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "sh")
|
cmd := exec.CommandContext(ctx, localShell())
|
||||||
cmd.Stdin = script
|
cmd.Stdin = script
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
@@ -683,13 +868,14 @@ func notifierNotify(ctx context.Context, task Task) error {
|
|||||||
TargetURL string `json:"target_url"`
|
TargetURL string `json:"target_url"`
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
runner, ok := gConfig.Runners[task.Runner]
|
config := getConfig()
|
||||||
|
runner, ok := config.Runners[task.Runner]
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("task %d has an unknown runner %s\n", task.ID, task.Runner)
|
log.Printf("task %d has an unknown runner %s\n", task.ID, task.Runner)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
payload.Context = runner.Name
|
payload.Context = runner.Name
|
||||||
payload.TargetURL = fmt.Sprintf("%s/task/%d", gConfig.Root, task.ID)
|
payload.TargetURL = fmt.Sprintf("%s/task/%d", config.Root, task.ID)
|
||||||
|
|
||||||
switch task.State {
|
switch task.State {
|
||||||
case taskStateNew:
|
case taskStateNew:
|
||||||
@@ -785,49 +971,6 @@ func notifierAwaken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Executor ----------------------------------------------------------------
|
// --- Executor ----------------------------------------------------------------
|
||||||
|
|
||||||
type terminalWriter struct {
|
|
||||||
b []byte
|
|
||||||
cur int
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tw *terminalWriter) Write(p []byte) (written int, err error) {
|
|
||||||
tw.mu.Lock()
|
|
||||||
defer tw.mu.Unlock()
|
|
||||||
|
|
||||||
// Extremely rudimentary emulation of a dumb terminal.
|
|
||||||
for _, b := range p {
|
|
||||||
// Enough is enough, writing too much is highly suspicious.
|
|
||||||
if len(tw.b) > 64<<20 {
|
|
||||||
return written, errors.New("too much terminal output")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch b {
|
|
||||||
case '\b':
|
|
||||||
if tw.cur > 0 && tw.b[tw.cur-1] != '\n' {
|
|
||||||
tw.cur--
|
|
||||||
}
|
|
||||||
case '\r':
|
|
||||||
for tw.cur > 0 && tw.b[tw.cur-1] != '\n' {
|
|
||||||
tw.cur--
|
|
||||||
}
|
|
||||||
case '\n':
|
|
||||||
tw.b = append(tw.b, b)
|
|
||||||
tw.cur = len(tw.b)
|
|
||||||
default:
|
|
||||||
tw.b = append(tw.b[:tw.cur], b)
|
|
||||||
tw.cur = len(tw.b)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
written += 1
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ~~~ Running task ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
// ~~~ Running task ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
// RunningTask stores all data pertaining to a currently running task.
|
// RunningTask stores all data pertaining to a currently running task.
|
||||||
@@ -850,13 +993,17 @@ type RunningTask struct {
|
|||||||
// newRunningTask prepares a task for running, without executing anything yet.
|
// newRunningTask prepares a task for running, without executing anything yet.
|
||||||
func newRunningTask(task Task) (*RunningTask, error) {
|
func newRunningTask(task Task) (*RunningTask, error) {
|
||||||
rt := &RunningTask{DB: task}
|
rt := &RunningTask{DB: task}
|
||||||
|
config := getConfig()
|
||||||
|
|
||||||
|
// This is for our own tracking, not actually written to database.
|
||||||
|
rt.DB.ChangedUnix = time.Now().Unix()
|
||||||
|
|
||||||
var ok bool
|
var ok bool
|
||||||
rt.Runner, ok = gConfig.Runners[rt.DB.Runner]
|
rt.Runner, ok = config.Runners[rt.DB.Runner]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unknown runner: %s", rt.DB.Runner)
|
return nil, fmt.Errorf("unknown runner: %s", rt.DB.Runner)
|
||||||
}
|
}
|
||||||
project, ok := gConfig.Projects[rt.DB.FullName()]
|
project, ok := config.Projects[rt.DB.FullName()]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("project configuration not found")
|
return nil, fmt.Errorf("project configuration not found")
|
||||||
}
|
}
|
||||||
@@ -918,9 +1065,26 @@ func newRunningTask(task Task) (*RunningTask, error) {
|
|||||||
return nil, fmt.Errorf("script/deploy: %w", err)
|
return nil, fmt.Errorf("script/deploy: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if os.Getenv("ACID_TERMINAL_DEBUG") != "" {
|
||||||
|
base := filepath.Join(executorTmpDir("/tmp"),
|
||||||
|
fmt.Sprintf("acid-%d-%s-%s-%s-",
|
||||||
|
task.ID, task.Owner, task.Repo, task.Runner))
|
||||||
|
rt.RunLog.Tee, _ = os.Create(base + "runlog")
|
||||||
|
rt.TaskLog.Tee, _ = os.Create(base + "tasklog")
|
||||||
|
// The deployment log should not be interesting.
|
||||||
|
}
|
||||||
return rt, nil
|
return rt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rt *RunningTask) close() {
|
||||||
|
for _, tee := range []io.WriteCloser{
|
||||||
|
rt.RunLog.Tee, rt.TaskLog.Tee, rt.DeployLog.Tee} {
|
||||||
|
if tee != nil {
|
||||||
|
tee.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// localEnv creates a process environment for locally run executables.
|
// localEnv creates a process environment for locally run executables.
|
||||||
func (rt *RunningTask) localEnv() []string {
|
func (rt *RunningTask) localEnv() []string {
|
||||||
return append(os.Environ(),
|
return append(os.Environ(),
|
||||||
@@ -929,6 +1093,10 @@ func (rt *RunningTask) localEnv() []string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rt *RunningTask) elapsed() int64 {
|
||||||
|
return int64(time.Since(time.Unix(rt.DB.ChangedUnix, 0)).Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
// update stores the running task's state in the database.
|
// update stores the running task's state in the database.
|
||||||
func (rt *RunningTask) update() error {
|
func (rt *RunningTask) update() error {
|
||||||
for _, i := range []struct {
|
for _, i := range []struct {
|
||||||
@@ -939,12 +1107,13 @@ func (rt *RunningTask) update() error {
|
|||||||
{&rt.TaskLog, &rt.DB.TaskLog},
|
{&rt.TaskLog, &rt.DB.TaskLog},
|
||||||
{&rt.DeployLog, &rt.DB.DeployLog},
|
{&rt.DeployLog, &rt.DB.DeployLog},
|
||||||
} {
|
} {
|
||||||
i.tw.mu.Lock()
|
i.tw.Lock()
|
||||||
defer i.tw.mu.Unlock()
|
defer i.tw.Unlock()
|
||||||
if *i.log = bytes.Clone(i.tw.b); *i.log == nil {
|
if *i.log = i.tw.Serialize(0); *i.log == nil {
|
||||||
*i.log = []byte{}
|
*i.log = []byte{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
rt.DB.DurationSeconds = rt.elapsed()
|
||||||
return rt.DB.update()
|
return rt.DB.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,12 +1170,12 @@ func executorDownload(client *ssh.Client, remoteRoot, localRoot string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func executorLocalShell() string {
|
func executorTmpDir(fallback string) string {
|
||||||
if shell := os.Getenv("SHELL"); shell != "" {
|
// See also: https://systemd.io/TEMPORARY_DIRECTORIES/
|
||||||
return shell
|
if tmp := os.Getenv("TMPDIR"); tmp != "" {
|
||||||
|
return tmp
|
||||||
}
|
}
|
||||||
// The os/user package doesn't store the parsed out shell field.
|
return fallback
|
||||||
return "/bin/sh"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func executorDeploy(
|
func executorDeploy(
|
||||||
@@ -1023,13 +1192,7 @@ func executorDeploy(
|
|||||||
|
|
||||||
// We expect the files to be moved elsewhere on the filesystem,
|
// We expect the files to be moved elsewhere on the filesystem,
|
||||||
// and they may get very large, so avoid /tmp.
|
// and they may get very large, so avoid /tmp.
|
||||||
//
|
dir := filepath.Join(executorTmpDir("/var/tmp"), "acid-deploy")
|
||||||
// See also: https://systemd.io/TEMPORARY_DIRECTORIES/
|
|
||||||
tmp := os.Getenv("TMPDIR")
|
|
||||||
if tmp == "" {
|
|
||||||
tmp = "/var/tmp"
|
|
||||||
}
|
|
||||||
dir := filepath.Join(tmp, "acid-deploy")
|
|
||||||
if err := os.RemoveAll(dir); err != nil {
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1042,9 +1205,10 @@ func executorDeploy(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, executorLocalShell(), "-c", script.String())
|
cmd := exec.CommandContext(ctx, localShell())
|
||||||
cmd.Env = rt.localEnv()
|
cmd.Env = rt.localEnv()
|
||||||
cmd.Dir = dir
|
cmd.Dir = dir
|
||||||
|
cmd.Stdin = script
|
||||||
cmd.Stdout = &rt.DeployLog
|
cmd.Stdout = &rt.DeployLog
|
||||||
cmd.Stderr = &rt.DeployLog
|
cmd.Stderr = &rt.DeployLog
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
@@ -1167,6 +1331,7 @@ func executorConnect(
|
|||||||
func executorRunTask(ctx context.Context, task Task) error {
|
func executorRunTask(ctx context.Context, task Task) error {
|
||||||
rt, err := newRunningTask(task)
|
rt, err := newRunningTask(task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
task.DurationSeconds = 0
|
||||||
task.State, task.Detail = taskStateError, "Misconfigured"
|
task.State, task.Detail = taskStateError, "Misconfigured"
|
||||||
task.Notified = 0
|
task.Notified = 0
|
||||||
task.RunLog = []byte(err.Error())
|
task.RunLog = []byte(err.Error())
|
||||||
@@ -1174,6 +1339,7 @@ func executorRunTask(ctx context.Context, task Task) error {
|
|||||||
task.DeployLog = []byte{}
|
task.DeployLog = []byte{}
|
||||||
return task.update()
|
return task.update()
|
||||||
}
|
}
|
||||||
|
defer rt.close()
|
||||||
|
|
||||||
ctx, cancelTimeout := context.WithTimeout(ctx, rt.timeout)
|
ctx, cancelTimeout := context.WithTimeout(ctx, rt.timeout)
|
||||||
defer cancelTimeout()
|
defer cancelTimeout()
|
||||||
@@ -1185,6 +1351,7 @@ func executorRunTask(ctx context.Context, task Task) error {
|
|||||||
f()
|
f()
|
||||||
}
|
}
|
||||||
locked(func() {
|
locked(func() {
|
||||||
|
rt.DB.DurationSeconds = 0
|
||||||
rt.DB.State, rt.DB.Detail = taskStateRunning, ""
|
rt.DB.State, rt.DB.Detail = taskStateRunning, ""
|
||||||
rt.DB.Notified = 0
|
rt.DB.Notified = 0
|
||||||
rt.DB.RunLog = []byte{}
|
rt.DB.RunLog = []byte{}
|
||||||
@@ -1272,19 +1439,20 @@ func executorRunTask(ctx context.Context, task Task) error {
|
|||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ee1 *ssh.ExitError
|
eeSSH *ssh.ExitError
|
||||||
ee2 *executorError
|
eeExec *exec.ExitError
|
||||||
|
ee3 *executorError
|
||||||
)
|
)
|
||||||
|
|
||||||
err = executorBuild(ctxRunner, client, rt)
|
err = executorBuild(ctxRunner, client, rt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
locked(func() {
|
locked(func() {
|
||||||
if errors.As(err, &ee1) {
|
if errors.As(err, &eeSSH) {
|
||||||
rt.DB.State, rt.DB.Detail = taskStateFailed, "Scripts failed"
|
rt.DB.State, rt.DB.Detail = taskStateFailed, "Scripts failed"
|
||||||
fmt.Fprintf(&rt.TaskLog, "\n%s\n", err)
|
fmt.Fprintf(&rt.TaskLog, "\n%s\n", err)
|
||||||
} else if errors.As(err, &ee2) {
|
} else if errors.As(err, &ee3) {
|
||||||
rt.DB.State, rt.DB.Detail = taskStateError, ee2.Detail
|
rt.DB.State, rt.DB.Detail = taskStateError, ee3.Detail
|
||||||
fmt.Fprintf(&rt.TaskLog, "\n%s\n", ee2.Err)
|
fmt.Fprintf(&rt.TaskLog, "\n%s\n", ee3.Err)
|
||||||
} else {
|
} else {
|
||||||
rt.DB.State, rt.DB.Detail = taskStateError, ""
|
rt.DB.State, rt.DB.Detail = taskStateError, ""
|
||||||
fmt.Fprintf(&rt.TaskLog, "\n%s\n", err)
|
fmt.Fprintf(&rt.TaskLog, "\n%s\n", err)
|
||||||
@@ -1304,12 +1472,12 @@ func executorRunTask(ctx context.Context, task Task) error {
|
|||||||
locked(func() {
|
locked(func() {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
rt.DB.State, rt.DB.Detail = taskStateSuccess, ""
|
rt.DB.State, rt.DB.Detail = taskStateSuccess, ""
|
||||||
} else if errors.As(err, &ee1) {
|
} else if errors.As(err, &eeExec) {
|
||||||
rt.DB.State, rt.DB.Detail = taskStateFailed, "Deployment failed"
|
rt.DB.State, rt.DB.Detail = taskStateFailed, "Deployment failed"
|
||||||
fmt.Fprintf(&rt.DeployLog, "\n%s\n", err)
|
fmt.Fprintf(&rt.DeployLog, "\n%s\n", err)
|
||||||
} else if errors.As(err, &ee2) {
|
} else if errors.As(err, &ee3) {
|
||||||
rt.DB.State, rt.DB.Detail = taskStateError, ee2.Detail
|
rt.DB.State, rt.DB.Detail = taskStateError, ee3.Detail
|
||||||
fmt.Fprintf(&rt.DeployLog, "\n%s\n", ee2.Err)
|
fmt.Fprintf(&rt.DeployLog, "\n%s\n", ee3.Err)
|
||||||
} else {
|
} else {
|
||||||
rt.DB.State, rt.DB.Detail = taskStateError, ""
|
rt.DB.State, rt.DB.Detail = taskStateError, ""
|
||||||
fmt.Fprintf(&rt.DeployLog, "\n%s\n", err)
|
fmt.Fprintf(&rt.DeployLog, "\n%s\n", err)
|
||||||
@@ -1393,6 +1561,11 @@ type Task struct {
|
|||||||
Hash string
|
Hash string
|
||||||
Runner string
|
Runner string
|
||||||
|
|
||||||
|
// True database names for these are occupied by accessors.
|
||||||
|
CreatedUnix int64
|
||||||
|
ChangedUnix int64
|
||||||
|
DurationSeconds int64
|
||||||
|
|
||||||
State taskState
|
State taskState
|
||||||
Detail string
|
Detail string
|
||||||
Notified int64
|
Notified int64
|
||||||
@@ -1404,7 +1577,7 @@ type Task struct {
|
|||||||
func (t *Task) FullName() string { return t.Owner + "/" + t.Repo }
|
func (t *Task) FullName() string { return t.Owner + "/" + t.Repo }
|
||||||
|
|
||||||
func (t *Task) RunnerName() string {
|
func (t *Task) RunnerName() string {
|
||||||
if runner, ok := gConfig.Runners[t.Runner]; !ok {
|
if runner, ok := getConfig().Runners[t.Runner]; !ok {
|
||||||
return t.Runner
|
return t.Runner
|
||||||
} else {
|
} else {
|
||||||
return runner.Name
|
return runner.Name
|
||||||
@@ -1412,26 +1585,77 @@ func (t *Task) RunnerName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) URL() string {
|
func (t *Task) URL() string {
|
||||||
return fmt.Sprintf("%s/task/%d", gConfig.Root, t.ID)
|
return fmt.Sprintf("%s/task/%d", getConfig().Root, t.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) RepoURL() string {
|
func (t *Task) RepoURL() string {
|
||||||
return fmt.Sprintf("%s/%s/%s", gConfig.Gitea, t.Owner, t.Repo)
|
return fmt.Sprintf("%s/%s/%s", getConfig().Gitea, t.Owner, t.Repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) CommitURL() string {
|
func (t *Task) CommitURL() string {
|
||||||
return fmt.Sprintf("%s/%s/%s/commit/%s",
|
return fmt.Sprintf("%s/%s/%s/commit/%s",
|
||||||
gConfig.Gitea, t.Owner, t.Repo, t.Hash)
|
getConfig().Gitea, t.Owner, t.Repo, t.Hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) CloneURL() string {
|
func (t *Task) CloneURL() string {
|
||||||
return fmt.Sprintf("%s/%s/%s.git", gConfig.Gitea, t.Owner, t.Repo)
|
return fmt.Sprintf("%s/%s/%s.git", getConfig().Gitea, t.Owner, t.Repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortDurationString(d time.Duration) string {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) Created() *time.Time {
|
||||||
|
if t.CreatedUnix == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tt := time.Unix(t.CreatedUnix, 0)
|
||||||
|
return &tt
|
||||||
|
}
|
||||||
|
func (t *Task) Changed() *time.Time {
|
||||||
|
if t.ChangedUnix == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tt := time.Unix(t.ChangedUnix, 0)
|
||||||
|
return &tt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) CreatedAgo() string {
|
||||||
|
if t.CreatedUnix == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return shortDurationString(time.Since(*t.Created()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) ChangedAgo() string {
|
||||||
|
if t.ChangedUnix == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return shortDurationString(time.Since(*t.Changed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) Duration() *time.Duration {
|
||||||
|
if t.DurationSeconds == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
td := time.Duration(t.DurationSeconds * int64(time.Second))
|
||||||
|
return &td
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) update() error {
|
func (t *Task) update() error {
|
||||||
_, err := gDB.ExecContext(context.Background(), `UPDATE task
|
_, err := gDB.ExecContext(context.Background(),
|
||||||
SET state = ?, detail = ?, notified = ?,
|
`UPDATE task SET changed = unixepoch('now'), duration = ?,
|
||||||
|
state = ?, detail = ?, notified = ?,
|
||||||
runlog = ?, tasklog = ?, deploylog = ? WHERE id = ?`,
|
runlog = ?, tasklog = ?, deploylog = ? WHERE id = ?`,
|
||||||
|
t.DurationSeconds,
|
||||||
t.State, t.Detail, t.Notified,
|
t.State, t.Detail, t.Notified,
|
||||||
t.RunLog, t.TaskLog, t.DeployLog, t.ID)
|
t.RunLog, t.TaskLog, t.DeployLog, t.ID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1453,6 +1677,10 @@ CREATE TABLE IF NOT EXISTS task(
|
|||||||
hash TEXT NOT NULL, -- commit hash
|
hash TEXT NOT NULL, -- commit hash
|
||||||
runner TEXT NOT NULL, -- the runner to use
|
runner TEXT NOT NULL, -- the runner to use
|
||||||
|
|
||||||
|
created INTEGER NOT NULL DEFAULT 0, -- creation timestamp
|
||||||
|
changed INTEGER NOT NULL DEFAULT 0, -- last state change timestamp
|
||||||
|
duration INTEGER NOT NULL DEFAULT 0, -- duration of last run
|
||||||
|
|
||||||
state INTEGER NOT NULL DEFAULT 0, -- task state
|
state INTEGER NOT NULL DEFAULT 0, -- task state
|
||||||
detail TEXT NOT NULL DEFAULT '', -- task state detail
|
detail TEXT NOT NULL DEFAULT '', -- task state detail
|
||||||
notified INTEGER NOT NULL DEFAULT 0, -- Gitea knows the state
|
notified INTEGER NOT NULL DEFAULT 0, -- Gitea knows the state
|
||||||
@@ -1510,13 +1738,28 @@ func dbOpen(path string) error {
|
|||||||
`task`, `deploylog`, `BLOB NOT NULL DEFAULT x''`); err != nil {
|
`task`, `deploylog`, `BLOB NOT NULL DEFAULT x''`); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
break
|
|
||||||
case 1:
|
case 1:
|
||||||
|
if err = dbEnsureColumn(tx,
|
||||||
|
`task`, `created`, `INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = dbEnsureColumn(tx,
|
||||||
|
`task`, `changed`, `INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = dbEnsureColumn(tx,
|
||||||
|
`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.
|
// The next migration goes here, remember to increment the number below.
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported database version: %d", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = tx.Exec(
|
if _, err = tx.Exec(
|
||||||
`PRAGMA user_version = ` + strconv.Itoa(1)); err != nil {
|
`PRAGMA user_version = ` + strconv.Itoa(2)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
@@ -1532,7 +1775,7 @@ func callRPC(args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost,
|
req, err := http.NewRequest(http.MethodPost,
|
||||||
fmt.Sprintf("%s/rpc", gConfig.Root), bytes.NewReader(body))
|
fmt.Sprintf("%s/rpc", getConfig().Root), bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1555,8 +1798,30 @@ func callRPC(args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterTTY exposes the internal virtual terminal filter.
|
||||||
|
func filterTTY(path string) {
|
||||||
|
var r io.Reader = os.Stdin
|
||||||
|
if path != "-" {
|
||||||
|
if f, err := os.Open(path); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
} else {
|
||||||
|
r = f
|
||||||
|
defer f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tw terminalWriter
|
||||||
|
if _, err := io.Copy(&tw, r); err != nil {
|
||||||
|
log.Printf("%s: %s\n", path, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stdout.Write(tw.Serialize(0)); err != nil {
|
||||||
|
log.Printf("%s: %s\n", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
version := flag.Bool("version", false, "show version and exit")
|
version := flag.Bool("version", false, "show version and exit")
|
||||||
|
tty := flag.Bool("tty", false, "run the internal virtual terminal filter")
|
||||||
|
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
f := flag.CommandLine.Output()
|
f := flag.CommandLine.Output()
|
||||||
@@ -1574,8 +1839,15 @@ func main() {
|
|||||||
fmt.Printf("%s %s\n", projectName, projectVersion)
|
fmt.Printf("%s %s\n", projectName, projectVersion)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if *tty {
|
||||||
|
for _, path := range flag.Args() {
|
||||||
|
filterTTY(path)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := parseConfig(flag.Arg(0)); err != nil {
|
gConfigPath = flag.Arg(0)
|
||||||
|
if err := loadConfig(); err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
if flag.NArg() > 1 {
|
if flag.NArg() > 1 {
|
||||||
@@ -1585,7 +1857,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := dbOpen(gConfig.DB); err != nil {
|
if err := dbOpen(getConfig().DB); err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
defer gDB.Close()
|
defer gDB.Close()
|
||||||
@@ -1594,7 +1866,7 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(
|
ctx, stop := signal.NotifyContext(
|
||||||
context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
server := &http.Server{Addr: gConfig.Listen}
|
server := &http.Server{Addr: getConfig().Listen}
|
||||||
http.HandleFunc("/{$}", handleTasks)
|
http.HandleFunc("/{$}", handleTasks)
|
||||||
http.HandleFunc("/task/{id}", handleTask)
|
http.HandleFunc("/task/{id}", handleTask)
|
||||||
http.HandleFunc("/push", handlePush)
|
http.HandleFunc("/push", handlePush)
|
||||||
|
|||||||
17
acid_test.go
17
acid_test.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"testing"
|
"testing"
|
||||||
ttemplate "text/template"
|
ttemplate "text/template"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTemplateQuote(t *testing.T) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
381
terminal.go
Normal file
381
terminal.go
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
type terminalLine struct {
|
||||||
|
// For simplicity, we assume that all runes take up one cell,
|
||||||
|
// including TAB and non-spacing ones.
|
||||||
|
// The next step would be grouping non-spacing characters,
|
||||||
|
// in particular Unicode modifier letters, with their base.
|
||||||
|
columns []rune
|
||||||
|
|
||||||
|
// updateGroup is the topmost line that has changed since this line
|
||||||
|
// has appeared, for the purpose of update tracking.
|
||||||
|
updateGroup int
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalWriter does a best-effort approximation of an infinite-size
|
||||||
|
// virtual terminal.
|
||||||
|
type terminalWriter struct {
|
||||||
|
sync.Mutex
|
||||||
|
Tee io.WriteCloser
|
||||||
|
lines []terminalLine
|
||||||
|
|
||||||
|
// Zero-based coordinates within lines.
|
||||||
|
column, line int
|
||||||
|
|
||||||
|
// lineTop is used as the base for positioning commands.
|
||||||
|
lineTop int
|
||||||
|
|
||||||
|
written int
|
||||||
|
byteBuffer []byte
|
||||||
|
runeBuffer []rune
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tw *terminalWriter) log(format string, v ...interface{}) {
|
||||||
|
if os.Getenv("ACID_TERMINAL_DEBUG") != "" {
|
||||||
|
log.Printf("terminal: "+format+"\n", v...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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++ {
|
||||||
|
b.WriteString(string(tw.lines[i].columns))
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tw *terminalWriter) Write(p []byte) (written int, err error) {
|
||||||
|
tw.Lock()
|
||||||
|
defer tw.Unlock()
|
||||||
|
|
||||||
|
// TODO(p): Rather use io.MultiWriter?
|
||||||
|
// Though I'm not sure what to do about closing (FD leaks).
|
||||||
|
// Eventually, any handles would be garbage collected in any case.
|
||||||
|
if tw.Tee != nil {
|
||||||
|
tw.Tee.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enough is enough, writing too much is highly suspicious.
|
||||||
|
ok, remaining := true, 64<<20-tw.written
|
||||||
|
if remaining < 0 {
|
||||||
|
ok, p = false, nil
|
||||||
|
} else if remaining < len(p) {
|
||||||
|
ok, p = false, p[:remaining]
|
||||||
|
}
|
||||||
|
tw.written += len(p)
|
||||||
|
|
||||||
|
// By now, more or less everything should run in UTF-8.
|
||||||
|
//
|
||||||
|
// This might have better performance with a ring buffer,
|
||||||
|
// so as to avoid reallocations.
|
||||||
|
b := append(tw.byteBuffer, p...)
|
||||||
|
if !ok {
|
||||||
|
b = append(b, "\nToo much terminal output\n"...)
|
||||||
|
}
|
||||||
|
for utf8.FullRune(b) {
|
||||||
|
r, len := utf8.DecodeRune(b)
|
||||||
|
b, tw.runeBuffer = b[len:], append(tw.runeBuffer, r)
|
||||||
|
}
|
||||||
|
tw.byteBuffer = b
|
||||||
|
for tw.processRunes() {
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tw *terminalWriter) processPrint(r rune) {
|
||||||
|
// Extend the buffer vertically.
|
||||||
|
for len(tw.lines) <= tw.line {
|
||||||
|
tw.lines = append(tw.lines,
|
||||||
|
terminalLine{updateGroup: len(tw.lines)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = min(tw.lines[i].updateGroup, tw.line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emulate `cat -v` for C0 characters.
|
||||||
|
seq := make([]rune, 0, 2)
|
||||||
|
if r < 32 && r != '\t' {
|
||||||
|
seq = append(seq, '^', 64+r)
|
||||||
|
} else {
|
||||||
|
seq = append(seq, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend the line horizontally and write the rune.
|
||||||
|
for _, r := range seq {
|
||||||
|
line := &tw.lines[tw.line]
|
||||||
|
for len(line.columns) <= tw.column {
|
||||||
|
line.columns = append(line.columns, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
line.columns[tw.column] = r
|
||||||
|
tw.column++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tw *terminalWriter) processFlush() {
|
||||||
|
tw.column = 0
|
||||||
|
tw.line = len(tw.lines)
|
||||||
|
tw.lineTop = tw.line
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tw *terminalWriter) processParsedCSI(
|
||||||
|
private rune, param, intermediate []rune, final rune) bool {
|
||||||
|
var params []int
|
||||||
|
if len(param) > 0 {
|
||||||
|
for _, p := range strings.Split(string(param), ";") {
|
||||||
|
i, _ := strconv.Atoi(p)
|
||||||
|
params = append(params, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if private == '?' && len(intermediate) == 0 &&
|
||||||
|
(final == 'h' || final == 'l') {
|
||||||
|
for _, p := range params {
|
||||||
|
// 25 (DECTCEM): There is no cursor to show or hide.
|
||||||
|
// 7 (DECAWM): We cannot wrap, we're infinite.
|
||||||
|
if !(p == 25 || (p == 7 && final == 'l')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if private != 0 || len(intermediate) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case final == 'C': // Cursor Forward
|
||||||
|
if len(params) == 0 {
|
||||||
|
tw.column++
|
||||||
|
} else if len(params) >= 1 {
|
||||||
|
tw.column += params[0]
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case final == 'D': // Cursor Backward
|
||||||
|
if len(params) == 0 {
|
||||||
|
tw.column--
|
||||||
|
} else if len(params) >= 1 {
|
||||||
|
tw.column -= params[0]
|
||||||
|
}
|
||||||
|
if tw.column < 0 {
|
||||||
|
tw.column = 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case final == 'E': // Cursor Next Line
|
||||||
|
if len(params) == 0 {
|
||||||
|
tw.line++
|
||||||
|
} else if len(params) >= 1 {
|
||||||
|
tw.line += params[0]
|
||||||
|
}
|
||||||
|
tw.column = 0
|
||||||
|
return true
|
||||||
|
case final == 'F': // Cursor Preceding Line
|
||||||
|
if len(params) == 0 {
|
||||||
|
tw.line--
|
||||||
|
} else if len(params) >= 1 {
|
||||||
|
tw.line -= params[0]
|
||||||
|
}
|
||||||
|
if tw.line < tw.lineTop {
|
||||||
|
tw.line = tw.lineTop
|
||||||
|
}
|
||||||
|
tw.column = 0
|
||||||
|
return true
|
||||||
|
case final == 'H': // Cursor Position
|
||||||
|
if len(params) == 0 {
|
||||||
|
tw.line = tw.lineTop
|
||||||
|
tw.column = 0
|
||||||
|
} else if len(params) < 2 || params[0] <= 0 || params[1] <= 0 {
|
||||||
|
return false
|
||||||
|
} else if params[0] >= 32766 && params[1] >= 32766 {
|
||||||
|
// Ignore attempts to scan terminal bounds.
|
||||||
|
} else {
|
||||||
|
tw.line = tw.lineTop + params[0] - 1
|
||||||
|
tw.column = params[1] - 1
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case final == 'J': // Erase in Display
|
||||||
|
if len(params) == 0 || params[0] == 0 || params[0] == 2 {
|
||||||
|
// We're not going to erase anything, thank you very much.
|
||||||
|
tw.processFlush()
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case final == 'K': // Erase in Line
|
||||||
|
if tw.line >= len(tw.lines) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
line := &tw.lines[tw.line]
|
||||||
|
if len(params) == 0 || params[0] == 0 {
|
||||||
|
if len(line.columns) > tw.column {
|
||||||
|
line.columns = line.columns[:tw.column]
|
||||||
|
}
|
||||||
|
} else if params[0] == 1 {
|
||||||
|
for i := 0; i < tw.column; i++ {
|
||||||
|
line.columns[i] = ' '
|
||||||
|
}
|
||||||
|
} else if params[0] == 2 {
|
||||||
|
line.columns = nil
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case final == 'm':
|
||||||
|
// Straight up ignoring all attributes, at least for now.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tw *terminalWriter) processCSI(rb []rune) ([]rune, bool) {
|
||||||
|
if len(rb) < 3 {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
i, private, param, intermediate := 2, rune(0), []rune{}, []rune{}
|
||||||
|
if rb[i] >= 0x3C && rb[i] <= 0x3F {
|
||||||
|
private = rb[i]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
for i < len(rb) && ((rb[i] >= '0' && rb[i] <= '9') || rb[i] == ';') {
|
||||||
|
param = append(param, rb[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
for i < len(rb) && rb[i] >= 0x20 && rb[i] <= 0x2F {
|
||||||
|
intermediate = append(intermediate, rb[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == len(rb) {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
if rb[i] < 0x40 || rb[i] > 0x7E {
|
||||||
|
return rb, false
|
||||||
|
}
|
||||||
|
if !tw.processParsedCSI(private, param, intermediate, rb[i]) {
|
||||||
|
tw.log("unhandled CSI %s", string(rb[2:i+1]))
|
||||||
|
return rb, false
|
||||||
|
}
|
||||||
|
return rb[i+1:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tw *terminalWriter) processEscape(rb []rune) ([]rune, bool) {
|
||||||
|
if len(rb) < 2 {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Very roughly following https://vt100.net/emu/dec_ansi_parser
|
||||||
|
// but being a bit stricter.
|
||||||
|
switch r := rb[1]; {
|
||||||
|
case r == '[':
|
||||||
|
return tw.processCSI(rb)
|
||||||
|
case r == ']':
|
||||||
|
// TODO(p): Skip this properly, once we actually hit it.
|
||||||
|
tw.log("unhandled OSC")
|
||||||
|
return rb, false
|
||||||
|
case r == 'P':
|
||||||
|
// TODO(p): Skip this properly, once we actually hit it.
|
||||||
|
tw.log("unhandled DCS")
|
||||||
|
return rb, false
|
||||||
|
|
||||||
|
// Only handling sequences we've seen bother us in real life.
|
||||||
|
case r == 'c':
|
||||||
|
// Full reset, use this to flush all output.
|
||||||
|
tw.processFlush()
|
||||||
|
return rb[2:], true
|
||||||
|
case r == 'M':
|
||||||
|
tw.line--
|
||||||
|
return rb[2:], true
|
||||||
|
|
||||||
|
case (r >= 0x30 && r <= 0x4F) || (r >= 0x51 && r <= 0x57) ||
|
||||||
|
r == 0x59 || r == 0x5A || r == 0x5C || (r >= 0x60 && r <= 0x7E):
|
||||||
|
// → esc_dispatch
|
||||||
|
tw.log("unhandled ESC %c", r)
|
||||||
|
return rb, false
|
||||||
|
//return rb[2:], true
|
||||||
|
case r >= 0x20 && r <= 0x2F:
|
||||||
|
// → escape intermediate
|
||||||
|
i := 2
|
||||||
|
for i < len(rb) && rb[i] >= 0x20 && rb[i] <= 0x2F {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == len(rb) {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
if rb[i] < 0x30 || rb[i] > 0x7E {
|
||||||
|
return rb, false
|
||||||
|
}
|
||||||
|
// → esc_dispatch
|
||||||
|
tw.log("unhandled ESC %s", string(rb[1:i+1]))
|
||||||
|
return rb, false
|
||||||
|
//return rb[i+1:], true
|
||||||
|
default:
|
||||||
|
// Note that Debian 12 has been seen to produce ESC<U+2026>
|
||||||
|
// and such due to some very blind string processing.
|
||||||
|
return rb, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tw *terminalWriter) processRunes() bool {
|
||||||
|
rb := tw.runeBuffer
|
||||||
|
if len(rb) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rb[0] {
|
||||||
|
case '\a':
|
||||||
|
// Ding dong!
|
||||||
|
case '\b':
|
||||||
|
if tw.column > 0 {
|
||||||
|
tw.column--
|
||||||
|
}
|
||||||
|
case '\n', '\v':
|
||||||
|
tw.line++
|
||||||
|
|
||||||
|
// Forced ONLCR flag, because that's what most shell output expects.
|
||||||
|
fallthrough
|
||||||
|
case '\r':
|
||||||
|
tw.column = 0
|
||||||
|
|
||||||
|
case '\x1b':
|
||||||
|
var ok bool
|
||||||
|
if rb, ok = tw.processEscape(rb); rb == nil {
|
||||||
|
return false
|
||||||
|
} else if ok {
|
||||||
|
tw.runeBuffer = rb
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsuccessful parses get printed for later inspection.
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
tw.processPrint(rb[0])
|
||||||
|
}
|
||||||
|
tw.runeBuffer = rb[1:]
|
||||||
|
return true
|
||||||
|
}
|
||||||
100
terminal_test.go
Normal file
100
terminal_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This could be way more extensive, but we're not aiming for perfection.
|
||||||
|
var tests = []struct {
|
||||||
|
push, want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// Escaping and UTF-8.
|
||||||
|
"\x03\x1bž\bř",
|
||||||
|
"^C^[ř\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Several kinds of sequences to be ignored.
|
||||||
|
"\x1bc\x1b[?7l\x1b[2J\x1b[0;1mSeaBIOS\rTea",
|
||||||
|
"TeaBIOS\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// New origin and absolute positioning.
|
||||||
|
"Line 1\n\x1bcWine B\nFine 3\x1b[1;6H2\x1b[HL\nL",
|
||||||
|
"Line 1\nLine 2\nLine 3\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In-line positioning (without corner cases).
|
||||||
|
"A\x1b[CB\x1b[2C?\x1b[DC\x1b[2D\b->",
|
||||||
|
"A B->C\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Up and down.
|
||||||
|
"\nB\x1bMA\v\vC" + "\x1b[4EG" + "\x1b[FF" + "\x1b[2FD" + "\x1b[EE",
|
||||||
|
" A\nB\nC\nD\nE\nF\nG\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In-line erasing.
|
||||||
|
"1234\b\b\x1b[K\n5678\b\b\x1b[0K\n" + "abcd\b\b\x1b[1K\nefgh\x1b[2K",
|
||||||
|
"12\n56\n cd\n\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerminal(t *testing.T) {
|
||||||
|
for _, test := range tests {
|
||||||
|
tw := terminalWriter{}
|
||||||
|
if _, err := tw.Write([]byte(test.push)); err != nil {
|
||||||
|
t.Errorf("%#v: %s", test.push, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
have := string(tw.Serialize(0))
|
||||||
|
if have != test.want {
|
||||||
|
t.Errorf("%#v: %#v; want %#v", test.push, have, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerminalExploded(t *testing.T) {
|
||||||
|
Loop:
|
||||||
|
for _, test := range tests {
|
||||||
|
tw := terminalWriter{}
|
||||||
|
for _, b := range []byte(test.push) {
|
||||||
|
if _, err := tw.Write([]byte{b}); err != nil {
|
||||||
|
t.Errorf("%#v: %s", test.push, err)
|
||||||
|
continue Loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
have := string(tw.Serialize(0))
|
||||||
|
if have != test.want {
|
||||||
|
t.Errorf("%#v: %#v; want %#v", test.push, have, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user