diff --git a/config/global.go b/config/global.go index c726de8e..91108061 100644 --- a/config/global.go +++ b/config/global.go @@ -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/"` diff --git a/constants/global.go b/constants/global.go index 519f80ef..33090287 100644 --- a/constants/global.go +++ b/constants/global.go @@ -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 ( diff --git a/crond/crontab.go b/crond/crontab.go index efcc4bb1..49969ac1 100644 --- a/crond/crontab.go +++ b/crond/crontab.go @@ -1,14 +1,9 @@ -//go:build !darwin && !windows -// +build !darwin,!windows - package crond import ( - "bytes" "fmt" "io" - "os" - "os/exec" + "os/user" "regexp" "strings" ) @@ -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 @@ -117,19 +125,30 @@ 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 { @@ -137,20 +156,22 @@ func (c *Crontab) Rewrite() error { 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) { @@ -158,20 +179,13 @@ func (c *Crontab) Remove() (int, error) { 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 { @@ -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) diff --git a/crond/crontab_test.go b/crond/crontab_test.go index 1f0f50e9..4bf74f17 100644 --- a/crond/crontab_test.go +++ b/crond/crontab_test.go @@ -1,15 +1,16 @@ -//go:build !darwin && !windows -// +build !darwin,!windows - package crond import ( + "fmt" "os" "os/exec" + "os/user" + "path/filepath" "strings" "testing" "github.com/creativeprojects/resticprofile/calendar" + "github.com/creativeprojects/resticprofile/platform" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -71,6 +72,7 @@ func TestDeleteLine(t *testing.T) { {"#\n#\n#\n00,30 * * * * /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup\n", true}, {"#\n#\n#\n# 00,30 * * * * /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n", false}, {"#\n#\n#\n00,30 * * * * /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n", true}, + {"#\n#\n#\n00,30 * * * * user /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n", true}, } for _, testRun := range testData { @@ -114,7 +116,7 @@ func TestSectionOnItsOwn(t *testing.T) { func TestUpdateEmptyCrontab(t *testing.T) { crontab := NewCrontab(nil) buffer := &strings.Builder{} - deleted, err := crontab.Update("", true, buffer) + deleted, err := crontab.update("", true, buffer) require.NoError(t, err) assert.Equal(t, 0, deleted) assert.Equal(t, "\n"+startMarker+endMarker, buffer.String()) @@ -126,7 +128,7 @@ func TestUpdateSimpleCrontab(t *testing.T) { event.Hour.MustAddValue(1) }), "", "", "", "resticprofile backup", "")}) buffer := &strings.Builder{} - deleted, err := crontab.Update("", true, buffer) + deleted, err := crontab.update("", true, buffer) require.NoError(t, err) assert.Equal(t, 0, deleted) assert.Equal(t, "\n"+startMarker+"01 01 * * *\tresticprofile backup\n"+endMarker, buffer.String()) @@ -138,7 +140,7 @@ func TestUpdateExistingCrontab(t *testing.T) { event.Hour.MustAddValue(1) }), "", "", "", "resticprofile backup", "")}) buffer := &strings.Builder{} - deleted, err := crontab.Update("something\n"+startMarker+endMarker, true, buffer) + deleted, err := crontab.update("something\n"+startMarker+endMarker, true, buffer) require.NoError(t, err) assert.Equal(t, 0, deleted) assert.Equal(t, "something\n"+startMarker+"01 01 * * *\tresticprofile backup\n"+endMarker, buffer.String()) @@ -150,24 +152,187 @@ func TestRemoveCrontab(t *testing.T) { event.Hour.MustAddValue(1) }), "config.yaml", "profile", "backup", "resticprofile backup", "")}) buffer := &strings.Builder{} - deleted, err := crontab.Update("something\n"+startMarker+"01 01 * * *\t/opt/resticprofile --no-ansi --config config.yaml --name profile backup\n"+endMarker, false, buffer) + deleted, err := crontab.update("something\n"+startMarker+"01 01 * * *\t/opt/resticprofile --no-ansi --config config.yaml --name profile backup\n"+endMarker, false, buffer) require.NoError(t, err) assert.Equal(t, 1, deleted) assert.Equal(t, "something\n"+startMarker+endMarker, buffer.String()) } -func TestLoadCurrent(t *testing.T) { - defer func() { - _ = os.Remove("./crontab") - }() - cmd := exec.Command("go", "build", "-o", "crontab", "./stdin") - err := cmd.Run() - require.NoError(t, err) - crontabBinary = "./crontab" +func TestFromFile(t *testing.T) { + file, err := filepath.Abs(filepath.Join(t.TempDir(), "crontab")) + + crontab := NewCrontab([]Entry{NewEntry(calendar.NewEvent(func(event *calendar.Event) { + event.Minute.MustAddValue(1) + event.Hour.MustAddValue(1) + }), "", "", "", "resticprofile backup", "")}) + + assert.ErrorContains(t, crontab.Rewrite(), "no contrab file was specified") + + crontab.SetFile(file) + + assert.NoFileExists(t, file) + assert.NoError(t, crontab.Rewrite()) + assert.FileExists(t, file) - crontab := NewCrontab(nil) - assert.NotNil(t, crontab) result, err := crontab.LoadCurrent() assert.NoError(t, err) - assert.Equal(t, "", result) + assert.Contains(t, result, "01 01 * * *\tresticprofile backup") +} + +func getExpectedUser(crontab *Crontab) (expectedUser string) { + if c, e := user.Current(); e != nil || strings.ContainsAny(c.Username, "\n \r\n") { + expectedUser = "testuser" + crontab.user = "testuser" + } else { + expectedUser = c.Username + } + return +} + +func TestFromFileDetectsUserColumn(t *testing.T) { + file, err := filepath.Abs(filepath.Join(t.TempDir(), "crontab")) + userLine := `17 * * * * root cd / && run-parts --report /etc/cron.hourly` + require.NoError(t, os.WriteFile(file, []byte("\n"+userLine+"\n"), 0600)) + cmdLine := "resticprofile --no-ansi --config config.yaml --name profile backup" + + crontab := NewCrontab([]Entry{NewEntry(calendar.NewEvent(func(event *calendar.Event) { + event.Minute.MustAddValue(1) + event.Hour.MustAddValue(1) + }), "config.yaml", "profile", "backup", cmdLine, "")}) + + crontab.SetFile(file) + expectedUser := getExpectedUser(crontab) + + assert.NoError(t, crontab.Rewrite()) + result, err := crontab.LoadCurrent() + assert.NoError(t, err) + assert.Contains(t, result, userLine) + assert.Contains(t, result, fmt.Sprintf("01 01 * * *\t%s\t%s", expectedUser, cmdLine)) + + _, err = crontab.Remove() + assert.NoError(t, err) + result, err = crontab.LoadCurrent() + assert.NoError(t, err) + assert.Contains(t, result, userLine) + assert.NotContains(t, result, cmdLine) +} + +func TestNewCrontabWithCurrentUser(t *testing.T) { + event := calendar.NewEvent(func(event *calendar.Event) { + event.Minute.MustAddValue(1) + event.Hour.MustAddValue(1) + }) + entry := NewEntry(event, "config.yaml", "profile", "backup", "", ""). + WithUser("*") + + crontab := NewCrontab([]Entry{entry}) + expectedUser := getExpectedUser(crontab) + assert.Equal(t, expectedUser, crontab.entries[0].user) +} + +func TestNoLoadCurrentFromNoEditFile(t *testing.T) { + file, err := filepath.Abs(filepath.Join(t.TempDir(), "crontab")) + assert.NoError(t, os.WriteFile(file, []byte("# DO NOT EDIT THIS FILE \n#\n#\n"), 0600)) + + crontab := NewCrontab(nil) + crontab.SetFile(file) + + _, err = crontab.LoadCurrent() + assert.ErrorContains(t, err, fmt.Sprintf(`refusing to change crontab with "DO NOT EDIT": %q`, file)) +} + +func TestDetectNeedsUserColumn(t *testing.T) { + t.Run("empty", func(t *testing.T) { + assert.False(t, detectNeedsUserColumn("")) + }) + + t.Run("by-header", func(t *testing.T) { + assert.True(t, detectNeedsUserColumn(`# * * * * * user cmd`)) + assert.True(t, detectNeedsUserColumn(`# * * * * * user-name command to be executed`)) + assert.False(t, detectNeedsUserColumn(`# * * * * * user cmd +# * * * * * cmd`)) + assert.False(t, detectNeedsUserColumn(`# * * * * * cmd +# * * * * * user cmd`)) + assert.True(t, detectNeedsUserColumn(`# * * * * * cmd +# * * * * * user cmd +17 * * * * root cd / && run-parts --report /etc/cron.hourly`)) + }) + + t.Run("by-entry", func(t *testing.T) { + assert.True(t, detectNeedsUserColumn(`17 * * * * root cd / && run-parts --report /etc/cron.hourly`)) + assert.True(t, detectNeedsUserColumn(`SHELL=/bin/sh +PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin +0 4 * * * root test -x /etc/cron.daily/popularity-contest && /etc/cron.daily/popularity-contest --crond`)) + }) + + t.Run("by-entry-statistically", func(t *testing.T) { + user := `17 * * * * root cd / && run-parts --report /etc/cron.hourly` + noUser := `01 01 * * * resticprofile backup` + compose := func(split int) string { + return strings.Repeat(user+"\n", split) + strings.Repeat(noUser+"\n", 10-split) + } + assert.False(t, detectNeedsUserColumn(compose(1))) + assert.False(t, detectNeedsUserColumn(compose(7))) + assert.True(t, detectNeedsUserColumn(compose(8))) + assert.True(t, detectNeedsUserColumn(compose(9))) + assert.True(t, detectNeedsUserColumn(compose(10))) + }) +} + +func TestUseCrontabBinary(t *testing.T) { + binary := platform.Executable("./crontab") + defer func() { _ = os.Remove(binary) }() + + cmd := exec.Command("go", "build", "-o", binary, "./mock") + require.NoError(t, cmd.Run()) + + crontab := NewCrontab(nil) + crontab.SetBinary(binary) + + t.Run("load-error", func(t *testing.T) { + result, err := crontab.LoadCurrent() + assert.Error(t, err) + assert.Empty(t, result) + }) + + t.Run("save-error", func(t *testing.T) { + err := crontab.Rewrite() + assert.Error(t, err) + }) + + t.Run("load-empty", func(t *testing.T) { + require.NoError(t, os.Setenv("NO_CRONTAB", "empty")) + defer os.Unsetenv("NO_CRONTAB") + + result, err := crontab.LoadCurrent() + assert.NoError(t, err) + assert.Equal(t, "", result) + }) + + t.Run("load-empty-for-user", func(t *testing.T) { + require.NoError(t, os.Setenv("NO_CRONTAB", "user")) + defer os.Unsetenv("NO_CRONTAB") + + result, err := crontab.LoadCurrent() + assert.NoError(t, err) + assert.Equal(t, "", result) + }) + + t.Run("load-crontab", func(t *testing.T) { + ct := "01 01 * * *\tresticprofile backup" + require.NoError(t, os.Setenv("CRONTAB", ct)) + defer os.Unsetenv("CRONTAB") + + result, err := crontab.LoadCurrent() + assert.NoError(t, err) + assert.Equal(t, ct, result) + }) + + t.Run("save-crontab", func(t *testing.T) { + ct := "01 01 * * *\tresticprofile backup" + require.NoError(t, os.Setenv("CRONTAB", ct)) + defer os.Unsetenv("CRONTAB") + + assert.NoError(t, crontab.Rewrite()) + }) } diff --git a/crond/entry.go b/crond/entry.go index a493546a..a75d7737 100644 --- a/crond/entry.go +++ b/crond/entry.go @@ -1,5 +1,3 @@ -//+build !darwin,!windows - package crond import ( @@ -18,6 +16,7 @@ type Entry struct { commandName string commandLine string workDir string + user string } // NewEntry creates a new crontab entry @@ -32,6 +31,18 @@ func NewEntry(event *calendar.Event, configFile, profileName, commandName, comma } } +// WithUser creates a new entry that adds a user that should run the command +func (e Entry) WithUser(user string) Entry { + e.user = strings.TrimSpace(user) + return e +} + +func (e Entry) HasUser() bool { return len(e.user) > 0 } + +func (e Entry) NeedsUser() bool { return e.user == "*" } + +func (e Entry) SkipUser() bool { return e.NeedsUser() || e.user == "-" } + // String returns the crontab line representation of the entry (end of line included) func (e Entry) String() string { minute, hour, dayOfMonth, month, dayOfWeek := "*", "*", "*", "*", "*" @@ -55,6 +66,9 @@ func (e Entry) String() string { // don't make ranges for days of the week as it can fail with high sunday (7) dayOfWeek = formatList(e.event.WeekDay.GetRangeValues(), formatWeekDay) } + if e.HasUser() && !e.SkipUser() { + return fmt.Sprintf("%s %s %s %s %s\t%s\t%s%s\n", minute, hour, dayOfMonth, month, dayOfWeek, e.user, wd, e.commandLine) + } return fmt.Sprintf("%s %s %s %s %s\t%s%s\n", minute, hour, dayOfMonth, month, dayOfWeek, wd, e.commandLine) } diff --git a/crond/entry_test.go b/crond/entry_test.go index 1007b3c0..221bb1fb 100644 --- a/crond/entry_test.go +++ b/crond/entry_test.go @@ -1,5 +1,3 @@ -//+build !darwin,!windows - package crond import ( @@ -11,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestEmptyUserEvent(t *testing.T) { +func TestEmptyEvent(t *testing.T) { entry := NewEntry(calendar.NewEvent(), "", "", "", "command line", "") buffer := &strings.Builder{} err := entry.Generate(buffer) @@ -19,6 +17,16 @@ func TestEmptyUserEvent(t *testing.T) { assert.Equal(t, "* * * * *\tcommand line\n", buffer.String()) } +func TestEntryWithUser(t *testing.T) { + entry := NewEntry(calendar.NewEvent(), "", "", "", "command line", "") + entry = entry.WithUser("root") + assert.Equal(t, "* * * * *\troot\tcommand line\n", entry.String()) + entry = entry.WithUser("-") + assert.Equal(t, "* * * * *\tcommand line\n", entry.String()) + entry = entry.WithUser("*") + assert.Equal(t, "* * * * *\tcommand line\n", entry.String()) +} + func TestEvents(t *testing.T) { testData := []struct { event string diff --git a/crond/io.go b/crond/io.go new file mode 100644 index 00000000..afcf328c --- /dev/null +++ b/crond/io.go @@ -0,0 +1,101 @@ +package crond + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "strings" +) + +const ( + maxCrontabFileSize = 16 * 1024 * 1024 + defaultCrontabFilePerms = fs.FileMode(0644) +) + +func verifyCrontabFile(file string) error { + if file == "" { + return fmt.Errorf("no contrab file was specified") + } + return nil +} + +func loadCrontabFile(file string) (content, charset string, err error) { + if err = verifyCrontabFile(file); err != nil { + return + } + var f *os.File + if f, err = os.Open(file); err == nil { + defer func() { _ = f.Close() }() + + var bytes []byte + bytes, err = io.ReadAll(io.LimitReader(f, maxCrontabFileSize)) + if err == nil && len(bytes) == maxCrontabFileSize { + err = fmt.Errorf("max file size of %d bytes exceeded in %q", maxCrontabFileSize, file) + } + if err == nil { + // TODO: handle charsets + charset = "" + content = string(bytes) + } + } else if errors.Is(err, os.ErrNotExist) { + err = nil + } + return +} + +func saveCrontabFile(file, content, charset string) (err error) { + if err = verifyCrontabFile(file); err != nil { + return + } + + // TODO: handle charsets + bytes := []byte(content) + + if len(bytes) >= maxCrontabFileSize { + err = fmt.Errorf("max file size of %d bytes exceeded in new %q", maxCrontabFileSize, file) + } else { + err = os.WriteFile(file, bytes, defaultCrontabFilePerms) + } + return +} + +func loadCrontab(file, crontabBinary string) (content, charset string, err error) { + if file == "" && crontabBinary != "" { + buffer := new(strings.Builder) + { + cmd := exec.Command(crontabBinary, "-l") + cmd.Stdout = buffer + cmd.Stderr = buffer + err = cmd.Run() + } + if err != nil { + if strings.HasPrefix(buffer.String(), "no crontab for ") { + err = nil // it's ok to be empty + buffer.Reset() + } else { + err = fmt.Errorf("%w: %s", err, buffer.String()) + } + } + if err == nil { + content = buffer.String() + } + return + } else { + return loadCrontabFile(file) + } +} + +func saveCrontab(file, content, charset, crontabBinary string) (err error) { + if file == "" && crontabBinary != "" { + cmd := exec.Command(crontabBinary, "-") + cmd.Stdin = strings.NewReader(content) + cmd.Stderr = os.Stderr + err = cmd.Run() + } else { + err = saveCrontabFile(file, content, charset) + } + return +} diff --git a/crond/mock/main.go b/crond/mock/main.go new file mode 100644 index 00000000..f4e9ea7a --- /dev/null +++ b/crond/mock/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + "slices" + "strings" +) + +func main() { + var list bool + flag.BoolVar(&list, "l", false, "list") + flag.Parse() + noCrontab := os.Getenv("NO_CRONTAB") + crontab := os.Getenv("CRONTAB") + + if list { + if noCrontab != "" { + if noCrontab == "user" { + println("no crontab for user") + os.Exit(1) + } else { + return + } + } else if crontab != "" { + print(crontab) + } else { + os.Exit(6) + } + } else { + if slices.Contains(os.Args, "-") { + if crontab != "" { + sb := new(strings.Builder) + if _, err := io.Copy(sb, os.Stdin); err == nil { + if stdin := strings.TrimSpace(sb.String()); stdin != crontab { + err = fmt.Errorf("%q != %q", crontab, stdin) + } + } + } else { + os.Exit(8) + } + } else { + os.Exit(10) + } + } +} diff --git a/crond/stdin/main.go b/crond/stdin/main.go deleted file mode 100644 index 842fa25a..00000000 --- a/crond/stdin/main.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -import ( - "flag" - "io" - "log" - "os" -) - -func main() { - var list bool - flag.BoolVar(&list, "l", false, "list") - flag.Parse() - if list { - return - } - _, err := io.Copy(os.Stdout, os.Stdin) - if err != nil { - log.Fatal(err) - } -} diff --git a/docs/content/schedules/_index.md b/docs/content/schedules/_index.md index 9ecd9a9e..54991d60 100644 --- a/docs/content/schedules/_index.md +++ b/docs/content/schedules/_index.md @@ -10,9 +10,11 @@ resticprofile is capable of managing scheduled backups for you. Under the hood i - **launchd** on macOS X - **Task Scheduler** on Windows - **systemd** where available (Linux and other BSDs) -- **crond** on supported platforms (Linux and other BSDs) +- **crond** as fallback (depends on the availability of a `crontab` binary) +- **crontab** files (low level, with (`*`) or without (`-`) user column) -On unixes (except macOS) resticprofile is using **systemd** by default. **crond** can be used instead if configured in `global` `scheduler` parameter: +On unixes (except macOS) resticprofile is using **systemd** if available and falls back to **crond**. +On any OS a **crond** compatible scheduler can be used instead if configured in `global` / `scheduler`: {{< tabs groupid="config-with-json" >}} {{% tab title="toml" %}} @@ -20,6 +22,9 @@ On unixes (except macOS) resticprofile is using **systemd** by default. **crond* ```toml [global] scheduler = "crond" + # scheduler = "crond:/usr/bin/crontab" + # scheduler = "crontab:*:/etc/cron.d/resticprofile" + # scheduler = "crontab:-:/var/spool/cron/crontabs/username" ``` {{% /tab %}} @@ -29,6 +34,9 @@ On unixes (except macOS) resticprofile is using **systemd** by default. **crond* --- global: scheduler: crond + # scheduler: "crond:/usr/bin/crontab" + # scheduler: "crontab:*:/etc/cron.d/resticprofile" + # scheduler: "crontab:-:/var/spool/cron/crontabs/username" ``` {{% /tab %}} @@ -37,6 +45,9 @@ global: ```hcl "global" = { "scheduler" = "crond" + # "scheduler" = "crond:/usr/bin/crontab" + # "scheduler" = "crontab:*:/etc/cron.d/resticprofile" + # "scheduler" = "crontab:-:/var/spool/cron/crontabs/username" } ``` @@ -54,7 +65,7 @@ global: {{% /tab %}} {{< /tabs >}} - +See also [reference / global section]({{% relref "/configuration/reference#section-profilebackup" %}}) for options on how to configure the scheduler. Each profile can be scheduled independently (groups are not available for scheduling yet - it will be available in version '2' of the configuration file). diff --git a/platform/darwin.go b/platform/darwin.go index 673fce03..c3304460 100644 --- a/platform/darwin.go +++ b/platform/darwin.go @@ -15,3 +15,5 @@ func IsWindows() bool { func SupportsSyslog() bool { return true } + +func Executable(name string) string { return name } diff --git a/platform/os_test.go b/platform/os_test.go index 9bfc0846..97e429af 100644 --- a/platform/os_test.go +++ b/platform/os_test.go @@ -17,5 +17,13 @@ func TestWindows(t *testing.T) { } func TestSupportsSyslog(t *testing.T) { - assert.Equal(t, runtime.GOOS != "windows", platform.SupportsSyslog()) + assert.Equal(t, !platform.IsWindows(), platform.SupportsSyslog()) +} + +func TestExecutable(t *testing.T) { + expected := "/path/to/app" + if platform.IsWindows() { + expected = "/path/to/app.exe" + } + assert.Equal(t, expected, platform.Executable("/path/to/app")) } diff --git a/platform/other.go b/platform/other.go index a7b34646..c633bdbe 100644 --- a/platform/other.go +++ b/platform/other.go @@ -15,3 +15,5 @@ func IsWindows() bool { func SupportsSyslog() bool { return true } + +func Executable(name string) string { return name } diff --git a/platform/windows.go b/platform/windows.go index 152b75b6..7881e0d4 100644 --- a/platform/windows.go +++ b/platform/windows.go @@ -2,6 +2,8 @@ package platform +import "fmt" + const LineSeparator = "\r\n" func IsDarwin() bool { @@ -15,3 +17,5 @@ func IsWindows() bool { func SupportsSyslog() bool { return false } + +func Executable(name string) string { return fmt.Sprintf("%s.exe", name) } diff --git a/schedule/handler.go b/schedule/handler.go index bf10c8c9..56980687 100644 --- a/schedule/handler.go +++ b/schedule/handler.go @@ -20,10 +20,39 @@ type Handler interface { DisplayJobStatus(job *Config) error } +// FindHandler creates a schedule handler depending on the configuration or nil if the config is not supported +func FindHandler(config SchedulerConfig) Handler { + for _, fallback := range []bool{false, true} { + for _, provider := range providers { + if handler := provider(config, fallback); handler != nil { + return handler + } + } + } + return nil +} + +// NewHandler creates a schedule handler depending on the configuration, panics if the config is not supported +func NewHandler(config SchedulerConfig) Handler { + if h := FindHandler(config); h != nil { + return h + } + panic(fmt.Errorf("scheduler %q is not supported in this environment", config.Type())) +} + +type HandlerProvider func(config SchedulerConfig, fallback bool) Handler + +var providers []HandlerProvider + +// AddHandlerProvider allows to register a provider for a SchedulerConfig handler +func AddHandlerProvider(provider HandlerProvider) { + providers = append(providers, provider) +} + func lookupBinary(name, binary string) error { found, err := exec.LookPath(binary) if err != nil || found == "" { - return fmt.Errorf("it doesn't look like %s is installed on your system (cannot find %q command in path)", name, binary) + return fmt.Errorf("it doesn't look like %s is installed on your system (cannot find %q in path)", name, binary) } return nil } diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index bf7dd25e..8064fcac 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -1,33 +1,39 @@ -//go:build !darwin && !windows - package schedule import ( "strings" "github.com/creativeprojects/resticprofile/calendar" + "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/crond" + "github.com/creativeprojects/resticprofile/platform" ) -var ( - crontabBinary = "crontab" -) +var crontabBinary = "crontab" // HandlerCrond is a handler for crond scheduling type HandlerCrond struct { - config SchedulerConfig + config SchedulerCrond } // NewHandlerCrond creates a new handler for crond scheduling func NewHandlerCrond(config SchedulerConfig) *HandlerCrond { - return &HandlerCrond{ - config: config, + cfg, ok := config.(SchedulerCrond) + if !ok { + cfg = SchedulerCrond{} + } + if cfg.CrontabBinary == "" { + cfg.CrontabBinary = platform.Executable(crontabBinary) } + return &HandlerCrond{config: cfg} } // Init verifies crond is available on this system func (h *HandlerCrond) Init() error { - return lookupBinary("crond", crontabBinary) + if len(h.config.CrontabFile) > 0 { + return nil + } + return lookupBinary("crond", h.config.CrontabBinary) } // Close does nothing with crond @@ -65,8 +71,13 @@ func (h *HandlerCrond) CreateJob(job *Config, schedules []*calendar.Event, permi job.Command+" "+strings.Join(job.Arguments, " "), job.WorkingDirectory, ) + if h.config.Username != "" { + entries[i] = entries[i].WithUser(h.config.Username) + } } crontab := crond.NewCrontab(entries) + crontab.SetFile(h.config.CrontabFile) + crontab.SetBinary(h.config.CrontabBinary) err := crontab.Rewrite() if err != nil { return err @@ -86,6 +97,8 @@ func (h *HandlerCrond) RemoveJob(job *Config, permission string) error { ), } crontab := crond.NewCrontab(entries) + crontab.SetFile(h.config.CrontabFile) + crontab.SetBinary(h.config.CrontabBinary) num, err := crontab.Remove() if err != nil { return err @@ -101,6 +114,13 @@ func (h *HandlerCrond) DisplayJobStatus(job *Config) error { return nil } -var ( - _ Handler = &HandlerCrond{} -) +// init registers HandlerCrond +func init() { + AddHandlerProvider(func(config SchedulerConfig, fallback bool) (hr Handler) { + if config.Type() == constants.SchedulerCrond || + (fallback && config.Type() == constants.SchedulerOSDefault) { + hr = NewHandlerCrond(config.Convert(constants.SchedulerCrond)) + } + return + }) +} diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 2bd3c485..30debd84 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -70,13 +70,6 @@ type HandlerLaunchd struct { fs afero.Fs } -func NewHandler(config SchedulerConfig) *HandlerLaunchd { - return &HandlerLaunchd{ - config: config, - fs: afero.NewOsFs(), - } -} - // Init verifies launchd is available on this system func (h *HandlerLaunchd) Init() error { return lookupBinary("launchd", launchdBin) @@ -410,3 +403,17 @@ func parseStatus(status string) map[string]string { } return output } + +// init registers HandlerLaunchd +func init() { + AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { + if config.Type() == constants.SchedulerLaunchd || + config.Type() == constants.SchedulerOSDefault { + hr = &HandlerLaunchd{ + config: config, + fs: afero.NewOsFs(), + } + } + return + }) +} diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 16d1767e..c309c42e 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -15,6 +15,11 @@ import ( "howett.net/plist" ) +func TestHandlerCrond(t *testing.T) { + handler := NewHandler(SchedulerCrond{}) + assert.IsType(t, &HandlerCrond{}, handler) +} + func TestPListEncoderWithCalendarInterval(t *testing.T) { expected := ` @@ -140,7 +145,7 @@ func TestLaunchdJobPreservesEnv(t *testing.T) { for i, fixture := range fixtures { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { - handler := NewHandler(SchedulerLaunchd{}) + handler := NewHandler(SchedulerLaunchd{}).(*HandlerLaunchd) cfg := &Config{ProfileName: "t", CommandName: "s", Environment: fixture.environment} launchdJob := handler.getLaunchdJob(cfg, []*calendar.Event{}) assert.Equal(t, fixture.expected, launchdJob.EnvironmentVariables) @@ -149,7 +154,7 @@ func TestLaunchdJobPreservesEnv(t *testing.T) { } func TestCreateUserPlist(t *testing.T) { - handler := NewHandler(SchedulerLaunchd{}) + handler := NewHandler(SchedulerLaunchd{}).(*HandlerLaunchd) handler.fs = afero.NewMemMapFs() launchdJob := &LaunchdJob{ @@ -163,7 +168,7 @@ func TestCreateUserPlist(t *testing.T) { } func TestCreateSystemPlist(t *testing.T) { - handler := NewHandler(SchedulerLaunchd{}) + handler := NewHandler(SchedulerLaunchd{}).(*HandlerLaunchd) handler.fs = afero.NewMemMapFs() launchdJob := &LaunchdJob{ diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index 3ec8a1b2..3fedaf68 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -46,13 +46,9 @@ type HandlerSystemd struct { func NewHandlerSystemd(config SchedulerConfig) *HandlerSystemd { cfg, ok := config.(SchedulerSystemd) if !ok { - return &HandlerSystemd{ - config: SchedulerSystemd{}, // empty configuration - } - } - return &HandlerSystemd{ - config: cfg, + cfg = SchedulerSystemd{} // empty configuration } + return &HandlerSystemd{config: cfg} } // Init verifies systemd is available on this system @@ -295,3 +291,14 @@ func runJournalCtlCommand(timerName string, unitType systemd.UnitType) error { fmt.Println("") return err } + +// init registers HandlerSystemd +func init() { + AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { + if config.Type() == constants.SchedulerSystemd || + config.Type() == constants.SchedulerOSDefault { + hr = NewHandlerSystemd(config.Convert(constants.SchedulerSystemd)) + } + return + }) +} diff --git a/schedule/handler_unix.go b/schedule/handler_unix.go deleted file mode 100644 index 027ab7c2..00000000 --- a/schedule/handler_unix.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build !darwin && !windows - -package schedule - -import "github.com/creativeprojects/resticprofile/constants" - -// NewHandler creates a crond or systemd handler depending on the configuration -func NewHandler(config SchedulerConfig) Handler { - if config.Type() == constants.SchedulerCrond { - return NewHandlerCrond(config) - } - return NewHandlerSystemd(config) -} diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index 09d71790..a7c6eddc 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -15,13 +15,6 @@ type HandlerWindows struct { config SchedulerConfig } -// NewHandler creates a new handler for windows task manager -func NewHandler(config SchedulerConfig) *HandlerWindows { - return &HandlerWindows{ - config: config, - } -} - // Init a connection to the task scheduler func (h *HandlerWindows) Init() error { return schtasks.Connect() @@ -99,3 +92,16 @@ func (h *HandlerWindows) DisplayJobStatus(job *Config) error { } return nil } + +// init registers HandlerWindows +func init() { + AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { + if config.Type() == constants.SchedulerWindows || + config.Type() == constants.SchedulerOSDefault { + hr = &HandlerWindows{ + config: config, + } + } + return + }) +} diff --git a/schedule/handler_windows_test.go b/schedule/handler_windows_test.go new file mode 100644 index 00000000..c235c9e1 --- /dev/null +++ b/schedule/handler_windows_test.go @@ -0,0 +1,19 @@ +//go:build windows + +package schedule + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHandlerCrond(t *testing.T) { + handler := NewHandler(SchedulerCrond{}) + assert.IsType(t, &HandlerCrond{}, handler) +} + +func TestHandlerDefaultOS(t *testing.T) { + handler := NewHandler(SchedulerDefaultOS{}) + assert.IsType(t, &HandlerWindows{}, handler) +} diff --git a/schedule/removeonly_test.go b/schedule/removeonly_test.go index 355ac60f..80376e4f 100644 --- a/schedule/removeonly_test.go +++ b/schedule/removeonly_test.go @@ -39,7 +39,7 @@ func TestDetectRemoveOnlyConfig(t *testing.T) { func TestRemoveOnlyJob(t *testing.T) { profile := "non-existent" - scheduler := NewScheduler(NewHandler(&SchedulerDefaultOS{}), profile) + scheduler := NewScheduler(NewHandler(SchedulerDefaultOS{}), profile) defer scheduler.Close() job := scheduler.NewJob(NewRemoveOnlyConfig(profile, "check")) diff --git a/schedule/schedule_test.go b/schedule/schedule_test.go index b37fdf1b..f43c68af 100644 --- a/schedule/schedule_test.go +++ b/schedule/schedule_test.go @@ -31,7 +31,7 @@ func TestExecutableIsAbsoluteOnAllPlatforms(t *testing.T) { } func TestInit(t *testing.T) { - scheduler := NewScheduler(NewHandler(&SchedulerDefaultOS{}), "profile") + scheduler := NewScheduler(NewHandler(SchedulerDefaultOS{}), "profile") err := scheduler.Init() defer scheduler.Close() require.NoError(t, err) @@ -41,7 +41,7 @@ func TestCrondInit(t *testing.T) { if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { t.Skip("crond scheduler is not supported on this platform") } - scheduler := NewScheduler(NewHandler(&SchedulerCrond{}), "profile") + scheduler := NewScheduler(NewHandler(SchedulerCrond{}), "profile") err := scheduler.Init() defer scheduler.Close() require.NoError(t, err) @@ -51,7 +51,7 @@ func TestSystemdInit(t *testing.T) { if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { t.Skip("systemd scheduler is not supported on this platform") } - scheduler := NewScheduler(NewHandler(&SchedulerSystemd{}), "profile") + scheduler := NewScheduler(NewHandler(SchedulerSystemd{}), "profile") err := scheduler.Init() defer scheduler.Close() require.NoError(t, err) @@ -60,7 +60,7 @@ func TestLaunchdInit(t *testing.T) { if runtime.GOOS != "darwin" { t.Skip("launchd scheduler is not supported on this platform") } - scheduler := NewScheduler(NewHandler(&SchedulerLaunchd{}), "profile") + scheduler := NewScheduler(NewHandler(SchedulerLaunchd{}), "profile") err := scheduler.Init() defer scheduler.Close() require.NoError(t, err) @@ -69,7 +69,7 @@ func TestWindowsInit(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("windows scheduler is not supported on this platform") } - scheduler := NewScheduler(NewHandler(&SchedulerWindows{}), "profile") + scheduler := NewScheduler(NewHandler(SchedulerWindows{}), "profile") err := scheduler.Init() defer scheduler.Close() require.NoError(t, err) diff --git a/schedule/scheduler_config.go b/schedule/scheduler_config.go index b7fca483..0b8a73c6 100644 --- a/schedule/scheduler_config.go +++ b/schedule/scheduler_config.go @@ -1,44 +1,57 @@ package schedule import ( + "fmt" + "regexp" + "strings" + "github.com/creativeprojects/resticprofile/config" "github.com/creativeprojects/resticprofile/constants" - "github.com/creativeprojects/resticprofile/platform" "github.com/spf13/afero" ) type SchedulerConfig interface { // Type of scheduler config ("windows", "launchd", "crond", "systemd" or "" for OS default) Type() string + Convert(typeName string) SchedulerConfig } -type SchedulerDefaultOS struct{} +type SchedulerDefaultOS struct { + defaults []SchedulerConfig +} -func (s SchedulerDefaultOS) Type() string { - return "" +func (s SchedulerDefaultOS) Type() string { return constants.SchedulerOSDefault } +func (s SchedulerDefaultOS) Convert(typeName string) SchedulerConfig { + for _, c := range s.defaults { + if c.Type() == typeName { + return c + } + } + return s } type SchedulerWindows struct{} -func (s SchedulerWindows) Type() string { - return constants.SchedulerWindows -} +func (s SchedulerWindows) Type() string { return constants.SchedulerWindows } +func (s SchedulerWindows) Convert(_ string) SchedulerConfig { return s } type SchedulerLaunchd struct { Fs afero.Fs } -func (s SchedulerLaunchd) Type() string { - return constants.SchedulerLaunchd -} +func (s SchedulerLaunchd) Type() string { return constants.SchedulerLaunchd } +func (s SchedulerLaunchd) Convert(_ string) SchedulerConfig { return s } +// SchedulerCrond configures crond compatible schedulers, either needs CrontabBinary or CrontabFile type SchedulerCrond struct { - Fs afero.Fs + Fs afero.Fs + CrontabFile string + CrontabBinary string + Username string } -func (s SchedulerCrond) Type() string { - return constants.SchedulerCrond -} +func (s SchedulerCrond) Type() string { return constants.SchedulerCrond } +func (s SchedulerCrond) Convert(_ string) SchedulerConfig { return s } type SchedulerSystemd struct { Fs afero.Fs @@ -46,14 +59,37 @@ type SchedulerSystemd struct { TimerTemplate string } -func (s SchedulerSystemd) Type() string { - return constants.SchedulerSystemd -} +func (s SchedulerSystemd) Type() string { return constants.SchedulerSystemd } +func (s SchedulerSystemd) Convert(_ string) SchedulerConfig { return s } func NewSchedulerConfig(global *config.Global) SchedulerConfig { - switch global.Scheduler { + // scheduler: resource + scheduler, resource, _ := strings.Cut(global.Scheduler, ":") + scheduler = strings.TrimSpace(scheduler) + resource = strings.TrimSpace(resource) + + switch scheduler { case constants.SchedulerCrond: - return SchedulerCrond{} + if len(resource) > 0 { + return SchedulerCrond{CrontabBinary: resource} + } else { + return SchedulerCrond{} + } + case constants.SchedulerCrontab: + if len(resource) > 0 { + if user, location, found := strings.Cut(resource, ":"); found { + user = strings.TrimSpace(user) + if !regexp.MustCompile(`^[A-Za-z]$`).MatchString(user) { + if user == "" { + user = "-" + } + return SchedulerCrond{CrontabFile: strings.TrimSpace(location), Username: user} + } + } + return SchedulerCrond{CrontabFile: resource} + } else { + panic(fmt.Errorf("invalid schedule %q, no crontab file was specified, expecting \"%s: filename\"", scheduler, scheduler)) + } case constants.SchedulerLaunchd: return SchedulerLaunchd{} case constants.SchedulerSystemd: @@ -64,13 +100,14 @@ func NewSchedulerConfig(global *config.Global) SchedulerConfig { case constants.SchedulerWindows: return SchedulerWindows{} default: - if !platform.IsDarwin() && !platform.IsWindows() { - return SchedulerSystemd{ - UnitTemplate: global.SystemdUnitTemplate, - TimerTemplate: global.SystemdTimerTemplate, - } + return SchedulerDefaultOS{ + defaults: []SchedulerConfig{ + SchedulerSystemd{ + UnitTemplate: global.SystemdUnitTemplate, + TimerTemplate: global.SystemdTimerTemplate, + }, + }, } - return SchedulerDefaultOS{} } } diff --git a/schedule/scheduler_config_test.go b/schedule/scheduler_config_test.go new file mode 100644 index 00000000..29589ac0 --- /dev/null +++ b/schedule/scheduler_config_test.go @@ -0,0 +1,64 @@ +package schedule + +import ( + "fmt" + "testing" + + "github.com/creativeprojects/resticprofile/config" + "github.com/creativeprojects/resticprofile/constants" + "github.com/stretchr/testify/assert" +) + +func TestOsDefaultConfig(t *testing.T) { + g := &config.Global{SystemdTimerTemplate: "timer.tpl", SystemdUnitTemplate: "unit.tpl"} + cfg := NewSchedulerConfig(g) + + t.Run("is-os-default", func(t *testing.T) { + assert.Equal(t, constants.SchedulerOSDefault, cfg.Type()) + }) + + t.Run("can-convert-to-systemd", func(t *testing.T) { + systemd := cfg.Convert(constants.SchedulerSystemd) + assert.Equal(t, SchedulerSystemd{TimerTemplate: "timer.tpl", UnitTemplate: "unit.tpl"}, systemd) + }) +} + +func TestSystemdConfig(t *testing.T) { + g := &config.Global{ + Scheduler: constants.SchedulerSystemd, + SystemdTimerTemplate: "timer.tpl", + SystemdUnitTemplate: "unit.tpl", + } + assert.Equal(t, SchedulerSystemd{TimerTemplate: "timer.tpl", UnitTemplate: "unit.tpl"}, NewSchedulerConfig(g)) +} + +func TestCrondConfig(t *testing.T) { + global := func(res string) *config.Global { + return &config.Global{Scheduler: fmt.Sprintf("%s : %s", constants.SchedulerCrond, res)} + } + t.Run("default-binary", func(t *testing.T) { + assert.Equal(t, SchedulerCrond{}, NewSchedulerConfig(global(""))) + }) + t.Run("custom-binary", func(t *testing.T) { + assert.Equal(t, SchedulerCrond{CrontabBinary: "/my/binary"}, NewSchedulerConfig(global("/my/binary"))) + }) +} + +func TestCrontabConfig(t *testing.T) { + global := func(res string) *config.Global { + return &config.Global{Scheduler: fmt.Sprintf("%s : %s", constants.SchedulerCrontab, res)} + } + t.Run("no-file-panic", func(t *testing.T) { + msg := `invalid schedule "crontab", no crontab file was specified, expecting "crontab: filename"` + assert.PanicsWithError(t, msg, func() { NewSchedulerConfig(global("")) }) + }) + t.Run("file-detect-user", func(t *testing.T) { + assert.Equal(t, SchedulerCrond{CrontabFile: "/my/file"}, NewSchedulerConfig(global("/my/file"))) + }) + t.Run("file-with-user", func(t *testing.T) { + assert.Equal(t, SchedulerCrond{CrontabFile: "/my/file", Username: "user"}, NewSchedulerConfig(global("user : /my/file"))) + }) + t.Run("file-no-user", func(t *testing.T) { + assert.Equal(t, SchedulerCrond{CrontabFile: "/my/file", Username: "-"}, NewSchedulerConfig(global(" : /my/file"))) + }) +}