Skip to content

Commit

Permalink
more tests, support user column in crontab file
Browse files Browse the repository at this point in the history
also adds more config options and examples in documentation
  • Loading branch information
jkellerer committed Mar 21, 2024
1 parent 4069c0e commit f4f0095
Show file tree
Hide file tree
Showing 18 changed files with 451 additions and 132 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 of your operating system. Alternatively use \"crond\" to select the cron daemon in supported environments or \"crontab:/path/to/crontab.file\" to write schedules into a crontab file without any further integration"`
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
102 changes: 88 additions & 14 deletions crond/crontab.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package crond
import (
"fmt"
"io"
"os/user"
"regexp"
"strings"
)
Expand All @@ -13,29 +14,40 @@ const (
)

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

func NewCrontab(entries []Entry) *Crontab {
return &Crontab{
entries: entries,
func NewCrontab(entries []Entry) (c *Crontab) {
c = &Crontab{entries: entries}

for i, entry := range c.entries {
if entry.NeedsUser() {
c.entries[i] = c.entries[i].WithUser(c.username())
}
}

return
}

// SetFile toggles whether to read & write a crontab file instead of using CrontabBinary
// 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 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 @@ -114,26 +126,52 @@ func (c *Crontab) Generate(w io.StringWriter) error {
}

func (c *Crontab) LoadCurrent() (content string, err error) {
content, c.charset, err = loadCrontab(c.file)
content, c.charset, err = loadCrontab(c.file, c.binary)
if err == nil {
content = cleanupCrontab(content)
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
}

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

buffer := new(strings.Builder)
_, err = c.Update(crontab, true, buffer)
_, err = c.update(crontab, true, buffer)
if err != nil {
return err
}

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

func (c *Crontab) Remove() (int, error) {
Expand All @@ -143,9 +181,9 @@ func (c *Crontab) Remove() (int, error) {
}

buffer := new(strings.Builder)
num, err := c.Update(crontab, false, buffer)
num, err := c.update(crontab, false, buffer)
if err == nil {
err = saveCrontab(c.file, buffer.String(), c.charset)
err = saveCrontab(c.file, buffer.String(), c.charset, c.binary)
}
return num, err
}
Expand All @@ -157,6 +195,42 @@ 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.
Expand Down
Loading

0 comments on commit f4f0095

Please sign in to comment.