Skip to content

Commit

Permalink
crond: add support for crontab file only (on any OS) (#289)
Browse files Browse the repository at this point in the history
* crond: add support for crontab file only (on any OS)

Allows to use an external scheduler that supports crontab format
the existing crond support using crontab binary remains unchanged for linux

* crond: fixed test on Windows

* added documentation

* Added `platform.Executable()`

* more tests, support user column in crontab file

also adds more config options and examples in documentation

* updated scheduler config docs index

* added link to config reference

* Update docs/content/schedules/_index.md

Co-authored-by: Fred <[email protected]>

---------

Co-authored-by: Fred <[email protected]>
  • Loading branch information
jkellerer and creativeprojects authored Mar 22, 2024
1 parent 85d5afc commit 5739b13
Show file tree
Hide file tree
Showing 26 changed files with 761 additions and 184 deletions.
2 changes: 1 addition & 1 deletion config/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type Global struct {
ResticStaleLockAge time.Duration `mapstructure:"restic-stale-lock-age" default:"1h" description:"The age an unused lock on a restic repository must have at least before resiticprofile attempts to unlock - see https://creativeprojects.github.io/resticprofile/usage/locks/"`
ShellBinary []string `mapstructure:"shell" default:"auto" examples:"sh;bash;pwsh;powershell;cmd" description:"The shell that is used to run commands (default is OS specific)"`
MinMemory uint64 `mapstructure:"min-memory" default:"100" description:"Minimum available memory (in MB) required to run any commands - see https://creativeprojects.github.io/resticprofile/usage/memory/"`
Scheduler string `mapstructure:"scheduler" description:"Leave blank for the default scheduler or use \"crond\" to select cron on supported operating systems"`
Scheduler string `mapstructure:"scheduler" default:"auto" examples:"auto;launchd;systemd;taskscheduler;crond;crond:/usr/bin/crontab;crontab:*:/etc/cron.d/resticprofile" description:"Selects the scheduler. Blank or \"auto\" uses the default scheduler of your operating system: \"launchd\", \"systemd\", \"taskscheduler\" or \"crond\" (as fallback). Alternatively you can set \"crond\" for cron compatible schedulers supporting the crontab executable API or \"crontab:[user:]file\" to write into a crontab file directly. The need for a user is detected if missing and can be set to a name, \"-\" (no user) or \"*\" (current user)."`
ScheduleDefaults *ScheduleBaseConfig `mapstructure:"schedule-defaults" default:"" description:"Sets defaults for all schedules"`
Log string `mapstructure:"log" default:"" examples:"/resticprofile.log;syslog-tcp://syslog-server:514;syslog:server;syslog:" description:"Sets the default log destination to be used if not specified in \"--log\" or \"schedule-log\" - see https://creativeprojects.github.io/resticprofile/configuration/logs/"`
CommandOutput string `mapstructure:"command-output" default:"auto" enum:"auto;log;console;all" description:"Sets the destination for command output (stderr/stdout). \"log\" sends output to the log file (if specified), \"console\" sends it to the console instead. \"auto\" sends it to \"both\" if console is a terminal otherwise to \"log\" only - see https://creativeprojects.github.io/resticprofile/configuration/logs/"`
Expand Down
10 changes: 6 additions & 4 deletions constants/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import (

// Scheduler type
const (
SchedulerLaunchd = "launchd"
SchedulerWindows = "taskscheduler"
SchedulerSystemd = "systemd"
SchedulerCrond = "crond"
SchedulerLaunchd = "launchd"
SchedulerWindows = "taskscheduler"
SchedulerSystemd = "systemd"
SchedulerCrond = "crond"
SchedulerCrontab = "crontab"
SchedulerOSDefault = ""
)

var (
Expand Down
154 changes: 103 additions & 51 deletions crond/crontab.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
//go:build !darwin && !windows
// +build !darwin,!windows

package crond

import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"os/user"
"regexp"
"strings"
)
Expand All @@ -19,27 +14,40 @@ const (
)

type Crontab struct {
entries []Entry
file, binary, charset, user string
entries []Entry
}

var (
crontabBinary = "crontab"
)
func NewCrontab(entries []Entry) (c *Crontab) {
c = &Crontab{entries: entries}

func NewCrontab(entries []Entry) *Crontab {
return &Crontab{
entries: entries,
for i, entry := range c.entries {
if entry.NeedsUser() {
c.entries[i] = c.entries[i].WithUser(c.username())
}
}

return
}

// SetBinary sets the crontab binary to use for reading and writing the crontab (if empty, SetFile must be used)
func (c *Crontab) SetBinary(crontabBinary string) {
c.binary = crontabBinary
}

// SetFile toggles whether to read & write a crontab file instead of using the crontab binary
func (c *Crontab) SetFile(file string) {
c.file = file
}

// Update crontab entries:
// update crontab entries:
//
// # If addEntries is set to true, it will delete and add all new entries
// - If addEntries is set to true, it will delete and add all new entries
//
// # If addEntries is set to false, it will only delete the matching entries
// - If addEntries is set to false, it will only delete the matching entries
//
// Return values are the number of entries deleted, and an error if any
func (c *Crontab) Update(source string, addEntries bool, w io.StringWriter) (int, error) {
func (c *Crontab) update(source string, addEntries bool, w io.StringWriter) (int, error) {
var err error
var deleted int

Expand Down Expand Up @@ -117,61 +125,67 @@ func (c *Crontab) Generate(w io.StringWriter) error {
return nil
}

func (c *Crontab) LoadCurrent() (string, error) {
buffer := &strings.Builder{}
cmd := exec.Command(crontabBinary, "-l")
cmd.Stdout = buffer
cmd.Stderr = buffer
err := cmd.Run()
if err != nil && strings.HasPrefix(buffer.String(), "no crontab for ") {
// it's ok to be empty
return "", nil
} else if err != nil {
return "", fmt.Errorf("%w: %s", err, buffer.String())
}
return cleanupCrontab(buffer.String()), nil
func (c *Crontab) LoadCurrent() (content string, err error) {
content, c.charset, err = loadCrontab(c.file, c.binary)
if err == nil {
if cleaned := cleanupCrontab(content); cleaned != content {
if len(c.file) == 0 {
content = cleaned
} else {
err = fmt.Errorf("refusing to change crontab with \"DO NOT EDIT\": %q", c.file)
}
}
}
return
}

func (c *Crontab) username() string {
if len(c.user) == 0 {
if current, err := user.Current(); err == nil {
c.user = current.Username
}
if len(c.user) == 0 || strings.ContainsAny(c.user, "\t \n\r") {
c.user = "root"
}
}
return c.user
}

func (c *Crontab) Rewrite() error {
crontab, err := c.LoadCurrent()
if err != nil {
return err
}
input := &bytes.Buffer{}
_, err = c.Update(crontab, true, input)
if err != nil {
return err

if len(c.file) > 0 && detectNeedsUserColumn(crontab) {
for i, entry := range c.entries {
if !entry.HasUser() {
c.entries[i] = entry.WithUser(c.username())
}
}
}

cmd := exec.Command(crontabBinary, "-")
cmd.Stdin = input
cmd.Stderr = os.Stderr
err = cmd.Run()
buffer := new(strings.Builder)
_, err = c.update(crontab, true, buffer)
if err != nil {
return err
}
return nil

return saveCrontab(c.file, buffer.String(), c.charset, c.binary)
}

func (c *Crontab) Remove() (int, error) {
crontab, err := c.LoadCurrent()
if err != nil {
return 0, err
}
buffer := &bytes.Buffer{}
num, err := c.Update(crontab, false, buffer)
if err != nil {
return num, err
}

cmd := exec.Command(crontabBinary, "-")
cmd.Stdin = buffer
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
return num, err
buffer := new(strings.Builder)
num, err := c.update(crontab, false, buffer)
if err == nil {
err = saveCrontab(c.file, buffer.String(), c.charset, c.binary)
}
return num, nil
return num, err
}

func cleanupCrontab(crontab string) string {
Expand All @@ -181,8 +195,46 @@ func cleanupCrontab(crontab string) string {
return pattern.ReplaceAllString(crontab, "")
}

// detectNeedsUserColumn attempts to detect if this crontab needs a user column or not (only relevant for direct file access)
func detectNeedsUserColumn(crontab string) bool {
headerR := regexp.MustCompile(`^#\s*[*]\s+[*]\s+[*]\s+[*]\s+[*](\s+user.*)?\s+(command|cmd).*$`)
entryR := regexp.MustCompile(`^\s*(\S+\s+\S+\s+\S+\s+\S+\s+\S+|@[a-z]+)((?:\s{2,}|\t)\S+)?(?:\s{2,}|\t)(\S.*)$`)

var header, userHeader int
var entries, userEntries float32
for _, line := range strings.Split(crontab, "\n") {
if m := headerR.FindStringSubmatch(line); m != nil {
header++
if len(m) == 3 && strings.HasPrefix(strings.TrimSpace(m[1]), "user") {
userHeader++
}
} else if m = entryR.FindStringSubmatch(line); m != nil {
entries++
if len(m) == 4 && len(m[2]) > 0 {
userEntries++
}
}
}

userEntryPercentage := float32(0)
if entries > 0 {
userEntryPercentage = userEntries / entries
}

if header > 0 {
if userHeader == header || (userHeader > 0 && userEntryPercentage > 0) {
return true
}
return false
} else {
return userEntryPercentage > 0.75
}
}

// extractOwnSection returns before our section, inside, and after if found.
//
// - It is not returning both start and end markers.
//
// - If not found, it returns the file content in the first string
func extractOwnSection(crontab string) (string, string, string, bool) {
start := strings.Index(crontab, startMarker)
Expand Down
Loading

0 comments on commit 5739b13

Please sign in to comment.