Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

crond: add support for crontab file only (on any OS) #289

Merged
merged 8 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
)

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 @@
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

Check warning on line 133 in crond/crontab.go

View check run for this annotation

Codecov / codecov/patch

crond/crontab.go#L133

Added line #L133 was not covered by tests
} 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"
}

Check warning on line 149 in crond/crontab.go

View check run for this annotation

Codecov / codecov/patch

crond/crontab.go#L148-L149

Added lines #L148 - L149 were not covered by tests
}
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 @@
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
Loading