Both are named for what they do rather than the tool underneath, the way Services is. Old links and saved rail orders still land in the right place. The Go packages and their endpoints keep their names, with a pointer in each to what the UI calls them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
814 lines
22 KiB
Go
814 lines
22 KiB
Go
// Package cron reads and writes what the UI calls the **Jobs** tab (named "Cron" until
|
|
// 2026-09-24; the package and its /api/cron endpoints kept the old name).
|
|
//
|
|
// Package cron reads every scheduled job on the host into one list: /etc/crontab,
|
|
// /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd
|
|
// timers. It computes next runs from the cron expressions, pulls last runs from the
|
|
// journal (cron logs "(user) CMD (command)" via syslog), and can toggle, edit and run
|
|
// jobs. Reads/writes that need root go through passwordless sudo when available.
|
|
package cron
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"hamlab/internal/priv"
|
|
)
|
|
|
|
type Job struct {
|
|
ID string `json:"id"`
|
|
Source string `json:"source"` // "/etc/crontab", "/etc/cron.d/x", "crontab:user", "/etc/cron.daily"
|
|
Line int `json:"line"` // 1-based line in Source (0 for run-parts scripts)
|
|
User string `json:"user"`
|
|
Schedule string `json:"schedule"`
|
|
Command string `json:"command"`
|
|
Enabled bool `json:"enabled"`
|
|
Next int64 `json:"next,omitempty"` // unix ms
|
|
Last int64 `json:"last,omitempty"`
|
|
OutFile string `json:"out_file,omitempty"` // parsed from a ">> file" redirect
|
|
Note string `json:"note,omitempty"`
|
|
Editable bool `json:"editable"`
|
|
raw string
|
|
}
|
|
|
|
type Timer struct {
|
|
Unit string `json:"unit"`
|
|
Activates string `json:"activates"`
|
|
Description string `json:"description"`
|
|
Active string `json:"active"`
|
|
Sub string `json:"sub"`
|
|
Enabled string `json:"enabled"`
|
|
Next int64 `json:"next,omitempty"`
|
|
Last int64 `json:"last,omitempty"`
|
|
}
|
|
|
|
type Overview struct {
|
|
Jobs []Job `json:"jobs"`
|
|
Timers []Timer `json:"timers"`
|
|
Logging bool `json:"logging"` // cron lines seen in the journal
|
|
Anacron bool `json:"anacron"`
|
|
Sudo bool `json:"sudo"`
|
|
}
|
|
|
|
type Run struct {
|
|
ID string `json:"id"`
|
|
JobID string `json:"job_id"`
|
|
Command string `json:"command"`
|
|
User string `json:"user"`
|
|
Started time.Time `json:"started"`
|
|
Finished time.Time `json:"finished,omitempty"`
|
|
Running bool `json:"running"`
|
|
ExitCode int `json:"exit_code"`
|
|
Output string `json:"output"`
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
type Manager struct {
|
|
sudo bool
|
|
journalSudo bool
|
|
self string
|
|
mu sync.Mutex
|
|
lastRuns map[string]int64 // user\x00command -> unix ms
|
|
lastAt time.Time
|
|
lastCursor string // journal cursor of the newest entry folded into lastRuns
|
|
logging bool
|
|
runs map[string]*Run
|
|
}
|
|
|
|
func New(sudo, journalSudo bool) *Manager {
|
|
m := &Manager{sudo: sudo, journalSudo: journalSudo, runs: map[string]*Run{}}
|
|
if u, err := user.Current(); err == nil {
|
|
m.self = u.Username
|
|
}
|
|
return m
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func run(ctx context.Context, stdin string, args ...string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
|
if stdin != "" {
|
|
cmd.Stdin = strings.NewReader(stdin)
|
|
}
|
|
var out, errb bytes.Buffer
|
|
cmd.Stdout, cmd.Stderr = &out, &errb
|
|
if err := cmd.Run(); err != nil {
|
|
msg := strings.TrimSpace(errb.String())
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return out.String(), errors.New(msg)
|
|
}
|
|
return out.String(), nil
|
|
}
|
|
|
|
// runLines streams a command's stdout to fn line by line, so multi-megabyte journal
|
|
// dumps never sit in memory whole.
|
|
func runLines(ctx context.Context, fn func(line []byte), args ...string) error {
|
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
|
var errb bytes.Buffer
|
|
cmd.Stderr = &errb
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
sc := bufio.NewScanner(stdout)
|
|
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
|
|
for sc.Scan() {
|
|
fn(sc.Bytes())
|
|
}
|
|
if err := cmd.Wait(); err != nil {
|
|
if msg := strings.TrimSpace(errb.String()); msg != "" {
|
|
return errors.New(msg)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) sudoArgs(args ...string) []string {
|
|
if m.sudo {
|
|
return priv.Wrap(args...)
|
|
}
|
|
return args
|
|
}
|
|
|
|
func jobID(source, raw string) string {
|
|
h := sha1.Sum([]byte(source + "\n" + raw))
|
|
return hex.EncodeToString(h[:6])
|
|
}
|
|
|
|
var userRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
|
|
var cronDName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // cron ignores other names in /etc/cron.d
|
|
|
|
// ---- parsing ----
|
|
|
|
// parseLine splits one crontab line. withUser is true for system crontabs (6th field is
|
|
// the user). Returns ok=false for comments, blanks, env assignments and garbage.
|
|
func parseLine(line string, withUser bool) (schedule, usr, command string, ok bool) {
|
|
t := strings.TrimSpace(line)
|
|
if t == "" || strings.HasPrefix(t, "#") {
|
|
return
|
|
}
|
|
if i := strings.Index(t, "="); i > 0 && !strings.ContainsAny(t[:i], " \t*") { // NAME=value
|
|
return
|
|
}
|
|
var fields []string
|
|
if strings.HasPrefix(t, "@") {
|
|
f := strings.Fields(t)
|
|
if len(f) < 2 {
|
|
return
|
|
}
|
|
fields = []string{f[0]}
|
|
t = strings.TrimSpace(t[len(f[0]):])
|
|
} else {
|
|
f := strings.Fields(t)
|
|
if len(f) < 6 {
|
|
return
|
|
}
|
|
fields = f[:5]
|
|
for _, x := range fields {
|
|
t = strings.TrimSpace(t[len(x):])
|
|
}
|
|
}
|
|
schedule = strings.Join(fields, " ")
|
|
if _, err := parseSchedule(schedule); err != nil {
|
|
return
|
|
}
|
|
if withUser {
|
|
f := strings.Fields(t)
|
|
if len(f) < 2 || !userRe.MatchString(f[0]) {
|
|
return
|
|
}
|
|
usr = f[0]
|
|
t = strings.TrimSpace(t[len(f[0]):])
|
|
}
|
|
if t == "" {
|
|
return
|
|
}
|
|
return schedule, usr, t, true
|
|
}
|
|
|
|
var redirectRe = regexp.MustCompile(`(?:^|\s)(?:>>|>|2>&1\s*>>|&>>|&>)\s*("?)([^\s"&|;]+)`)
|
|
|
|
func outFile(cmd string) string {
|
|
for _, m := range redirectRe.FindAllStringSubmatch(cmd, -1) {
|
|
if p := m[2]; strings.HasPrefix(p, "/") && p != "/dev/null" {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// parseSource turns crontab text into jobs. Commented-out lines that still parse as a
|
|
// job are reported disabled, so a "# 0 5 * * * ..." example shows up as such.
|
|
func (m *Manager) parseSource(source, text string, withUser, editable bool, defaultUser string, now time.Time, anacron bool) []Job {
|
|
var jobs []Job
|
|
lines := strings.Split(text, "\n")
|
|
isComment := func(i int) bool {
|
|
return i >= 0 && i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "#")
|
|
}
|
|
for i, line := range lines {
|
|
enabled := true
|
|
probe := line
|
|
if t := strings.TrimSpace(line); strings.HasPrefix(t, "#") {
|
|
// A commented-out job sits on its own; a job-shaped line inside a block of
|
|
// comments is documentation (Debian's stock header has two such examples).
|
|
if isComment(i-1) && isComment(i+1) {
|
|
continue
|
|
}
|
|
enabled = false
|
|
probe = strings.TrimLeft(t, "# ")
|
|
}
|
|
schedule, usr, command, ok := parseLine(probe, withUser)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if usr == "" {
|
|
usr = defaultUser
|
|
}
|
|
if !enabled {
|
|
if _, err := user.Lookup(usr); err != nil {
|
|
continue // "user-name" placeholder and the like
|
|
}
|
|
}
|
|
j := Job{ID: jobID(source, line), Source: source, Line: i + 1, User: usr, Schedule: schedule, Command: command,
|
|
Enabled: enabled, OutFile: outFile(command), Editable: editable, raw: line}
|
|
if enabled {
|
|
if next, ok := nextRun(schedule, now); ok {
|
|
j.Next = next.UnixMilli()
|
|
}
|
|
}
|
|
if strings.Contains(command, "run-parts") {
|
|
if dir := runPartsDir(command); dir != "" {
|
|
if anacron && strings.Contains(command, "anacron") {
|
|
j.Note = "skipped: anacron is installed and runs " + dir + " instead"
|
|
}
|
|
jobs = append(jobs, j)
|
|
jobs = append(jobs, m.runPartsJobs(dir, j)...)
|
|
continue
|
|
}
|
|
}
|
|
jobs = append(jobs, j)
|
|
}
|
|
return jobs
|
|
}
|
|
|
|
var runPartsRe = regexp.MustCompile(`run-parts\s+(?:--\S+\s+)*(/\S+)`)
|
|
|
|
func runPartsDir(cmd string) string {
|
|
if m := runPartsRe.FindStringSubmatch(cmd); m != nil {
|
|
return m[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// runPartsJobs lists the executable scripts a run-parts line will run, as read-only jobs.
|
|
func (m *Manager) runPartsJobs(dir string, parent Job) []Job {
|
|
ents, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []Job
|
|
for _, e := range ents {
|
|
if e.IsDir() || !cronDName.MatchString(e.Name()) {
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil || info.Mode()&0o111 == 0 {
|
|
continue
|
|
}
|
|
p := filepath.Join(dir, e.Name())
|
|
out = append(out, Job{ID: jobID(dir, e.Name()), Source: dir, User: parent.User, Schedule: parent.Schedule, Command: p,
|
|
Enabled: parent.Enabled, Next: parent.Next, Note: "run by " + parent.Source + " via run-parts", raw: e.Name()})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---- sources ----
|
|
|
|
type source struct {
|
|
name string // job Source
|
|
withUser bool
|
|
user string // default user for user crontabs
|
|
editable bool
|
|
}
|
|
|
|
func (m *Manager) readSource(ctx context.Context, s source) (string, error) {
|
|
if strings.HasPrefix(s.name, "crontab:") {
|
|
u := s.user
|
|
if u == m.self {
|
|
out, err := run(ctx, "", "crontab", "-l")
|
|
if err != nil && strings.Contains(err.Error(), "no crontab") {
|
|
return "", nil
|
|
}
|
|
return out, err
|
|
}
|
|
if !m.sudo {
|
|
return "", errors.New("needs sudo")
|
|
}
|
|
out, err := run(ctx, "", priv.Wrap("crontab", "-u", u, "-l")...)
|
|
if err != nil && strings.Contains(err.Error(), "no crontab") {
|
|
return "", nil
|
|
}
|
|
return out, err
|
|
}
|
|
b, err := os.ReadFile(s.name)
|
|
if err != nil && m.sudo {
|
|
out, err2 := run(ctx, "", priv.Wrap("cat", s.name)...)
|
|
return out, err2
|
|
}
|
|
return string(b), err
|
|
}
|
|
|
|
func (m *Manager) sources(ctx context.Context) []source {
|
|
var out []source
|
|
if _, err := os.Stat("/etc/crontab"); err == nil {
|
|
out = append(out, source{name: "/etc/crontab", withUser: true, editable: m.sudo})
|
|
}
|
|
if ents, err := os.ReadDir("/etc/cron.d"); err == nil {
|
|
for _, e := range ents {
|
|
if !e.IsDir() && cronDName.MatchString(e.Name()) {
|
|
out = append(out, source{name: "/etc/cron.d/" + e.Name(), withUser: true, editable: m.sudo})
|
|
}
|
|
}
|
|
}
|
|
// per-user crontabs: Debian keeps them in /var/spool/cron/crontabs, cronie in /var/spool/cron
|
|
users := map[string]bool{}
|
|
if m.sudo {
|
|
for _, dir := range []string{"/var/spool/cron/crontabs", "/var/spool/cron"} {
|
|
if out, err := run(ctx, "", priv.Wrap("ls", "-1", dir)...); err == nil {
|
|
for _, n := range strings.Split(out, "\n") {
|
|
if n = strings.TrimSpace(n); userRe.MatchString(n) {
|
|
users[n] = true
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if m.self != "" {
|
|
users[m.self] = true
|
|
}
|
|
names := make([]string, 0, len(users))
|
|
for u := range users {
|
|
names = append(names, u)
|
|
}
|
|
sort.Strings(names)
|
|
for _, u := range names {
|
|
out = append(out, source{name: "crontab:" + u, user: u, editable: u == m.self || m.sudo})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *Manager) findSource(ctx context.Context, name string) (source, bool) {
|
|
for _, s := range m.sources(ctx) {
|
|
if s.name == name {
|
|
return s, true
|
|
}
|
|
}
|
|
return source{}, false
|
|
}
|
|
|
|
// ---- overview ----
|
|
|
|
func (m *Manager) Overview(ctx context.Context) Overview {
|
|
now := time.Now()
|
|
_, anacronErr := os.Stat("/usr/sbin/anacron")
|
|
anacron := anacronErr == nil
|
|
ov := Overview{Jobs: []Job{}, Timers: []Timer{}, Anacron: anacron, Sudo: m.sudo}
|
|
for _, s := range m.sources(ctx) {
|
|
text, err := m.readSource(ctx, s)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ov.Jobs = append(ov.Jobs, m.parseSource(s.name, text, s.withUser, s.editable, s.user, now, anacron)...)
|
|
}
|
|
last := m.lastRunsFromJournal(ctx)
|
|
ov.Logging = m.logging
|
|
for i := range ov.Jobs {
|
|
j := &ov.Jobs[i]
|
|
if t, ok := last[j.User+"\x00"+j.Command]; ok {
|
|
j.Last = t
|
|
}
|
|
}
|
|
ov.Timers = m.timers(ctx)
|
|
return ov
|
|
}
|
|
|
|
var cronLogRe = regexp.MustCompile(`^\((\S+)\) CMD \((.*)\)$`)
|
|
|
|
// lastRunsFromJournal maps user+command to the most recent "(user) CMD (command)" line.
|
|
// The first call scans 30 days; later calls only read entries after the last cursor, so
|
|
// the cost per refresh is proportional to new cron activity, not to history.
|
|
func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
|
|
m.mu.Lock()
|
|
if time.Since(m.lastAt) < 2*time.Second && m.lastRuns != nil {
|
|
r := m.lastRuns
|
|
m.mu.Unlock()
|
|
return r
|
|
}
|
|
cursor := m.lastCursor
|
|
res := map[string]int64{}
|
|
for k, v := range m.lastRuns {
|
|
res[k] = v
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q"}
|
|
if cursor != "" {
|
|
args = append(args, "--after-cursor="+cursor)
|
|
} else {
|
|
args = append(args, "--since", "-30d", "-n", "20000")
|
|
}
|
|
if m.journalSudo {
|
|
args = priv.Wrap(args...)
|
|
}
|
|
seen := m.logging
|
|
_ = runLines(ctx, func(line []byte) {
|
|
var raw struct {
|
|
Msg any `json:"MESSAGE"`
|
|
TS string `json:"__REALTIME_TIMESTAMP"`
|
|
Cursor string `json:"__CURSOR"`
|
|
}
|
|
if json.Unmarshal(line, &raw) != nil {
|
|
return
|
|
}
|
|
seen = true
|
|
if raw.Cursor != "" {
|
|
cursor = raw.Cursor
|
|
}
|
|
msg, _ := raw.Msg.(string)
|
|
mm := cronLogRe.FindStringSubmatch(msg)
|
|
if mm == nil {
|
|
return
|
|
}
|
|
us, _ := strconv.ParseInt(raw.TS, 10, 64)
|
|
k := mm[1] + "\x00" + mm[2]
|
|
if us/1000 > res[k] {
|
|
res[k] = us / 1000
|
|
}
|
|
}, args...)
|
|
m.mu.Lock()
|
|
m.lastRuns, m.lastAt, m.lastCursor, m.logging = res, time.Now(), cursor, seen
|
|
m.mu.Unlock()
|
|
return res
|
|
}
|
|
|
|
// JobLogs returns the journal lines for one job's command (most recent last).
|
|
func (m *Manager) JobLogs(ctx context.Context, j Job, n int) ([]map[string]any, error) {
|
|
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q", "-n", "20000"}
|
|
if m.journalSudo {
|
|
args = priv.Wrap(args...)
|
|
}
|
|
var res []map[string]any
|
|
needle := "(" + j.User + ") CMD (" + j.Command + ")"
|
|
err := runLines(ctx, func(line []byte) {
|
|
if !bytes.Contains(line, []byte(j.Command[:min(len(j.Command), 40)])) { // cheap pre-filter before JSON
|
|
return
|
|
}
|
|
var raw struct {
|
|
Msg any `json:"MESSAGE"`
|
|
TS string `json:"__REALTIME_TIMESTAMP"`
|
|
PID string `json:"_PID"`
|
|
}
|
|
if json.Unmarshal(line, &raw) != nil {
|
|
return
|
|
}
|
|
msg, _ := raw.Msg.(string)
|
|
if !strings.Contains(msg, needle) {
|
|
return
|
|
}
|
|
us, _ := strconv.ParseInt(raw.TS, 10, 64)
|
|
res = append(res, map[string]any{"t": us / 1000, "p": 6, "ident": "CRON", "pid": raw.PID, "msg": msg})
|
|
}, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(res) > n {
|
|
res = res[len(res)-n:]
|
|
}
|
|
if res == nil {
|
|
res = []map[string]any{}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// OutputTail returns the last n lines of the job's redirect target.
|
|
func (m *Manager) OutputTail(ctx context.Context, j Job, n int) (string, error) {
|
|
if j.OutFile == "" {
|
|
return "", errors.New("job has no output file")
|
|
}
|
|
out, err := run(ctx, "", m.sudoArgs("tail", "-n", strconv.Itoa(n), j.OutFile)...)
|
|
return out, err
|
|
}
|
|
|
|
// ---- timers ----
|
|
|
|
// timers: one `systemctl show` over every loaded timer instead of three list commands.
|
|
func (m *Manager) timers(ctx context.Context) []Timer {
|
|
out, err := run(ctx, "", "systemctl", "show", "*.timer", "--no-pager",
|
|
"-p", "Id,Description,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,LastTriggerUSec,Triggers")
|
|
if err != nil {
|
|
return []Timer{}
|
|
}
|
|
unix := func(v string) int64 { // "Sat 2026-09-19 05:47:13 PDT", "n/a" or ""
|
|
for _, layout := range []string{"Mon 2006-01-02 15:04:05 MST", "2006-01-02 15:04:05 MST", "Mon 2006-01-02 15:04:05"} {
|
|
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
|
return t.UnixMilli()
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
res := []Timer{}
|
|
for _, block := range strings.Split(out, "\n\n") {
|
|
t := Timer{}
|
|
for _, line := range strings.Split(block, "\n") {
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch k {
|
|
case "Id":
|
|
t.Unit = v
|
|
case "Description":
|
|
t.Description = v
|
|
case "ActiveState":
|
|
t.Active = v
|
|
case "SubState":
|
|
t.Sub = v
|
|
case "UnitFileState":
|
|
t.Enabled = v
|
|
case "NextElapseUSecRealtime":
|
|
t.Next = unix(v)
|
|
case "LastTriggerUSec":
|
|
t.Last = unix(v)
|
|
case "Triggers":
|
|
t.Activates = strings.Fields(v + " ")[0:1][0]
|
|
}
|
|
}
|
|
if t.Unit != "" {
|
|
res = append(res, t)
|
|
}
|
|
}
|
|
sort.Slice(res, func(i, j int) bool { return res[i].Unit < res[j].Unit })
|
|
return res
|
|
}
|
|
|
|
var timerRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@._:\\-]{0,255}\.timer$`)
|
|
|
|
func (m *Manager) TimerAction(ctx context.Context, unit, action string) error {
|
|
if !timerRe.MatchString(unit) {
|
|
return errors.New("invalid timer name")
|
|
}
|
|
switch action {
|
|
case "start", "stop", "enable", "disable":
|
|
_, err := run(ctx, "", m.sudoArgs("systemctl", action, unit)...)
|
|
return err
|
|
case "run": // fire the service the timer activates, right now
|
|
out, err := run(ctx, "", "systemctl", "show", unit, "-p", "Triggers", "--value")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
svc := strings.TrimSpace(strings.Split(out, " ")[0])
|
|
if svc == "" {
|
|
return errors.New("timer triggers nothing")
|
|
}
|
|
_, err = run(ctx, "", m.sudoArgs("systemctl", "start", svc)...)
|
|
return err
|
|
}
|
|
return errors.New("unknown action")
|
|
}
|
|
|
|
// ---- job lookup and editing ----
|
|
|
|
func (m *Manager) FindJob(ctx context.Context, id string) (Job, bool) {
|
|
for _, j := range m.Overview(ctx).Jobs {
|
|
if j.ID == id {
|
|
return j, true
|
|
}
|
|
}
|
|
return Job{}, false
|
|
}
|
|
|
|
// Source returns a crontab's raw text for the editor.
|
|
func (m *Manager) Source(ctx context.Context, name string) (text string, editable bool, err error) {
|
|
s, ok := m.findSource(ctx, name)
|
|
if !ok {
|
|
return "", false, errors.New("unknown source")
|
|
}
|
|
text, err = m.readSource(ctx, s)
|
|
return text, s.editable, err
|
|
}
|
|
|
|
// validate rejects any line that is neither blank, comment, env assignment, nor a job.
|
|
func validate(text string, withUser bool) error {
|
|
for i, line := range strings.Split(text, "\n") {
|
|
t := strings.TrimSpace(line)
|
|
if t == "" || strings.HasPrefix(t, "#") {
|
|
continue
|
|
}
|
|
if k, _, ok := strings.Cut(t, "="); ok && !strings.ContainsAny(strings.TrimSpace(k), " \t*") {
|
|
continue
|
|
}
|
|
if _, _, _, ok := parseLine(t, withUser); !ok {
|
|
return fmt.Errorf("line %d: not a valid cron entry", i+1)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WriteSource replaces a crontab. User crontabs go through crontab(1) so cron reloads
|
|
// them; system files are written in place with their permissions preserved.
|
|
func (m *Manager) WriteSource(ctx context.Context, name, text string) error {
|
|
s, ok := m.findSource(ctx, name)
|
|
if !ok {
|
|
return errors.New("unknown source")
|
|
}
|
|
if !s.editable {
|
|
return errors.New("source is read-only here (no sudo)")
|
|
}
|
|
if !strings.HasSuffix(text, "\n") {
|
|
text += "\n"
|
|
}
|
|
if err := validate(text, s.withUser); err != nil {
|
|
return err
|
|
}
|
|
if strings.HasPrefix(name, "crontab:") {
|
|
if s.user == m.self {
|
|
_, err := run(ctx, text, "crontab", "-")
|
|
return err
|
|
}
|
|
_, err := run(ctx, text, priv.Wrap("crontab", "-u", s.user, "-")...)
|
|
return err
|
|
}
|
|
if name != "/etc/crontab" && !strings.HasPrefix(name, "/etc/cron.d/") {
|
|
return errors.New("refusing to write outside /etc/crontab and /etc/cron.d")
|
|
}
|
|
_, err := run(ctx, text, priv.Wrap("tee", name)...)
|
|
m.mu.Lock()
|
|
m.lastAt = time.Time{}
|
|
m.mu.Unlock()
|
|
return err
|
|
}
|
|
|
|
// Toggle comments or uncomments a job's line. The line must still read as the job did
|
|
// when it was listed, so a concurrent edit can't be clobbered.
|
|
func (m *Manager) Toggle(ctx context.Context, j Job, enable bool) error {
|
|
if j.Line == 0 || !j.Editable {
|
|
return errors.New("this job can't be toggled here")
|
|
}
|
|
s, ok := m.findSource(ctx, j.Source)
|
|
if !ok {
|
|
return errors.New("unknown source")
|
|
}
|
|
text, err := m.readSource(ctx, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lines := strings.Split(text, "\n")
|
|
if j.Line > len(lines) || lines[j.Line-1] != j.raw {
|
|
return errors.New("crontab changed underneath; reload and try again")
|
|
}
|
|
if enable {
|
|
lines[j.Line-1] = strings.TrimLeft(strings.TrimSpace(j.raw), "# ")
|
|
} else {
|
|
lines[j.Line-1] = "#" + j.raw
|
|
}
|
|
return m.WriteSource(ctx, j.Source, strings.Join(lines, "\n"))
|
|
}
|
|
|
|
// ---- run now ----
|
|
|
|
// StartRun executes the job's command as its user, the way cron would (sh -c, cwd = home,
|
|
// '%' turned into newlines/stdin), in the background. Output is kept in memory.
|
|
func (m *Manager) StartRun(ctx context.Context, j Job) (*Run, error) {
|
|
if j.User != m.self && !m.sudo {
|
|
return nil, errors.New("needs sudo to run as " + j.User)
|
|
}
|
|
cmdline, stdin := cronPercent(j.Command)
|
|
args := []string{"sh", "-c", cmdline}
|
|
if j.User != m.self {
|
|
args = priv.AsUser(j.User, args...)
|
|
}
|
|
r := &Run{ID: jobID("run", strconv.FormatInt(time.Now().UnixNano(), 10)), JobID: j.ID, Command: j.Command, User: j.User, Started: time.Now(), Running: true}
|
|
runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
|
cmd := exec.CommandContext(runCtx, args[0], args[1:]...)
|
|
if stdin != "" {
|
|
cmd.Stdin = strings.NewReader(stdin)
|
|
}
|
|
w := &capWriter{r: r}
|
|
cmd.Stdout, cmd.Stderr = w, w
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
m.mu.Lock()
|
|
m.runs[r.ID] = r
|
|
for id, old := range m.runs { // keep the map small
|
|
if !old.Running && time.Since(old.Finished) > time.Hour {
|
|
delete(m.runs, id)
|
|
}
|
|
}
|
|
m.mu.Unlock()
|
|
go func() {
|
|
defer cancel()
|
|
err := cmd.Wait()
|
|
r.mu.Lock()
|
|
r.Running, r.Finished = false, time.Now()
|
|
if err != nil {
|
|
if ee, ok := err.(*exec.ExitError); ok {
|
|
r.ExitCode = ee.ExitCode()
|
|
} else {
|
|
r.ExitCode = -1
|
|
}
|
|
}
|
|
r.mu.Unlock()
|
|
m.mu.Lock()
|
|
m.lastAt = time.Time{}
|
|
m.mu.Unlock()
|
|
}()
|
|
return r, nil
|
|
}
|
|
|
|
func (m *Manager) GetRun(id string) (*Run, bool) {
|
|
m.mu.Lock()
|
|
r, ok := m.runs[id]
|
|
m.mu.Unlock()
|
|
return r, ok
|
|
}
|
|
|
|
// Snapshot returns a copy safe to encode.
|
|
func (r *Run) Snapshot() Run {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return Run{ID: r.ID, JobID: r.JobID, Command: r.Command, User: r.User, Started: r.Started, Finished: r.Finished,
|
|
Running: r.Running, ExitCode: r.ExitCode, Output: r.buf.String()}
|
|
}
|
|
|
|
type capWriter struct{ r *Run }
|
|
|
|
func (w *capWriter) Write(p []byte) (int, error) {
|
|
w.r.mu.Lock()
|
|
defer w.r.mu.Unlock()
|
|
w.r.buf.Write(p)
|
|
if w.r.buf.Len() > 256<<10 { // keep the tail
|
|
b := w.r.buf.Bytes()
|
|
w.r.buf = *bytes.NewBuffer(append([]byte("…\n"), b[len(b)-200<<10:]...))
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// cronPercent applies crontab(5) rules: an unescaped % ends the command; later ones are
|
|
// newlines fed to stdin. "\%" is a literal percent.
|
|
func cronPercent(cmd string) (string, string) {
|
|
var out, in strings.Builder
|
|
cur := &out
|
|
esc := false
|
|
for _, c := range cmd {
|
|
switch {
|
|
case esc:
|
|
if c != '%' {
|
|
cur.WriteRune('\\')
|
|
}
|
|
cur.WriteRune(c)
|
|
esc = false
|
|
case c == '\\':
|
|
esc = true
|
|
case c == '%':
|
|
if cur == &out {
|
|
cur = &in
|
|
} else {
|
|
in.WriteRune('\n')
|
|
}
|
|
default:
|
|
cur.WriteRune(c)
|
|
}
|
|
}
|
|
return out.String(), in.String()
|
|
}
|