cronr: recurring/one-shot jobs as systemd user timers
add (schedule validated by systemd-analyze, units + script printed), list (next elapse + last result), run, logs, rm. Command stored as a script so quoting never meets ExecStart; PATH includes ~/.local/bin so agent tools resolve. cronr-* namespace guards foreign units. Smoked live: add -> run -> journal -> rm. Spec: doc/tool-parity.md 3.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
This commit is contained in:
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