Compare commits
6 Commits
dev/svg-ma
...
dev/giteac
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b54458511 | |||
| 5a9d462087 | |||
| 78c7b32eb2 | |||
| c676dc270a | |||
| 6c8cae885b | |||
| 2c34b66da5 |
54
cronr/README.md
Normal file
54
cronr/README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# cronr — agent scheduling via systemd user timers
|
||||||
|
|
||||||
|
Lets an agent create recurring or one-shot jobs that **survive session
|
||||||
|
exit and reboot** — what agy's in-memory `schedule` tool and Claude's
|
||||||
|
in-session wakeups cannot do. No daemon: systemd runs the jobs, cronr
|
||||||
|
just manages namespaced `cronr-<name>` units. Closes the
|
||||||
|
`CronCreate/List/Delete` gap from
|
||||||
|
[`doc/tool-parity.md`](../doc/tool-parity.md) §3.1.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
cronr add <name> --schedule "OnCalendar spec" --cmd "shell command"
|
||||||
|
cronr add <name> --at "YYYY-MM-DD HH:MM" --cmd "shell command" # one-shot
|
||||||
|
cronr list # schedule, next run, last result per job
|
||||||
|
cronr run <name> # run the job right now (timer untouched)
|
||||||
|
cronr logs <name> [--lines N] # the job's journal
|
||||||
|
cronr rm <name> # disable and delete
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cronr add nightly-ci --schedule "*-*-* 07:00" --cmd 'agy -p "check CI status and notify"'
|
||||||
|
cronr add backup-ping --schedule "Mon *-*-* 09:00" --cmd 'notifyr send --msg "weekly backup check"'
|
||||||
|
cronr add once --at "2026-08-10 03:00" --cmd 'systemctl --user restart helmd'
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
`add` writes three files and prints all of them, so the result is
|
||||||
|
fully verifiable:
|
||||||
|
|
||||||
|
- `~/.local/share/cronr/<name>.sh` — the command, verbatim (shell
|
||||||
|
quoting never meets systemd's ExecStart parsing)
|
||||||
|
- `~/.config/systemd/user/cronr-<name>.service` — oneshot, with
|
||||||
|
`~/.local/bin` on PATH so agent tools (notifyr, svgc, agy…) resolve
|
||||||
|
- `~/.config/systemd/user/cronr-<name>.timer` — `OnCalendar=…`,
|
||||||
|
`Persistent=true` for recurring jobs (missed runs fire on next boot)
|
||||||
|
|
||||||
|
Schedules are validated by `systemd-analyze calendar` before anything
|
||||||
|
is written — you get systemd's own error text plus the computed next
|
||||||
|
elapse. `list`/`rm` only ever see `cronr-*` units, so other services
|
||||||
|
are untouchable by construction.
|
||||||
|
|
||||||
|
`cronr run <name>` starts the service immediately — handy for testing
|
||||||
|
a job before trusting the schedule.
|
||||||
|
|
||||||
|
## Build & test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go build -o build/cronr .
|
||||||
|
```
|
||||||
3
cronr/go.mod
Normal file
3
cronr/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module gitea.brasse-pc.eu/brasse/agent-tools/cronr
|
||||||
|
|
||||||
|
go 1.24
|
||||||
300
cronr/main.go
Normal file
300
cronr/main.go
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
// cronr schedules recurring or one-shot jobs as systemd user timers,
|
||||||
|
// so an agent can set up work that survives session exit and reboot.
|
||||||
|
// No daemon of its own — systemd does the running. See
|
||||||
|
// doc/tool-parity.md §3.1.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.brasse-pc.eu/brasse/agent-tools/cronr/unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
const usage = `cronr - agent-friendly scheduling via systemd user timers
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cronr add <name> --schedule "OnCalendar spec" --cmd "shell command"
|
||||||
|
cronr add <name> --at "YYYY-MM-DD HH:MM" --cmd "shell command" one-shot
|
||||||
|
cronr list all cronr jobs: schedule, next run, last result
|
||||||
|
cronr run <name> run the job now (does not touch the timer)
|
||||||
|
cronr logs <name> [--lines N] journal for the job
|
||||||
|
cronr rm <name> disable and delete the job
|
||||||
|
cronr version
|
||||||
|
|
||||||
|
Schedule examples (systemd OnCalendar; validated with systemd-analyze):
|
||||||
|
"*-*-* 07:00" every morning at 07:00
|
||||||
|
"Mon *-*-* 09:00" mondays 09:00
|
||||||
|
"*:0/15" every 15 minutes
|
||||||
|
|
||||||
|
The job's command is stored as a script in ~/.local/share/cronr/ and
|
||||||
|
runs with ~/.local/bin on PATH, so agent tools (notifyr, svgc, agy)
|
||||||
|
work as in a login shell. Everything cronr creates is namespaced
|
||||||
|
cronr-<name> — it never touches other units.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
cronr add nightly-ci --schedule "*-*-* 07:00" --cmd 'agy -p "check CI and notify"'
|
||||||
|
`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Print(usage)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "add":
|
||||||
|
cmdAdd(os.Args[2:])
|
||||||
|
case "list":
|
||||||
|
cmdList()
|
||||||
|
case "run":
|
||||||
|
requireName(os.Args[2:], "run")
|
||||||
|
sh("systemctl", "--user", "start", unit.Prefix+os.Args[2]+".service")
|
||||||
|
fmt.Printf("started %s — see: cronr logs %s\n", os.Args[2], os.Args[2])
|
||||||
|
case "logs":
|
||||||
|
cmdLogs(os.Args[2:])
|
||||||
|
case "rm":
|
||||||
|
cmdRm(os.Args[2:])
|
||||||
|
case "version", "--version", "-v":
|
||||||
|
fmt.Println("cronr", version)
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
fmt.Print(usage)
|
||||||
|
default:
|
||||||
|
die("unknown command %q — run 'cronr help'", os.Args[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unitDir() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".config", "systemd", "user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scriptDir() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".local", "share", "cronr")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdAdd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("add", flag.ExitOnError)
|
||||||
|
schedule := fs.String("schedule", "", "OnCalendar spec for recurring jobs")
|
||||||
|
at := fs.String("at", "", `one-shot time "YYYY-MM-DD HH:MM"`)
|
||||||
|
cmd := fs.String("cmd", "", "shell command the job runs (required)")
|
||||||
|
pos := parseInterspersed(fs, args)
|
||||||
|
if len(pos) != 1 {
|
||||||
|
die("add needs exactly one job name")
|
||||||
|
}
|
||||||
|
name := pos[0]
|
||||||
|
if !unit.ValidName(name) {
|
||||||
|
die("invalid job name %q (letters, digits, - and _)", name)
|
||||||
|
}
|
||||||
|
if *cmd == "" {
|
||||||
|
die("--cmd is required")
|
||||||
|
}
|
||||||
|
if (*schedule == "") == (*at == "") {
|
||||||
|
die("give exactly one of --schedule (recurring) or --at (one-shot)")
|
||||||
|
}
|
||||||
|
spec := *schedule
|
||||||
|
oneshot := false
|
||||||
|
if *at != "" {
|
||||||
|
var err error
|
||||||
|
spec, err = unit.AtToCalendar(*at)
|
||||||
|
if err != nil {
|
||||||
|
die("%v", err)
|
||||||
|
}
|
||||||
|
oneshot = true
|
||||||
|
}
|
||||||
|
// systemd itself is the authority on calendar specs
|
||||||
|
if out, err := exec.Command("systemd-analyze", "calendar", spec).CombinedOutput(); err != nil {
|
||||||
|
die("systemd rejects the schedule %q:\n%s", spec, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
svcName, tmrName := unit.UnitNames(name)
|
||||||
|
if _, err := os.Stat(filepath.Join(unitDir(), tmrName)); err == nil {
|
||||||
|
die("job %s already exists (cronr rm %s first)", name, name)
|
||||||
|
}
|
||||||
|
scriptPath := filepath.Join(scriptDir(), name+".sh")
|
||||||
|
if err := os.MkdirAll(scriptDir(), 0o755); err != nil {
|
||||||
|
die("%v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(unitDir(), 0o755); err != nil {
|
||||||
|
die("%v", err)
|
||||||
|
}
|
||||||
|
writes := []struct{ path, content string }{
|
||||||
|
{scriptPath, unit.Script(*cmd)},
|
||||||
|
{filepath.Join(unitDir(), svcName), unit.Service(name, scriptPath)},
|
||||||
|
{filepath.Join(unitDir(), tmrName), unit.Timer(name, spec, oneshot)},
|
||||||
|
}
|
||||||
|
for _, w := range writes {
|
||||||
|
mode := os.FileMode(0o644)
|
||||||
|
if strings.HasSuffix(w.path, ".sh") {
|
||||||
|
mode = 0o755
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(w.path, []byte(w.content), mode); err != nil {
|
||||||
|
die("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sh("systemctl", "--user", "daemon-reload")
|
||||||
|
sh("systemctl", "--user", "enable", "--now", tmrName)
|
||||||
|
|
||||||
|
fmt.Printf("job %s created and enabled\n\n", name)
|
||||||
|
for _, w := range writes {
|
||||||
|
fmt.Printf("--- %s ---\n%s\n", w.path, w.content)
|
||||||
|
}
|
||||||
|
fmt.Print(nextRun(spec))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdList() {
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(unitDir(), unit.Prefix+"*.timer"))
|
||||||
|
if len(matches) == 0 {
|
||||||
|
fmt.Println("no cronr jobs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Strings(matches)
|
||||||
|
fmt.Printf("%-24s %-22s %-26s %s\n", "NAME", "SCHEDULE", "NEXT", "LAST RESULT")
|
||||||
|
for _, m := range matches {
|
||||||
|
name := unit.JobName(m)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
spec := ""
|
||||||
|
if data, err := os.ReadFile(m); err == nil {
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.HasPrefix(line, "OnCalendar=") {
|
||||||
|
spec = strings.TrimPrefix(line, "OnCalendar=")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next := strings.TrimPrefix(nextRun(spec), "next run: ")
|
||||||
|
svcName, _ := unit.UnitNames(name)
|
||||||
|
last := lastResult(svcName)
|
||||||
|
fmt.Printf("%-24s %-22s %-26s %s\n", name, spec, strings.TrimSpace(next), last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextRun asks systemd-analyze when the spec fires next.
|
||||||
|
func nextRun(spec string) string {
|
||||||
|
out, err := exec.Command("systemd-analyze", "calendar", spec).Output()
|
||||||
|
if err != nil {
|
||||||
|
return "next run: ?\n"
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "Next elapse:") {
|
||||||
|
return "next run: " + strings.TrimSpace(strings.TrimPrefix(line, "Next elapse:")) + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "next run: never (already elapsed?)\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastResult(svcName string) string {
|
||||||
|
out, err := exec.Command("systemctl", "--user", "show", svcName,
|
||||||
|
"-p", "ExecMainStatus", "-p", "ExecMainExitTimestamp").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
status, when := "?", ""
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, "ExecMainStatus="); ok {
|
||||||
|
status = v
|
||||||
|
}
|
||||||
|
if v, ok := strings.CutPrefix(line, "ExecMainExitTimestamp="); ok {
|
||||||
|
when = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if when == "" {
|
||||||
|
return "never ran"
|
||||||
|
}
|
||||||
|
if status == "0" {
|
||||||
|
return "ok (" + when + ")"
|
||||||
|
}
|
||||||
|
return "exit " + status + " (" + when + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdLogs(args []string) {
|
||||||
|
fs := flag.NewFlagSet("logs", flag.ExitOnError)
|
||||||
|
lines := fs.Int("lines", 50, "number of journal lines")
|
||||||
|
pos := parseInterspersed(fs, args)
|
||||||
|
if len(pos) != 1 {
|
||||||
|
die("logs needs exactly one job name")
|
||||||
|
}
|
||||||
|
svcName, _ := unit.UnitNames(pos[0])
|
||||||
|
c := exec.Command("journalctl", "--user", "-u", svcName, "-n", fmt.Sprint(*lines), "--no-pager")
|
||||||
|
c.Stdout, c.Stderr = os.Stdout, os.Stderr
|
||||||
|
c.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdRm(args []string) {
|
||||||
|
requireName(args, "rm")
|
||||||
|
name := args[0]
|
||||||
|
if !unit.ValidName(name) {
|
||||||
|
die("invalid job name %q", name)
|
||||||
|
}
|
||||||
|
svcName, tmrName := unit.UnitNames(name)
|
||||||
|
if _, err := os.Stat(filepath.Join(unitDir(), tmrName)); err != nil {
|
||||||
|
die("no such job %s", name)
|
||||||
|
}
|
||||||
|
sh("systemctl", "--user", "disable", "--now", tmrName)
|
||||||
|
for _, p := range []string{
|
||||||
|
filepath.Join(unitDir(), tmrName),
|
||||||
|
filepath.Join(unitDir(), svcName),
|
||||||
|
filepath.Join(scriptDir(), name+".sh"),
|
||||||
|
} {
|
||||||
|
os.Remove(p)
|
||||||
|
}
|
||||||
|
sh("systemctl", "--user", "daemon-reload")
|
||||||
|
fmt.Printf("job %s removed\n", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireName(args []string, cmd string) {
|
||||||
|
if len(args) < 1 || strings.HasPrefix(args[0], "-") {
|
||||||
|
die("%s needs a job name", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sh runs a command, dying with its output on failure — every call
|
||||||
|
// here is a systemctl whose failure should stop the operation.
|
||||||
|
func sh(name string, args ...string) {
|
||||||
|
out, err := exec.Command(name, args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
die("%s %s: %v\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInterspersed lets flags appear before or after positional args.
|
||||||
|
func parseInterspersed(fs *flag.FlagSet, args []string) []string {
|
||||||
|
var flags, pos []string
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
a := args[i]
|
||||||
|
if len(a) > 1 && a[0] == '-' {
|
||||||
|
flags = append(flags, a)
|
||||||
|
name := strings.TrimLeft(a, "-")
|
||||||
|
if eq := strings.Index(name, "="); eq >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f := fs.Lookup(name); f != nil {
|
||||||
|
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i+1 < len(args) {
|
||||||
|
i++
|
||||||
|
flags = append(flags, args[i])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pos = append(pos, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.Parse(flags)
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
|
||||||
|
func die(format string, args ...interface{}) {
|
||||||
|
fmt.Fprintf(os.Stderr, "cronr: "+format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
96
cronr/unit/unit.go
Normal file
96
cronr/unit/unit.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Package unit generates the systemd user units cronr manages. Pure
|
||||||
|
// text generation — systemd does the scheduling, cronr owns no daemon.
|
||||||
|
package unit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prefix namespaces everything cronr creates so list/rm can never
|
||||||
|
// touch units it does not own.
|
||||||
|
const Prefix = "cronr-"
|
||||||
|
|
||||||
|
var nameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
|
||||||
|
|
||||||
|
// ValidName reports whether a job name is safe for unit/file names.
|
||||||
|
func ValidName(name string) bool {
|
||||||
|
return len(name) <= 64 && nameRe.MatchString(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script wraps the job command in an executable shell script — the
|
||||||
|
// unit ExecStart points here, so arbitrary quoting in the command
|
||||||
|
// never meets systemd's ExecStart parsing.
|
||||||
|
func Script(cmd string) string {
|
||||||
|
return "#!/bin/sh\n# generated by cronr - the job's command lives here so systemd\n# unit quoting never mangles it\n" + cmd + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service renders the .service unit. PATH gets ~/.local/bin first so
|
||||||
|
// jobs can call agent tools (notifyr, svgc, agy, ...) like a login
|
||||||
|
// shell would.
|
||||||
|
func Service(name, scriptPath string) string {
|
||||||
|
return fmt.Sprintf(`[Unit]
|
||||||
|
Description=cronr job %s
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
Environment=PATH=%%h/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/bin/sh %s
|
||||||
|
`, name, scriptPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer renders the .timer unit. Recurring jobs get Persistent=true
|
||||||
|
// (a missed run fires at next boot/login); one-shots do not.
|
||||||
|
func Timer(name, calendarSpec string, oneshot bool) string {
|
||||||
|
persistent := "true"
|
||||||
|
if oneshot {
|
||||||
|
persistent = "false"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`[Unit]
|
||||||
|
Description=cronr timer for %s
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=%s
|
||||||
|
Persistent=%s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
`, name, calendarSpec, persistent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnitNames returns the service and timer unit names for a job.
|
||||||
|
func UnitNames(name string) (service, timer string) {
|
||||||
|
return Prefix + name + ".service", Prefix + name + ".timer"
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobName extracts the job name from a cronr unit filename, or ""
|
||||||
|
// if the filename is not cronr's.
|
||||||
|
func JobName(unitFile string) string {
|
||||||
|
base := unitFile
|
||||||
|
if i := strings.LastIndex(base, "/"); i >= 0 {
|
||||||
|
base = base[i+1:]
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(base, Prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
base = strings.TrimPrefix(base, Prefix)
|
||||||
|
for _, suffix := range []string{".timer", ".service"} {
|
||||||
|
if strings.HasSuffix(base, suffix) {
|
||||||
|
return strings.TrimSuffix(base, suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// AtToCalendar converts "YYYY-MM-DD HH:MM" (or with seconds) to a
|
||||||
|
// systemd calendar spec for one-shot jobs. systemd accepts the format
|
||||||
|
// as-is; this just validates the shape early with a helpful error.
|
||||||
|
var atRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$`)
|
||||||
|
|
||||||
|
func AtToCalendar(at string) (string, error) {
|
||||||
|
if !atRe.MatchString(at) {
|
||||||
|
return "", fmt.Errorf("--at must be \"YYYY-MM-DD HH:MM[:SS]\", got %q", at)
|
||||||
|
}
|
||||||
|
return at, nil
|
||||||
|
}
|
||||||
84
cronr/unit/unit_test.go
Normal file
84
cronr/unit/unit_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package unit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidName(t *testing.T) {
|
||||||
|
for _, ok := range []string{"nightly-ci-check", "a", "Job_2", "x1-y2"} {
|
||||||
|
if !ValidName(ok) {
|
||||||
|
t.Errorf("%q should be valid", ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "-leading", "has space", "slash/y", "ä", strings.Repeat("x", 65), "dot.name"} {
|
||||||
|
if ValidName(bad) {
|
||||||
|
t.Errorf("%q should be invalid", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScriptKeepsCommandVerbatim(t *testing.T) {
|
||||||
|
cmd := `agy -p "check CI, say 'hi' & notify" | tee /tmp/x`
|
||||||
|
s := Script(cmd)
|
||||||
|
if !strings.HasPrefix(s, "#!/bin/sh\n") {
|
||||||
|
t.Error("missing shebang")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, cmd) {
|
||||||
|
t.Error("command was mangled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceUnit(t *testing.T) {
|
||||||
|
s := Service("nightly", "/home/x/.local/share/cronr/nightly.sh")
|
||||||
|
for _, want := range []string{
|
||||||
|
"Type=oneshot",
|
||||||
|
"ExecStart=/bin/sh /home/x/.local/share/cronr/nightly.sh",
|
||||||
|
"Environment=PATH=%h/.local/bin",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(s, want) {
|
||||||
|
t.Errorf("service missing %q:\n%s", want, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimerUnit(t *testing.T) {
|
||||||
|
rec := Timer("nightly", "*-*-* 07:00", false)
|
||||||
|
if !strings.Contains(rec, "OnCalendar=*-*-* 07:00") || !strings.Contains(rec, "Persistent=true") {
|
||||||
|
t.Errorf("recurring timer wrong:\n%s", rec)
|
||||||
|
}
|
||||||
|
once := Timer("boot", "2026-08-06 03:00", true)
|
||||||
|
if !strings.Contains(once, "Persistent=false") {
|
||||||
|
t.Errorf("one-shot timer should not be persistent:\n%s", once)
|
||||||
|
}
|
||||||
|
if !strings.Contains(once, "WantedBy=timers.target") {
|
||||||
|
t.Error("timer missing install section")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnitAndJobNames(t *testing.T) {
|
||||||
|
svc, tmr := UnitNames("nightly")
|
||||||
|
if svc != "cronr-nightly.service" || tmr != "cronr-nightly.timer" {
|
||||||
|
t.Errorf("unit names: %s %s", svc, tmr)
|
||||||
|
}
|
||||||
|
if JobName("/home/x/.config/systemd/user/cronr-nightly.timer") != "nightly" {
|
||||||
|
t.Error("JobName failed on full path")
|
||||||
|
}
|
||||||
|
if JobName("helmd.service") != "" {
|
||||||
|
t.Error("foreign unit must not map to a job")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtToCalendar(t *testing.T) {
|
||||||
|
if _, err := AtToCalendar("2026-08-06 03:00"); err != nil {
|
||||||
|
t.Errorf("valid --at rejected: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := AtToCalendar("2026-08-06 03:00:30"); err != nil {
|
||||||
|
t.Errorf("valid --at with seconds rejected: %v", err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"imorgon", "03:00", "2026-8-6 03:00", "2026-08-06T03:00"} {
|
||||||
|
if _, err := AtToCalendar(bad); err == nil {
|
||||||
|
t.Errorf("bad --at %q accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
giteactl/README.md
Normal file
70
giteactl/README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# giteactl — Gitea Actions, CI logs and releases for agents
|
||||||
|
|
||||||
|
Removes the biggest daily friction from
|
||||||
|
[`doc/tool-parity.md`](../doc/tool-parity.md) §4.1: CI status was
|
||||||
|
polled ad hoc, logs lived as zst files on the Pi5, and the
|
||||||
|
hard-learned rule *"more than 2 heavy builds take the Pi5 down"* had
|
||||||
|
no tooling. One stable command prefix for all of it.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
giteactl runs <repo> [--limit 10] runs: status, branch, duration, title
|
||||||
|
giteactl log <repo> <run> [--job N] print a job log
|
||||||
|
giteactl wait <repo> [--timeout 30m] block until newest run finishes
|
||||||
|
giteactl wait-quiet [--max-active 1] block until the runner is quiet
|
||||||
|
giteactl release <repo> [<tag>] release assets + download URLs
|
||||||
|
```
|
||||||
|
|
||||||
|
| Exit | wait | wait-quiet |
|
||||||
|
|------|------|------------|
|
||||||
|
| 0 | run finished **green** | runner quiet — safe to push |
|
||||||
|
| 2 | run finished red | — |
|
||||||
|
| 3 | timeout, still running | timeout, still busy |
|
||||||
|
|
||||||
|
The serialization rule as one line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
giteactl wait-quiet && git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Log fetching
|
||||||
|
|
||||||
|
`giteactl log` tries three sources in order:
|
||||||
|
|
||||||
|
1. **API** `…/actions/jobs/{id}/logs` — needs `token` in the config
|
||||||
|
2. **Public web route** — works anonymously for public repos
|
||||||
|
3. **ssh + zstd** — reads Gitea's file storage
|
||||||
|
(`actions_log/<owner>/<repo>/<hex(id%256)>/<id>.log.zst`) through
|
||||||
|
the read-only `pi5-claude` ssh account and decompresses locally
|
||||||
|
|
||||||
|
So public-repo logs work with zero setup; private repos need a token
|
||||||
|
(or fall back to ssh).
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
`~/.config/giteactl/config.json`, created on first run
|
||||||
|
(`GITEACTL_CONFIG` overrides):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://gitea.brasse-pc.eu",
|
||||||
|
"owner": "brasse",
|
||||||
|
"token": "",
|
||||||
|
"log_ssh_host": "pi5-claude",
|
||||||
|
"log_dir": "/srv/storage1/gitea/actions_log",
|
||||||
|
"heavy_repos": ["agent-helm", "agent-tools", "FitnessDroid", "brasse-pc.eu-v2", "Archivum", "lyssnarr", "Npm-cli"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`heavy_repos` is what `wait-quiet` watches. Private repos it cannot
|
||||||
|
read become **warnings, not failures** — add a token to remove the
|
||||||
|
blind spots. Create one in Gitea: Settings → Applications →
|
||||||
|
Generate token (read-only scopes suffice).
|
||||||
|
|
||||||
|
## Build & test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go build -o build/giteactl .
|
||||||
|
```
|
||||||
280
giteactl/gitea/gitea.go
Normal file
280
giteactl/gitea/gitea.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// Package gitea wraps the slice of Gitea's REST API that agents need
|
||||||
|
// daily: Actions runs, job logs and releases — plus the wait logic
|
||||||
|
// that encodes the homelab's "serialize heavy builds" rule.
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Task is one Actions job as returned by /actions/tasks. Gitea calls
|
||||||
|
// these tasks; each run can hold several.
|
||||||
|
type Task struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
HeadBranch string `json:"head_branch"`
|
||||||
|
RunNumber int64 `json:"run_number"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
DisplayTitle string `json:"display_title"`
|
||||||
|
WorkflowID string `json:"workflow_id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
RunStartedAt time.Time `json:"run_started_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active reports whether the task still occupies (or will occupy) the
|
||||||
|
// runner.
|
||||||
|
func (t Task) Active() bool {
|
||||||
|
switch t.Status {
|
||||||
|
case "running", "waiting", "blocked":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK reports whether the task ended well (skipped counts as ok).
|
||||||
|
func (t Task) OK() bool {
|
||||||
|
return t.Status == "success" || t.Status == "skipped"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Task) Duration() time.Duration {
|
||||||
|
if t.RunStartedAt.IsZero() || t.UpdatedAt.IsZero() || t.Active() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
d := t.UpdatedAt.Sub(t.RunStartedAt)
|
||||||
|
if d < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release is a Gitea release with its downloadable assets.
|
||||||
|
type Release struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PublishedAt time.Time `json:"published_at"`
|
||||||
|
Assets []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
DownloadURL string `json:"browser_download_url"`
|
||||||
|
} `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string // e.g. https://gitea.brasse-pc.eu
|
||||||
|
Owner string
|
||||||
|
Token string // optional; required for private repos
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(baseURL, owner, token string) *Client {
|
||||||
|
return &Client{
|
||||||
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
Owner: owner,
|
||||||
|
Token: token,
|
||||||
|
HTTP: &http.Client{Timeout: 60 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(path string) (*http.Response, error) {
|
||||||
|
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+c.Token)
|
||||||
|
}
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("not found (private repo without token in the config?)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("gitea answered %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks lists a repo's Actions jobs, newest first.
|
||||||
|
func (c *Client) Tasks(repo string, limit int) ([]Task, error) {
|
||||||
|
resp, err := c.get(fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks?limit=%d",
|
||||||
|
url.PathEscape(c.Owner), url.PathEscape(repo), limit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out struct {
|
||||||
|
WorkflowRuns []Task `json:"workflow_runs"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Slice(out.WorkflowRuns, func(i, j int) bool {
|
||||||
|
return out.WorkflowRuns[i].ID > out.WorkflowRuns[j].ID
|
||||||
|
})
|
||||||
|
if limit > 0 && len(out.WorkflowRuns) > limit {
|
||||||
|
out.WorkflowRuns = out.WorkflowRuns[:limit]
|
||||||
|
}
|
||||||
|
return out.WorkflowRuns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogAPI fetches a job log through the token-authenticated API.
|
||||||
|
func (c *Client) LogAPI(repo string, taskID int64) (string, error) {
|
||||||
|
resp, err := c.get(fmt.Sprintf("/api/v1/repos/%s/%s/actions/jobs/%d/logs",
|
||||||
|
url.PathEscape(c.Owner), url.PathEscape(repo), taskID))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||||
|
return string(data), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogWeb fetches a job log through the web route, which works
|
||||||
|
// anonymously for public repos (job = index within the run, not id).
|
||||||
|
func (c *Client) LogWeb(repo string, runNumber int64, jobIndex int) (string, error) {
|
||||||
|
resp, err := c.get(fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs",
|
||||||
|
url.PathEscape(c.Owner), url.PathEscape(repo), runNumber, jobIndex))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(string(data)), "<") {
|
||||||
|
return "", fmt.Errorf("got HTML instead of a log (login page? private repo needs a token)")
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZstPath is where Gitea's file storage keeps a task's compressed log
|
||||||
|
// on the server: <dir>/<owner>/<repo>/<hex(taskID%256)>/<taskID>.log.zst.
|
||||||
|
// Used by the ssh fallback for logs the HTTP routes no longer serve.
|
||||||
|
func ZstPath(dir, owner, repo string, taskID int64) string {
|
||||||
|
return fmt.Sprintf("%s/%s/%s/%02x/%d.log.zst", dir, owner, repo, taskID%256, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Releases lists a repo's releases, newest first.
|
||||||
|
func (c *Client) Releases(repo string, limit int) ([]Release, error) {
|
||||||
|
resp, err := c.get(fmt.Sprintf("/api/v1/repos/%s/%s/releases?limit=%d",
|
||||||
|
url.PathEscape(c.Owner), url.PathEscape(repo), limit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out []Release
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wait logic (injectable fetch so it is testable) ---
|
||||||
|
|
||||||
|
type TaskFetcher func(repo string) ([]Task, error)
|
||||||
|
|
||||||
|
type WaitResult struct {
|
||||||
|
Done bool // latest run finished within the timeout
|
||||||
|
AllOK bool
|
||||||
|
Tasks []Task // the latest run's tasks (or last seen)
|
||||||
|
Attempts int
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitRun polls until every task of repo's newest run has finished.
|
||||||
|
func WaitRun(repo string, fetch TaskFetcher, interval, timeout time.Duration, log func(string)) (WaitResult, error) {
|
||||||
|
start := time.Now()
|
||||||
|
res := WaitResult{}
|
||||||
|
for {
|
||||||
|
res.Attempts++
|
||||||
|
tasks, err := fetch(repo)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
return res, fmt.Errorf("repo has no Actions runs")
|
||||||
|
}
|
||||||
|
latest := tasks[0].RunNumber
|
||||||
|
var runTasks []Task
|
||||||
|
active := false
|
||||||
|
allOK := true
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.RunNumber != latest {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runTasks = append(runTasks, t)
|
||||||
|
if t.Active() {
|
||||||
|
active = true
|
||||||
|
} else if !t.OK() {
|
||||||
|
allOK = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.Tasks = runTasks
|
||||||
|
if !active {
|
||||||
|
res.Done, res.AllOK = true, allOK
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
if log != nil {
|
||||||
|
log(fmt.Sprintf("run #%d still active (%d jobs), waiting...", latest, len(runTasks)))
|
||||||
|
}
|
||||||
|
if time.Since(start)+interval > timeout {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
time.Sleep(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountActive counts active jobs across the given repos.
|
||||||
|
func CountActive(repos []string, fetch TaskFetcher) (int, []string, error) {
|
||||||
|
count := 0
|
||||||
|
var busy []string
|
||||||
|
for _, r := range repos {
|
||||||
|
tasks, err := fetch(r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("%s: %w", r, err)
|
||||||
|
}
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.Active() {
|
||||||
|
count++
|
||||||
|
busy = append(busy, fmt.Sprintf("%s#%d(%s)", r, t.RunNumber, t.Status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, busy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitQuiet blocks until at most maxActive jobs run across repos —
|
||||||
|
// the "serialize pushes, >2 heavy builds take the Pi5 down" rule.
|
||||||
|
func WaitQuiet(repos []string, maxActive int, fetch TaskFetcher, interval, timeout time.Duration, log func(string)) (bool, error) {
|
||||||
|
start := time.Now()
|
||||||
|
for {
|
||||||
|
n, busy, err := CountActive(repos, fetch)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if n <= maxActive {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if log != nil {
|
||||||
|
log(fmt.Sprintf("%d active builds (max %d): %s", n, maxActive, strings.Join(busy, " ")))
|
||||||
|
}
|
||||||
|
if time.Since(start)+interval > timeout {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
time.Sleep(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
149
giteactl/gitea/gitea_test.go
Normal file
149
giteactl/gitea/gitea_test.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTasksParsesAndSorts(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.Contains(r.URL.Path, "/repos/brasse/agent-tools/actions/tasks") {
|
||||||
|
t.Errorf("unexpected path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "token tok" {
|
||||||
|
t.Errorf("token header missing")
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `{"workflow_runs":[
|
||||||
|
{"id":110,"name":"build","run_number":5,"status":"success"},
|
||||||
|
{"id":111,"name":"build-release","run_number":6,"status":"running"}]}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
tasks, err := New(srv.URL, "brasse", "tok").Tasks("agent-tools", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(tasks) != 2 || tasks[0].ID != 111 {
|
||||||
|
t.Errorf("tasks not sorted newest first: %+v", tasks)
|
||||||
|
}
|
||||||
|
if !tasks[0].Active() || tasks[1].Active() {
|
||||||
|
t.Error("Active() wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivateRepoHint(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
defer srv.Close()
|
||||||
|
_, err := New(srv.URL, "brasse", "").Tasks("infra-Doc", 5)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "token") {
|
||||||
|
t.Errorf("404 should hint about tokens, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogWebRejectsHTML(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, "<!DOCTYPE html><html>login page</html>")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
_, err := New(srv.URL, "brasse", "").LogWeb("x", 1, 0)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "HTML") {
|
||||||
|
t.Errorf("HTML response should error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZstPathHexBucket(t *testing.T) {
|
||||||
|
// verified against the real Pi5 layout: task 53 -> 35/53.log.zst
|
||||||
|
cases := map[int64]string{
|
||||||
|
53: "/logs/brasse/FitnessDroid/35/53.log.zst",
|
||||||
|
52: "/logs/brasse/FitnessDroid/34/52.log.zst",
|
||||||
|
86: "/logs/brasse/FitnessDroid/56/86.log.zst",
|
||||||
|
2: "/logs/brasse/FitnessDroid/02/2.log.zst",
|
||||||
|
258: "/logs/brasse/FitnessDroid/02/258.log.zst",
|
||||||
|
}
|
||||||
|
for id, want := range cases {
|
||||||
|
if got := ZstPath("/logs", "brasse", "FitnessDroid", id); got != want {
|
||||||
|
t.Errorf("ZstPath(%d) = %s, want %s", id, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitRunFinishes(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
fetch := func(repo string) ([]Task, error) {
|
||||||
|
calls++
|
||||||
|
status := "running"
|
||||||
|
if calls >= 3 {
|
||||||
|
status = "success"
|
||||||
|
}
|
||||||
|
return []Task{
|
||||||
|
{ID: 2, RunNumber: 7, Status: status},
|
||||||
|
{ID: 1, RunNumber: 6, Status: "failure"}, // older run must not matter
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
res, err := WaitRun("x", fetch, time.Millisecond, time.Second, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.Done || !res.AllOK || res.Attempts != 3 {
|
||||||
|
t.Errorf("unexpected: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitRunRedFailure(t *testing.T) {
|
||||||
|
fetch := func(repo string) ([]Task, error) {
|
||||||
|
return []Task{{ID: 2, RunNumber: 7, Status: "failure"}}, nil
|
||||||
|
}
|
||||||
|
res, err := WaitRun("x", fetch, time.Millisecond, time.Second, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.Done || res.AllOK {
|
||||||
|
t.Errorf("failure must give Done && !AllOK: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitRunTimeout(t *testing.T) {
|
||||||
|
fetch := func(repo string) ([]Task, error) {
|
||||||
|
return []Task{{ID: 2, RunNumber: 7, Status: "running"}}, nil
|
||||||
|
}
|
||||||
|
res, err := WaitRun("x", fetch, 5*time.Millisecond, 15*time.Millisecond, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Done {
|
||||||
|
t.Errorf("should have timed out: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitQuietCountsAcrossRepos(t *testing.T) {
|
||||||
|
step := 0
|
||||||
|
fetch := func(repo string) ([]Task, error) {
|
||||||
|
// step 0: both repos busy; step >= 1: only one
|
||||||
|
if repo == "a" && step > 0 {
|
||||||
|
return []Task{{ID: 1, Status: "success"}}, nil
|
||||||
|
}
|
||||||
|
return []Task{{ID: 2, Status: "running"}}, nil
|
||||||
|
}
|
||||||
|
log := func(string) { step++ }
|
||||||
|
ok, err := WaitQuiet([]string{"a", "b"}, 1, fetch, time.Millisecond, time.Second, log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Error("should reach quiet state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDuration(t *testing.T) {
|
||||||
|
start := time.Now().Add(-90 * time.Second)
|
||||||
|
tk := Task{Status: "success", RunStartedAt: start, UpdatedAt: start.Add(75 * time.Second)}
|
||||||
|
if d := tk.Duration(); d != 75*time.Second {
|
||||||
|
t.Errorf("duration = %s", d)
|
||||||
|
}
|
||||||
|
if (Task{Status: "running", RunStartedAt: start}).Duration() != 0 {
|
||||||
|
t.Error("active task should have zero duration")
|
||||||
|
}
|
||||||
|
}
|
||||||
3
giteactl/go.mod
Normal file
3
giteactl/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module gitea.brasse-pc.eu/brasse/agent-tools/giteactl
|
||||||
|
|
||||||
|
go 1.24
|
||||||
330
giteactl/main.go
Normal file
330
giteactl/main.go
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
// giteactl gives agents first-class access to Gitea Actions runs, job
|
||||||
|
// logs and releases — including the hard-learned homelab rule
|
||||||
|
// "serialize heavy builds" (wait-quiet). See doc/tool-parity.md §4.1.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.brasse-pc.eu/brasse/agent-tools/giteactl/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
const usage = `giteactl - Gitea Actions runs, CI logs and releases for agents
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
giteactl runs <repo> [--limit 10] list runs: status, branch, duration
|
||||||
|
giteactl log <repo> <run> [--job N] print a run's job log
|
||||||
|
giteactl wait <repo> [--timeout 30m] [--interval 15s]
|
||||||
|
block until the newest run finishes
|
||||||
|
exit 0 = green, 2 = red, 3 = timeout
|
||||||
|
giteactl wait-quiet [--max-active 1] [--timeout 30m]
|
||||||
|
block until <=N builds are active
|
||||||
|
across the heavy_repos in the config
|
||||||
|
giteactl release <repo> [<tag>] release assets + download URLs
|
||||||
|
giteactl version
|
||||||
|
|
||||||
|
Config: ~/.config/giteactl/config.json (created on first run;
|
||||||
|
GITEACTL_CONFIG overrides). token is needed for private repos; public
|
||||||
|
repos work without. Log fetching tries the API (token), then the
|
||||||
|
public web route, then ssh+zstd against the server's log storage.
|
||||||
|
|
||||||
|
The Pi5 rule: more than 2 heavy builds take every service down.
|
||||||
|
Always 'giteactl wait-quiet && git push' when pushing build-triggering
|
||||||
|
repos.
|
||||||
|
`
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
LogSSHHost string `json:"log_ssh_host"`
|
||||||
|
LogDir string `json:"log_dir"`
|
||||||
|
HeavyRepos []string `json:"heavy_repos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfig() *config {
|
||||||
|
return &config{
|
||||||
|
URL: "https://gitea.brasse-pc.eu",
|
||||||
|
Owner: "brasse",
|
||||||
|
Token: "",
|
||||||
|
LogSSHHost: "pi5-claude",
|
||||||
|
LogDir: "/srv/storage1/gitea/actions_log",
|
||||||
|
HeavyRepos: []string{"agent-helm", "agent-tools", "FitnessDroid", "brasse-pc.eu-v2", "Archivum", "lyssnarr", "Npm-cli"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configPath() string {
|
||||||
|
if p := os.Getenv("GITEACTL_CONFIG"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
dir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "giteactl", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() *config {
|
||||||
|
cfg := defaultConfig()
|
||||||
|
path := configPath()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err == nil {
|
||||||
|
out, _ := json.MarshalIndent(cfg, "", " ")
|
||||||
|
os.WriteFile(path, append(out, '\n'), 0o600)
|
||||||
|
fmt.Fprintf(os.Stderr, "giteactl: created %s\n", path)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
json.Unmarshal(data, cfg)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Print(usage)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
cfg := loadConfig()
|
||||||
|
client := gitea.New(cfg.URL, cfg.Owner, cfg.Token)
|
||||||
|
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "runs":
|
||||||
|
cmdRuns(client, os.Args[2:])
|
||||||
|
case "log":
|
||||||
|
cmdLog(client, cfg, os.Args[2:])
|
||||||
|
case "wait":
|
||||||
|
cmdWait(client, os.Args[2:])
|
||||||
|
case "wait-quiet":
|
||||||
|
cmdWaitQuiet(client, cfg, os.Args[2:])
|
||||||
|
case "release":
|
||||||
|
cmdRelease(client, os.Args[2:])
|
||||||
|
case "version", "--version", "-v":
|
||||||
|
fmt.Println("giteactl", version)
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
fmt.Print(usage)
|
||||||
|
default:
|
||||||
|
die("unknown command %q — run 'giteactl help'", os.Args[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdRuns(client *gitea.Client, args []string) {
|
||||||
|
fs := flag.NewFlagSet("runs", flag.ExitOnError)
|
||||||
|
limit := fs.Int("limit", 10, "max jobs to list")
|
||||||
|
pos := parseInterspersed(fs, args)
|
||||||
|
if len(pos) != 1 {
|
||||||
|
die("runs needs exactly one repo name")
|
||||||
|
}
|
||||||
|
tasks, err := client.Tasks(pos[0], *limit)
|
||||||
|
if err != nil {
|
||||||
|
die("%v", err)
|
||||||
|
}
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
fmt.Println("no Actions runs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%-6s %-18s %-9s %-14s %-9s %s\n", "RUN", "JOB", "STATUS", "BRANCH", "TOOK", "TITLE")
|
||||||
|
for _, t := range tasks {
|
||||||
|
took := ""
|
||||||
|
if d := t.Duration(); d > 0 {
|
||||||
|
took = d.Round(time.Second).String()
|
||||||
|
}
|
||||||
|
fmt.Printf("%-6d %-18s %-9s %-14s %-9s %s\n",
|
||||||
|
t.RunNumber, trunc(t.Name, 18), t.Status, trunc(t.HeadBranch, 14), took, trunc(t.DisplayTitle, 46))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdLog(client *gitea.Client, cfg *config, args []string) {
|
||||||
|
fs := flag.NewFlagSet("log", flag.ExitOnError)
|
||||||
|
job := fs.Int("job", 0, "job index within the run (when a run has several)")
|
||||||
|
pos := parseInterspersed(fs, args)
|
||||||
|
if len(pos) != 2 {
|
||||||
|
die("log needs: giteactl log <repo> <run-number>")
|
||||||
|
}
|
||||||
|
repo := pos[0]
|
||||||
|
runNo, err := strconv.ParseInt(pos[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
die("%q is not a run number", pos[1])
|
||||||
|
}
|
||||||
|
tasks, err := client.Tasks(repo, 100)
|
||||||
|
if err != nil {
|
||||||
|
die("%v", err)
|
||||||
|
}
|
||||||
|
var runTasks []gitea.Task
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.RunNumber == runNo {
|
||||||
|
runTasks = append(runTasks, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(runTasks) == 0 {
|
||||||
|
die("run %d not found among the latest 100 jobs", runNo)
|
||||||
|
}
|
||||||
|
if *job >= len(runTasks) {
|
||||||
|
die("run %d has %d jobs (0..%d)", runNo, len(runTasks), len(runTasks)-1)
|
||||||
|
}
|
||||||
|
// tasks are newest-first; job index counts from the run's start
|
||||||
|
task := runTasks[len(runTasks)-1-*job]
|
||||||
|
|
||||||
|
// 1) API (needs token), 2) public web route, 3) ssh + zstd
|
||||||
|
if cfg.Token != "" {
|
||||||
|
if log, err := client.LogAPI(repo, task.ID); err == nil {
|
||||||
|
fmt.Print(log)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if log, err := client.LogWeb(repo, runNo, *job); err == nil {
|
||||||
|
fmt.Print(log)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := gitea.ZstPath(cfg.LogDir, cfg.Owner, repo, task.ID)
|
||||||
|
fmt.Fprintf(os.Stderr, "giteactl: HTTP routes failed, trying ssh %s cat %s\n", cfg.LogSSHHost, path)
|
||||||
|
ssh := exec.Command("sh", "-c",
|
||||||
|
fmt.Sprintf("ssh %s cat %q | zstd -dc", cfg.LogSSHHost, path))
|
||||||
|
ssh.Stdout, ssh.Stderr = os.Stdout, os.Stderr
|
||||||
|
if err := ssh.Run(); err != nil {
|
||||||
|
die("all log sources failed (api/web/ssh): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdWait(client *gitea.Client, args []string) {
|
||||||
|
fs := flag.NewFlagSet("wait", flag.ExitOnError)
|
||||||
|
timeout := fs.Duration("timeout", 30*time.Minute, "total time budget")
|
||||||
|
interval := fs.Duration("interval", 15*time.Second, "poll interval")
|
||||||
|
pos := parseInterspersed(fs, args)
|
||||||
|
if len(pos) != 1 {
|
||||||
|
die("wait needs exactly one repo name")
|
||||||
|
}
|
||||||
|
res, err := gitea.WaitRun(pos[0], func(r string) ([]gitea.Task, error) { return client.Tasks(r, 30) },
|
||||||
|
*interval, *timeout, func(s string) { fmt.Fprintln(os.Stderr, "giteactl: "+s) })
|
||||||
|
if err != nil {
|
||||||
|
die("%v", err)
|
||||||
|
}
|
||||||
|
for _, t := range res.Tasks {
|
||||||
|
fmt.Printf("run %d %-18s %s\n", t.RunNumber, t.Name, t.Status)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !res.Done:
|
||||||
|
fmt.Fprintln(os.Stderr, "giteactl: timeout — run still active")
|
||||||
|
os.Exit(3)
|
||||||
|
case !res.AllOK:
|
||||||
|
fmt.Fprintln(os.Stderr, "giteactl: run finished RED")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "giteactl: run finished green")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdWaitQuiet(client *gitea.Client, cfg *config, args []string) {
|
||||||
|
fs := flag.NewFlagSet("wait-quiet", flag.ExitOnError)
|
||||||
|
maxActive := fs.Int("max-active", 1, "max simultaneously active builds")
|
||||||
|
timeout := fs.Duration("timeout", 30*time.Minute, "total time budget")
|
||||||
|
interval := fs.Duration("interval", 20*time.Second, "poll interval")
|
||||||
|
parseInterspersed(fs, args)
|
||||||
|
// private repos without a token become blind spots, not failures —
|
||||||
|
// warn once per repo and count the ones we can see
|
||||||
|
warned := map[string]bool{}
|
||||||
|
fetch := func(r string) ([]gitea.Task, error) {
|
||||||
|
tasks, err := client.Tasks(r, 10)
|
||||||
|
if err != nil {
|
||||||
|
if !warned[r] {
|
||||||
|
warned[r] = true
|
||||||
|
fmt.Fprintf(os.Stderr, "giteactl: warning: cannot check %s (%v)\n", r, err)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
ok, err := gitea.WaitQuiet(cfg.HeavyRepos, *maxActive, fetch,
|
||||||
|
*interval, *timeout, func(s string) { fmt.Fprintln(os.Stderr, "giteactl: "+s) })
|
||||||
|
if err != nil {
|
||||||
|
die("%v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
fmt.Fprintln(os.Stderr, "giteactl: timeout — runner still busy")
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
fmt.Println("runner quiet — safe to push")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdRelease(client *gitea.Client, args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
die("release needs: giteactl release <repo> [<tag>]")
|
||||||
|
}
|
||||||
|
repo := args[0]
|
||||||
|
releases, err := client.Releases(repo, 30)
|
||||||
|
if err != nil {
|
||||||
|
die("%v", err)
|
||||||
|
}
|
||||||
|
if len(releases) == 0 {
|
||||||
|
fmt.Println("no releases")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rel := releases[0]
|
||||||
|
if len(args) > 1 {
|
||||||
|
found := false
|
||||||
|
for _, r := range releases {
|
||||||
|
if r.TagName == args[1] {
|
||||||
|
rel, found = r, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
die("no release with tag %q", args[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("%s (%s, published %s)\n", rel.TagName, rel.Name, rel.PublishedAt.Local().Format("2006-01-02 15:04"))
|
||||||
|
for _, a := range rel.Assets {
|
||||||
|
fmt.Printf(" %-28s %8.1f KiB %s\n", a.Name, float64(a.Size)/1024, a.DownloadURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trunc(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n-1] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInterspersed lets flags appear before or after positional args.
|
||||||
|
func parseInterspersed(fs *flag.FlagSet, args []string) []string {
|
||||||
|
var flags, pos []string
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
a := args[i]
|
||||||
|
if len(a) > 1 && a[0] == '-' {
|
||||||
|
flags = append(flags, a)
|
||||||
|
name := strings.TrimLeft(a, "-")
|
||||||
|
if eq := strings.Index(name, "="); eq >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f := fs.Lookup(name); f != nil {
|
||||||
|
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i+1 < len(args) {
|
||||||
|
i++
|
||||||
|
flags = append(flags, args[i])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pos = append(pos, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.Parse(flags)
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
|
||||||
|
func die(format string, args ...interface{}) {
|
||||||
|
fmt.Fprintf(os.Stderr, "giteactl: "+format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
44
waitfor/README.md
Normal file
44
waitfor/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# waitfor — block until a condition holds
|
||||||
|
|
||||||
|
Replaces the hand-rolled `while ! curl …; do sleep 5; done` loops that
|
||||||
|
each need a fresh approval in an agent session with **one stable
|
||||||
|
command prefix**. The agent makes one blocking call instead of burning
|
||||||
|
turns polling. Closes the `Monitor` gap from
|
||||||
|
[`doc/tool-parity.md`](../doc/tool-parity.md) §3.2.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
waitfor --cmd "shell command" [--matches regex] [--interval 30s]
|
||||||
|
[--timeout 20m] [--then "shell command"] [--verbose]
|
||||||
|
```
|
||||||
|
|
||||||
|
The condition holds when `--cmd` exits 0 **and**, if `--matches` is
|
||||||
|
given, its combined output matches the regex. The first attempt runs
|
||||||
|
immediately; then every `--interval` until `--timeout`.
|
||||||
|
|
||||||
|
| Exit | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| 0 | condition met (`--then` runs afterwards, if given) |
|
||||||
|
| 3 | timeout — last output still printed so the caller sees the state |
|
||||||
|
| 1 | usage/config error (empty `--cmd`, bad regex) |
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# wait until Gitea answers again
|
||||||
|
waitfor --cmd "curl -sf https://gitea.brasse-pc.eu/api/healthz" --interval 30s --timeout 20m
|
||||||
|
|
||||||
|
# wait until a container is listed (read-only Pi5 wrapper)
|
||||||
|
waitfor --cmd "ssh pi5-claude sudo claude-docker ps" --matches gitea --timeout 10m
|
||||||
|
|
||||||
|
# wait for a file, then notify the phone (composes with notifyr)
|
||||||
|
waitfor --cmd "test -f /tmp/report.html" --then 'notifyr send --msg "report is ready"'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build & test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go build -o build/waitfor .
|
||||||
|
```
|
||||||
3
waitfor/go.mod
Normal file
3
waitfor/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module gitea.brasse-pc.eu/brasse/agent-tools/waitfor
|
||||||
|
|
||||||
|
go 1.24
|
||||||
86
waitfor/main.go
Normal file
86
waitfor/main.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// waitfor blocks until a shell condition holds — the agent-friendly
|
||||||
|
// replacement for hand-rolled poll loops that each need a fresh
|
||||||
|
// command approval. See doc/tool-parity.md §3.2.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.brasse-pc.eu/brasse/agent-tools/waitfor/wait"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
const usage = `waitfor - block until a condition holds
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
waitfor --cmd "shell command" [--matches regex] [--interval 30s]
|
||||||
|
[--timeout 20m] [--then "shell command"] [--verbose]
|
||||||
|
waitfor version
|
||||||
|
|
||||||
|
The condition holds when --cmd exits 0 and (if given) its combined
|
||||||
|
output matches --matches. The first attempt runs immediately.
|
||||||
|
|
||||||
|
Exit codes: 0 condition met, 3 timeout, 1 error. The last command
|
||||||
|
output is printed either way, so the caller always sees the state.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
waitfor --cmd "curl -sf https://gitea.brasse-pc.eu/api/healthz" --interval 30s --timeout 20m
|
||||||
|
waitfor --cmd "ssh pi5-claude sudo claude-docker ps" --matches gitea --timeout 10m
|
||||||
|
waitfor --cmd "test -f /tmp/done" --then 'notifyr send --msg "done!"'
|
||||||
|
`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) >= 2 && (os.Args[1] == "version" || os.Args[1] == "--version" || os.Args[1] == "-v") {
|
||||||
|
fmt.Println("waitfor", version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(os.Args) >= 2 && (os.Args[1] == "help" || os.Args[1] == "--help" || os.Args[1] == "-h") {
|
||||||
|
fmt.Print(usage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fs := flag.NewFlagSet("waitfor", flag.ExitOnError)
|
||||||
|
cmd := fs.String("cmd", "", "shell command to poll (required)")
|
||||||
|
matches := fs.String("matches", "", "regex the output must match")
|
||||||
|
interval := fs.Duration("interval", 10*time.Second, "time between attempts")
|
||||||
|
timeout := fs.Duration("timeout", 10*time.Minute, "total time budget")
|
||||||
|
then := fs.String("then", "", "shell command to run when the condition is met")
|
||||||
|
verbose := fs.Bool("verbose", false, "log every attempt to stderr")
|
||||||
|
fs.Usage = func() { fmt.Fprint(os.Stderr, usage) }
|
||||||
|
fs.Parse(os.Args[1:])
|
||||||
|
if *cmd == "" {
|
||||||
|
fmt.Fprint(os.Stderr, usage)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := wait.Wait(wait.Options{
|
||||||
|
Cmd: *cmd, Matches: *matches, Interval: *interval, Timeout: *timeout, Verbose: *verbose,
|
||||||
|
}, wait.ShellRunner, func(s string) { fmt.Fprintln(os.Stderr, "waitfor: "+s) })
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "waitfor:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out := strings.TrimRight(res.LastOut, "\n"); out != "" {
|
||||||
|
fmt.Println(out)
|
||||||
|
}
|
||||||
|
if !res.Met {
|
||||||
|
fmt.Fprintf(os.Stderr, "waitfor: timeout after %s (%d attempts, last exit %d)\n",
|
||||||
|
res.Elapsed.Round(time.Second), res.Attempts, res.LastExit)
|
||||||
|
os.Exit(3)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "waitfor: condition met after %s (%d attempts)\n",
|
||||||
|
res.Elapsed.Round(time.Millisecond), res.Attempts)
|
||||||
|
if *then != "" {
|
||||||
|
t := exec.Command("sh", "-c", *then)
|
||||||
|
t.Stdout, t.Stderr = os.Stdout, os.Stderr
|
||||||
|
if err := t.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "waitfor: --then command failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
waitfor/wait/wait.go
Normal file
94
waitfor/wait/wait.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Package wait blocks until a shell condition holds: run a command
|
||||||
|
// every interval until it exits 0 (and, optionally, its output matches
|
||||||
|
// a regex) or a timeout expires. One blocking call instead of an
|
||||||
|
// agent burning turns on poll loops.
|
||||||
|
package wait
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Cmd string // shell command to run each attempt (required)
|
||||||
|
Matches string // optional regex the output must match
|
||||||
|
Interval time.Duration // between attempts
|
||||||
|
Timeout time.Duration // total budget
|
||||||
|
Verbose bool // progress line per attempt to the log func
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Met bool
|
||||||
|
Attempts int
|
||||||
|
Elapsed time.Duration
|
||||||
|
LastOut string
|
||||||
|
LastExit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner executes a shell command, returning combined output and exit
|
||||||
|
// code. Separated out so tests can fake it.
|
||||||
|
type Runner func(cmd string) (string, int)
|
||||||
|
|
||||||
|
// ShellRunner runs via sh -c with combined stdout+stderr.
|
||||||
|
func ShellRunner(cmd string) (string, int) {
|
||||||
|
c := exec.Command("sh", "-c", cmd)
|
||||||
|
out, err := c.CombinedOutput()
|
||||||
|
code := 0
|
||||||
|
if err != nil {
|
||||||
|
code = 1
|
||||||
|
if ee, ok := err.(*exec.ExitError); ok {
|
||||||
|
code = ee.ExitCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out), code
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait polls until the condition holds or the timeout expires. The
|
||||||
|
// first attempt runs immediately. log receives progress lines when
|
||||||
|
// Verbose is set (pass nil otherwise).
|
||||||
|
func Wait(opts Options, run Runner, log func(string)) (Result, error) {
|
||||||
|
if opts.Cmd == "" {
|
||||||
|
return Result{}, fmt.Errorf("no command given")
|
||||||
|
}
|
||||||
|
var re *regexp.Regexp
|
||||||
|
if opts.Matches != "" {
|
||||||
|
var err error
|
||||||
|
re, err = regexp.Compile(opts.Matches)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("bad --matches regex: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.Interval <= 0 {
|
||||||
|
opts.Interval = 10 * time.Second
|
||||||
|
}
|
||||||
|
if opts.Timeout <= 0 {
|
||||||
|
opts.Timeout = 10 * time.Minute
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
res := Result{}
|
||||||
|
for {
|
||||||
|
res.Attempts++
|
||||||
|
out, code := run(opts.Cmd)
|
||||||
|
res.LastOut, res.LastExit = out, code
|
||||||
|
met := code == 0 && (re == nil || re.MatchString(out))
|
||||||
|
if opts.Verbose && log != nil {
|
||||||
|
state := "not yet"
|
||||||
|
if met {
|
||||||
|
state = "met"
|
||||||
|
}
|
||||||
|
log(fmt.Sprintf("attempt %d: exit %d, condition %s", res.Attempts, code, state))
|
||||||
|
}
|
||||||
|
if met {
|
||||||
|
res.Met = true
|
||||||
|
res.Elapsed = time.Since(start)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
if time.Since(start)+opts.Interval > opts.Timeout {
|
||||||
|
res.Elapsed = time.Since(start)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
time.Sleep(opts.Interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
92
waitfor/wait/wait_test.go
Normal file
92
waitfor/wait/wait_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package wait
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMetOnExitZero(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
run := func(cmd string) (string, int) {
|
||||||
|
calls++
|
||||||
|
if calls < 3 {
|
||||||
|
return "not ready", 1
|
||||||
|
}
|
||||||
|
return "ready", 0
|
||||||
|
}
|
||||||
|
res, err := Wait(Options{Cmd: "x", Interval: time.Millisecond, Timeout: time.Second}, run, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.Met || res.Attempts != 3 || res.LastOut != "ready" {
|
||||||
|
t.Errorf("unexpected result: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesRequiredOnTopOfExitZero(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
run := func(cmd string) (string, int) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
return "gitea starting", 0 // exit 0 but no match yet
|
||||||
|
}
|
||||||
|
return "gitea healthy", 0
|
||||||
|
}
|
||||||
|
res, err := Wait(Options{Cmd: "x", Matches: "healthy", Interval: time.Millisecond, Timeout: time.Second}, run, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.Met || res.Attempts != 2 {
|
||||||
|
t.Errorf("match should gate success: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeoutReportsLastOutput(t *testing.T) {
|
||||||
|
run := func(cmd string) (string, int) { return "still broken", 7 }
|
||||||
|
res, err := Wait(Options{Cmd: "x", Interval: 5 * time.Millisecond, Timeout: 20 * time.Millisecond}, run, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Met {
|
||||||
|
t.Error("should have timed out")
|
||||||
|
}
|
||||||
|
if res.LastOut != "still broken" || res.LastExit != 7 {
|
||||||
|
t.Errorf("last output lost: %+v", res)
|
||||||
|
}
|
||||||
|
if res.Attempts < 1 {
|
||||||
|
t.Error("should have tried at least once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadRegexRejected(t *testing.T) {
|
||||||
|
if _, err := Wait(Options{Cmd: "x", Matches: "("}, nil, nil); err == nil {
|
||||||
|
t.Error("bad regex accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyCommandRejected(t *testing.T) {
|
||||||
|
if _, err := Wait(Options{}, nil, nil); err == nil {
|
||||||
|
t.Error("empty command accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration: real shell, waiting for a file to appear (the exact
|
||||||
|
// case from the agy live test in doc/tool-parity.md).
|
||||||
|
func TestShellRunnerFileAppears(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "flag")
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
os.WriteFile(path, []byte("x"), 0o644)
|
||||||
|
}()
|
||||||
|
res, err := Wait(Options{
|
||||||
|
Cmd: "test -f " + path, Interval: 10 * time.Millisecond, Timeout: 2 * time.Second,
|
||||||
|
}, ShellRunner, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.Met {
|
||||||
|
t.Errorf("file never seen: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user