Compare commits

...

16 Commits

Author SHA1 Message Date
b766c9ef20 Improve current Arch Linux run log
All checks were successful
Alpine 3.22 Success
2025-11-23 19:38:51 +01:00
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
4f2c2dc8da Order tasks by change date first
All checks were successful
Alpine 3.20 Success
The user presumably does not want to look everywhere for recent tasks.
2024-12-26 16:24:54 +01:00
55a6693942 Fix deployment error processing 2024-12-26 16:18:37 +01:00
d5981249b1 Add time information 2024-12-26 16:17:45 +01:00
4a7fc55c92 Runtime configuration changes
All checks were successful
Alpine 3.20 Success
Through an RPC command, because systemd documentation told us to.
2024-12-26 12:03:00 +01:00
2bd231b84f Fix Makefile dependencies, extend tests
All checks were successful
Alpine 3.20 Success
2024-12-26 00:40:58 +01:00
fb291b6def Improve the terminal filter
All checks were successful
Alpine 3.20 Success
The new filter comes with these enhancements:

 - Processing is rune-wise rather than byte-wise;
   it assumes UTF-8 input and single-cell wide characters,
   but this condition should be /usually/ satisfied.
 - Unprocessed control characters are escaped, `cat -v` style.
 - A lot of escape sequences is at least recognised, if not processed.
 - Rudimentary preparation for efficient dynamic updates
   of task views, through Javascript.

We make terminal resets and screen clearing commands
flush all output and assume that the terminal has a new origin
for any later positioning commands.
This appears to work well enough with GRUB, at least.

The filter is now exposed through a command line option.
2024-12-25 23:14:54 +01:00
14a15e8b59 Prevent a data race 2024-12-25 23:14:54 +01:00
0746797c73 Add optional raw log redirection
For now using an environment variable.
2024-12-25 23:14:38 +01:00
ec656d8b2a Make do with a2x when there is no asciidoctor 2024-12-24 15:43:35 +01:00
7 changed files with 937 additions and 153 deletions

View File

@@ -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
purpose with or without fee is hereby granted.

View File

@@ -5,10 +5,11 @@ version = dev
outputs = acid acid.1
all: $(outputs)
acid: acid.go
acid: acid.go terminal.go
go build -ldflags "-X 'main.projectVersion=$(version)'" -o $@
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
go test
clean:

View File

@@ -37,6 +37,8 @@ Commands
*restart* [_ID_]...::
Schedule tasks with the given IDs to be rerun.
Run this command without arguments to pick up external database changes.
*reload*::
Reload configuration.
Configuration
-------------
@@ -65,6 +67,17 @@ which has the following fields:
*RunnerName*::
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*::
*acid* link to the task, where its log output can be seen.
*RepoURL*::

568
acid.go
View File

@@ -26,6 +26,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
ttemplate "text/template"
"time"
@@ -40,8 +41,8 @@ var (
projectName = "acid"
projectVersion = "?"
gConfig Config = Config{Listen: ":http"}
gNotifyScript *ttemplate.Template
gConfigPath string
gConfig atomic.Pointer[Config]
gDB *sql.DB
gNotifierSignal = make(chan struct{}, 1)
@@ -52,6 +53,8 @@ var (
gRunning = make(map[int64]*RunningTask)
)
func getConfig() *Config { return gConfig.Load() }
// --- Config ------------------------------------------------------------------
type Config struct {
@@ -65,6 +68,8 @@ type Config struct {
Runners map[string]ConfigRunner `yaml:"runners"` // script runners
Projects map[string]ConfigProject `yaml:"projects"` // configured projects
notifyTemplate *ttemplate.Template
}
type ConfigRunner struct {
@@ -86,8 +91,9 @@ type ConfigProject struct {
func (cf *ConfigProject) AutomaticRunners() (runners []string) {
// We pass through unknown runner names,
// so that they can cause reference errors later.
config := getConfig()
for runner := range cf.Runners {
if r, _ := gConfig.Runners[runner]; !r.Manual {
if r, _ := config.Runners[runner]; !r.Manual {
runners = append(runners, runner)
}
}
@@ -102,19 +108,30 @@ type ConfigProjectRunner struct {
Timeout string `yaml:"timeout"` // timeout duration
}
func parseConfig(path string) error {
if f, err := os.Open(path); err != nil {
// loadConfig reloads configuration.
// 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
} else if err = yaml.NewDecoder(f).Decode(&gConfig); err != nil {
} else if err = yaml.NewDecoder(f).Decode(new); err != nil {
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
gNotifyScript, err =
ttemplate.New("notify").Funcs(shellFuncs).Parse(gConfig.Notify)
new.notifyTemplate, err =
ttemplate.New("notify").Funcs(shellFuncs).Parse(new.Notify)
if err != nil {
return err
}
gConfig.Store(new)
return nil
}
var shellFuncs = ttemplate.FuncMap{
"quote": func(word string) string {
// History expansion is annoying, don't let it cut us.
@@ -136,8 +153,16 @@ 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(gConfig.Secret))
payloadHmac := hmac.New(sha256.New, []byte(getConfig().Secret))
payloadHmac.Write(b)
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) (
*http.Request, error) {
req, err := http.NewRequestWithContext(
ctx, method, gConfig.Gitea+path, body)
ctx, method, getConfig().Gitea+path, body)
if req != nil {
req.Header.Set("Authorization", "token "+gConfig.Token)
req.Header.Set("Authorization", "token "+getConfig().Token)
req.Header.Set("Accept", "application/json")
}
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) {
rows, err := gDB.QueryContext(ctx, `
SELECT id, owner, repo, hash, runner,
created, changed, duration,
state, detail, notified,
runlog, tasklog, deploylog FROM task `+query, args...)
if err != nil {
@@ -167,11 +193,13 @@ func getTasks(ctx context.Context, query string, args ...any) ([]Task, error) {
for rows.Next() {
var t Task
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.RunLog, &t.TaskLog, &t.DeployLog)
if err != nil {
return nil, err
}
// We could also update some fields from gRunning.
tasks = append(tasks, t)
}
return tasks, rows.Err()
@@ -192,6 +220,8 @@ var templateTasks = template.Must(template.New("tasks").Parse(`
<thead>
<tr>
<th>ID</th>
<th>Created</th>
<th>Changed</th>
<th>Repository</th>
<th>Hash</th>
<th>Runner</th>
@@ -204,6 +234,8 @@ var templateTasks = template.Must(template.New("tasks").Parse(`
{{range .}}
<tr>
<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="{{.CommitURL}}">{{.Hash}}</a></td>
<td>{{.RunnerName}}</td>
@@ -219,7 +251,7 @@ var templateTasks = template.Must(template.New("tasks").Parse(`
`))
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 {
http.Error(w,
"Error retrieving tasks: "+err.Error(),
@@ -238,41 +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>
<!-- 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>
<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>
<dd id="notified">{{.Notified}}</dd>
<dt>Duration</dt>
<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 {
@@ -293,34 +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))
}
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 {
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)
}
}
@@ -347,8 +515,9 @@ func createTasks(ctx context.Context,
}
defer tx.Rollback()
stmt, err := tx.Prepare(`INSERT INTO task(owner, repo, hash, runner)
VALUES (?, ?, ?, ?)`)
stmt, err := tx.Prepare(
`INSERT INTO task(owner, repo, hash, runner, created, changed)
VALUES (?, ?, ?, ?, unixepoch('now'), unixepoch('now'))`)
if err != nil {
return err
}
@@ -397,7 +566,7 @@ func handlePush(w http.ResponseWriter, r *http.Request) {
log.Printf("received push: %s %s\n",
event.Repository.FullName, event.HeadCommit.ID)
project, ok := gConfig.Projects[event.Repository.FullName]
project, ok := getConfig().Projects[event.Repository.FullName]
if !ok {
// This is okay, don't set any commit statuses.
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,
// 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)
if err != nil {
return fmt.Errorf("%d: %w", id, err)
@@ -506,7 +678,7 @@ func rpcEnqueue(ctx context.Context,
return fmt.Errorf("%s: %w", ref, err)
}
project, ok := gConfig.Projects[owner+"/"+repo]
project, ok := getConfig().Projects[owner+"/"+repo]
if !ok {
return fmt.Errorf("project configuration not found")
}
@@ -558,6 +730,17 @@ func rpcRestart(ctx context.Context,
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 {
@@ -570,6 +753,8 @@ var rpcCommands = map[string]struct {
"Create or restart tasks for the given reference."},
"restart": {rpcRestart, "[ID]...",
"Schedule tasks with the given IDs to be rerun."},
"reload": {rpcReload, "",
"Reload configuration."},
}
func rpcPrintCommands(w io.Writer) {
@@ -656,12 +841,12 @@ func handleRPC(w http.ResponseWriter, r *http.Request) {
func notifierRunCommand(ctx context.Context, task Task) {
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)
return
}
cmd := exec.CommandContext(ctx, "sh")
cmd := exec.CommandContext(ctx, localShell())
cmd.Stdin = script
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -683,13 +868,14 @@ func notifierNotify(ctx context.Context, task Task) error {
TargetURL string `json:"target_url"`
}{}
runner, ok := gConfig.Runners[task.Runner]
config := getConfig()
runner, ok := config.Runners[task.Runner]
if !ok {
log.Printf("task %d has an unknown runner %s\n", task.ID, task.Runner)
return nil
}
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 {
case taskStateNew:
@@ -785,49 +971,6 @@ func notifierAwaken() {
}
// --- 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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// 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.
func newRunningTask(task Task) (*RunningTask, error) {
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
rt.Runner, ok = gConfig.Runners[rt.DB.Runner]
rt.Runner, ok = config.Runners[rt.DB.Runner]
if !ok {
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 {
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)
}
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
}
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.
func (rt *RunningTask) localEnv() []string {
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.
func (rt *RunningTask) update() error {
for _, i := range []struct {
@@ -939,12 +1107,13 @@ func (rt *RunningTask) update() error {
{&rt.TaskLog, &rt.DB.TaskLog},
{&rt.DeployLog, &rt.DB.DeployLog},
} {
i.tw.mu.Lock()
defer i.tw.mu.Unlock()
if *i.log = bytes.Clone(i.tw.b); *i.log == nil {
i.tw.Lock()
defer i.tw.Unlock()
if *i.log = i.tw.Serialize(0); *i.log == nil {
*i.log = []byte{}
}
}
rt.DB.DurationSeconds = rt.elapsed()
return rt.DB.update()
}
@@ -1001,12 +1170,12 @@ func executorDownload(client *ssh.Client, remoteRoot, localRoot string) error {
return nil
}
func executorLocalShell() string {
if shell := os.Getenv("SHELL"); shell != "" {
return shell
func executorTmpDir(fallback string) string {
// See also: https://systemd.io/TEMPORARY_DIRECTORIES/
if tmp := os.Getenv("TMPDIR"); tmp != "" {
return tmp
}
// The os/user package doesn't store the parsed out shell field.
return "/bin/sh"
return fallback
}
func executorDeploy(
@@ -1023,13 +1192,7 @@ func executorDeploy(
// We expect the files to be moved elsewhere on the filesystem,
// and they may get very large, so avoid /tmp.
//
// See also: https://systemd.io/TEMPORARY_DIRECTORIES/
tmp := os.Getenv("TMPDIR")
if tmp == "" {
tmp = "/var/tmp"
}
dir := filepath.Join(tmp, "acid-deploy")
dir := filepath.Join(executorTmpDir("/var/tmp"), "acid-deploy")
if err := os.RemoveAll(dir); err != nil {
return err
}
@@ -1042,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()
@@ -1167,6 +1331,7 @@ func executorConnect(
func executorRunTask(ctx context.Context, task Task) error {
rt, err := newRunningTask(task)
if err != nil {
task.DurationSeconds = 0
task.State, task.Detail = taskStateError, "Misconfigured"
task.Notified = 0
task.RunLog = []byte(err.Error())
@@ -1174,6 +1339,7 @@ func executorRunTask(ctx context.Context, task Task) error {
task.DeployLog = []byte{}
return task.update()
}
defer rt.close()
ctx, cancelTimeout := context.WithTimeout(ctx, rt.timeout)
defer cancelTimeout()
@@ -1185,6 +1351,7 @@ func executorRunTask(ctx context.Context, task Task) error {
f()
}
locked(func() {
rt.DB.DurationSeconds = 0
rt.DB.State, rt.DB.Detail = taskStateRunning, ""
rt.DB.Notified = 0
rt.DB.RunLog = []byte{}
@@ -1272,19 +1439,20 @@ func executorRunTask(ctx context.Context, task Task) error {
defer client.Close()
var (
ee1 *ssh.ExitError
ee2 *executorError
eeSSH *ssh.ExitError
eeExec *exec.ExitError
ee3 *executorError
)
err = executorBuild(ctxRunner, client, rt)
if err != nil {
locked(func() {
if errors.As(err, &ee1) {
if errors.As(err, &eeSSH) {
rt.DB.State, rt.DB.Detail = taskStateFailed, "Scripts failed"
fmt.Fprintf(&rt.TaskLog, "\n%s\n", err)
} else if errors.As(err, &ee2) {
rt.DB.State, rt.DB.Detail = taskStateError, ee2.Detail
fmt.Fprintf(&rt.TaskLog, "\n%s\n", ee2.Err)
} else if errors.As(err, &ee3) {
rt.DB.State, rt.DB.Detail = taskStateError, ee3.Detail
fmt.Fprintf(&rt.TaskLog, "\n%s\n", ee3.Err)
} else {
rt.DB.State, rt.DB.Detail = taskStateError, ""
fmt.Fprintf(&rt.TaskLog, "\n%s\n", err)
@@ -1304,12 +1472,12 @@ func executorRunTask(ctx context.Context, task Task) error {
locked(func() {
if err == nil {
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"
fmt.Fprintf(&rt.DeployLog, "\n%s\n", err)
} else if errors.As(err, &ee2) {
rt.DB.State, rt.DB.Detail = taskStateError, ee2.Detail
fmt.Fprintf(&rt.DeployLog, "\n%s\n", ee2.Err)
} else if errors.As(err, &ee3) {
rt.DB.State, rt.DB.Detail = taskStateError, ee3.Detail
fmt.Fprintf(&rt.DeployLog, "\n%s\n", ee3.Err)
} else {
rt.DB.State, rt.DB.Detail = taskStateError, ""
fmt.Fprintf(&rt.DeployLog, "\n%s\n", err)
@@ -1393,6 +1561,11 @@ type Task struct {
Hash string
Runner string
// True database names for these are occupied by accessors.
CreatedUnix int64
ChangedUnix int64
DurationSeconds int64
State taskState
Detail string
Notified int64
@@ -1404,7 +1577,7 @@ type Task struct {
func (t *Task) FullName() string { return t.Owner + "/" + t.Repo }
func (t *Task) RunnerName() string {
if runner, ok := gConfig.Runners[t.Runner]; !ok {
if runner, ok := getConfig().Runners[t.Runner]; !ok {
return t.Runner
} else {
return runner.Name
@@ -1412,26 +1585,77 @@ func (t *Task) RunnerName() 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 {
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 {
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 {
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 {
_, err := gDB.ExecContext(context.Background(), `UPDATE task
SET state = ?, detail = ?, notified = ?,
_, err := gDB.ExecContext(context.Background(),
`UPDATE task SET changed = unixepoch('now'), duration = ?,
state = ?, detail = ?, notified = ?,
runlog = ?, tasklog = ?, deploylog = ? WHERE id = ?`,
t.DurationSeconds,
t.State, t.Detail, t.Notified,
t.RunLog, t.TaskLog, t.DeployLog, t.ID)
if err == nil {
@@ -1453,6 +1677,10 @@ CREATE TABLE IF NOT EXISTS task(
hash TEXT NOT NULL, -- commit hash
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
detail TEXT NOT NULL DEFAULT '', -- task state detail
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 {
return err
}
break
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.
default:
return fmt.Errorf("unsupported database version: %d", version)
}
if _, err = tx.Exec(
`PRAGMA user_version = ` + strconv.Itoa(1)); err != nil {
`PRAGMA user_version = ` + strconv.Itoa(2)); err != nil {
return err
}
return tx.Commit()
@@ -1532,7 +1775,7 @@ func callRPC(args []string) error {
}
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 {
return err
}
@@ -1555,8 +1798,30 @@ func callRPC(args []string) error {
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() {
version := flag.Bool("version", false, "show version and exit")
tty := flag.Bool("tty", false, "run the internal virtual terminal filter")
flag.Usage = func() {
f := flag.CommandLine.Output()
@@ -1574,8 +1839,15 @@ func main() {
fmt.Printf("%s %s\n", projectName, projectVersion)
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)
}
if flag.NArg() > 1 {
@@ -1585,7 +1857,7 @@ func main() {
return
}
if err := dbOpen(gConfig.DB); err != nil {
if err := dbOpen(getConfig().DB); err != nil {
log.Fatalln(err)
}
defer gDB.Close()
@@ -1594,7 +1866,7 @@ func main() {
ctx, stop := signal.NotifyContext(
context.Background(), syscall.SIGINT, syscall.SIGTERM)
server := &http.Server{Addr: gConfig.Listen}
server := &http.Server{Addr: getConfig().Listen}
http.HandleFunc("/{$}", handleTasks)
http.HandleFunc("/task/{id}", handleTask)
http.HandleFunc("/push", handlePush)

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

381
terminal.go Normal file
View 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
View 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)
}
}