Merge dev/cronr: cronr v1
This commit is contained in:
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user