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