diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 08c8cefb..5d84c23d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -44,7 +44,8 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
- version: v1.60
+ version: v1.61
+ args: --timeout=5m
- name: Test
run: make test-ci
diff --git a/Makefile b/Makefile
index 4ae9c6f8..14820178 100644
--- a/Makefile
+++ b/Makefile
@@ -87,6 +87,10 @@ $(GOBIN)/mockery: verify $(GOBIN)/eget
@echo "[*] $@"
"$(GOBIN)/eget" vektra/mockery --upgrade-only --to '$(GOBIN)'
+$(GOBIN)/golangci-lint: verify $(GOBIN)/eget
+ @echo "[*] $@"
+ "$(GOBIN)/eget" golangci/golangci-lint --tag v1.61.0 --asset=tar.gz --upgrade-only --to '$(GOBIN)'
+
prepare_build: verify download
@echo "[*] $@"
@@ -296,14 +300,14 @@ checklinks:
muffet -b 8192 --exclude="(linux.die.net|stackoverflow.com)" http://localhost:1313/resticprofile/
.PHONY: lint
-lint:
+lint: $(GOBIN)/golangci-lint
@echo "[*] $@"
GOOS=darwin golangci-lint run
GOOS=linux golangci-lint run
GOOS=windows golangci-lint run
.PHONY: fix
-fix:
+fix: $(GOBIN)/golangci-lint
@echo "[*] $@"
$(GOCMD) mod tidy
$(GOCMD) fix ./...
diff --git a/calendar/event.go b/calendar/event.go
index 0360ddd9..cbfb0b9b 100644
--- a/calendar/event.go
+++ b/calendar/event.go
@@ -97,6 +97,9 @@ func (e *Event) Parse(input string) error {
func (e *Event) Next(from time.Time) time.Time {
// start from time and increment of 1 minute each time
next := from.Truncate(time.Minute) // truncate all the seconds
+ if from.Second() > 0 {
+ next = next.Add(time.Minute) // it's too late for the current minute
+ }
// should stop in 2 years time to avoid an infinite loop
endYear := from.Year() + 2
for next.Year() <= endYear {
diff --git a/calendar/event_test.go b/calendar/event_test.go
index 9d505c01..d472c10f 100644
--- a/calendar/event_test.go
+++ b/calendar/event_test.go
@@ -161,16 +161,23 @@ func TestNextTrigger(t *testing.T) {
// the base time is the example in the Go documentation https://golang.org/pkg/time/
ref, err := time.Parse(time.ANSIC, "Mon Jan 2 15:04:05 2006")
require.NoError(t, err)
+ refNoSecond, err := time.Parse(time.ANSIC, "Mon Jan 2 15:04:00 2006")
+ require.NoError(t, err)
- testData := []struct{ event, trigger string }{
- {"*:*:*", "2006-01-02 15:04:00"}, // seconds are zeroed out
- {"03-*", "2006-03-01 00:00:00"},
- {"*-01", "2006-02-01 00:00:00"},
- {"*:*:11", "2006-01-02 15:04:00"}, // again, seconds are zeroed out
- {"*:11:*", "2006-01-02 15:11:00"},
- {"11:*:*", "2006-01-03 11:00:00"},
- {"tue", "2006-01-03 00:00:00"},
- {"2003-*-*", "0001-01-01 00:00:00"},
+ testData := []struct {
+ event, trigger string
+ ref time.Time
+ }{
+ {"*:*:*", "2006-01-02 15:04:00", refNoSecond}, // at the exact same second
+ {"*:*:*", "2006-01-02 15:05:00", ref}, // seconds are zeroed out => take next minute
+ {"03-*", "2006-03-01 00:00:00", ref},
+ {"*-01", "2006-02-01 00:00:00", ref},
+ {"*:*:11", "2006-01-02 15:04:00", refNoSecond}, // at the exact same second
+ {"*:*:11", "2006-01-02 15:05:00", ref}, // seconds are zeroed out => take next minute
+ {"*:11:*", "2006-01-02 15:11:00", ref},
+ {"11:*:*", "2006-01-03 11:00:00", ref},
+ {"tue", "2006-01-03 00:00:00", ref},
+ {"2003-*-*", "0001-01-01 00:00:00", ref},
}
for _, testItem := range testData {
@@ -178,7 +185,7 @@ func TestNextTrigger(t *testing.T) {
event := NewEvent()
err = event.Parse(testItem.event)
assert.NoError(t, err)
- assert.Equal(t, testItem.trigger, event.Next(ref).String()[0:len(testItem.trigger)])
+ assert.Equal(t, testItem.trigger, event.Next(testItem.ref).String()[0:len(testItem.trigger)])
})
}
}
diff --git a/codecov.yml b/codecov.yml
index 8a6734e8..69c88912 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -11,6 +11,7 @@ ignore:
- run_profile.go
- syslog.go
- syslog_windows.go
+ - "**/mocks/*.go"
codecov:
notify:
diff --git a/commands.go b/commands.go
index 8527e5d7..f76d3d6e 100644
--- a/commands.go
+++ b/commands.go
@@ -11,6 +11,7 @@ import (
"slices"
"strconv"
"strings"
+ "sync"
"time"
"github.com/creativeprojects/clog"
@@ -25,6 +26,7 @@ import (
var (
ownCommands = NewOwnCommands()
+ elevation sync.Once
)
func init() {
@@ -99,7 +101,8 @@ func getOwnCommands() []ownCommand {
needConfiguration: true,
hide: false,
flags: map[string]string{
- "--no-start": "don't start the timer/service (systemd/launch only)",
+ "--no-start": "don't start the job after installing (systemd/launch only)",
+ "--start": "start the job after installing (systemd/launch only)",
"--all": "add all scheduled jobs of all profiles and groups",
},
},
@@ -323,8 +326,11 @@ func retryElevated(err error, flags commandLineFlags) error {
}
// maybe can find a better way than searching for the word "denied"?
if platform.IsWindows() && !flags.isChild && strings.Contains(err.Error(), "denied") {
- clog.Info("restarting resticprofile in elevated mode...")
- err := elevated()
+ // we try only once, otherwise we return the original error
+ elevation.Do(func() {
+ clog.Info("restarting resticprofile in elevated mode...")
+ err = elevated()
+ })
if err != nil {
return err
}
diff --git a/commands_schedule.go b/commands_schedule.go
index 81a842b7..79a74b21 100644
--- a/commands_schedule.go
+++ b/commands_schedule.go
@@ -24,9 +24,9 @@ func createSchedule(_ io.Writer, ctx commandContext) error {
defer c.DisplayConfigurationIssues()
type profileJobs struct {
- scheduler schedule.SchedulerConfig
- name string
- jobs []*config.Schedule
+ schedulerConfig schedule.SchedulerConfig
+ name string
+ jobs []*config.Schedule
}
allJobs := make([]profileJobs, 0, 1)
@@ -55,12 +55,12 @@ func createSchedule(_ io.Writer, ctx commandContext) error {
}
}
- allJobs = append(allJobs, profileJobs{scheduler: scheduler, name: profileName, jobs: jobs})
+ allJobs = append(allJobs, profileJobs{schedulerConfig: scheduler, name: profileName, jobs: jobs})
}
// Step 2: Schedule all collected jobs
for _, j := range allJobs {
- err := scheduleJobs(schedule.NewHandler(j.scheduler), j.name, j.jobs)
+ err := scheduleJobs(schedule.NewHandler(j.schedulerConfig), j.jobs)
if err != nil {
return retryElevated(err, flags)
}
@@ -70,26 +70,44 @@ func createSchedule(_ io.Writer, ctx commandContext) error {
}
func removeSchedule(_ io.Writer, ctx commandContext) error {
+ var err error
c := ctx.config
flags := ctx.flags
args := ctx.request.arguments
- // Unschedule all jobs of all selected profiles
- for _, profileName := range selectProfilesAndGroups(c, flags, args) {
- profileFlags := flagsForProfile(flags, profileName)
+ if slices.Contains(args, "--legacy") { // TODO: remove this option in the future
+ // Unschedule all jobs of all selected profiles
+ for _, profileName := range selectProfilesAndGroups(c, flags, args) {
+ profileFlags := flagsForProfile(flags, profileName)
- scheduler, jobs, err := getRemovableScheduleJobs(c, profileFlags)
- if err != nil {
- return err
- }
+ schedulerConfig, jobs, err := getRemovableScheduleJobs(c, profileFlags)
+ if err != nil {
+ return err
+ }
- err = removeJobs(schedule.NewHandler(scheduler), profileName, jobs)
- if err != nil {
- return retryElevated(err, flags)
+ err = removeJobs(schedule.NewHandler(schedulerConfig), jobs)
+ if err != nil {
+ err = retryElevated(err, flags)
+ }
+ if err != nil {
+ // we keep trying to remove the other jobs
+ clog.Error(err)
+ }
}
+ return nil
}
- return nil
+ profileName := ctx.request.profile
+ if slices.Contains(args, "--all") {
+ // Unschedule all jobs of all profiles
+ profileName = ""
+ }
+ schedulerConfig := schedule.NewSchedulerConfig(ctx.global)
+ err = removeScheduledJobs(schedule.NewHandler(schedulerConfig), ctx.config.GetConfigFile(), profileName)
+ if err != nil {
+ err = retryElevated(err, flags)
+ }
+ return err
}
func statusSchedule(w io.Writer, ctx commandContext) error {
@@ -99,38 +117,53 @@ func statusSchedule(w io.Writer, ctx commandContext) error {
defer c.DisplayConfigurationIssues()
- if !slices.Contains(args, "--all") {
- scheduler, schedules, _, err := getScheduleJobs(c, flags)
- if err != nil {
- return err
- }
- if len(schedules) == 0 {
- clog.Warningf("profile or group %s has no schedule", flags.name)
+ if slices.Contains(flags.resticArgs, "--legacy") { // TODO: remove this option in the future
+ // single profile or group
+ if !slices.Contains(args, "--all") {
+ schedulerConfig, schedules, _, err := getScheduleJobs(c, flags)
+ if err != nil {
+ return err
+ }
+ if len(schedules) == 0 {
+ clog.Warningf("profile or group %s has no schedule", flags.name)
+ return nil
+ }
+ err = statusScheduleProfileOrGroup(schedulerConfig, schedules, flags)
+ if err != nil {
+ return err
+ }
return nil
}
- err = statusScheduleProfileOrGroup(scheduler, schedules, flags)
- if err != nil {
- return err
- }
- }
- for _, profileName := range selectProfilesAndGroups(c, flags, args) {
- profileFlags := flagsForProfile(flags, profileName)
- scheduler, schedules, schedulable, err := getScheduleJobs(c, profileFlags)
- if err != nil {
- return err
- }
- // it's all fine if this profile has no schedule
- if len(schedules) == 0 {
- continue
- }
- clog.Infof("%s %q:", cases.Title(language.English).String(schedulable.Kind()), profileName)
- err = statusScheduleProfileOrGroup(scheduler, schedules, profileFlags)
- if err != nil {
- // display the error but keep going with the other profiles
- clog.Error(err)
+ // all profiles and groups
+ for _, profileName := range selectProfilesAndGroups(c, flags, args) {
+ profileFlags := flagsForProfile(flags, profileName)
+ scheduler, schedules, schedulable, err := getScheduleJobs(c, profileFlags)
+ if err != nil {
+ return err
+ }
+ // it's all fine if this profile has no schedule
+ if len(schedules) == 0 {
+ continue
+ }
+ clog.Infof("%s %q:", cases.Title(language.English).String(schedulable.Kind()), profileName)
+ err = statusScheduleProfileOrGroup(scheduler, schedules, profileFlags)
+ if err != nil {
+ // display the error but keep going with the other profiles
+ clog.Error(err)
+ }
}
}
+ profileName := ctx.request.profile
+ if slices.Contains(args, "--all") {
+ // Unschedule all jobs of all profiles
+ profileName = ""
+ }
+ schedulerConfig := schedule.NewSchedulerConfig(ctx.global)
+ err := statusScheduledJobs(schedule.NewHandler(schedulerConfig), ctx.config.GetConfigFile(), profileName)
+ if err != nil {
+ return retryElevated(err, flags)
+ }
return nil
}
@@ -159,8 +192,8 @@ func flagsForProfile(flags commandLineFlags, profileName string) commandLineFlag
return flags
}
-func statusScheduleProfileOrGroup(scheduler schedule.SchedulerConfig, schedules []*config.Schedule, flags commandLineFlags) error {
- err := statusJobs(schedule.NewHandler(scheduler), flags.name, schedules)
+func statusScheduleProfileOrGroup(schedulerConfig schedule.SchedulerConfig, schedules []*config.Schedule, flags commandLineFlags) error {
+ err := statusJobs(schedule.NewHandler(schedulerConfig), flags.name, schedules)
if err != nil {
return retryElevated(err, flags)
}
diff --git a/commands_schedule_test.go b/commands_schedule_test.go
index defe4485..181aa5ba 100644
--- a/commands_schedule_test.go
+++ b/commands_schedule_test.go
@@ -37,24 +37,38 @@ profiles:
profile-schedule-inline:
backup:
- schedule: daily
+ schedule: "*:00,30"
profile-schedule-struct:
backup:
schedule:
- at: daily
+ at: "*:00,30"
`
+const scheduleIntegrationTestsCrontab = `
+### this content was generated by resticprofile, please leave this line intact ###
+00,30 * * * * user cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile
+### end of resticprofile content, please leave this line intact ###
+`
+
func TestCommandsIntegrationUsingCrontab(t *testing.T) {
crontab := filepath.Join(t.TempDir(), "crontab")
+ err := os.WriteFile(crontab, []byte(scheduleIntegrationTestsCrontab), 0o600)
+ require.NoError(t, err)
+
cfg, err := config.Load(
bytes.NewBufferString(fmt.Sprintf(scheduleIntegrationTestsConfiguration, crontab)),
config.FormatYAML,
+ config.WithConfigFile("config.yaml"),
)
require.NoError(t, err)
require.NotNil(t, cfg)
+ global, err := cfg.GetGlobalSection()
+ require.NoError(t, err)
+ require.NotNil(t, global)
+
testCases := []struct {
name string
contains string
@@ -62,15 +76,15 @@ func TestCommandsIntegrationUsingCrontab(t *testing.T) {
}{
{
name: "",
- err: config.ErrNotFound,
+ err: nil,
},
{
name: "profile-schedule-inline",
- contains: "Original form: daily",
+ contains: "Original form: *-*-* *:00,30:00",
},
{
name: "profile-schedule-struct",
- contains: "Original form: daily",
+ contains: "Original form: *-*-* *:00,30:00",
},
}
@@ -82,6 +96,7 @@ func TestCommandsIntegrationUsingCrontab(t *testing.T) {
flags: commandLineFlags{
name: tc.name,
},
+ global: global,
},
}
output := &bytes.Buffer{}
diff --git a/commands_test.go b/commands_test.go
index c10d8e1f..9c1105ef 100644
--- a/commands_test.go
+++ b/commands_test.go
@@ -14,6 +14,7 @@ import (
"github.com/creativeprojects/resticprofile/schedule"
"github.com/creativeprojects/resticprofile/util/collect"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestPanicCommand(t *testing.T) {
@@ -66,14 +67,15 @@ schedule = "daily"
for _, jobConfig := range schedules {
configOrigin := jobConfig.ScheduleOrigin()
- scheduler := schedule.NewScheduler(schedule.NewHandler(schedule.SchedulerDefaultOS{}), configOrigin.Name)
- defer func(s *schedule.Scheduler) { s.Close() }(scheduler) // Capture current ref to scheduler to be able to close it when function returns.
+ handler := schedule.NewHandler(schedule.SchedulerDefaultOS{})
+ require.NoError(t, handler.Init())
+ defer func(s schedule.Handler) { s.Close() }(handler) // Capture current ref to scheduler to be able to close it when function returns.
if configOrigin.Command == constants.CommandCheck {
- assert.False(t, scheduler.NewJob(scheduleToConfig(jobConfig)).RemoveOnly())
+ assert.False(t, schedule.NewJob(handler, scheduleToConfig(jobConfig)).RemoveOnly())
declaredCount++
} else {
- assert.True(t, scheduler.NewJob(scheduleToConfig(jobConfig)).RemoveOnly())
+ assert.True(t, schedule.NewJob(handler, scheduleToConfig(jobConfig)).RemoveOnly())
}
}
diff --git a/complete_test.go b/complete_test.go
index 53e1d0d1..cf34264d 100644
--- a/complete_test.go
+++ b/complete_test.go
@@ -271,15 +271,15 @@ func TestCompleter(t *testing.T) {
{args: []string{"self-update", "-q"}, expected: nil},
// Can completion commands after flags
- {args: []string{"--verbose", "schedule", "-"}, expected: []string{"--all", "--no-start"}},
- {args: []string{"--log", "file", "schedule", "-"}, expected: []string{"--all", "--no-start"}},
+ {args: []string{"--verbose", "schedule", "-"}, expected: []string{"--all", "--no-start", "--start"}},
+ {args: []string{"--log", "file", "schedule", "-"}, expected: []string{"--all", "--no-start", "--start"}},
// Flags are returned only once
{args: []string{"--verb"}, expected: []string{"--verbose"}},
{args: []string{"--verb", "--verb"}, expected: []string{"--verbose"}},
{args: []string{"--verbose", "--verb"}, expected: nil},
- {args: []string{"schedule", "-"}, expected: []string{"--all", "--no-start"}},
- {args: []string{"schedule", "--all", "-"}, expected: []string{"--no-start"}},
+ {args: []string{"schedule", "-"}, expected: []string{"--all", "--no-start", "--start"}},
+ {args: []string{"schedule", "--all", "-"}, expected: []string{"--no-start", "--start"}},
// Exact command match returns nothing (no duplication)
{args: []string{"schedule"}, expected: nil},
@@ -295,9 +295,9 @@ func TestCompleter(t *testing.T) {
{args: []string{"__POS:2", "--log", "out.log", "--verbose", "schedule", "-"}, expected: []string{RequestFileCompletion}},
{args: []string{"__POS:4", "--log", "out.log", "--verbose", "schedule", "-"}, expected: nil},
{args: []string{"__POS:4", "--log", "out.log", "--verbose", "schedule"}, expected: nil},
- {args: []string{"__POS:5", "--log", "out.log", "--verbose", "schedule", "-"}, expected: []string{"--all", "--no-start"}},
- {args: []string{"__POS:5", "--log", "out.log", "--verbose", "schedule"}, expected: []string{"--all", "--no-start"}},
- {args: []string{"__POS:INVALID", "--log", "out.log", "--verbose", "schedule", "-"}, expected: []string{"--all", "--no-start"}},
+ {args: []string{"__POS:5", "--log", "out.log", "--verbose", "schedule", "-"}, expected: []string{"--all", "--no-start", "--start"}},
+ {args: []string{"__POS:5", "--log", "out.log", "--verbose", "schedule"}, expected: []string{"--all", "--no-start", "--start"}},
+ {args: []string{"__POS:INVALID", "--log", "out.log", "--verbose", "schedule", "-"}, expected: []string{"--all", "--no-start", "--start"}},
{args: []string{"__POS:INVALID", "--log", "out.log", "--verbose", "schedule"}, expected: nil},
// Unknown is delegated to restic
diff --git a/config/config.go b/config/config.go
index 46bf6e99..b35bdfca 100644
--- a/config/config.go
+++ b/config/config.go
@@ -131,8 +131,11 @@ func LoadFile(configFile, format string) (config *Config, err error) {
// Load configuration from reader
// This should only be used for unit tests
-func Load(input io.Reader, format string) (config *Config, err error) {
+func Load(input io.Reader, format string, options ...func(cfg *Config)) (config *Config, err error) {
config = newConfig(format)
+ for _, option := range options {
+ option(config)
+ }
err = config.addTemplate(input, config.configFile, true)
return
}
diff --git a/config/load_options.go b/config/load_options.go
new file mode 100644
index 00000000..2b1ecf92
--- /dev/null
+++ b/config/load_options.go
@@ -0,0 +1,7 @@
+package config
+
+func WithConfigFile(configFile string) func(cfg *Config) {
+ return func(cfg *Config) {
+ cfg.configFile = configFile
+ }
+}
diff --git a/crond/crontab.go b/crond/crontab.go
index 03d4924b..39a0fd00 100644
--- a/crond/crontab.go
+++ b/crond/crontab.go
@@ -1,25 +1,47 @@
package crond
import (
+ "errors"
"fmt"
"io"
"os/user"
"regexp"
"strings"
+
+ "github.com/spf13/afero"
)
const (
- startMarker = "### this content was generated by resticprofile, please leave this line intact ###\n"
- endMarker = "### end of resticprofile content, please leave this line intact ###\n"
+ startMarker = "### this content was generated by resticprofile, please leave this line intact ###\n"
+ endMarker = "### end of resticprofile content, please leave this line intact ###\n"
+ timeExp = `^(([\d,\/\-\*]+[ \t]?){5})`
+ userExp = `[\t]+(\w+[\t]+)?`
+ workDirExp = `(cd .+ && )?`
+ configExp = `([^\s]+.+--config[ =]"?([^"\n]+)"? `
+ legacyExp = `[^\n]*--name[ =]([^\s]+)( --.+)? ([a-z]+))$`
+ runScheduleExp = `run-schedule ([^\s]+)@([^\s]+))$`
+)
+
+var (
+ legacyPattern = regexp.MustCompile(timeExp + userExp + workDirExp + configExp + legacyExp)
+ runSchedulePattern = regexp.MustCompile(timeExp + userExp + workDirExp + configExp + runScheduleExp)
+)
+
+var (
+ ErrEntryNoMatch = errors.New("line doesn't match a typical resticprofile entry")
)
type Crontab struct {
file, binary, charset, user string
entries []Entry
+ fs afero.Fs
}
func NewCrontab(entries []Entry) (c *Crontab) {
- c = &Crontab{entries: entries}
+ c = &Crontab{
+ entries: entries,
+ fs: afero.NewOsFs(),
+ }
for i, entry := range c.entries {
if entry.NeedsUser() {
@@ -31,13 +53,22 @@ func NewCrontab(entries []Entry) (c *Crontab) {
}
// 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) {
+func (c *Crontab) SetBinary(crontabBinary string) *Crontab {
c.binary = crontabBinary
+ return c
}
// SetFile toggles whether to read & write a crontab file instead of using the crontab binary
-func (c *Crontab) SetFile(file string) {
+func (c *Crontab) SetFile(file string) *Crontab {
c.file = file
+ return c
+}
+
+// SetFs sets the filesystem to use for reading and writing the crontab file.
+// If not set, it will use the default filesystem.
+func (c *Crontab) SetFs(fs afero.Fs) *Crontab {
+ c.fs = fs
+ return c
}
// update crontab entries:
@@ -92,7 +123,7 @@ func (c *Crontab) update(source string, addEntries bool, w io.StringWriter) (int
}
if addEntries {
- err = c.Generate(w)
+ err = c.generate(w)
if err != nil {
return deleted, err
}
@@ -112,7 +143,7 @@ func (c *Crontab) update(source string, addEntries bool, w io.StringWriter) (int
return deleted, nil
}
-func (c *Crontab) Generate(w io.StringWriter) error {
+func (c *Crontab) generate(w io.StringWriter) error {
var err error
if len(c.entries) > 0 {
for _, entry := range c.entries {
@@ -126,7 +157,7 @@ func (c *Crontab) Generate(w io.StringWriter) error {
}
func (c *Crontab) LoadCurrent() (content string, err error) {
- content, c.charset, err = loadCrontab(c.file, c.binary)
+ content, c.charset, err = loadCrontab(c.fs, c.file, c.binary)
if err == nil {
if cleaned := cleanupCrontab(content); cleaned != content {
if len(c.file) == 0 {
@@ -171,7 +202,7 @@ func (c *Crontab) Rewrite() error {
return err
}
- return saveCrontab(c.file, buffer.String(), c.charset, c.binary)
+ return saveCrontab(c.fs, c.file, buffer.String(), c.charset, c.binary)
}
func (c *Crontab) Remove() (int, error) {
@@ -183,11 +214,26 @@ func (c *Crontab) Remove() (int, error) {
buffer := new(strings.Builder)
num, err := c.update(crontab, false, buffer)
if err == nil {
- err = saveCrontab(c.file, buffer.String(), c.charset, c.binary)
+ err = saveCrontab(c.fs, c.file, buffer.String(), c.charset, c.binary)
}
return num, err
}
+func (c *Crontab) GetEntries() ([]Entry, error) {
+ crontab, err := c.LoadCurrent()
+ if err != nil {
+ return nil, err
+ }
+
+ _, ownSection, _, sectionFound := extractOwnSection(crontab)
+ if !sectionFound {
+ return nil, nil
+ }
+
+ entries := parseEntries(ownSection)
+ return entries, nil
+}
+
func cleanupCrontab(crontab string) string {
// this pattern detects if a header has been added to the output of "crontab -l"
pattern := regexp.MustCompile(`^# DO NOT EDIT THIS FILE[^\n]*\n#[^\n]*\n#[^\n]*\n`)
@@ -275,3 +321,74 @@ func deleteLine(crontab string, entry Entry) (string, bool, error) {
}
return crontab, false, nil
}
+
+func parseEntries(crontab string) []Entry {
+ lines := strings.Split(crontab, "\n")
+ entries := make([]Entry, 0, len(lines))
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if len(line) == 0 || strings.HasPrefix(line, "#") {
+ continue
+ }
+ entry, _ := parseEntry(line)
+ if entry == nil {
+ continue
+ }
+ entries = append(entries, *entry)
+ }
+ return entries
+}
+
+func parseEntry(line string) (*Entry, error) {
+ // should match lines like:
+ // 00,15,30,45 * * * * /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup
+ // 00,15,30,45 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup
+ // or a line like:
+ // 00,15,30,45 * * * * /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile
+ // also with quotes around the config file:
+ // 00,15,30,45 * * * * /home/resticprofile --no-ansi --config "config.yaml" run-schedule backup@profile
+
+ // try legacy pattern first
+ matches := legacyPattern.FindStringSubmatch(line)
+ if len(matches) == 10 {
+ event, err := parseEvent(matches[1])
+ if err != nil {
+ return nil, fmt.Errorf("cannot parse %q: %w", matches[1], err)
+ }
+ return &Entry{
+ event: event,
+ user: getUserValue(matches[3]),
+ workDir: getWorkdirValue(matches[4]),
+ commandLine: matches[5],
+ configFile: matches[6],
+ profileName: matches[7],
+ commandName: matches[9],
+ }, nil
+ }
+ // then try the current pattern
+ matches = runSchedulePattern.FindStringSubmatch(line)
+ if len(matches) == 9 {
+ event, err := parseEvent(matches[1])
+ if err != nil {
+ return nil, fmt.Errorf("cannot parse %q: %w", matches[1], err)
+ }
+ return &Entry{
+ event: event,
+ user: getUserValue(matches[3]),
+ workDir: getWorkdirValue(matches[4]),
+ commandLine: matches[5],
+ configFile: matches[6],
+ commandName: matches[7],
+ profileName: matches[8],
+ }, nil
+ }
+ return nil, ErrEntryNoMatch
+}
+
+func getUserValue(user string) string {
+ return strings.TrimSpace(user)
+}
+
+func getWorkdirValue(workdir string) string {
+ return strings.TrimSuffix(strings.TrimPrefix(workdir, "cd "), " && ")
+}
diff --git a/crond/crontab_test.go b/crond/crontab_test.go
index 1c36467c..e12bc34d 100644
--- a/crond/crontab_test.go
+++ b/crond/crontab_test.go
@@ -11,6 +11,7 @@ import (
"github.com/creativeprojects/resticprofile/calendar"
"github.com/creativeprojects/resticprofile/platform"
+ "github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -18,7 +19,7 @@ import (
func TestGenerateEmptyCrontab(t *testing.T) {
crontab := NewCrontab(nil)
buffer := &strings.Builder{}
- err := crontab.Generate(buffer)
+ err := crontab.generate(buffer)
require.NoError(t, err)
assert.Equal(t, "", buffer.String())
}
@@ -29,7 +30,7 @@ func TestGenerateSimpleCrontab(t *testing.T) {
event.Hour.MustAddValue(1)
}), "", "", "", "resticprofile backup", "")})
buffer := &strings.Builder{}
- err := crontab.Generate(buffer)
+ err := crontab.generate(buffer)
require.NoError(t, err)
assert.Equal(t, "01 01 * * *\tresticprofile backup\n", buffer.String())
}
@@ -40,7 +41,7 @@ func TestGenerateWorkDirCrontab(t *testing.T) {
event.Hour.MustAddValue(1)
}), "", "", "", "resticprofile backup", "workdir")})
buffer := &strings.Builder{}
- err := crontab.Generate(buffer)
+ err := crontab.generate(buffer)
require.NoError(t, err)
assert.Equal(t, "01 01 * * *\tcd workdir && resticprofile backup\n", buffer.String())
}
@@ -76,6 +77,14 @@ func TestDeleteLine(t *testing.T) {
{"#\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},
{"#\n#\n#\n00,30 * * * * user /home/resticprofile --no-ansi --config \"config.yaml\" run-schedule backup@profile\n", true},
+ {"#\n#\n#\n# 00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup\n", false},
+ {"#\n#\n#\n00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup\n", true},
+ {"#\n#\n#\n# 00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n", false},
+ {"#\n#\n#\n00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n", true},
+ {"#\n#\n#\n# 00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config \"config.yaml\" run-schedule backup@profile\n", false},
+ {"#\n#\n#\n00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config \"config.yaml\" run-schedule backup@profile\n", true},
+ {"#\n#\n#\n00,30 * * * * user cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n", true},
+ {"#\n#\n#\n00,30 * * * * user cd /workdir && /home/resticprofile --no-ansi --config \"config.yaml\" run-schedule backup@profile\n", true},
}
for _, testRun := range testData {
@@ -170,13 +179,20 @@ func TestFromFile(t *testing.T) {
event.Hour.MustAddValue(1)
}), "", "", "", "resticprofile backup", "")})
- assert.ErrorContains(t, crontab.Rewrite(), "no contrab file was specified")
+ assert.ErrorIs(t, crontab.Rewrite(), ErrNoCrontabFile)
+ fs := afero.NewMemMapFs()
+ crontab.SetFs(fs)
crontab.SetFile(file)
- assert.NoFileExists(t, file)
+ exist, err := afero.Exists(fs, file)
+ require.NoError(t, err)
+ assert.False(t, exist)
+
assert.NoError(t, crontab.Rewrite())
- assert.FileExists(t, file)
+ exist, err = afero.Exists(fs, file)
+ require.NoError(t, err)
+ assert.True(t, exist)
result, err := crontab.LoadCurrent()
assert.NoError(t, err)
@@ -194,11 +210,11 @@ func getExpectedUser(crontab *Crontab) (expectedUser string) {
}
func TestFromFileDetectsUserColumn(t *testing.T) {
- file, err := filepath.Abs(filepath.Join(t.TempDir(), "crontab"))
- require.NoError(t, err)
+ fs := afero.NewMemMapFs()
+ file := "/var/spool/cron/crontabs/user"
userLine := `17 * * * * root cd / && run-parts --report /etc/cron.hourly`
- require.NoError(t, os.WriteFile(file, []byte("\n"+userLine+"\n"), 0600))
+ require.NoError(t, afero.WriteFile(fs, 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) {
@@ -206,6 +222,7 @@ func TestFromFileDetectsUserColumn(t *testing.T) {
event.Hour.MustAddValue(1)
}), "config.yaml", "profile", "backup", cmdLine, "")})
+ crontab.SetFs(fs)
crontab.SetFile(file)
expectedUser := getExpectedUser(crontab)
@@ -237,15 +254,16 @@ func TestNewCrontabWithCurrentUser(t *testing.T) {
}
func TestNoLoadCurrentFromNoEditFile(t *testing.T) {
- file, err := filepath.Abs(filepath.Join(t.TempDir(), "crontab"))
- require.NoError(t, err)
+ fs := afero.NewMemMapFs()
+ file := "/var/spool/cron/crontabs/user"
- assert.NoError(t, os.WriteFile(file, []byte("# DO NOT EDIT THIS FILE \n#\n#\n"), 0600))
+ assert.NoError(t, afero.WriteFile(fs, file, []byte("# DO NOT EDIT THIS FILE \n#\n#\n"), 0600))
- crontab := NewCrontab(nil)
- crontab.SetFile(file)
+ crontab := NewCrontab(nil).
+ SetFile(file).
+ SetFs(fs)
- _, err = crontab.LoadCurrent()
+ _, err := crontab.LoadCurrent()
assert.ErrorContains(t, err, fmt.Sprintf(`refusing to change crontab with "DO NOT EDIT": %q`, file))
}
@@ -344,3 +362,112 @@ func TestUseCrontabBinary(t *testing.T) {
assert.NoError(t, crontab.Rewrite())
})
}
+
+func TestParseEntry(t *testing.T) {
+ scheduledEvent := calendar.NewEvent(func(e *calendar.Event) {
+ _ = e.Second.AddValue(0)
+ _ = e.Minute.AddValue(0)
+ _ = e.Minute.AddValue(30)
+ })
+ testData := []struct {
+ source string
+ expectEntry *Entry
+ }{
+ {
+ source: "00,30 * * * * /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup",
+ expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup"},
+ },
+ {
+ source: "00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup",
+ expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup"},
+ },
+ {
+ source: "00,30 * * * * /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile",
+ expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"},
+ },
+ {
+ source: "00,30 * * * * /home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile",
+ expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"},
+ },
+ {
+ source: "00,30 * * * * user /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile",
+ expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", user: "user", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"},
+ },
+ {
+ source: "00,30 * * * * user /home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile",
+ expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", user: "user", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"},
+ },
+ {
+ source: "00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile",
+ expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"},
+ },
+ {
+ source: "00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile",
+ expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"},
+ },
+ {
+ source: "00,30 * * * * user cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile",
+ expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", user: "user", workDir: "/workdir", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"},
+ },
+ {
+ source: "00,30 * * * * user cd /workdir && /home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile",
+ expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", user: "user", workDir: "/workdir", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"},
+ },
+ }
+
+ for _, testRun := range testData {
+ t.Run("", func(t *testing.T) {
+ entry, err := parseEntry(testRun.source)
+ require.NoError(t, err)
+ assert.Equal(t, testRun.expectEntry.CommandLine(), entry.CommandLine())
+ assert.Equal(t, testRun.expectEntry.CommandName(), entry.CommandName())
+ assert.Equal(t, testRun.expectEntry.ConfigFile(), entry.ConfigFile())
+ assert.Equal(t, testRun.expectEntry.ProfileName(), entry.ProfileName())
+ assert.Equal(t, testRun.expectEntry.User(), entry.User())
+ assert.Equal(t, testRun.expectEntry.WorkDir(), entry.WorkDir())
+ assert.Equal(t, testRun.expectEntry.Event().String(), entry.Event().String())
+ })
+ }
+}
+
+func TestGetEntries(t *testing.T) {
+ fs := afero.NewMemMapFs()
+ file := "/var/spool/cron/crontabs/user"
+
+ t.Run("no crontab file", func(t *testing.T) {
+ crontab := NewCrontab(nil).SetFile(file).SetFs(fs)
+ entries, err := crontab.GetEntries()
+ require.NoError(t, err)
+ assert.Nil(t, entries)
+ })
+
+ t.Run("empty crontab file", func(t *testing.T) {
+ require.NoError(t, afero.WriteFile(fs, file, []byte(""), 0600))
+ crontab := NewCrontab(nil).SetFile(file).SetFs(fs)
+ entries, err := crontab.GetEntries()
+ require.NoError(t, err)
+ assert.Nil(t, entries)
+ })
+
+ t.Run("crontab with no own section", func(t *testing.T) {
+ require.NoError(t, afero.WriteFile(fs, file, []byte("some other content\n"), 0600))
+ crontab := NewCrontab(nil).SetFile(file).SetFs(fs)
+ entries, err := crontab.GetEntries()
+ require.NoError(t, err)
+ assert.Nil(t, entries)
+ })
+
+ t.Run("crontab with own section", func(t *testing.T) {
+ content := "some other content\n" + startMarker + "00,30 * * * *\tcd workdir && /some/bin/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n" + endMarker + "more content\n"
+ require.NoError(t, afero.WriteFile(fs, file, []byte(content), 0600))
+ crontab := NewCrontab(nil).SetFile(file).SetFs(fs)
+ entries, err := crontab.GetEntries()
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+ assert.Equal(t, "config.yaml", entries[0].configFile)
+ assert.Equal(t, "profile", entries[0].profileName)
+ assert.Equal(t, "backup", entries[0].commandName)
+ assert.Equal(t, "workdir", entries[0].workDir)
+ assert.Equal(t, "/some/bin/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", entries[0].commandLine)
+ })
+}
diff --git a/crond/entry.go b/crond/entry.go
index a75d7737..0e98fc5d 100644
--- a/crond/entry.go
+++ b/crond/entry.go
@@ -78,6 +78,28 @@ func (e Entry) Generate(w io.StringWriter) error {
return err
}
+func (e Entry) Event() *calendar.Event {
+ return e.event
+}
+func (e Entry) ConfigFile() string {
+ return e.configFile
+}
+func (e Entry) ProfileName() string {
+ return e.profileName
+}
+func (e Entry) CommandName() string {
+ return e.commandName
+}
+func (e Entry) CommandLine() string {
+ return e.commandLine
+}
+func (e Entry) WorkDir() string {
+ return e.workDir
+}
+func (e Entry) User() string {
+ return e.user
+}
+
func formatWeekDay(weekDay int) string {
if weekDay >= 7 {
weekDay -= 7
diff --git a/crond/io.go b/crond/io.go
index 85da19d4..4b7fae77 100644
--- a/crond/io.go
+++ b/crond/io.go
@@ -8,6 +8,8 @@ import (
"os"
"os/exec"
"strings"
+
+ "github.com/spf13/afero"
)
const (
@@ -15,19 +17,61 @@ const (
defaultCrontabFilePerms = fs.FileMode(0644)
)
+var (
+ ErrNoCrontabFile = errors.New("no crontab file was specified")
+)
+
+func loadCrontab(fs afero.Fs, 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(fs, file)
+ }
+}
+
+func saveCrontab(fs afero.Fs, 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(fs, file, content, charset)
+ }
+ return
+}
+
func verifyCrontabFile(file string) error {
if file == "" {
- return fmt.Errorf("no contrab file was specified")
+ return ErrNoCrontabFile
}
return nil
}
-func loadCrontabFile(file string) (content, charset string, err error) {
+func loadCrontabFile(fs afero.Fs, 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 {
+ var f afero.File
+ if f, err = fs.Open(file); err == nil {
defer func() { _ = f.Close() }()
var bytes []byte
@@ -47,7 +91,7 @@ func loadCrontabFile(file string) (content, charset string, err error) {
}
//nolint:unparam
-func saveCrontabFile(file, content, charset string) (err error) {
+func saveCrontabFile(fs afero.Fs, file, content, charset string) (err error) {
if err = verifyCrontabFile(file); err != nil {
return
}
@@ -58,45 +102,7 @@ func saveCrontabFile(file, content, charset string) (err error) {
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)
+ err = afero.WriteFile(fs, file, bytes, defaultCrontabFilePerms)
}
return
}
diff --git a/crond/parse_event.go b/crond/parse_event.go
new file mode 100644
index 00000000..be345594
--- /dev/null
+++ b/crond/parse_event.go
@@ -0,0 +1,86 @@
+package crond
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/creativeprojects/resticprofile/calendar"
+)
+
+func parseEvent(source string) (*calendar.Event, error) {
+ event := calendar.NewEvent()
+ source = strings.ReplaceAll(source, "\t", " ")
+ source = strings.ReplaceAll(source, " ", " ")
+ source = strings.TrimSpace(source)
+ parts := strings.Split(source, " ")
+ if len(parts) != 5 {
+ return nil, fmt.Errorf("expected 5 fields but found %d: %q", len(parts), source)
+ }
+
+ err := event.Second.AddValue(0)
+ if err != nil {
+ return nil, err
+ }
+
+ for index, eventField := range []*calendar.Value{
+ event.Minute,
+ event.Hour,
+ event.Day,
+ event.Month,
+ event.WeekDay,
+ } {
+ err := parseField(parts[index], eventField)
+ if err != nil {
+ return event, fmt.Errorf("error parsing %q: %w", parts[index], err)
+ }
+ }
+ return event, nil
+}
+
+func parseField(field string, eventField *calendar.Value) error {
+ if field == "*" {
+ return nil
+ }
+ // list of values
+ if strings.Contains(field, ",") {
+ parts := strings.Split(field, ",")
+ for _, part := range parts {
+ err := parseField(part, eventField)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+ // range of values
+ if strings.Contains(field, "-") {
+ parts := strings.Split(field, "-")
+ if len(parts) != 2 {
+ return fmt.Errorf("expecting 2 values, found %d: %q", len(parts), field)
+ }
+ start, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return err
+ }
+ end, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return err
+ }
+ err = eventField.AddRange(start, end)
+ if err != nil {
+ return err
+ }
+ return nil
+ }
+ // single value
+ value, err := strconv.Atoi(field)
+ if err != nil {
+ return err
+ }
+ err = eventField.AddValue(value)
+ if err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/crond/parse_event_test.go b/crond/parse_event_test.go
new file mode 100644
index 00000000..6a87cab2
--- /dev/null
+++ b/crond/parse_event_test.go
@@ -0,0 +1,46 @@
+package crond
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseEvents(t *testing.T) {
+ testData := []struct {
+ cron string
+ expected string
+ }{
+ {"00 00 * * 1,2,3,4,6,0", "Sun..Thu,Sat *-*-* 00:00:00"},
+ {"23 01,02 * * 1,0", "Sun,Mon *-*-* 01,02:23:00"},
+ {"00 00 01 * 3", "Wed *-*-01 00:00:00"},
+ {"00 00 01 * 3", "Wed *-*-01 00:00:00"},
+ {"48 17 * * 3", "Wed *-*-* 17:48:00"},
+ {"02 01 15 10 2,3,4,5,6", "Tue..Sat *-10-15 01:02:00"},
+ {"00 00 07 * *", "*-*-07 00:00:00"},
+ {"00 00 15 10 *", "*-10-15 00:00:00"},
+ {"00 17 * 12 1", "Mon *-12-* 17:00:00"},
+ {"00 17 * 12 0", "Sun *-12-* 17:00:00"},
+ {"30 * 01-03 * 1,5", "Mon,Fri *-*-01..03 *:30:00"},
+ {"10,20,30 12-14 * * *", "*-*-* 12..14:10,20,30:00"},
+ {"05 08 05 03 *", "*-03-05 08:05:00"},
+ {"05 08 * * *", "*-*-* 08:05:00"},
+ {"40 05 * * *", "*-*-* 05:40:00"},
+ {"05 08 05 12 6,0", "Sun,Sat *-12-05 08:05:00"},
+ {"05 08 * * 6,0", "Sun,Sat *-*-* 08:05:00"},
+ {"40 05 05 03 *", "*-03-05 05:40:00"},
+ {"00 00 05 02-04 *", "*-02..04-05 00:00:00"},
+ {"00 00 05 03 *", "*-03-05 00:00:00"},
+ {"00 00 * * 1,2,3,4,5,6,0", "Sun..Sat *-*-* 00:00:00"},
+ {"00 00 * * 0,1", "Sun,Mon *-*-* 00:00:00"},
+ }
+
+ for _, testRun := range testData {
+ t.Run(testRun.cron, func(t *testing.T) {
+ event, err := parseEvent(testRun.cron)
+ require.NoError(t, err)
+ assert.Equal(t, testRun.expected, event.String())
+ })
+ }
+}
diff --git a/docs/layouts/partials/custom-header.html b/docs/layouts/partials/custom-header.html
deleted file mode 100644
index 43b17711..00000000
--- a/docs/layouts/partials/custom-header.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
diff --git a/examples/linux.yaml b/examples/linux.yaml
index 45c0bd28..8430fc83 100644
--- a/examples/linux.yaml
+++ b/examples/linux.yaml
@@ -12,6 +12,7 @@ global:
ionice-class: 3
ionice-level: 7
nice: 19
+ scheduler: crontab:*:/tmp/crontab
default:
password-file: key
@@ -100,10 +101,14 @@ self:
backup:
extended-status: true
source: ./
+ # schedule:
+ # at: "*:15"
+ # permission: system
copy:
initialize: true
schedule-permission: user
schedule:
+ - "*:15"
- "*:45"
src:
diff --git a/examples/v2.yaml b/examples/v2.yaml
index fbb1961e..d08743eb 100644
--- a/examples/v2.yaml
+++ b/examples/v2.yaml
@@ -7,12 +7,19 @@ global:
priority: low
# restic-binary: ~/fake_restic
# legacy-arguments: true
+ scheduler: crontab:*:/tmp/crontab
+
groups:
full-backup:
description: Full Backup
+ continue-on-error: true
profiles:
- root
- - src
+ - self
+ schedules:
+ backup:
+ at: "*:*"
+
profiles:
default:
description: Contains default parameters like repository and password file
@@ -26,11 +33,13 @@ profiles:
copy:
password-file: key
repository: "/Volumes/RAMDisk/{{ .Profile.Name }}-copy"
+
space:
description: Repository contains space
initialize: false
password-file: key
repository: "/Volumes/RAMDisk/with space"
+
documents:
inherit: default
backup:
@@ -41,6 +50,7 @@ profiles:
tag:
- dev
- "{{ .Profile.Name }}"
+
root:
backup:
schedule: "*:0,15,30,45"
@@ -89,6 +99,7 @@ profiles:
tag:
- dev
- "{{ .Profile.Name }}"
+
self:
force-inactive-lock: true
initialize: true
@@ -123,6 +134,7 @@ profiles:
initialize: true
schedule:
- "*:45"
+
prom:
force-inactive-lock: true
initialize: true
@@ -142,6 +154,7 @@ profiles:
- "{{ .Profile.Name }}"
# exclude:
# - examples/private
+
system:
initialize: true
no-cache: true
@@ -153,6 +166,7 @@ profiles:
schedule-permission: system
forget:
schedule: "weekly"
+
src:
backup:
check-before: true
@@ -183,11 +197,13 @@ profiles:
tag:
- dev
- "{{ .Profile.Name }}"
+
home:
inherit: default
# cache-dir: "${TMPDIR}.restic/"
backup:
source: "${HOME}/Projects"
+
stdin:
backup:
stdin: true
@@ -200,6 +216,7 @@ profiles:
tag:
- dev
- "{{ .Profile.Name }}"
+
dropbox:
initialize: false
inherit: default
@@ -208,6 +225,7 @@ profiles:
check-before: false
no-error-on-warning: true
source: "../../../../../Dropbox"
+
escape:
initialize: true
inherit: default
diff --git a/lock/lock.go b/lock/lock.go
index fa1dd2a2..da600ba6 100644
--- a/lock/lock.go
+++ b/lock/lock.go
@@ -109,7 +109,7 @@ func (l *Lock) LastPID() (int32, error) {
if contents[i] != "" {
pid, err := strconv.ParseInt(contents[i], 10, 32)
if err == nil {
- return int32(pid), nil //nolint:gosec
+ return int32(pid), nil
}
}
}
diff --git a/priority/ioprio_linux.go b/priority/ioprio_linux.go
index af992321..06034f9a 100644
--- a/priority/ioprio_linux.go
+++ b/priority/ioprio_linux.go
@@ -71,8 +71,8 @@ func getIOPrio(who IOPrioWho) (IOPrioClass, int, error) {
if errno != 0 {
return 0, 0, errnoToError(errno)
}
- class := IOPrioClass(r1 >> IOPrioClassShift) //nolint:gosec
- value := int(r1 & IOPrioMask) //nolint:gosec
+ class := IOPrioClass(r1 >> IOPrioClassShift)
+ value := int(r1 & IOPrioMask)
return class, value, nil
}
diff --git a/schedule/calendar_interval.go b/schedule/calendar_interval.go
new file mode 100644
index 00000000..c99a3a73
--- /dev/null
+++ b/schedule/calendar_interval.go
@@ -0,0 +1,139 @@
+//go:build darwin
+
+package schedule
+
+import "github.com/creativeprojects/resticprofile/calendar"
+
+const (
+ intervalMinute = "Minute"
+ intervalHour = "Hour"
+ intervalWeekday = "Weekday"
+ intervalDay = "Day"
+ intervalMonth = "Month"
+)
+
+// CalendarInterval contains date and time trigger definition inside a map.
+// keys of the map should be:
+//
+// "Month" Month of year (1..12, 1 being January)
+// "Day" Day of month (1..31)
+// "Weekday" Day of week (0..7, 0 and 7 being Sunday)
+// "Hour" Hour of day (0..23)
+// "Minute" Minute of hour (0..59)
+type CalendarInterval map[string]int
+
+// newCalendarInterval creates a new map of 5 elements
+func newCalendarInterval() *CalendarInterval {
+ var value CalendarInterval = make(map[string]int, 5)
+ return &value
+}
+
+func (c *CalendarInterval) clone() *CalendarInterval {
+ clone := newCalendarInterval()
+ for key, value := range *c {
+ (*clone)[key] = value
+ }
+ return clone
+}
+
+// getCalendarIntervalsFromSchedules converts schedules into launchd calendar events
+// let's say we've setup these rules:
+//
+// Mon-Fri *-*-* *:0,30:00 = every half hour
+// Sat *-*-* 0,12:00:00 = twice a day on saturday
+// *-*-01 *:*:* = the first of each month
+//
+// it should translate as:
+// 1st rule
+//
+// Weekday = Monday, Minute = 0
+// Weekday = Monday, Minute = 30
+// ... same from Tuesday to Thurday
+// Weekday = Friday, Minute = 0
+// Weekday = Friday, Minute = 30
+//
+// Total of 10 rules
+// 2nd rule
+//
+// Weekday = Saturday, Hour = 0
+// Weekday = Saturday, Hour = 12
+//
+// Total of 2 rules
+// 3rd rule
+//
+// Day = 1
+//
+// Total of 1 rule
+func getCalendarIntervalsFromSchedules(schedules []*calendar.Event) []CalendarInterval {
+ entries := make([]CalendarInterval, 0, len(schedules))
+ for _, schedule := range schedules {
+ entries = append(entries, getCalendarIntervalsFromScheduleTree(generateTreeOfSchedules(schedule))...)
+ }
+ return entries
+}
+
+func getCalendarIntervalsFromScheduleTree(tree []*treeElement) []CalendarInterval {
+ entries := make([]CalendarInterval, 0)
+ for _, element := range tree {
+ // creates a new calendar entry for each tip of the branch
+ newEntry := newCalendarInterval()
+ fillInValueFromScheduleTreeElement(newEntry, element, &entries)
+ }
+ return entries
+}
+
+func fillInValueFromScheduleTreeElement(currentEntry *CalendarInterval, element *treeElement, entries *[]CalendarInterval) {
+ setCalendarIntervalValueFromType(currentEntry, element.value, element.elementType)
+ if len(element.subElements) == 0 {
+ // end of the line, this entry is finished
+ *entries = append(*entries, *currentEntry)
+ return
+ }
+ for _, subElement := range element.subElements {
+ // new branch means new calendar entry
+ fillInValueFromScheduleTreeElement(currentEntry.clone(), subElement, entries)
+ }
+}
+
+func setCalendarIntervalValueFromType(entry *CalendarInterval, value int, typeValue calendar.TypeValue) {
+ if entry == nil {
+ entry = newCalendarInterval()
+ }
+ switch typeValue {
+ case calendar.TypeWeekDay:
+ (*entry)[intervalWeekday] = value
+ case calendar.TypeMonth:
+ (*entry)[intervalMonth] = value
+ case calendar.TypeDay:
+ (*entry)[intervalDay] = value
+ case calendar.TypeHour:
+ (*entry)[intervalHour] = value
+ case calendar.TypeMinute:
+ (*entry)[intervalMinute] = value
+ }
+}
+
+// parseCalendarIntervals converts calendar intervals into a single calendar event.
+// TODO: find a pattern on how to split into multiple events when needed
+func parseCalendarIntervals(intervals []CalendarInterval) []string {
+ event := calendar.NewEvent(func(e *calendar.Event) {
+ _ = e.Second.AddValue(0)
+ })
+ for _, interval := range intervals {
+ for key, value := range interval {
+ switch key {
+ case intervalMinute:
+ _ = event.Minute.AddValue(value)
+ case intervalHour:
+ _ = event.Hour.AddValue(value)
+ case intervalWeekday:
+ _ = event.WeekDay.AddValue(value)
+ case intervalDay:
+ _ = event.Day.AddValue(value)
+ case intervalMonth:
+ _ = event.Month.AddValue(value)
+ }
+ }
+ }
+ return []string{event.String()}
+}
diff --git a/schedule/calendar_interval_test.go b/schedule/calendar_interval_test.go
new file mode 100644
index 00000000..8936de14
--- /dev/null
+++ b/schedule/calendar_interval_test.go
@@ -0,0 +1,55 @@
+//go:build darwin
+
+package schedule
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseCalendarIntervals(t *testing.T) {
+ tests := []struct {
+ name string
+ intervals []CalendarInterval
+ expected []string
+ }{
+ {
+ name: "Single interval",
+ intervals: []CalendarInterval{
+ {
+ intervalMinute: 30,
+ intervalHour: 14,
+ intervalWeekday: 3,
+ intervalDay: 15,
+ intervalMonth: 6,
+ },
+ },
+ expected: []string{"Wed *-06-15 14:30:00"},
+ },
+ {
+ name: "Multiple intervals",
+ intervals: []CalendarInterval{
+ {
+ intervalMinute: 0,
+ },
+ {
+ intervalMinute: 30,
+ },
+ },
+ expected: []string{"*-*-* *:00,30:00"},
+ },
+ {
+ name: "Empty intervals",
+ intervals: []CalendarInterval{},
+ expected: []string{"*-*-* *:*:00"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := parseCalendarIntervals(tt.intervals)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
diff --git a/schedule/command_arguments.go b/schedule/command_arguments.go
index 9ad618f8..8791b8fa 100644
--- a/schedule/command_arguments.go
+++ b/schedule/command_arguments.go
@@ -1,6 +1,9 @@
package schedule
-import "strings"
+import (
+ "slices"
+ "strings"
+)
type CommandArguments struct {
args []string
@@ -12,6 +15,18 @@ func NewCommandArguments(args []string) CommandArguments {
}
}
+// Trim returns a new CommandArguments with the specified flags removed from the arguments
+func (ca CommandArguments) Trim(removeArgs []string) CommandArguments {
+ args := make([]string, 0, len(ca.args))
+ for _, arg := range ca.args {
+ if slices.Contains(removeArgs, arg) {
+ continue
+ }
+ args = append(args, arg)
+ }
+ return NewCommandArguments(args)
+}
+
func (ca CommandArguments) RawArgs() []string {
result := make([]string, len(ca.args))
copy(result, ca.args)
@@ -39,6 +54,21 @@ func (ca CommandArguments) String() string {
return b.String()
}
+// ConfigFile returns the value of the --config argument, if present
+func (ca CommandArguments) ConfigFile() string {
+ if len(ca.args) == 0 {
+ return ""
+ }
+ for i, arg := range ca.args {
+ if arg == "--config" {
+ if i+1 < len(ca.args) {
+ return ca.args[i+1]
+ }
+ }
+ }
+ return ""
+}
+
func (ca CommandArguments) writeString(b *strings.Builder, str string) {
if strings.Contains(str, " ") {
b.WriteString(`"`)
diff --git a/schedule/command_arguments_test.go b/schedule/command_arguments_test.go
index c6c226f0..2d2f9098 100644
--- a/schedule/command_arguments_test.go
+++ b/schedule/command_arguments_test.go
@@ -57,3 +57,28 @@ func TestString(t *testing.T) {
}
}
}
+
+func TestConfigFile(t *testing.T) {
+ tests := []struct {
+ args []string
+ expected string
+ }{
+ {[]string{}, ""},
+ {[]string{"--config"}, ""},
+ {[]string{"--config", "config.toml"}, "config.toml"},
+ {[]string{"--config", "C:\\Program Files\\config.toml"}, "C:\\Program Files\\config.toml"},
+ {[]string{"--name", "backup", "--config", "config.toml"}, "config.toml"},
+ {[]string{"--config", "config.toml", "--name", "backup"}, "config.toml"},
+ {[]string{"--name", "backup", "--config", "config.toml", "--no-ansi"}, "config.toml"},
+ {[]string{"--name", "backup", "--no-ansi", "--config", "config.toml"}, "config.toml"},
+ {[]string{"--name", "backup", "--no-ansi"}, ""},
+ }
+
+ for _, test := range tests {
+ ca := NewCommandArguments(test.args)
+ result := ca.ConfigFile()
+ if result != test.expected {
+ t.Errorf("expected %s, got %s", test.expected, result)
+ }
+ }
+}
diff --git a/schedule/config.go b/schedule/config.go
index 434d6fc6..2479a4d3 100644
--- a/schedule/config.go
+++ b/schedule/config.go
@@ -8,24 +8,22 @@ import (
// Config contains all information to schedule a profile command
type Config struct {
- ProfileName string
- CommandName string // restic command
- Schedules []string
- Permission string
- WorkingDirectory string
- Command string // path to resticprofile executable
- Arguments CommandArguments
- Environment []string
- JobDescription string
- TimerDescription string
- Priority string // Priority is either "background" or "standard"
- ConfigFile string
- Flags map[string]string // flags added to the command line
- IgnoreOnBattery bool
- IgnoreOnBatteryLessThan int
- AfterNetworkOnline bool
- SystemdDropInFiles []string
- removeOnly bool
+ ProfileName string
+ CommandName string // restic command
+ Schedules []string
+ Permission string
+ WorkingDirectory string
+ Command string // path to resticprofile executable
+ Arguments CommandArguments
+ Environment []string
+ JobDescription string
+ TimerDescription string
+ Priority string // Priority is either "background" or "standard"
+ ConfigFile string
+ Flags map[string]string // flags added to the command line
+ AfterNetworkOnline bool
+ SystemdDropInFiles []string
+ removeOnly bool
}
// NewRemoveOnlyConfig creates a job config that may be used to call Job.Remove() on a scheduled job
diff --git a/schedule/config_test.go b/schedule/config_test.go
index b0fc5d6b..ec7f8f6f 100644
--- a/schedule/config_test.go
+++ b/schedule/config_test.go
@@ -8,22 +8,20 @@ import (
func TestScheduleProperties(t *testing.T) {
schedule := Config{
- ProfileName: "profile",
- CommandName: "command name",
- Schedules: []string{"1", "2", "3"},
- Permission: "admin",
- WorkingDirectory: "home",
- Command: "command",
- Arguments: NewCommandArguments([]string{"1", "2"}),
- Environment: []string{"test=dev"},
- JobDescription: "job",
- TimerDescription: "timer",
- Priority: "",
- ConfigFile: "config",
- Flags: map[string]string{},
- removeOnly: false,
- IgnoreOnBattery: false,
- IgnoreOnBatteryLessThan: 0,
+ ProfileName: "profile",
+ CommandName: "command name",
+ Schedules: []string{"1", "2", "3"},
+ Permission: "admin",
+ WorkingDirectory: "home",
+ Command: "command",
+ Arguments: NewCommandArguments([]string{"1", "2"}),
+ Environment: []string{"test=dev"},
+ JobDescription: "job",
+ TimerDescription: "timer",
+ Priority: "",
+ ConfigFile: "config",
+ Flags: map[string]string{},
+ removeOnly: false,
}
assert.Equal(t, "config", schedule.ConfigFile)
diff --git a/schedule/errors.go b/schedule/errors.go
index c6ed980c..ada5bf8d 100644
--- a/schedule/errors.go
+++ b/schedule/errors.go
@@ -4,6 +4,6 @@ import "errors"
// Generic errors
var (
- ErrServiceNotFound = errors.New("service not found")
- ErrServiceNotRunning = errors.New("service is not running")
+ ErrScheduledJobNotFound = errors.New("scheduled job not found")
+ ErrScheduledJobNotRunning = errors.New("scheduled job is not running")
)
diff --git a/schedule/handler.go b/schedule/handler.go
index 56980687..a3f1b1e1 100644
--- a/schedule/handler.go
+++ b/schedule/handler.go
@@ -12,12 +12,12 @@ type Handler interface {
Init() error
Close()
ParseSchedules(schedules []string) ([]*calendar.Event, error)
- DisplayParsedSchedules(command string, events []*calendar.Event)
- DisplaySchedules(command string, schedules []string) error
+ DisplaySchedules(profile, command string, schedules []string) error
DisplayStatus(profileName string) error
CreateJob(job *Config, schedules []*calendar.Event, permission string) error
RemoveJob(job *Config, permission string) error
DisplayJobStatus(job *Config) error
+ Scheduled(profileName string) ([]Config, error)
}
// FindHandler creates a schedule handler depending on the configuration or nil if the config is not supported
diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go
index 2d69f103..e75b6ded 100644
--- a/schedule/handler_crond.go
+++ b/schedule/handler_crond.go
@@ -1,10 +1,14 @@
package schedule
import (
+ "slices"
+
"github.com/creativeprojects/resticprofile/calendar"
"github.com/creativeprojects/resticprofile/constants"
"github.com/creativeprojects/resticprofile/crond"
"github.com/creativeprojects/resticprofile/platform"
+ "github.com/creativeprojects/resticprofile/shell"
+ "github.com/spf13/afero"
)
var crontabBinary = "crontab"
@@ -12,6 +16,7 @@ var crontabBinary = "crontab"
// HandlerCrond is a handler for crond scheduling
type HandlerCrond struct {
config SchedulerCrond
+ fs afero.Fs
}
// NewHandlerCrond creates a new handler for crond scheduling
@@ -43,12 +48,12 @@ func (h *HandlerCrond) ParseSchedules(schedules []string) ([]*calendar.Event, er
return parseSchedules(schedules)
}
-func (h *HandlerCrond) DisplayParsedSchedules(command string, events []*calendar.Event) {
- displayParsedSchedules(command, events)
-}
-
-// DisplaySchedules does nothing with crond
-func (h *HandlerCrond) DisplaySchedules(command string, schedules []string) error {
+func (h *HandlerCrond) DisplaySchedules(profile, command string, schedules []string) error {
+ events, err := parseSchedules(schedules)
+ if err != nil {
+ return err
+ }
+ displayParsedSchedules(profile, command, events)
return nil
}
@@ -73,9 +78,10 @@ func (h *HandlerCrond) CreateJob(job *Config, schedules []*calendar.Event, permi
entries[i] = entries[i].WithUser(h.config.Username)
}
}
- crontab := crond.NewCrontab(entries)
- crontab.SetFile(h.config.CrontabFile)
- crontab.SetBinary(h.config.CrontabBinary)
+ crontab := crond.NewCrontab(entries).
+ SetFile(h.config.CrontabFile).
+ SetBinary(h.config.CrontabBinary).
+ SetFs(h.fs)
err := crontab.Rewrite()
if err != nil {
return err
@@ -94,15 +100,16 @@ func (h *HandlerCrond) RemoveJob(job *Config, permission string) error {
job.WorkingDirectory,
),
}
- crontab := crond.NewCrontab(entries)
- crontab.SetFile(h.config.CrontabFile)
- crontab.SetBinary(h.config.CrontabBinary)
+ crontab := crond.NewCrontab(entries).
+ SetFile(h.config.CrontabFile).
+ SetBinary(h.config.CrontabBinary).
+ SetFs(h.fs)
num, err := crontab.Remove()
if err != nil {
return err
}
if num == 0 {
- return ErrServiceNotFound
+ return ErrScheduledJobNotFound
}
return nil
}
@@ -112,13 +119,50 @@ func (h *HandlerCrond) DisplayJobStatus(job *Config) error {
return nil
}
+func (h *HandlerCrond) Scheduled(profileName string) ([]Config, error) {
+ crontab := crond.NewCrontab(nil).
+ SetFile(h.config.CrontabFile).
+ SetBinary(h.config.CrontabBinary).
+ SetFs(h.fs)
+ entries, err := crontab.GetEntries()
+ if err != nil {
+ return nil, err
+ }
+ configs := make([]Config, 0, len(entries))
+ for _, entry := range entries {
+ profileName := entry.ProfileName()
+ commandName := entry.CommandName()
+ configFile := entry.ConfigFile()
+ if index := slices.IndexFunc(configs, func(cfg Config) bool {
+ return cfg.ProfileName == profileName && cfg.CommandName == commandName && cfg.ConfigFile == configFile
+ }); index >= 0 {
+ configs[index].Schedules = append(configs[index].Schedules, entry.Event().String())
+ } else {
+ commandLine := entry.CommandLine()
+ args := shell.SplitArguments(commandLine)
+ configs = append(configs, Config{
+ ProfileName: profileName,
+ CommandName: commandName,
+ ConfigFile: configFile,
+ Schedules: []string{entry.Event().String()},
+ Command: args[0],
+ Arguments: NewCommandArguments(args[1:]),
+ WorkingDirectory: entry.WorkDir(),
+ })
+ }
+ }
+ return configs, nil
+}
+
// init registers HandlerCrond
func init() {
- AddHandlerProvider(func(config SchedulerConfig, fallback bool) (hr Handler) {
+ AddHandlerProvider(func(config SchedulerConfig, fallback bool) Handler {
if config.Type() == constants.SchedulerCrond ||
(fallback && config.Type() == constants.SchedulerOSDefault) {
- hr = NewHandlerCrond(config.Convert(constants.SchedulerCrond))
+ handler := NewHandlerCrond(config.Convert(constants.SchedulerCrond))
+ handler.fs = afero.NewOsFs()
+ return handler
}
- return
+ return nil
})
}
diff --git a/schedule/handler_crond_test.go b/schedule/handler_crond_test.go
new file mode 100644
index 00000000..e8c277fd
--- /dev/null
+++ b/schedule/handler_crond_test.go
@@ -0,0 +1,71 @@
+package schedule
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/creativeprojects/resticprofile/calendar"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadingCrondScheduled(t *testing.T) {
+ hourly := calendar.NewEvent(func(e *calendar.Event) {
+ e.Minute.MustAddValue(0)
+ e.Second.MustAddValue(0)
+ })
+
+ testCases := []struct {
+ job Config
+ schedules []*calendar.Event
+ }{
+ {
+ job: Config{
+ ProfileName: "self",
+ CommandName: "check",
+ Command: "/bin/resticprofile",
+ Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "--name", "self", "check"}),
+ WorkingDirectory: "/resticprofile",
+ Schedules: []string{"*-*-* *:00:00"},
+ ConfigFile: "examples/dev.yaml",
+ },
+ schedules: []*calendar.Event{
+ hourly,
+ },
+ },
+ {
+ job: Config{
+ ProfileName: "test.scheduled",
+ CommandName: "backup",
+ Command: "/bin/resticprofile",
+ Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "config file.yaml", "--name", "test.scheduled", "backup"}),
+ WorkingDirectory: "/resticprofile",
+ Schedules: []string{"*-*-* *:00:00"},
+ ConfigFile: "config file.yaml",
+ },
+ schedules: []*calendar.Event{
+ hourly,
+ },
+ },
+ }
+
+ tempFile := filepath.Join(t.TempDir(), "crontab")
+ handler := NewHandler(SchedulerCrond{
+ CrontabFile: tempFile,
+ }).(*HandlerCrond)
+ handler.fs = afero.NewMemMapFs()
+
+ expectedJobs := []Config{}
+ for _, testCase := range testCases {
+ expectedJobs = append(expectedJobs, testCase.job)
+
+ err := handler.CreateJob(&testCase.job, testCase.schedules, testCase.job.Permission)
+ require.NoError(t, err)
+ }
+
+ scheduled, err := handler.Scheduled("")
+ require.NoError(t, err)
+
+ assert.ElementsMatch(t, expectedJobs, scheduled)
+}
diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go
index d5604d0e..b7518a36 100644
--- a/schedule/handler_darwin.go
+++ b/schedule/handler_darwin.go
@@ -8,10 +8,12 @@ import (
"os/exec"
"path"
"regexp"
+ "slices"
"sort"
"strings"
"text/tabwriter"
+ "github.com/creativeprojects/clog"
"github.com/creativeprojects/resticprofile/calendar"
"github.com/creativeprojects/resticprofile/constants"
"github.com/creativeprojects/resticprofile/term"
@@ -36,7 +38,7 @@ const (
GlobalAgentPath = "/Library/LaunchAgents"
GlobalDaemons = "/Library/LaunchDaemons"
- namePrefix = "local.resticprofile"
+ namePrefix = "local.resticprofile."
agentExtension = ".agent.plist"
daemonExtension = ".plist"
@@ -84,12 +86,12 @@ func (h *HandlerLaunchd) ParseSchedules(schedules []string) ([]*calendar.Event,
return parseSchedules(schedules)
}
-func (h *HandlerLaunchd) DisplayParsedSchedules(command string, events []*calendar.Event) {
- displayParsedSchedules(command, events)
-}
-
-// DisplaySchedules does nothing with launchd
-func (h *HandlerLaunchd) DisplaySchedules(command string, schedules []string) error {
+func (h *HandlerLaunchd) DisplaySchedules(profile, command string, schedules []string) error {
+ events, err := parseSchedules(schedules)
+ if err != nil {
+ return err
+ }
+ displayParsedSchedules(profile, command, events)
return nil
}
@@ -117,90 +119,21 @@ func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, per
return err
}
- if _, noStart := job.GetFlag("no-start"); !noStart {
- // ask the user if he wants to start the service now
+ if _, start := job.GetFlag("start"); start {
name := getJobName(job.ProfileName, job.CommandName)
- message := `
-By default, a macOS agent access is restricted. If you leave it to start in the background it's likely to fail.
-You have to start it manually the first time to accept the requests for access:
-
-%% %s %s %s
-
-Do you want to start it now?`
- answer := term.AskYesNo(os.Stdin, fmt.Sprintf(message, launchctlBin, launchdStart, name), true)
- if answer {
- // start the service
- cmd := exec.Command(launchctlBin, launchdStart, name)
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- err = cmd.Run()
- if err != nil {
- return err
- }
+
+ // start the service
+ cmd := exec.Command(launchctlBin, launchdStart, name)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ err = cmd.Run()
+ if err != nil {
+ return err
}
}
return nil
}
-func (h *HandlerLaunchd) getLaunchdJob(job *Config, schedules []*calendar.Event) *LaunchdJob {
- name := getJobName(job.ProfileName, job.CommandName)
- // we always set the log file in the job settings as a default
- // if changed in the configuration via schedule-log the standard output will be empty anyway
- logfile := name + ".log"
-
- // Format schedule env, adding PATH if not yet provided by the schedule config
- env := util.NewDefaultEnvironment(job.Environment...)
- if !env.Has("PATH") {
- env.Put("PATH", os.Getenv("PATH"))
- }
-
- lowPriorityIO := true
- nice := constants.DefaultBackgroundNiceFlag
- if job.GetPriority() == constants.SchedulePriorityStandard {
- lowPriorityIO = false
- nice = constants.DefaultStandardNiceFlag
- }
-
- launchdJob := &LaunchdJob{
- Label: name,
- Program: job.Command,
- ProgramArguments: append([]string{job.Command, "--no-prio"}, job.Arguments.RawArgs()...),
- StandardOutPath: logfile,
- StandardErrorPath: logfile,
- WorkingDirectory: job.WorkingDirectory,
- StartCalendarInterval: getCalendarIntervalsFromSchedules(schedules),
- EnvironmentVariables: env.ValuesAsMap(),
- Nice: nice,
- ProcessType: priorityValues[job.GetPriority()],
- LowPriorityIO: lowPriorityIO,
- }
- return launchdJob
-}
-
-func (h *HandlerLaunchd) createPlistFile(launchdJob *LaunchdJob, permission string) (string, error) {
- filename, err := getFilename(launchdJob.Label, permission)
- if err != nil {
- return "", err
- }
- if permission != constants.SchedulePermissionSystem {
- // in some very recent installations of macOS, the user's LaunchAgents folder may not exist
- _ = h.fs.MkdirAll(path.Dir(filename), 0o700)
- }
- file, err := h.fs.Create(filename)
- if err != nil {
- return "", err
- }
- defer file.Close()
-
- encoder := plist.NewEncoder(file)
- encoder.Indent("\t")
- err = encoder.Encode(launchdJob)
- if err != nil {
- return filename, err
- }
- return filename, nil
-}
-
// RemoveJob stops and unloads the agent from launchd, then removes the configuration file
func (h *HandlerLaunchd) RemoveJob(job *Config, permission string) error {
name := getJobName(job.ProfileName, job.CommandName)
@@ -210,7 +143,7 @@ func (h *HandlerLaunchd) RemoveJob(job *Config, permission string) error {
}
if _, err := os.Stat(filename); err != nil && os.IsNotExist(err) {
- return ErrServiceNotFound
+ return ErrScheduledJobNotFound
}
// stop the service in case it's already running
stop := exec.Command(launchctlBin, launchdStop, name)
@@ -244,7 +177,7 @@ func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error {
cmd := exec.Command(launchctlBin, launchdList, getJobName(job.ProfileName, job.CommandName))
output, err := cmd.Output()
if cmd.ProcessState.ExitCode() == codeServiceNotFound {
- return ErrServiceNotFound
+ return ErrScheduledJobNotFound
}
if err != nil {
return err
@@ -257,12 +190,15 @@ func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error {
// order keys alphabetically
keys := make([]string, 0, len(status))
for key := range status {
+ if slices.Contains([]string{"LimitLoadToSessionType", "OnDemand"}, key) {
+ continue
+ }
keys = append(keys, key)
}
sort.Strings(keys)
writer := tabwriter.NewWriter(term.GetOutput(), 0, 0, 0, ' ', tabwriter.AlignRight)
for _, key := range keys {
- fmt.Fprintf(writer, "%s:\t %s\n", key, status[key])
+ fmt.Fprintf(writer, "%s:\t %s\n", spacedTitle(key), status[key])
}
writer.Flush()
fmt.Println("")
@@ -270,124 +206,177 @@ func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error {
return nil
}
-var (
- _ Handler = &HandlerLaunchd{}
-)
-
-// CalendarInterval contains date and time trigger definition inside a map.
-// keys of the map should be:
-//
-// "Month" Month of year (1..12, 1 being January)
-// "Day" Day of month (1..31)
-// "Weekday" Day of week (0..7, 0 and 7 being Sunday)
-// "Hour" Hour of day (0..23)
-// "Minute" Minute of hour (0..59)
-type CalendarInterval map[string]int
-
-// newCalendarInterval creates a new map of 5 elements
-func newCalendarInterval() *CalendarInterval {
- var value CalendarInterval = make(map[string]int, 5)
- return &value
+func (h *HandlerLaunchd) Scheduled(profileName string) ([]Config, error) {
+ jobs := make([]Config, 0)
+ if profileName == "" {
+ profileName = "*"
+ } else {
+ profileName = strings.ToLower(profileName)
+ }
+ // system jobs
+ systemJobs := h.getScheduledJob(profileName, constants.SchedulePermissionSystem)
+ jobs = append(jobs, systemJobs...)
+ // user jobs
+ userJobs := h.getScheduledJob(profileName, constants.SchedulePermissionUser)
+ jobs = append(jobs, userJobs...)
+ return jobs, nil
}
-func (c *CalendarInterval) clone() *CalendarInterval {
- clone := newCalendarInterval()
- for key, value := range *c {
- (*clone)[key] = value
+func (h *HandlerLaunchd) getLaunchdJob(job *Config, schedules []*calendar.Event) *LaunchdJob {
+ name := getJobName(job.ProfileName, job.CommandName)
+ // we always set the log file in the job settings as a default
+ // if changed in the configuration via schedule-log the standard output will be empty anyway
+ logfile := name + ".log"
+
+ // Format schedule env, adding PATH if not yet provided by the schedule config
+ env := util.NewDefaultEnvironment(job.Environment...)
+ if !env.Has("PATH") {
+ env.Put("PATH", os.Getenv("PATH"))
+ }
+
+ lowPriorityIO := true
+ nice := constants.DefaultBackgroundNiceFlag
+ if job.GetPriority() == constants.SchedulePriorityStandard {
+ lowPriorityIO = false
+ nice = constants.DefaultStandardNiceFlag
+ }
+
+ launchdJob := &LaunchdJob{
+ Label: name,
+ Program: job.Command,
+ ProgramArguments: append([]string{job.Command, "--no-prio"}, job.Arguments.RawArgs()...),
+ StandardOutPath: logfile,
+ StandardErrorPath: logfile,
+ WorkingDirectory: job.WorkingDirectory,
+ StartCalendarInterval: getCalendarIntervalsFromSchedules(schedules),
+ EnvironmentVariables: env.ValuesAsMap(),
+ Nice: nice,
+ ProcessType: priorityValues[job.GetPriority()],
+ LowPriorityIO: lowPriorityIO,
}
- return clone
+ return launchdJob
}
-func getJobName(profileName, command string) string {
- return fmt.Sprintf("%s.%s.%s", namePrefix, strings.ToLower(profileName), command)
+func (h *HandlerLaunchd) getScheduledJob(profileName, permission string) []Config {
+ matches, err := afero.Glob(h.fs, getSchedulePattern(profileName, permission))
+ if err != nil {
+ clog.Warningf("Error while listing %s jobs: %s", permission, err)
+ }
+ jobs := make([]Config, 0, len(matches))
+ for _, match := range matches {
+ job, err := h.getJobConfig(match)
+ if err != nil {
+ clog.Warning(err)
+ continue
+ }
+ if job != nil {
+ job.Permission = permission
+ jobs = append(jobs, *job)
+ }
+ }
+ return jobs
}
-func getFilename(name, permission string) (string, error) {
+func getSchedulePattern(profileName, permission string) string {
+ pattern := "%s%s.*%s"
if permission == constants.SchedulePermissionSystem {
- return path.Join(GlobalDaemons, name+daemonExtension), nil
+ return fmt.Sprintf(pattern, path.Join(GlobalDaemons, namePrefix), profileName, daemonExtension)
}
home, err := os.UserHomeDir()
if err != nil {
- return "", err
+ return ""
}
- return path.Join(home, UserAgentPath, name+agentExtension), nil
+ return fmt.Sprintf(pattern, path.Join(home, UserAgentPath, namePrefix), profileName, agentExtension)
}
-// getCalendarIntervalsFromSchedules converts schedules into launchd calendar events
-// let's say we've setup these rules:
-//
-// Mon-Fri *-*-* *:0,30:00 = every half hour
-// Sat *-*-* 0,12:00:00 = twice a day on saturday
-// *-*-01 *:*:* = the first of each month
-//
-// it should translate as:
-// 1st rule
-//
-// Weekday = Monday, Minute = 0
-// Weekday = Monday, Minute = 30
-// ... same from Tuesday to Thurday
-// Weekday = Friday, Minute = 0
-// Weekday = Friday, Minute = 30
-//
-// Total of 10 rules
-// 2nd rule
-//
-// Weekday = Saturday, Hour = 0
-// Weekday = Saturday, Hour = 12
-//
-// Total of 2 rules
-// 3rd rule
-//
-// Day = 1
-//
-// Total of 1 rule
-func getCalendarIntervalsFromSchedules(schedules []*calendar.Event) []CalendarInterval {
- entries := make([]CalendarInterval, 0, len(schedules))
- for _, schedule := range schedules {
- entries = append(entries, getCalendarIntervalsFromScheduleTree(generateTreeOfSchedules(schedule))...)
- }
- return entries
+func getCommandAndProfileFromFilename(filename string) (command string, profile string) {
+ // try removing both daemon and agent extensions
+ filename = strings.TrimSuffix(filename, agentExtension) // longer one
+ filename = strings.TrimSuffix(filename, daemonExtension) // shorter one
+ filename = strings.TrimPrefix(path.Base(filename), namePrefix)
+ parts := strings.Split(filename, ".")
+ command = parts[len(parts)-1]
+ profile = strings.Join(parts[:len(parts)-1], ".")
+ return
}
-func getCalendarIntervalsFromScheduleTree(tree []*treeElement) []CalendarInterval {
- entries := make([]CalendarInterval, 0)
- for _, element := range tree {
- // creates a new calendar entry for each tip of the branch
- newEntry := newCalendarInterval()
- fillInValueFromScheduleTreeElement(newEntry, element, &entries)
+func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) {
+ commandName, profileName := getCommandAndProfileFromFilename(filename)
+
+ launchdJob, err := h.readPlistFile(filename)
+ if err != nil {
+ return nil, fmt.Errorf("error reading plist file: %w", err)
}
- return entries
+ args := NewCommandArguments(launchdJob.ProgramArguments[2:]) // first is binary, second is --no-prio
+ job := &Config{
+ ProfileName: profileName,
+ CommandName: commandName,
+ Command: launchdJob.Program,
+ ConfigFile: args.ConfigFile(),
+ Arguments: args,
+ WorkingDirectory: launchdJob.WorkingDirectory,
+ Schedules: parseCalendarIntervals(launchdJob.StartCalendarInterval),
+ }
+ return job, nil
}
-func fillInValueFromScheduleTreeElement(currentEntry *CalendarInterval, element *treeElement, entries *[]CalendarInterval) {
- setCalendarIntervalValueFromType(currentEntry, element.value, element.elementType)
- if len(element.subElements) == 0 {
- // end of the line, this entry is finished
- *entries = append(*entries, *currentEntry)
- return
+func (h *HandlerLaunchd) createPlistFile(launchdJob *LaunchdJob, permission string) (string, error) {
+ filename, err := getFilename(launchdJob.Label, permission)
+ if err != nil {
+ return "", err
+ }
+ if permission != constants.SchedulePermissionSystem {
+ // in some very recent installations of macOS, the user's LaunchAgents folder may not exist
+ _ = h.fs.MkdirAll(path.Dir(filename), 0o700)
+ }
+ file, err := h.fs.Create(filename)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+
+ encoder := plist.NewEncoder(file)
+ encoder.Indent("\t")
+ err = encoder.Encode(launchdJob)
+ if err != nil {
+ return filename, err
+ }
+ return filename, nil
+}
+
+func (h *HandlerLaunchd) readPlistFile(filename string) (*LaunchdJob, error) {
+ file, err := h.fs.Open(filename)
+ if err != nil {
+ return nil, err
}
- for _, subElement := range element.subElements {
- // new branch means new calendar entry
- fillInValueFromScheduleTreeElement(currentEntry.clone(), subElement, entries)
+ defer file.Close()
+
+ decoder := plist.NewDecoder(file)
+ launchdJob := new(LaunchdJob)
+ err = decoder.Decode(launchdJob)
+ if err != nil {
+ return nil, err
}
+ return launchdJob, nil
+}
+
+var (
+ _ Handler = &HandlerLaunchd{}
+)
+
+func getJobName(profileName, command string) string {
+ return fmt.Sprintf("%s%s.%s", namePrefix, strings.ToLower(profileName), command)
}
-func setCalendarIntervalValueFromType(entry *CalendarInterval, value int, typeValue calendar.TypeValue) {
- if entry == nil {
- entry = newCalendarInterval()
- }
- switch typeValue {
- case calendar.TypeWeekDay:
- (*entry)["Weekday"] = value
- case calendar.TypeMonth:
- (*entry)["Month"] = value
- case calendar.TypeDay:
- (*entry)["Day"] = value
- case calendar.TypeHour:
- (*entry)["Hour"] = value
- case calendar.TypeMinute:
- (*entry)["Minute"] = value
+func getFilename(name, permission string) (string, error) {
+ if permission == constants.SchedulePermissionSystem {
+ return path.Join(GlobalDaemons, name+daemonExtension), nil
}
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return path.Join(home, UserAgentPath, name+agentExtension), nil
}
func parseStatus(status string) map[string]string {
diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go
index c309c42e..6ad95ac0 100644
--- a/schedule/handler_darwin_test.go
+++ b/schedule/handler_darwin_test.go
@@ -9,6 +9,7 @@ import (
"testing"
"github.com/creativeprojects/resticprofile/calendar"
+ "github.com/creativeprojects/resticprofile/constants"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -180,3 +181,71 @@ func TestCreateSystemPlist(t *testing.T) {
_, err = handler.fs.Stat(filename)
assert.NoError(t, err)
}
+
+func TestReadingLaunchdScheduled(t *testing.T) {
+ calendarEvent := calendar.NewEvent(func(e *calendar.Event) {
+ _ = e.Second.AddValue(0)
+ _ = e.Minute.AddValue(0)
+ _ = e.Minute.AddValue(30)
+ })
+ testCases := []struct {
+ job Config
+ schedules []*calendar.Event
+ }{
+ {
+ job: Config{
+ ProfileName: "testscheduled",
+ CommandName: "backup",
+ Command: "/bin/resticprofile",
+ Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "--name", "self", "check"}),
+ WorkingDirectory: "/resticprofile",
+ Permission: constants.SchedulePermissionSystem,
+ ConfigFile: "examples/dev.yaml",
+ Schedules: []string{"*-*-* *:00,30:00"},
+ },
+ schedules: []*calendar.Event{calendarEvent},
+ },
+ {
+ job: Config{
+ ProfileName: "test.scheduled",
+ CommandName: "backup",
+ Command: "/bin/resticprofile",
+ Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "config file.yaml", "--name", "self", "backup"}),
+ WorkingDirectory: "/resticprofile",
+ Permission: constants.SchedulePermissionSystem,
+ ConfigFile: "config file.yaml",
+ Schedules: []string{"*-*-* *:00,30:00"},
+ },
+ schedules: []*calendar.Event{calendarEvent},
+ },
+ {
+ job: Config{
+ ProfileName: "testscheduled",
+ CommandName: "backup",
+ Command: "/bin/resticprofile",
+ Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "--name", "self", "check"}),
+ WorkingDirectory: "/resticprofile",
+ Permission: constants.SchedulePermissionUser,
+ ConfigFile: "examples/dev.yaml",
+ Schedules: []string{"*-*-* *:00,30:00"},
+ },
+ schedules: []*calendar.Event{calendarEvent},
+ },
+ }
+
+ handler := NewHandler(SchedulerLaunchd{}).(*HandlerLaunchd)
+ handler.fs = afero.NewMemMapFs()
+
+ expectedJobs := []Config{}
+ for _, testCase := range testCases {
+ expectedJobs = append(expectedJobs, testCase.job)
+
+ _, err := handler.createPlistFile(handler.getLaunchdJob(&testCase.job, testCase.schedules), testCase.job.Permission)
+ require.NoError(t, err)
+ }
+
+ scheduled, err := handler.Scheduled("")
+ require.NoError(t, err)
+
+ assert.ElementsMatch(t, expectedJobs, scheduled)
+}
diff --git a/schedule/handler_fake_test.go b/schedule/handler_fake_test.go
deleted file mode 100644
index cebb64d6..00000000
--- a/schedule/handler_fake_test.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package schedule
-
-import (
- "testing"
-
- "github.com/creativeprojects/resticprofile/calendar"
-)
-
-const (
- errorMethodNotRegistered = "method not registered in mock"
-)
-
-type mockHandler struct {
- t *testing.T
- init func() error
- close func()
- parseSchedules func(schedules []string) ([]*calendar.Event, error)
- displayParsedSchedules func(command string, events []*calendar.Event)
- displaySchedules func(command string, schedules []string) error
- displayStatus func(profileName string) error
- createJob func(job *Config, schedules []*calendar.Event, permission string) error
- removeJob func(job *Config, permission string) error
- displayJobStatus func(job *Config) error
-}
-
-func (h mockHandler) Init() error {
- if h.init == nil {
- h.t.Fatal(errorMethodNotRegistered)
- }
- return h.init()
-}
-
-func (h mockHandler) Close() {
- if h.close == nil {
- h.t.Fatal(errorMethodNotRegistered)
- }
- h.close()
-}
-
-func (h mockHandler) ParseSchedules(schedules []string) ([]*calendar.Event, error) {
- if h.parseSchedules == nil {
- h.t.Fatal(errorMethodNotRegistered)
- }
- return h.parseSchedules(schedules)
-}
-
-func (h mockHandler) DisplayParsedSchedules(command string, events []*calendar.Event) {
- if h.displayParsedSchedules == nil {
- h.t.Fatal(errorMethodNotRegistered)
- }
- h.displayParsedSchedules(command, events)
-}
-
-func (h mockHandler) DisplaySchedules(command string, schedules []string) error {
- if h.displaySchedules == nil {
- h.t.Fatal(errorMethodNotRegistered)
- }
- return h.displaySchedules(command, schedules)
-}
-
-func (h mockHandler) DisplayStatus(profileName string) error {
- if h.displayStatus == nil {
- h.t.Fatal(errorMethodNotRegistered)
- }
- return h.displayStatus(profileName)
-}
-
-func (h mockHandler) CreateJob(job *Config, schedules []*calendar.Event, permission string) error {
- if h.createJob == nil {
- h.t.Fatal(errorMethodNotRegistered)
- }
- return h.createJob(job, schedules, permission)
-}
-
-func (h mockHandler) RemoveJob(job *Config, permission string) error {
- if h.removeJob == nil {
- h.t.Fatal(errorMethodNotRegistered)
- }
- return h.removeJob(job, permission)
-}
-
-func (h mockHandler) DisplayJobStatus(job *Config) error {
- if h.displayJobStatus == nil {
- h.t.Fatal(errorMethodNotRegistered)
- }
- return h.displayJobStatus(job)
-}
-
-var (
- _ Handler = &mockHandler{}
-)
diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go
index bc6b1eb9..99c90260 100644
--- a/schedule/handler_systemd.go
+++ b/schedule/handler_systemd.go
@@ -3,15 +3,19 @@
package schedule
import (
+ "bytes"
+ "encoding/json"
"fmt"
"os"
"os/exec"
"path"
+ "slices"
"strings"
"github.com/creativeprojects/clog"
"github.com/creativeprojects/resticprofile/calendar"
"github.com/creativeprojects/resticprofile/constants"
+ "github.com/creativeprojects/resticprofile/shell"
"github.com/creativeprojects/resticprofile/systemd"
"github.com/creativeprojects/resticprofile/term"
)
@@ -25,6 +29,7 @@ const (
systemctlReload = "daemon-reload"
flagUserUnit = "--user"
flagNoPager = "--no-pager"
+ unitNotFound = "not-found"
// https://www.freedesktop.org/software/systemd/man/systemctl.html#Exit%20status
codeStatusNotRunning = 3
@@ -66,14 +71,17 @@ func (h *HandlerSystemd) ParseSchedules(schedules []string) ([]*calendar.Event,
return nil, nil
}
-// DisplayParsedSchedules does nothing with systemd
-func (h *HandlerSystemd) DisplayParsedSchedules(command string, events []*calendar.Event) {}
-
// DisplaySchedules displays the schedules through the systemd-analyze command
-func (h *HandlerSystemd) DisplaySchedules(command string, schedules []string) error {
- return displaySystemdSchedules(command, schedules)
+func (h *HandlerSystemd) DisplaySchedules(profile, command string, schedules []string) error {
+ return displaySystemdSchedules(profile, command, schedules)
}
+// Timers summary
+// ===============
+// NEXT LEFT LAST PASSED UNIT ACTIVATES
+// Tue 2024-10-29 18:45:00 GMT 28min left Tue 2024-10-29 18:16:09 GMT 38s ago resticprofile-copy@profile-self.timer resticprofile-copy@profile-self.service
+//
+// 1 timers listed.
func (h *HandlerSystemd) DisplayStatus(profileName string) error {
var (
status string
@@ -86,7 +94,7 @@ func (h *HandlerSystemd) DisplayStatus(profileName string) error {
// otherwise user timers
status, err = getSystemdStatus(profileName, systemd.UserUnit)
}
- if err != nil || status == "" || strings.HasPrefix(status, "0 timers") {
+ if err != nil || status == "" || strings.Contains(status, "0 timers") {
// fail silently
return nil
}
@@ -107,7 +115,7 @@ func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, per
}
err := systemd.Generate(systemd.Config{
- CommandLine: job.Command + " " + strings.Join(append([]string{"--no-prio"}, job.Arguments.RawArgs()...), " "),
+ CommandLine: job.Command + " --no-prio " + job.Arguments.String(),
Environment: job.Environment,
WorkingDirectory: job.WorkingDirectory,
Title: job.ProfileName,
@@ -129,15 +137,10 @@ func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, per
return err
}
- if unitType == systemd.SystemUnit {
- // tell systemd we've changed some system configuration files
- cmd := exec.Command(systemctlBinary, systemctlReload)
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- err = cmd.Run()
- if err != nil {
- return err
- }
+ // tell systemd we've changed some system configuration files
+ err = runSystemctlReload(unitType)
+ if err != nil {
+ return err
}
timerName := systemd.GetTimerFile(job.ProfileName, job.CommandName)
@@ -170,6 +173,15 @@ func (h *HandlerSystemd) RemoveJob(job *Config, permission string) error {
unitType = systemd.SystemUnit
}
var err error
+ serviceFile := systemd.GetServiceFile(job.ProfileName, job.CommandName)
+ unitLoaded, err := unitLoaded(serviceFile, unitType)
+ if err != nil {
+ return err
+ }
+ if !unitLoaded {
+ return ErrScheduledJobNotFound
+ }
+
timerFile := systemd.GetTimerFile(job.ProfileName, job.CommandName)
// stop the job
@@ -192,7 +204,6 @@ func (h *HandlerSystemd) RemoveJob(job *Config, permission string) error {
}
}
- serviceFile := systemd.GetServiceFile(job.ProfileName, job.CommandName)
dropInDir := systemd.GetServiceFileDropInDir(job.ProfileName, job.CommandName)
timerDropInDir := systemd.GetTimerFileDropInDir(job.ProfileName, job.CommandName)
@@ -208,25 +219,51 @@ func (h *HandlerSystemd) RemoveJob(job *Config, permission string) error {
clog.Errorf("failed removing %q, error: %s. Please remove this path", pathToRemove, err.Error())
}
}
+
+ // tell systemd we've changed some system configuration files
+ _ = runSystemctlReload(unitType)
return nil
}
// DisplayJobStatus displays information of a systemd service/timer
func (h *HandlerSystemd) DisplayJobStatus(job *Config) error {
+ serviceName := systemd.GetServiceFile(job.ProfileName, job.CommandName)
timerName := systemd.GetTimerFile(job.ProfileName, job.CommandName)
permission := getSchedulePermission(job.Permission)
+ systemdType := systemd.UserUnit
if permission == constants.SchedulePermissionSystem {
- err := runJournalCtlCommand(timerName, systemd.SystemUnit)
- if err != nil {
- clog.Warningf("cannot read system logs: %v", err)
- }
- return runSystemctlCommand(timerName, systemctlStatus, systemd.SystemUnit, false)
+ systemdType = systemd.SystemUnit
}
- err := runJournalCtlCommand(timerName, systemd.UserUnit)
+ unitLoaded, err := unitLoaded(serviceName, systemdType)
if err != nil {
- clog.Warningf("cannot read user logs: %v", err)
+ return err
}
- return runSystemctlCommand(timerName, systemctlStatus, systemd.UserUnit, false)
+ if !unitLoaded {
+ return ErrScheduledJobNotFound
+ }
+ _ = runJournalCtlCommand(timerName, systemdType)
+ return runSystemctlCommand(timerName, systemctlStatus, systemdType, false)
+}
+
+func (h *HandlerSystemd) Scheduled(profileName string) ([]Config, error) {
+ configs := []Config{}
+
+ cfgs, err := getConfigs(profileName, systemd.SystemUnit)
+ if err != nil {
+ clog.Errorf("cannot list system units: %s", err)
+ }
+ if len(cfgs) > 0 {
+ configs = append(configs, cfgs...)
+ }
+
+ cfgs, err = getConfigs(profileName, systemd.UserUnit)
+ if err != nil {
+ clog.Errorf("cannot list user units: %s", err)
+ }
+ if len(cfgs) > 0 {
+ configs = append(configs, cfgs...)
+ }
+ return configs, nil
}
var (
@@ -268,13 +305,13 @@ func runSystemctlCommand(timerName, command string, unitType systemd.UnitType, s
}
err := cmd.Run()
if command == systemctlStatus && cmd.ProcessState.ExitCode() == codeStatusUnitNotFound {
- return ErrServiceNotFound
+ return ErrScheduledJobNotFound
}
if command == systemctlStatus && cmd.ProcessState.ExitCode() == codeStatusNotRunning {
- return ErrServiceNotRunning
+ return ErrScheduledJobNotRunning
}
if command == systemctlStop && cmd.ProcessState.ExitCode() == codeStopUnitNotFound {
- return ErrServiceNotFound
+ return ErrScheduledJobNotFound
}
return err
}
@@ -295,6 +332,114 @@ func runJournalCtlCommand(timerName string, unitType systemd.UnitType) error {
return err
}
+func runSystemctlReload(unitType systemd.UnitType) error {
+ args := []string{systemctlReload}
+ if unitType == systemd.UserUnit {
+ args = append(args, flagUserUnit)
+ }
+ clog.Debugf("starting command \"%s %s\"", journalctlBinary, strings.Join(args, " "))
+ cmd := exec.Command(systemctlBinary, args...)
+ cmd.Stdout = term.GetOutput()
+ cmd.Stderr = term.GetErrorOutput()
+ err := cmd.Run()
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func listUnits(profile string, unitType systemd.UnitType) ([]SystemdUnit, error) {
+ if profile == "" {
+ profile = "*"
+ }
+ pattern := fmt.Sprintf("resticprofile-*@profile-%s.service", profile)
+ args := []string{"list-units", "--all", flagNoPager, "--output", "json"}
+ if unitType == systemd.UserUnit {
+ args = append(args, flagUserUnit)
+ }
+ args = append(args, pattern)
+
+ clog.Debugf("starting command \"%s %s\"", systemctlBinary, strings.Join(args, " "))
+ stdout := &bytes.Buffer{}
+ stderr := &bytes.Buffer{}
+ cmd := exec.Command(systemctlBinary, args...)
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+ err := cmd.Run()
+ if err != nil {
+ return nil, fmt.Errorf("error running command: %w\n%s", err, stderr.String())
+ }
+ var units []SystemdUnit
+ decoder := json.NewDecoder(stdout)
+ err = decoder.Decode(&units)
+ if err != nil {
+ return nil, fmt.Errorf("error decoding JSON: %w\n%s", err, stdout.String())
+ }
+ return units, err
+}
+
+func unitLoaded(serviceName string, unitType systemd.UnitType) (bool, error) {
+ units, err := listUnits("", unitType)
+ if err != nil {
+ return false, err
+ }
+ return slices.ContainsFunc(units, func(unit SystemdUnit) bool {
+ return unit.Unit == serviceName && unit.Load != unitNotFound
+ }), nil
+}
+
+func getConfigs(profileName string, unitType systemd.UnitType) ([]Config, error) {
+ units, err := listUnits(profileName, unitType)
+ if err != nil {
+ return nil, err
+ }
+ configs := make([]Config, 0, len(units))
+ for _, unit := range units {
+ if unit.Load == unitNotFound {
+ continue
+ }
+ cfg, err := systemd.Read(unit.Unit, unitType)
+ if err != nil {
+ clog.Errorf("cannot read information from unit %q: %s", unit.Unit, err)
+ continue
+ }
+ if cfg == nil {
+ continue
+ }
+ configs = append(configs, toScheduleConfig(*cfg, unitType))
+ }
+ return configs, nil
+}
+
+func toScheduleConfig(systemdConfig systemd.Config, unitType systemd.UnitType) Config {
+ var command string
+ cmdLine := shell.SplitArguments(systemdConfig.CommandLine)
+ if len(cmdLine) > 0 {
+ command = cmdLine[0]
+ }
+ args := NewCommandArguments(cmdLine[1:])
+
+ permission := constants.SchedulePermissionUser
+ if unitType == systemd.SystemUnit {
+ permission = constants.SchedulePermissionSystem
+ }
+
+ cfg := Config{
+ ConfigFile: args.ConfigFile(),
+ ProfileName: systemdConfig.Title,
+ CommandName: systemdConfig.SubTitle,
+ WorkingDirectory: systemdConfig.WorkingDirectory,
+ Command: command,
+ Arguments: args.Trim([]string{"--no-prio"}),
+ JobDescription: systemdConfig.JobDescription,
+ Environment: systemdConfig.Environment,
+ Permission: permission,
+ Schedules: systemdConfig.Schedules,
+ Priority: systemdConfig.Priority,
+ }
+ return cfg
+}
+
// init registers HandlerSystemd
func init() {
AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) {
diff --git a/schedule/handler_systemd_test.go b/schedule/handler_systemd_test.go
new file mode 100644
index 00000000..826bea47
--- /dev/null
+++ b/schedule/handler_systemd_test.go
@@ -0,0 +1,85 @@
+//go:build !darwin && !windows
+
+package schedule
+
+import (
+ "os"
+ "testing"
+
+ "github.com/creativeprojects/resticprofile/calendar"
+ "github.com/creativeprojects/resticprofile/constants"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadingSystemdScheduled(t *testing.T) {
+ event := calendar.NewEvent()
+ require.NoError(t, event.Parse("2020-01-01"))
+
+ schedulePermission := constants.SchedulePermissionUser
+
+ testCases := []struct {
+ job Config
+ schedules []*calendar.Event
+ }{
+ {
+ job: Config{
+ ProfileName: "testscheduled",
+ CommandName: "backup",
+ Command: "/tmp/bin/resticprofile",
+ Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "--name", "self", "check"}),
+ WorkingDirectory: "/resticprofile",
+ Permission: schedulePermission,
+ ConfigFile: "examples/dev.yaml",
+ Schedules: []string{event.String()},
+ },
+ schedules: []*calendar.Event{event},
+ },
+ {
+ job: Config{
+ ProfileName: "test.scheduled",
+ CommandName: "backup",
+ Command: "/tmp/bin/resticprofile",
+ Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "config file.yaml", "--name", "self", "backup"}),
+ WorkingDirectory: "/resticprofile",
+ Permission: schedulePermission,
+ ConfigFile: "config file.yaml",
+ Schedules: []string{event.String()},
+ },
+ schedules: []*calendar.Event{event},
+ },
+ }
+ userHome, err := os.UserHomeDir()
+ require.NoError(t, err)
+
+ handler := NewHandler(SchedulerSystemd{}).(*HandlerSystemd)
+
+ expectedJobs := []Config{}
+ for _, testCase := range testCases {
+ job := testCase.job
+ err := handler.CreateJob(&job, testCase.schedules, schedulePermission)
+
+ toRemove := &job
+ t.Cleanup(func() {
+ _ = handler.RemoveJob(toRemove, schedulePermission)
+ })
+ require.NoError(t, err)
+
+ job.Environment = []string{"HOME=" + userHome}
+ expectedJobs = append(expectedJobs, job)
+ }
+
+ scheduled, err := handler.Scheduled("")
+ require.NoError(t, err)
+
+ testScheduled := make([]Config, 0, len(scheduled))
+ for _, s := range scheduled {
+ if s.ConfigFile != "config file.yaml" && s.ConfigFile != "examples/dev.yaml" {
+ t.Logf("Ignoring config file %s", s.ConfigFile)
+ continue
+ }
+ testScheduled = append(testScheduled, s)
+ }
+
+ assert.ElementsMatch(t, expectedJobs, testScheduled)
+}
diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go
index cc3af507..5b3a24a9 100644
--- a/schedule/handler_windows.go
+++ b/schedule/handler_windows.go
@@ -8,6 +8,7 @@ import (
"github.com/creativeprojects/resticprofile/calendar"
"github.com/creativeprojects/resticprofile/constants"
"github.com/creativeprojects/resticprofile/schtasks"
+ "github.com/creativeprojects/resticprofile/shell"
)
// HandlerWindows is using windows task manager
@@ -30,13 +31,13 @@ func (h *HandlerWindows) ParseSchedules(schedules []string) ([]*calendar.Event,
return parseSchedules(schedules)
}
-// DisplayParsedSchedules via term output
-func (h *HandlerWindows) DisplayParsedSchedules(command string, events []*calendar.Event) {
- displayParsedSchedules(command, events)
-}
-
-// DisplaySchedules does nothing on windows
-func (h *HandlerWindows) DisplaySchedules(command string, schedules []string) error {
+// DisplaySchedules via term output
+func (h *HandlerWindows) DisplaySchedules(profile, command string, schedules []string) error {
+ events, err := parseSchedules(schedules)
+ if err != nil {
+ return err
+ }
+ displayParsedSchedules(profile, command, events)
return nil
}
@@ -74,7 +75,7 @@ func (h *HandlerWindows) RemoveJob(job *Config, permission string) error {
err := schtasks.Delete(job.ProfileName, job.CommandName)
if err != nil {
if errors.Is(err, schtasks.ErrNotRegistered) {
- return ErrServiceNotFound
+ return ErrScheduledJobNotFound
}
return err
}
@@ -86,13 +87,36 @@ func (h *HandlerWindows) DisplayJobStatus(job *Config) error {
err := schtasks.Status(job.ProfileName, job.CommandName)
if err != nil {
if errors.Is(err, schtasks.ErrNotRegistered) {
- return ErrServiceNotFound
+ return ErrScheduledJobNotFound
}
return err
}
return nil
}
+func (h *HandlerWindows) Scheduled(profileName string) ([]Config, error) {
+ tasks, err := schtasks.Registered()
+ if err != nil {
+ return nil, err
+ }
+ configs := make([]Config, 0, len(tasks))
+ for _, task := range tasks {
+ if profileName == "" || task.ProfileName == profileName {
+ args := NewCommandArguments(shell.SplitArguments(task.Arguments))
+ configs = append(configs, Config{
+ ConfigFile: args.ConfigFile(),
+ ProfileName: task.ProfileName,
+ CommandName: task.CommandName,
+ Command: task.Command,
+ Arguments: args,
+ WorkingDirectory: task.WorkingDirectory,
+ JobDescription: task.JobDescription,
+ })
+ }
+ }
+ return configs, nil
+}
+
// init registers HandlerWindows
func init() {
AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) {
diff --git a/schedule/job.go b/schedule/job.go
index 3af2392c..fab69003 100644
--- a/schedule/job.go
+++ b/schedule/job.go
@@ -8,13 +8,20 @@ import (
// Job: common code for all scheduling systems
//
+var ErrJobCanBeRemovedOnly = errors.New("job can be removed only")
+
// Job scheduler
type Job struct {
config *Config
handler Handler
}
-var ErrJobCanBeRemovedOnly = errors.New("job can be removed only")
+func NewJob(handler Handler, config *Config) *Job {
+ return &Job{
+ config: config,
+ handler: handler,
+ }
+}
// Accessible checks if the current user is permitted to access the job
func (j *Job) Accessible() bool {
@@ -34,22 +41,16 @@ func (j *Job) Create() error {
return permissionError("create")
}
- schedules, err := j.handler.ParseSchedules(j.config.Schedules)
- if err != nil {
+ if err := j.handler.DisplaySchedules(j.config.ProfileName, j.config.CommandName, j.config.Schedules); err != nil {
return err
}
- if len(schedules) > 0 {
- j.handler.DisplayParsedSchedules(j.config.CommandName, schedules)
- } else {
- err := j.handler.DisplaySchedules(j.config.CommandName, j.config.Schedules)
- if err != nil {
- return err
- }
+ schedules, err := j.handler.ParseSchedules(j.config.Schedules)
+ if err != nil {
+ return err
}
- err = j.handler.CreateJob(j.config, schedules, permission)
- if err != nil {
+ if err = j.handler.CreateJob(j.config, schedules, permission); err != nil {
return err
}
@@ -83,21 +84,11 @@ func (j *Job) Status() error {
return ErrJobCanBeRemovedOnly
}
- schedules, err := j.handler.ParseSchedules(j.config.Schedules)
- if err != nil {
+ if err := j.handler.DisplaySchedules(j.config.ProfileName, j.config.CommandName, j.config.Schedules); err != nil {
return err
}
- if len(schedules) > 0 {
- j.handler.DisplayParsedSchedules(j.config.CommandName, schedules)
- } else {
- if err := j.handler.DisplaySchedules(j.config.CommandName, j.config.Schedules); err != nil {
- return err
- }
- }
-
- err = j.handler.DisplayJobStatus(j.config)
- if err != nil {
+ if err := j.handler.DisplayJobStatus(j.config); err != nil {
return err
}
return nil
diff --git a/schedule/job_test.go b/schedule/job_test.go
index 87d3e182..2cf0ae78 100644
--- a/schedule/job_test.go
+++ b/schedule/job_test.go
@@ -1,131 +1,75 @@
-package schedule
+package schedule_test
import (
"errors"
"testing"
"github.com/creativeprojects/resticprofile/calendar"
- "github.com/stretchr/testify/assert"
+ "github.com/creativeprojects/resticprofile/schedule"
+ "github.com/creativeprojects/resticprofile/schedule/mocks"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
)
-func TestCreateJobHappyPathSystemd(t *testing.T) {
- counter := 0
- handler := mockHandler{
- t: t,
- parseSchedules: func(schedules []string) ([]*calendar.Event, error) {
- counter |= 1
- return nil, nil
- },
- displaySchedules: func(command string, schedules []string) error {
- counter |= 2
- return nil
- },
- createJob: func(job *Config, schedules []*calendar.Event, permission string) error {
- counter |= 4
- return nil
- },
- }
- job := Job{
- config: &Config{},
- handler: handler,
- }
- err := job.Create()
- assert.NoError(t, err)
+func TestCreateJobHappyPath(t *testing.T) {
+ handler := mocks.NewHandler(t)
+ handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(nil)
+ handler.EXPECT().ParseSchedules([]string{}).Return([]*calendar.Event{}, nil)
+ handler.EXPECT().CreateJob(mock.AnythingOfType("*schedule.Config"), []*calendar.Event{}, "user").Return(nil)
- assert.Equal(t, 1|2|4, counter)
-}
+ job := schedule.NewJob(handler, &schedule.Config{
+ ProfileName: "profile",
+ CommandName: "backup",
+ Schedules: []string{},
+ Permission: "user",
+ })
-func TestCreateJobHappyPathOther(t *testing.T) {
- counter := 0
- handler := mockHandler{
- t: t,
- parseSchedules: func(schedules []string) ([]*calendar.Event, error) {
- counter |= 1
- return []*calendar.Event{calendar.NewEvent()}, nil
- },
- displayParsedSchedules: func(command string, events []*calendar.Event) {
- counter |= 2
- },
- createJob: func(job *Config, schedules []*calendar.Event, permission string) error {
- counter |= 4
- return nil
- },
- }
- job := Job{
- config: &Config{},
- handler: handler,
- }
err := job.Create()
- assert.NoError(t, err)
-
- assert.Equal(t, 1|2|4, counter)
+ require.NoError(t, err)
}
-func TestCreateJobSadPath1(t *testing.T) {
- counter := 0
- handler := mockHandler{
- t: t,
- parseSchedules: func(schedules []string) ([]*calendar.Event, error) {
- counter |= 1
- return nil, errors.New("test!")
- },
- }
- job := Job{
- config: &Config{},
- handler: handler,
- }
- err := job.Create()
- assert.Error(t, err)
+func TestCreateJobErrorParseSchedules(t *testing.T) {
+ handler := mocks.NewHandler(t)
+ handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(nil)
+ handler.EXPECT().ParseSchedules([]string{}).Return(nil, errors.New("test!"))
- assert.Equal(t, 1, counter)
-}
+ job := schedule.NewJob(handler, &schedule.Config{
+ ProfileName: "profile",
+ CommandName: "backup",
+ Schedules: []string{},
+ })
-func TestCreateJobSadPath2(t *testing.T) {
- counter := 0
- handler := mockHandler{
- t: t,
- parseSchedules: func(schedules []string) ([]*calendar.Event, error) {
- counter |= 1
- return nil, nil
- },
- displaySchedules: func(command string, schedules []string) error {
- counter |= 2
- return errors.New("test!")
- },
- }
- job := Job{
- config: &Config{},
- handler: handler,
- }
err := job.Create()
- assert.Error(t, err)
-
- assert.Equal(t, 1|2, counter)
+ require.Error(t, err)
}
-func TestCreateJobSadPath3(t *testing.T) {
- counter := 0
- handler := mockHandler{
- t: t,
- parseSchedules: func(schedules []string) ([]*calendar.Event, error) {
- counter |= 1
- return nil, nil
- },
- displaySchedules: func(command string, schedules []string) error {
- counter |= 2
- return nil
- },
- createJob: func(job *Config, schedules []*calendar.Event, permission string) error {
- counter |= 4
- return errors.New("test!")
- },
- }
- job := Job{
- config: &Config{},
- handler: handler,
- }
+func TestCreateJobErrorDisplaySchedules(t *testing.T) {
+ handler := mocks.NewHandler(t)
+ handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(errors.New("test!"))
+
+ job := schedule.NewJob(handler, &schedule.Config{
+ ProfileName: "profile",
+ CommandName: "backup",
+ Schedules: []string{},
+ })
+
err := job.Create()
- assert.Error(t, err)
+ require.Error(t, err)
+}
- assert.Equal(t, 1|2|4, counter)
+func TestCreateJobErrorCreate(t *testing.T) {
+ handler := mocks.NewHandler(t)
+ handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(nil)
+ handler.EXPECT().ParseSchedules([]string{}).Return([]*calendar.Event{}, nil)
+ handler.EXPECT().CreateJob(mock.AnythingOfType("*schedule.Config"), []*calendar.Event{}, "user").Return(errors.New("test!"))
+
+ job := schedule.NewJob(handler, &schedule.Config{
+ ProfileName: "profile",
+ CommandName: "backup",
+ Schedules: []string{},
+ Permission: "user",
+ })
+
+ err := job.Create()
+ require.Error(t, err)
}
diff --git a/schedule/mocks/Handler.go b/schedule/mocks/Handler.go
index 37af71ec..58064d55 100644
--- a/schedule/mocks/Handler.go
+++ b/schedule/mocks/Handler.go
@@ -148,51 +148,17 @@ func (_c *Handler_DisplayJobStatus_Call) RunAndReturn(run func(*schedule.Config)
return _c
}
-// DisplayParsedSchedules provides a mock function with given fields: command, events
-func (_m *Handler) DisplayParsedSchedules(command string, events []*calendar.Event) {
- _m.Called(command, events)
-}
-
-// Handler_DisplayParsedSchedules_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisplayParsedSchedules'
-type Handler_DisplayParsedSchedules_Call struct {
- *mock.Call
-}
-
-// DisplayParsedSchedules is a helper method to define mock.On call
-// - command string
-// - events []*calendar.Event
-func (_e *Handler_Expecter) DisplayParsedSchedules(command interface{}, events interface{}) *Handler_DisplayParsedSchedules_Call {
- return &Handler_DisplayParsedSchedules_Call{Call: _e.mock.On("DisplayParsedSchedules", command, events)}
-}
-
-func (_c *Handler_DisplayParsedSchedules_Call) Run(run func(command string, events []*calendar.Event)) *Handler_DisplayParsedSchedules_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(string), args[1].([]*calendar.Event))
- })
- return _c
-}
-
-func (_c *Handler_DisplayParsedSchedules_Call) Return() *Handler_DisplayParsedSchedules_Call {
- _c.Call.Return()
- return _c
-}
-
-func (_c *Handler_DisplayParsedSchedules_Call) RunAndReturn(run func(string, []*calendar.Event)) *Handler_DisplayParsedSchedules_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// DisplaySchedules provides a mock function with given fields: command, schedules
-func (_m *Handler) DisplaySchedules(command string, schedules []string) error {
- ret := _m.Called(command, schedules)
+// DisplaySchedules provides a mock function with given fields: profile, command, schedules
+func (_m *Handler) DisplaySchedules(profile string, command string, schedules []string) error {
+ ret := _m.Called(profile, command, schedules)
if len(ret) == 0 {
panic("no return value specified for DisplaySchedules")
}
var r0 error
- if rf, ok := ret.Get(0).(func(string, []string) error); ok {
- r0 = rf(command, schedules)
+ if rf, ok := ret.Get(0).(func(string, string, []string) error); ok {
+ r0 = rf(profile, command, schedules)
} else {
r0 = ret.Error(0)
}
@@ -206,15 +172,16 @@ type Handler_DisplaySchedules_Call struct {
}
// DisplaySchedules is a helper method to define mock.On call
+// - profile string
// - command string
// - schedules []string
-func (_e *Handler_Expecter) DisplaySchedules(command interface{}, schedules interface{}) *Handler_DisplaySchedules_Call {
- return &Handler_DisplaySchedules_Call{Call: _e.mock.On("DisplaySchedules", command, schedules)}
+func (_e *Handler_Expecter) DisplaySchedules(profile interface{}, command interface{}, schedules interface{}) *Handler_DisplaySchedules_Call {
+ return &Handler_DisplaySchedules_Call{Call: _e.mock.On("DisplaySchedules", profile, command, schedules)}
}
-func (_c *Handler_DisplaySchedules_Call) Run(run func(command string, schedules []string)) *Handler_DisplaySchedules_Call {
+func (_c *Handler_DisplaySchedules_Call) Run(run func(profile string, command string, schedules []string)) *Handler_DisplaySchedules_Call {
_c.Call.Run(func(args mock.Arguments) {
- run(args[0].(string), args[1].([]string))
+ run(args[0].(string), args[1].(string), args[2].([]string))
})
return _c
}
@@ -224,7 +191,7 @@ func (_c *Handler_DisplaySchedules_Call) Return(_a0 error) *Handler_DisplaySched
return _c
}
-func (_c *Handler_DisplaySchedules_Call) RunAndReturn(run func(string, []string) error) *Handler_DisplaySchedules_Call {
+func (_c *Handler_DisplaySchedules_Call) RunAndReturn(run func(string, string, []string) error) *Handler_DisplaySchedules_Call {
_c.Call.Return(run)
return _c
}
@@ -425,6 +392,64 @@ func (_c *Handler_RemoveJob_Call) RunAndReturn(run func(*schedule.Config, string
return _c
}
+// Scheduled provides a mock function with given fields: profileName
+func (_m *Handler) Scheduled(profileName string) ([]schedule.Config, error) {
+ ret := _m.Called(profileName)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Scheduled")
+ }
+
+ var r0 []schedule.Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) ([]schedule.Config, error)); ok {
+ return rf(profileName)
+ }
+ if rf, ok := ret.Get(0).(func(string) []schedule.Config); ok {
+ r0 = rf(profileName)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]schedule.Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(profileName)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Handler_Scheduled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Scheduled'
+type Handler_Scheduled_Call struct {
+ *mock.Call
+}
+
+// Scheduled is a helper method to define mock.On call
+// - profileName string
+func (_e *Handler_Expecter) Scheduled(profileName interface{}) *Handler_Scheduled_Call {
+ return &Handler_Scheduled_Call{Call: _e.mock.On("Scheduled", profileName)}
+}
+
+func (_c *Handler_Scheduled_Call) Run(run func(profileName string)) *Handler_Scheduled_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *Handler_Scheduled_Call) Return(_a0 []schedule.Config, _a1 error) *Handler_Scheduled_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *Handler_Scheduled_Call) RunAndReturn(run func(string) ([]schedule.Config, error)) *Handler_Scheduled_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
// NewHandler creates a new instance of Handler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewHandler(t interface {
diff --git a/schedule/removeonly_test.go b/schedule/removeonly_test.go
index 7abc9d70..91331f2a 100644
--- a/schedule/removeonly_test.go
+++ b/schedule/removeonly_test.go
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestNewRemoveOnlyConfig(t *testing.T) {
@@ -39,10 +40,11 @@ func TestDetectRemoveOnlyConfig(t *testing.T) {
func TestRemoveOnlyJob(t *testing.T) {
profile := "non-existent"
- scheduler := NewScheduler(NewHandler(SchedulerDefaultOS{}), profile)
- defer scheduler.Close()
+ handler := NewHandler(SchedulerDefaultOS{})
+ require.NoError(t, handler.Init())
+ defer handler.Close()
- job := scheduler.NewJob(NewRemoveOnlyConfig(profile, "check"))
+ job := NewJob(handler, NewRemoveOnlyConfig(profile, "check"))
assert.Equal(t, ErrJobCanBeRemovedOnly, job.Create())
assert.Equal(t, ErrJobCanBeRemovedOnly, job.Status())
diff --git a/schedule/schedule_test.go b/schedule/schedule_test.go
index 2112c0e4..0e96278c 100644
--- a/schedule/schedule_test.go
+++ b/schedule/schedule_test.go
@@ -31,9 +31,9 @@ func TestExecutableIsAbsoluteOnAllPlatforms(t *testing.T) {
}
func TestInit(t *testing.T) {
- scheduler := NewScheduler(NewHandler(SchedulerDefaultOS{}), "profile")
- err := scheduler.Init()
- defer scheduler.Close()
+ handler := (NewHandler(SchedulerDefaultOS{}))
+ err := handler.Init()
+ defer handler.Close()
require.NoError(t, err)
}
@@ -41,9 +41,9 @@ func TestCrondInit(t *testing.T) {
if platform.IsWindows() {
t.Skip("crond scheduler is not supported on this platform")
}
- scheduler := NewScheduler(NewHandler(SchedulerCrond{}), "profile")
- err := scheduler.Init()
- defer scheduler.Close()
+ handler := (NewHandler(SchedulerCrond{}))
+ err := handler.Init()
+ defer handler.Close()
require.NoError(t, err)
}
@@ -51,26 +51,26 @@ func TestSystemdInit(t *testing.T) {
if platform.IsWindows() || platform.IsDarwin() {
t.Skip("systemd scheduler is not supported on this platform")
}
- scheduler := NewScheduler(NewHandler(SchedulerSystemd{}), "profile")
- err := scheduler.Init()
- defer scheduler.Close()
+ handler := (NewHandler(SchedulerSystemd{}))
+ err := handler.Init()
+ defer handler.Close()
require.NoError(t, err)
}
func TestLaunchdInit(t *testing.T) {
if !platform.IsDarwin() {
t.Skip("launchd scheduler is not supported on this platform")
}
- scheduler := NewScheduler(NewHandler(SchedulerLaunchd{}), "profile")
- err := scheduler.Init()
- defer scheduler.Close()
+ handler := (NewHandler(SchedulerLaunchd{}))
+ err := handler.Init()
+ defer handler.Close()
require.NoError(t, err)
}
func TestWindowsInit(t *testing.T) {
if !platform.IsWindows() {
t.Skip("windows scheduler is not supported on this platform")
}
- scheduler := NewScheduler(NewHandler(SchedulerWindows{}), "profile")
- err := scheduler.Init()
- defer scheduler.Close()
+ handler := (NewHandler(SchedulerWindows{}))
+ err := handler.Init()
+ defer handler.Close()
require.NoError(t, err)
}
diff --git a/schedule/scheduler.go b/schedule/scheduler.go
deleted file mode 100644
index 9bdaa7b3..00000000
--- a/schedule/scheduler.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package schedule
-
-import (
- "github.com/creativeprojects/clog"
-)
-
-// Scheduler
-type Scheduler struct {
- profileName string
- handler Handler
-}
-
-// NewScheduler creates a Scheduler object
-func NewScheduler(handler Handler, profileName string) *Scheduler {
- return &Scheduler{
- profileName: profileName,
- handler: handler,
- }
-}
-
-// Init
-func (s *Scheduler) Init() error {
- return s.handler.Init()
-}
-
-// Close
-func (s *Scheduler) Close() {
- s.handler.Close()
-}
-
-// NewJob instantiates a Job object (of SchedulerJob interface) to schedule jobs
-func (s *Scheduler) NewJob(config *Config) *Job {
- return &Job{
- config: config,
- handler: s.handler,
- }
-}
-
-// DisplayStatus
-func (s *Scheduler) DisplayStatus() {
- err := s.handler.DisplayStatus(s.profileName)
- if err != nil {
- clog.Error(err)
- }
-}
diff --git a/schedule/schedules.go b/schedule/schedules.go
index 13cc0302..582aeafc 100644
--- a/schedule/schedules.go
+++ b/schedule/schedules.go
@@ -2,16 +2,27 @@ package schedule
import (
"errors"
+ "fmt"
"os/exec"
+ "strings"
"time"
"github.com/creativeprojects/resticprofile/calendar"
+ "github.com/creativeprojects/resticprofile/platform"
"github.com/creativeprojects/resticprofile/term"
)
-const (
- displayHeader = "\nAnalyzing %s schedule %d/%d\n=================================\n"
-)
+func displayHeader(profile, command string, index, total int) {
+ term.Print(platform.LineSeparator)
+ header := fmt.Sprintf("Profile (or Group) %s: %s schedule", profile, command)
+ if total > 1 {
+ header += fmt.Sprintf(" %d/%d", index, total)
+ }
+ term.Print(header)
+ term.Print(platform.LineSeparator)
+ term.Print(strings.Repeat("=", len(header)))
+ term.Print(platform.LineSeparator)
+}
// parseSchedules creates a *calendar.Event from a string
func parseSchedules(schedules []string) ([]*calendar.Event, error) {
@@ -30,10 +41,10 @@ func parseSchedules(schedules []string) ([]*calendar.Event, error) {
return events, nil
}
-func displayParsedSchedules(command string, events []*calendar.Event) {
+func displayParsedSchedules(profile, command string, events []*calendar.Event) {
now := time.Now().Round(time.Second)
for index, event := range events {
- term.Printf(displayHeader, command, index+1, len(events))
+ displayHeader(profile, command, index+1, len(events))
next := event.Next(now)
term.Printf(" Original form: %s\n", event.Input())
term.Printf("Normalized form: %s\n", event.String())
@@ -41,15 +52,15 @@ func displayParsedSchedules(command string, events []*calendar.Event) {
term.Printf(" (in UTC): %s\n", next.UTC().Format(time.UnixDate))
term.Printf(" From now: %s left\n", next.Sub(now))
}
- term.Print("\n")
+ term.Print(platform.LineSeparator)
}
-func displaySystemdSchedules(command string, schedules []string) error {
+func displaySystemdSchedules(profile, command string, schedules []string) error {
for index, schedule := range schedules {
if schedule == "" {
return errors.New("empty schedule")
}
- term.Printf(displayHeader, command, index+1, len(schedules))
+ displayHeader(profile, command, index+1, len(schedules))
cmd := exec.Command("systemd-analyze", "calendar", schedule)
cmd.Stdout = term.GetOutput()
cmd.Stderr = term.GetErrorOutput()
@@ -58,6 +69,6 @@ func displaySystemdSchedules(command string, schedules []string) error {
return err
}
}
- term.Print("\n")
+ term.Print(platform.LineSeparator)
return nil
}
diff --git a/schedule/schedules_test.go b/schedule/schedules_test.go
index 5d371247..3277a7ca 100644
--- a/schedule/schedules_test.go
+++ b/schedule/schedules_test.go
@@ -4,31 +4,32 @@ import (
"bytes"
"os"
"os/exec"
- "runtime"
"testing"
+ "github.com/creativeprojects/resticprofile/platform"
"github.com/creativeprojects/resticprofile/term"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestParseEmptySchedules(t *testing.T) {
_, err := parseSchedules([]string{})
- assert.NoError(t, err)
+ require.NoError(t, err)
}
func TestParseSchedulesWithEmpty(t *testing.T) {
_, err := parseSchedules([]string{""})
- assert.Error(t, err)
+ require.Error(t, err)
}
func TestParseSchedulesWithError(t *testing.T) {
_, err := parseSchedules([]string{"parse error"})
- assert.Error(t, err)
+ require.Error(t, err)
}
func TestParseScheduleDaily(t *testing.T) {
events, err := parseSchedules([]string{"daily"})
- assert.NoError(t, err)
+ require.NoError(t, err)
assert.Len(t, events, 1)
assert.Equal(t, "daily", events[0].Input())
assert.Equal(t, "*-*-* 00:00:00", events[0].String())
@@ -36,13 +37,13 @@ func TestParseScheduleDaily(t *testing.T) {
func TestDisplayParseSchedules(t *testing.T) {
events, err := parseSchedules([]string{"daily"})
- assert.NoError(t, err)
+ require.NoError(t, err)
buffer := &bytes.Buffer{}
term.SetOutput(buffer)
defer term.SetOutput(os.Stdout)
- displayParsedSchedules("command", events)
+ displayParsedSchedules("profile", "command", events)
output := buffer.String()
assert.Contains(t, output, "Original form: daily\n")
assert.Contains(t, output, "Normalized form: *-*-* 00:00:00\n")
@@ -50,13 +51,13 @@ func TestDisplayParseSchedules(t *testing.T) {
func TestDisplayParseSchedulesIndexAndTotal(t *testing.T) {
events, err := parseSchedules([]string{"daily", "monthly", "yearly"})
- assert.NoError(t, err)
+ require.NoError(t, err)
buffer := &bytes.Buffer{}
term.SetOutput(buffer)
defer term.SetOutput(os.Stdout)
- displayParsedSchedules("command", events)
+ displayParsedSchedules("profile", "command", events)
output := buffer.String()
assert.Contains(t, output, "schedule 1/3")
assert.Contains(t, output, "schedule 2/3")
@@ -64,8 +65,8 @@ func TestDisplayParseSchedulesIndexAndTotal(t *testing.T) {
}
func TestDisplaySystemdSchedulesWithEmpty(t *testing.T) {
- err := displaySystemdSchedules("command", []string{""})
- assert.Error(t, err)
+ err := displaySystemdSchedules("profile", "command", []string{""})
+ require.Error(t, err)
}
func TestDisplaySystemdSchedules(t *testing.T) {
@@ -78,8 +79,8 @@ func TestDisplaySystemdSchedules(t *testing.T) {
term.SetOutput(buffer)
defer term.SetOutput(os.Stdout)
- err = displaySystemdSchedules("command", []string{"daily"})
- assert.NoError(t, err)
+ err = displaySystemdSchedules("profile", "command", []string{"daily"})
+ require.NoError(t, err)
output := buffer.String()
assert.Contains(t, output, "Original form: daily")
@@ -87,9 +88,9 @@ func TestDisplaySystemdSchedules(t *testing.T) {
}
func TestDisplaySystemdSchedulesError(t *testing.T) {
- if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
+ if !platform.IsWindows() && !platform.IsDarwin() {
t.Skip()
}
- err := displaySystemdSchedules("command", []string{"daily"})
- assert.Error(t, err)
+ err := displaySystemdSchedules("profile", "command", []string{"daily"})
+ require.Error(t, err)
}
diff --git a/schedule/spaced_title.go b/schedule/spaced_title.go
new file mode 100644
index 00000000..c81421a9
--- /dev/null
+++ b/schedule/spaced_title.go
@@ -0,0 +1,18 @@
+//go:build darwin
+
+package schedule
+
+import "strings"
+
+func spacedTitle(title string) string {
+ var previous rune
+ sb := strings.Builder{}
+ for _, char := range title {
+ if char >= 'A' && char <= 'Z' && previous != ' ' && sb.Len() > 0 {
+ sb.WriteByte(' ')
+ }
+ sb.WriteRune(char)
+ previous = char
+ }
+ return sb.String()
+}
diff --git a/schedule/spaced_title_test.go b/schedule/spaced_title_test.go
new file mode 100644
index 00000000..26e14199
--- /dev/null
+++ b/schedule/spaced_title_test.go
@@ -0,0 +1,25 @@
+//go:build darwin
+
+package schedule
+
+import "testing"
+
+func TestSpacedTitle(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"NoSpacesHere", "No Spaces Here"},
+ {"Already Spaced", "Already Spaced"},
+ {"", ""},
+ {"lowercase", "lowercase"},
+ {"ALLCAPS", "A L L C A P S"},
+ }
+
+ for _, test := range tests {
+ result := spacedTitle(test.input)
+ if result != test.expected {
+ t.Errorf("spacedTitle(%q) = %q; expected %q", test.input, result, test.expected)
+ }
+ }
+}
diff --git a/schedule/systemd_unit.go b/schedule/systemd_unit.go
new file mode 100644
index 00000000..54a43ae7
--- /dev/null
+++ b/schedule/systemd_unit.go
@@ -0,0 +1,11 @@
+//go:build !darwin && !windows
+
+package schedule
+
+type SystemdUnit struct {
+ Unit string `json:"unit"`
+ Load string `json:"load"`
+ Active string `json:"active"`
+ Sub string `json:"sub"`
+ Description string `json:"description"`
+}
diff --git a/schedule/tree_darwin.go b/schedule/tree_darwin.go
index 872a2d9d..89dcd204 100644
--- a/schedule/tree_darwin.go
+++ b/schedule/tree_darwin.go
@@ -36,7 +36,7 @@ func generateTreeOfSchedules(event *calendar.Event) []*treeElement {
// add each new element to the child of all the current elements
element.subElements = make([]*treeElement, len(subTree))
copy(element.subElements, subTree)
- // the new current element is a slice concatening all the child slices into one big one
+ // the new current element is a slice concatenating all the child slices into one big one
newCurrentElements = append(newCurrentElements, element.subElements...)
}
// full horizontal view of the current row of the tree
diff --git a/schedule_jobs.go b/schedule_jobs.go
index 2ca0440a..bbebaa13 100644
--- a/schedule_jobs.go
+++ b/schedule_jobs.go
@@ -10,7 +10,7 @@ import (
"github.com/creativeprojects/resticprofile/schedule"
)
-func scheduleJobs(handler schedule.Handler, profileName string, configs []*config.Schedule) error {
+func scheduleJobs(handler schedule.Handler, configs []*config.Schedule) error {
wd, err := os.Getwd()
if err != nil {
return err
@@ -20,12 +20,11 @@ func scheduleJobs(handler schedule.Handler, profileName string, configs []*confi
return err
}
- scheduler := schedule.NewScheduler(handler, profileName)
- err = scheduler.Init()
+ err = handler.Init()
if err != nil {
return err
}
- defer scheduler.Close()
+ defer handler.Close()
for _, cfg := range configs {
scheduleConfig := scheduleToConfig(cfg)
@@ -44,7 +43,7 @@ func scheduleJobs(handler schedule.Handler, profileName string, configs []*confi
scheduleConfig.TimerDescription =
fmt.Sprintf("%s timer for profile %s in %s", scheduleConfig.CommandName, scheduleConfig.ProfileName, scheduleConfig.ConfigFile)
- job := scheduler.NewJob(scheduleConfig)
+ job := schedule.NewJob(handler, scheduleConfig)
err = job.Create()
if err != nil {
return fmt.Errorf("error creating job %s/%s: %w",
@@ -57,17 +56,16 @@ func scheduleJobs(handler schedule.Handler, profileName string, configs []*confi
return nil
}
-func removeJobs(handler schedule.Handler, profileName string, configs []*config.Schedule) error {
- scheduler := schedule.NewScheduler(handler, profileName)
- err := scheduler.Init()
+func removeJobs(handler schedule.Handler, configs []*config.Schedule) error {
+ err := handler.Init()
if err != nil {
return err
}
- defer scheduler.Close()
+ defer handler.Close()
for _, cfg := range configs {
scheduleConfig := scheduleToConfig(cfg)
- job := scheduler.NewJob(scheduleConfig)
+ job := schedule.NewJob(handler, scheduleConfig)
// Skip over non-accessible, RemoveOnly jobs since they may not exist and must not causes errors
if job.RemoveOnly() && !job.Accessible() {
@@ -77,10 +75,10 @@ func removeJobs(handler schedule.Handler, profileName string, configs []*config.
// Try to remove the job
err := job.Remove()
if err != nil {
- if errors.Is(err, schedule.ErrServiceNotFound) {
+ if errors.Is(err, schedule.ErrScheduledJobNotFound) {
// Display a warning and keep going. Skip message for RemoveOnly jobs since they may not exist
if !job.RemoveOnly() {
- clog.Warningf("service %s/%s not found", scheduleConfig.ProfileName, scheduleConfig.CommandName)
+ clog.Warningf("scheduled job %s/%s not found", scheduleConfig.ProfileName, scheduleConfig.CommandName)
}
continue
}
@@ -95,27 +93,57 @@ func removeJobs(handler schedule.Handler, profileName string, configs []*config.
return nil
}
+func removeScheduledJobs(handler schedule.Handler, configFile, profileName string) error {
+ err := handler.Init()
+ if err != nil {
+ return err
+ }
+ defer handler.Close()
+
+ clog.Debugf("looking up schedules from configuration file %s", configFile)
+ configs, err := handler.Scheduled(profileName)
+ if err != nil {
+ return err
+ }
+ if len(configs) == 0 {
+ clog.Info("no scheduled jobs found")
+ return nil
+ }
+ for _, cfg := range configs {
+ if cfg.ConfigFile != configFile {
+ clog.Debugf("skipping job %s/%s from configuration file %s", cfg.ProfileName, cfg.CommandName, cfg.ConfigFile)
+ continue
+ }
+ job := schedule.NewJob(handler, &cfg)
+ err = job.Remove()
+ if err != nil {
+ clog.Error(err)
+ }
+ clog.Infof("scheduled job %s/%s removed", cfg.ProfileName, cfg.CommandName)
+ }
+ return nil
+}
+
func statusJobs(handler schedule.Handler, profileName string, configs []*config.Schedule) error {
- scheduler := schedule.NewScheduler(handler, profileName)
- err := scheduler.Init()
+ err := handler.Init()
if err != nil {
return err
}
- defer scheduler.Close()
+ defer handler.Close()
for _, cfg := range configs {
scheduleConfig := scheduleToConfig(cfg)
- job := scheduler.NewJob(scheduleConfig)
+ job := schedule.NewJob(handler, scheduleConfig)
err := job.Status()
if err != nil {
- if errors.Is(err, schedule.ErrServiceNotFound) {
+ if errors.Is(err, schedule.ErrScheduledJobNotFound) {
// Display a warning and keep going
- clog.Warningf("service %s/%s not found", scheduleConfig.ProfileName, scheduleConfig.CommandName)
+ clog.Warningf("scheduled job %s/%s not found", scheduleConfig.ProfileName, scheduleConfig.CommandName)
continue
}
- if errors.Is(err, schedule.ErrServiceNotRunning) {
+ if errors.Is(err, schedule.ErrScheduledJobNotRunning) {
// Display a warning and keep going
- clog.Warningf("service %s/%s is not running", scheduleConfig.ProfileName, scheduleConfig.CommandName)
+ clog.Warningf("scheduled job %s/%s is not running", scheduleConfig.ProfileName, scheduleConfig.CommandName)
continue
}
return fmt.Errorf("error querying status of job %s/%s: %w",
@@ -124,7 +152,44 @@ func statusJobs(handler schedule.Handler, profileName string, configs []*config.
err)
}
}
- scheduler.DisplayStatus()
+ err = handler.DisplayStatus(profileName)
+ if err != nil {
+ clog.Error(err)
+ }
+ return nil
+}
+
+func statusScheduledJobs(handler schedule.Handler, configFile, profileName string) error {
+ err := handler.Init()
+ if err != nil {
+ return err
+ }
+ defer handler.Close()
+
+ clog.Debugf("looking up schedules from configuration file %s", configFile)
+ configs, err := handler.Scheduled(profileName)
+ if err != nil {
+ return err
+ }
+ if len(configs) == 0 {
+ clog.Info("no scheduled jobs found")
+ return nil
+ }
+ for _, cfg := range configs {
+ if cfg.ConfigFile != configFile {
+ clog.Debugf("skipping job %s/%s from configuration file %s", cfg.ProfileName, cfg.CommandName, cfg.ConfigFile)
+ continue
+ }
+ job := schedule.NewJob(handler, &cfg)
+ err := job.Status()
+ if err != nil {
+ clog.Error(err)
+ }
+ }
+ err = handler.DisplayStatus(profileName)
+ if err != nil {
+ clog.Error(err)
+ }
return nil
}
@@ -135,22 +200,20 @@ func scheduleToConfig(sched *config.Schedule) *schedule.Config {
return schedule.NewRemoveOnlyConfig(origin.Name, origin.Command)
}
return &schedule.Config{
- ProfileName: origin.Name,
- CommandName: origin.Command,
- Schedules: sched.Schedules,
- Permission: sched.Permission,
- WorkingDirectory: "",
- Command: "",
- Arguments: schedule.NewCommandArguments(nil),
- Environment: sched.Environment,
- JobDescription: "",
- TimerDescription: "",
- Priority: sched.Priority,
- ConfigFile: sched.ConfigFile,
- Flags: sched.Flags,
- IgnoreOnBattery: sched.IgnoreOnBattery.IsTrue(),
- IgnoreOnBatteryLessThan: sched.IgnoreOnBatteryLessThan,
- AfterNetworkOnline: sched.AfterNetworkOnline.IsTrue(),
- SystemdDropInFiles: sched.SystemdDropInFiles,
+ ProfileName: origin.Name,
+ CommandName: origin.Command,
+ Schedules: sched.Schedules,
+ Permission: sched.Permission,
+ WorkingDirectory: "",
+ Command: "",
+ Arguments: schedule.NewCommandArguments(nil),
+ Environment: sched.Environment,
+ JobDescription: "",
+ TimerDescription: "",
+ Priority: sched.Priority,
+ ConfigFile: sched.ConfigFile,
+ Flags: sched.Flags,
+ AfterNetworkOnline: sched.AfterNetworkOnline.IsTrue(),
+ SystemdDropInFiles: sched.SystemdDropInFiles,
}
}
diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go
index a6e26829..ef3d8ddf 100644
--- a/schedule_jobs_test.go
+++ b/schedule_jobs_test.go
@@ -25,7 +25,7 @@ func TestScheduleNilJobs(t *testing.T) {
handler.EXPECT().Init().Return(nil)
handler.EXPECT().Close()
- err := scheduleJobs(handler, "profile", nil)
+ err := scheduleJobs(handler, nil)
assert.NoError(t, err)
}
@@ -36,7 +36,7 @@ func TestSimpleScheduleJob(t *testing.T) {
handler.EXPECT().Init().Return(nil)
handler.EXPECT().Close()
handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil)
- handler.EXPECT().DisplayParsedSchedules("backup", []*calendar.Event{{}})
+ handler.EXPECT().DisplaySchedules("profile", "backup", []string{"sched"}).Return(nil)
handler.EXPECT().CreateJob(
mock.AnythingOfType("*schedule.Config"),
mock.AnythingOfType("[]*calendar.Event"),
@@ -49,7 +49,7 @@ func TestSimpleScheduleJob(t *testing.T) {
scheduleConfig := configForJob("backup", "sched")
scheduleConfig.ConfigFile = "config file"
- err := scheduleJobs(handler, "profile", []*config.Schedule{scheduleConfig})
+ err := scheduleJobs(handler, []*config.Schedule{scheduleConfig})
assert.NoError(t, err)
}
@@ -60,7 +60,7 @@ func TestFailScheduleJob(t *testing.T) {
handler.EXPECT().Init().Return(nil)
handler.EXPECT().Close()
handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil)
- handler.EXPECT().DisplayParsedSchedules("backup", []*calendar.Event{{}})
+ handler.EXPECT().DisplaySchedules("profile", "backup", []string{"sched"}).Return(nil)
handler.EXPECT().CreateJob(
mock.AnythingOfType("*schedule.Config"),
mock.AnythingOfType("[]*calendar.Event"),
@@ -68,7 +68,7 @@ func TestFailScheduleJob(t *testing.T) {
Return(errors.New("error creating job"))
scheduleConfig := configForJob("backup", "sched")
- err := scheduleJobs(handler, "profile", []*config.Schedule{scheduleConfig})
+ err := scheduleJobs(handler, []*config.Schedule{scheduleConfig})
assert.Error(t, err)
}
@@ -79,7 +79,7 @@ func TestRemoveNilJobs(t *testing.T) {
handler.EXPECT().Init().Return(nil)
handler.EXPECT().Close()
- err := removeJobs(handler, "profile", nil)
+ err := removeJobs(handler, nil)
assert.NoError(t, err)
}
@@ -97,7 +97,7 @@ func TestRemoveJob(t *testing.T) {
})
scheduleConfig := configForJob("backup", "sched")
- err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig})
+ err := removeJobs(handler, []*config.Schedule{scheduleConfig})
assert.NoError(t, err)
}
@@ -115,7 +115,7 @@ func TestRemoveJobNoConfig(t *testing.T) {
})
scheduleConfig := configForJob("backup")
- err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig})
+ err := removeJobs(handler, []*config.Schedule{scheduleConfig})
assert.NoError(t, err)
}
@@ -129,7 +129,7 @@ func TestFailRemoveJob(t *testing.T) {
Return(errors.New("error removing job"))
scheduleConfig := configForJob("backup", "sched")
- err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig})
+ err := removeJobs(handler, []*config.Schedule{scheduleConfig})
assert.Error(t, err)
}
@@ -140,10 +140,10 @@ func TestNoFailRemoveUnknownJob(t *testing.T) {
handler.EXPECT().Init().Return(nil)
handler.EXPECT().Close()
handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")).
- Return(schedule.ErrServiceNotFound)
+ Return(schedule.ErrScheduledJobNotFound)
scheduleConfig := configForJob("backup", "sched")
- err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig})
+ err := removeJobs(handler, []*config.Schedule{scheduleConfig})
assert.NoError(t, err)
}
@@ -154,10 +154,10 @@ func TestNoFailRemoveUnknownRemoveOnlyJob(t *testing.T) {
handler.EXPECT().Init().Return(nil)
handler.EXPECT().Close()
handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")).
- Return(schedule.ErrServiceNotFound)
+ Return(schedule.ErrScheduledJobNotFound)
scheduleConfig := configForJob("backup")
- err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig})
+ err := removeJobs(handler, []*config.Schedule{scheduleConfig})
assert.NoError(t, err)
}
@@ -179,8 +179,7 @@ func TestStatusJob(t *testing.T) {
handler := mocks.NewHandler(t)
handler.EXPECT().Init().Return(nil)
handler.EXPECT().Close()
- handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil)
- handler.EXPECT().DisplayParsedSchedules("backup", []*calendar.Event{{}})
+ handler.EXPECT().DisplaySchedules("profile", "backup", []string{"sched"}).Return(nil)
handler.EXPECT().DisplayJobStatus(mock.AnythingOfType("*schedule.Config")).Return(nil)
handler.EXPECT().DisplayStatus("profile").Return(nil)
@@ -200,3 +199,72 @@ func TestStatusRemoveOnlyJob(t *testing.T) {
err := statusJobs(handler, "profile", []*config.Schedule{scheduleConfig})
assert.Error(t, err)
}
+
+func TestRemoveScheduledJobs(t *testing.T) {
+ testCases := []struct {
+ removeProfileName string
+ fromConfigFile string
+ scheduledConfigs []schedule.Config
+ removedConfigs []schedule.Config
+ permission string
+ }{
+ {
+ removeProfileName: "profile_no_config",
+ fromConfigFile: "configFile",
+ scheduledConfigs: []schedule.Config{},
+ removedConfigs: []schedule.Config{},
+ permission: "user",
+ },
+ {
+ removeProfileName: "profile_one_config_to_remove",
+ fromConfigFile: "configFile",
+ scheduledConfigs: []schedule.Config{
+ {
+ ProfileName: "profile_one_config_to_remove",
+ CommandName: "backup",
+ ConfigFile: "configFile",
+ Permission: "user",
+ },
+ },
+ removedConfigs: []schedule.Config{
+ {
+ ProfileName: "profile_one_config_to_remove",
+ CommandName: "backup",
+ ConfigFile: "configFile",
+ Permission: "user",
+ },
+ },
+ permission: "user",
+ },
+ {
+ removeProfileName: "profile_different_config_file",
+ fromConfigFile: "configFile",
+ scheduledConfigs: []schedule.Config{
+ {
+ ProfileName: "profile_different_config_file",
+ CommandName: "backup",
+ ConfigFile: "other_configFile",
+ Permission: "user",
+ },
+ },
+ removedConfigs: []schedule.Config{},
+ permission: "user",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.removeProfileName, func(t *testing.T) {
+ handler := mocks.NewHandler(t)
+ handler.EXPECT().Init().Return(nil)
+ handler.EXPECT().Close()
+
+ handler.EXPECT().Scheduled(tc.removeProfileName).Return(tc.scheduledConfigs, nil)
+ for _, cfg := range tc.removedConfigs {
+ handler.EXPECT().RemoveJob(&cfg, tc.permission).Return(nil)
+ }
+
+ err := removeScheduledJobs(handler, tc.fromConfigFile, tc.removeProfileName)
+ assert.NoError(t, err)
+ })
+ }
+}
diff --git a/schtasks/taskscheduler.go b/schtasks/taskscheduler.go
index 5de4dfb0..99a2e6e5 100644
--- a/schtasks/taskscheduler.go
+++ b/schtasks/taskscheduler.go
@@ -519,7 +519,7 @@ func Delete(title, subtitle string) error {
taskName := getTaskPath(title, subtitle)
err := taskService.DeleteTask(taskName)
if err != nil {
- if strings.Contains(err.Error(), "doesn't exist") {
+ if strings.Contains(err.Error(), "doesn't exist") || strings.Contains(err.Error(), "cannot find") {
return fmt.Errorf("%w: %s", ErrNotRegistered, taskName)
}
return err
@@ -562,6 +562,45 @@ func getTaskPath(profileName, commandName string) string {
return fmt.Sprintf("%s%s %s", tasksPath, profileName, commandName)
}
+func Registered() ([]Config, error) {
+ if !IsConnected() {
+ return nil, ErrNotConnected
+ }
+
+ tasks, err := taskService.GetRegisteredTasks()
+ if err != nil {
+ return nil, err
+ }
+ configs := make([]Config, 0, len(tasks))
+ for _, task := range tasks {
+ if !strings.HasPrefix(task.Path, tasksPath) {
+ continue
+ }
+ taskPath := strings.TrimPrefix(task.Path, tasksPath)
+ parts := strings.Split(taskPath, " ")
+ if len(parts) < 2 {
+ clog.Warningf("cannot parse task path: %s", task.Path)
+ continue
+ }
+ profileName := strings.Join(parts[:len(parts)-1], " ")
+ commandName := parts[len(parts)-1]
+ cfg := Config{
+ ProfileName: profileName,
+ CommandName: commandName,
+ JobDescription: task.Definition.RegistrationInfo.Description,
+ }
+ if len(task.Definition.Actions) > 0 {
+ if action, ok := task.Definition.Actions[0].(taskmaster.ExecAction); ok {
+ cfg.WorkingDirectory = action.WorkingDir
+ cfg.Command = action.Path
+ cfg.Arguments = action.Args
+ }
+ }
+ configs = append(configs, cfg)
+ }
+ return configs, nil
+}
+
// compileDifferences is creating two slices: the first one is the duration between each trigger,
// the second one is a list of all the differences in between
//
diff --git a/schtasks/taskscheduler_test.go b/schtasks/taskscheduler_test.go
index 0bedfcb9..bd247189 100644
--- a/schtasks/taskscheduler_test.go
+++ b/schtasks/taskscheduler_test.go
@@ -114,8 +114,8 @@ func TestCompileDifferences(t *testing.T) {
require.NoError(t, err)
start := event.Next(ref)
diff, uniques := compileDifferences(event.GetAllInBetween(start, start.Add(24*time.Hour)))
- assert.ElementsMatch(t, testItem.differences, diff)
- assert.ElementsMatch(t, testItem.unique, uniques)
+ assert.ElementsMatch(t, testItem.differences, diff, "duration between triggers")
+ assert.ElementsMatch(t, testItem.unique, uniques, "unique set of durations between triggers")
}
}
@@ -140,6 +140,8 @@ func TestTaskSchedulerConversion(t *testing.T) {
task := taskmaster.Definition{}
createSchedules(&task, schedules)
+ require.Len(t, task.Triggers, 5)
+
// 1st task should be a single event
singleEvent, ok := task.Triggers[0].(taskmaster.TimeTrigger)
require.True(t, ok)
@@ -390,3 +392,54 @@ func exportTask(taskName string) (string, error) {
err := cmd.Run()
return buffer.String(), err
}
+
+func TestRegisteredTasks(t *testing.T) {
+ tasks := []Config{
+ {
+ ProfileName: "test1",
+ CommandName: "backup",
+ Command: "echo",
+ Arguments: "hello there",
+ WorkingDirectory: "C:\\",
+ JobDescription: "test1",
+ },
+ {
+ ProfileName: "test 2",
+ CommandName: "check",
+ Command: "echo",
+ Arguments: "hello there",
+ WorkingDirectory: "C:\\",
+ JobDescription: "test 2",
+ },
+ {
+ ProfileName: "test 3",
+ CommandName: "forget",
+ Command: "echo",
+ Arguments: "hello there",
+ WorkingDirectory: "C:\\",
+ JobDescription: "test 3",
+ },
+ }
+ err := Connect()
+ defer Close()
+ assert.NoError(t, err)
+
+ event := calendar.NewEvent()
+ err = event.Parse("2020-01-02 03:04") // will never get triggered
+ require.NoError(t, err)
+
+ for _, task := range tasks {
+ // user logged in doesn't need a password
+ err = createUserLoggedOnTask(&task, []*calendar.Event{event})
+ assert.NoError(t, err)
+
+ defer func() {
+ _ = Delete(task.ProfileName, task.CommandName)
+ }()
+ }
+
+ registeredTasks, err := Registered()
+ assert.NoError(t, err)
+
+ assert.ElementsMatch(t, tasks, registeredTasks)
+}
diff --git a/shell/split_arguments.go b/shell/split_arguments.go
new file mode 100644
index 00000000..59a74144
--- /dev/null
+++ b/shell/split_arguments.go
@@ -0,0 +1,32 @@
+package shell
+
+import (
+ "strings"
+
+ "github.com/creativeprojects/resticprofile/platform"
+)
+
+func SplitArguments(commandLine string) []string {
+ args := make([]string, 0)
+ sb := &strings.Builder{}
+ quoted := false
+ escaped := false
+ for _, r := range commandLine {
+ if r == '\\' && !escaped && !platform.IsWindows() {
+ escaped = true
+ } else if r == '"' && !escaped {
+ quoted = !quoted
+ escaped = false
+ } else if !quoted && !escaped && r == ' ' {
+ args = append(args, sb.String())
+ sb.Reset()
+ } else {
+ sb.WriteRune(r)
+ escaped = false
+ }
+ }
+ if sb.Len() > 0 {
+ args = append(args, sb.String())
+ }
+ return args
+}
diff --git a/shell/split_arguments_test.go b/shell/split_arguments_test.go
new file mode 100644
index 00000000..94838bbd
--- /dev/null
+++ b/shell/split_arguments_test.go
@@ -0,0 +1,78 @@
+package shell
+
+import (
+ "testing"
+
+ "github.com/creativeprojects/resticprofile/platform"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSplitArguments(t *testing.T) {
+ testCases := []struct {
+ commandLine string
+ expectedArgs []string
+ windowsMode bool
+ unixMode bool
+ }{
+ {
+ commandLine: `cmd arg1 arg2`,
+ expectedArgs: []string{"cmd", "arg1", "arg2"},
+ },
+ {
+ commandLine: `cmd "arg with spaces" arg3`,
+ expectedArgs: []string{"cmd", "arg with spaces", "arg3"},
+ },
+ {
+ commandLine: `cmd "arg with spaces" "another arg"`,
+ expectedArgs: []string{"cmd", "arg with spaces", "another arg"},
+ },
+ {
+ commandLine: `cmd "arg with spaces"`,
+ expectedArgs: []string{"cmd", "arg with spaces"},
+ },
+ {
+ commandLine: `cmd`,
+ expectedArgs: []string{"cmd"},
+ },
+ {
+ commandLine: `"cmd file"`,
+ expectedArgs: []string{"cmd file"},
+ },
+ {
+ commandLine: `"cmd file" arg`,
+ expectedArgs: []string{"cmd file", "arg"},
+ },
+ {
+ commandLine: `cmd "arg \"with\" spaces"`,
+ expectedArgs: []string{"cmd", "arg \"with\" spaces"},
+ unixMode: true,
+ },
+ {
+ commandLine: `cmd arg\ with\ spaces`,
+ expectedArgs: []string{"cmd", "arg with spaces"},
+ unixMode: true,
+ },
+ {
+ commandLine: `args --with folder/file.txt`,
+ expectedArgs: []string{"args", "--with", "folder/file.txt"},
+ },
+ {
+ commandLine: `args --with folder\file.txt`,
+ expectedArgs: []string{"args", "--with", "folder\\file.txt"},
+ windowsMode: true,
+ },
+ }
+
+ for _, testCase := range testCases {
+ if testCase.windowsMode && !platform.IsWindows() {
+ continue
+ }
+ if testCase.unixMode && platform.IsWindows() {
+ continue
+ }
+ t.Run(testCase.commandLine, func(t *testing.T) {
+ args := SplitArguments(testCase.commandLine)
+ assert.Equal(t, testCase.expectedArgs, args)
+ })
+ }
+}
diff --git a/systemd/drop_ins.go b/systemd/drop_ins.go
index 35a5d26d..589241b3 100644
--- a/systemd/drop_ins.go
+++ b/systemd/drop_ins.go
@@ -65,7 +65,7 @@ func CreateDropIns(dir string, files []string) error {
_, notOrphaned := fileBasenamesOwned[f.Name()]
if createdByUs && !notOrphaned {
orphanPath := filepath.Join(dir, f.Name())
- clog.Infof("deleting orphaned drop-in file %v", orphanPath)
+ clog.Debugf("deleting orphaned drop-in file %v", orphanPath)
if err := fs.Remove(orphanPath); err != nil {
return err
}
@@ -78,15 +78,19 @@ func CreateDropIns(dir string, files []string) error {
// to signify it wasn't created outside of resticprofile, i.e. we own it
dropInFileOwned := getOwnedName(dropInFileBase)
dstPath := filepath.Join(dir, dropInFileOwned)
- clog.Infof("writing %v", dstPath)
+ clog.Debugf("writing %v", dstPath)
dst, err := fs.Create(dstPath)
if err != nil {
return err
}
+ defer dst.Close()
+
src, err := fs.Open(dropInFilePath)
if err != nil {
return err
}
+ defer src.Close()
+
if _, err := io.Copy(dst, src); err != nil {
return err
}
diff --git a/systemd/generate.go b/systemd/generate.go
index 672b8cb7..d44912da 100644
--- a/systemd/generate.go
+++ b/systemd/generate.go
@@ -172,7 +172,7 @@ func Generate(config Config) error {
return err
}
filePathName := filepath.Join(systemdUserDir, systemdProfile)
- clog.Infof("writing %v", filePathName)
+ clog.Debugf("writing %v", filePathName)
if err = afero.WriteFile(fs, filePathName, data.Bytes(), defaultPermission); err != nil {
return err
}
@@ -190,7 +190,7 @@ func Generate(config Config) error {
return err
}
filePathName = filepath.Join(systemdUserDir, timerProfile)
- clog.Infof("writing %v", filePathName)
+ clog.Debugf("writing %v", filePathName)
if err = afero.WriteFile(fs, filePathName, data.Bytes(), defaultPermission); err != nil {
return err
}
diff --git a/systemd/read.go b/systemd/read.go
new file mode 100644
index 00000000..19e11935
--- /dev/null
+++ b/systemd/read.go
@@ -0,0 +1,120 @@
+//go:build !darwin && !windows
+
+package systemd
+
+import (
+ "bytes"
+ "path"
+ "strconv"
+ "strings"
+
+ "github.com/spf13/afero"
+)
+
+func Read(unit string, unitType UnitType) (*Config, error) {
+ var err error
+ unitsDir := systemdSystemDir
+ if unitType == UserUnit {
+ unitsDir, err = GetUserDir()
+ if err != nil {
+ return nil, err
+ }
+ }
+ filename := path.Join(unitsDir, unit)
+ serviceSections, err := readSystemdUnit(filename)
+ if err != nil {
+ return nil, err
+ }
+ filename = strings.Replace(filename, ".service", ".timer", 1)
+ timerSections, err := readSystemdUnit(filename)
+ if err != nil {
+ return nil, err
+ }
+
+ profileName, commandName := parseServiceFileName(unit)
+ cfg := &Config{
+ Title: profileName,
+ SubTitle: commandName,
+ JobDescription: getSingleValue(serviceSections, "Unit", "Description"),
+ WorkingDirectory: getSingleValue(serviceSections, "Service", "WorkingDirectory"),
+ CommandLine: getSingleValue(serviceSections, "Service", "ExecStart"),
+ UnitType: unitType,
+ Environment: getValues(serviceSections, "Service", "Environment"),
+ Nice: getIntegerValue(serviceSections, "Service", "Nice"),
+ IOSchedulingClass: getIntegerValue(serviceSections, "Service", "IOSchedulingClass"),
+ IOSchedulingPriority: getIntegerValue(serviceSections, "Service", "IOSchedulingPriority"),
+ Schedules: getValues(timerSections, "Timer", "OnCalendar"),
+ Priority: "background", // TODO fix this hard-coded value
+ }
+ return cfg, nil
+}
+
+func readSystemdUnit(filename string) (map[string]map[string][]string, error) {
+ content, err := afero.ReadFile(fs, filename)
+ if err != nil {
+ return nil, err
+ }
+ currentSection := ""
+ sections := make(map[string]map[string][]string, 3)
+ lines := bytes.Split(content, []byte("\n"))
+ for _, line := range lines {
+ line = bytes.TrimSpace(line)
+ if len(line) == 0 {
+ continue
+ }
+ if bytes.HasPrefix(line, []byte("[")) && bytes.HasSuffix(line, []byte("]")) {
+ // start of a section
+ currentSection = string(bytes.TrimSpace(line[1 : len(line)-1]))
+ continue
+ }
+ if key, value, found := strings.Cut(string(line), "="); found {
+ value = strings.Trim(value, `"`)
+ if sections[currentSection] == nil {
+ sections[currentSection] = map[string][]string{
+ key: {value},
+ }
+ } else if sections[currentSection][key] == nil {
+ sections[currentSection][key] = []string{value}
+ } else {
+ sections[currentSection][key] = append(sections[currentSection][key], value)
+ }
+ }
+ }
+ return sections, nil
+}
+
+func getIntegerValue(from map[string]map[string][]string, section, key string) int {
+ str := getSingleValue(from, section, key)
+ value, _ := strconv.Atoi(str)
+ return value
+}
+
+func getSingleValue(from map[string]map[string][]string, section, key string) string {
+ if section, found := from[section]; found {
+ if values, found := section[key]; found {
+ if len(values) > 0 {
+ return values[0]
+ }
+ }
+ }
+ return ""
+}
+
+func getValues(from map[string]map[string][]string, section, key string) []string {
+ if section, found := from[section]; found {
+ if values, found := section[key]; found {
+ return values
+ }
+ }
+ return nil
+}
+
+// parseServiceFileName to detect profile and command names from the file name.
+// format is: `resticprofile-backup@profile-name.service`
+func parseServiceFileName(filename string) (profileName, commandName string) {
+ filename = strings.TrimPrefix(filename, "resticprofile-")
+ filename = strings.TrimSuffix(filename, ".service")
+ commandName, profileName, _ = strings.Cut(filename, "@")
+ profileName = strings.TrimPrefix(profileName, "profile-")
+ return
+}
diff --git a/systemd/read_test.go b/systemd/read_test.go
new file mode 100644
index 00000000..3b40c3c9
--- /dev/null
+++ b/systemd/read_test.go
@@ -0,0 +1,143 @@
+//go:build !darwin && !windows
+
+package systemd
+
+import (
+ "fmt"
+ "os"
+ "path"
+ "testing"
+
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ testServiceUnit = `[Unit]
+Description=resticprofile copy for profile self in examples/linux.yaml
+OnFailure=unit-status-mail@%n.service
+
+[Service]
+Type=notify
+WorkingDirectory=/home/linux/go/src/github.com/creativeprojects/resticprofile
+ExecStart=/tmp/go-build982790897/b001/exe/resticprofile --no-prio --no-ansi --config examples/linux.yaml run-schedule copy@self
+Nice=19
+IOSchedulingClass=3
+IOSchedulingPriority=7
+Environment="RESTICPROFILE_SCHEDULE_ID=examples/linux.yaml:copy@self"
+Environment="HOME=/home/linux"
+`
+ testTimerUnit = `[Unit]
+Description=copy timer for profile self in examples/linux.yaml
+
+[Timer]
+OnCalendar=*:45
+Unit=resticprofile-copy@profile-self.service
+Persistent=true
+
+[Install]
+WantedBy=timers.target`
+)
+
+func TestReadUnitFile(t *testing.T) {
+ fs = afero.NewMemMapFs()
+ unitFile := "resticprofile-copy@profile-self.service"
+ timerFile := "resticprofile-copy@profile-self.timer"
+ require.NoError(t, afero.WriteFile(fs, path.Join(systemdSystemDir, unitFile), []byte(testServiceUnit), 0o600))
+ require.NoError(t, afero.WriteFile(fs, path.Join(systemdSystemDir, timerFile), []byte(testTimerUnit), 0o600))
+
+ cfg, err := Read(unitFile, SystemUnit)
+ require.NoError(t, err)
+ assert.NotNil(t, cfg)
+
+ expected := &Config{
+ CommandLine: "/tmp/go-build982790897/b001/exe/resticprofile --no-prio --no-ansi --config examples/linux.yaml run-schedule copy@self",
+ Environment: []string{"RESTICPROFILE_SCHEDULE_ID=examples/linux.yaml:copy@self", "HOME=/home/linux"},
+ WorkingDirectory: "/home/linux/go/src/github.com/creativeprojects/resticprofile",
+ Title: "self",
+ SubTitle: "copy",
+ JobDescription: "resticprofile copy for profile self in examples/linux.yaml",
+ TimerDescription: "",
+ Schedules: []string{"*:45"},
+ UnitType: SystemUnit,
+ Priority: "background",
+ UnitFile: "",
+ TimerFile: "",
+ DropInFiles: []string(nil),
+ AfterNetworkOnline: false,
+ Nice: 19,
+ CPUSchedulingPolicy: "",
+ IOSchedulingClass: 3,
+ IOSchedulingPriority: 7,
+ }
+ assert.Equal(t, expected, cfg)
+}
+
+func TestReadSystemUnit(t *testing.T) {
+ testCases := []struct {
+ config Config
+ }{
+ {
+ config: Config{
+ CommandLine: "/bin/resticprofile --config profiles.yaml run-schedule backup@profile1",
+ WorkingDirectory: "/workdir",
+ Title: "profile1",
+ SubTitle: "backup",
+ JobDescription: "job description",
+ TimerDescription: "timer description",
+ Schedules: []string{"daily"},
+ UnitType: SystemUnit,
+ Priority: "background",
+ },
+ },
+ {
+ config: Config{
+ CommandLine: "/bin/resticprofile --no-ansi --config profiles.yaml run-schedule check@profile2",
+ WorkingDirectory: "/workdir",
+ Title: "profile2",
+ SubTitle: "check",
+ JobDescription: "",
+ TimerDescription: "timer description",
+ Schedules: []string{"daily", "weekly"},
+ UnitType: UserUnit,
+ Priority: "background",
+ Environment: []string{
+ "TMP=/tmp",
+ },
+ },
+ },
+ }
+
+ fs = afero.NewMemMapFs()
+
+ for _, tc := range testCases {
+ t.Run("", func(t *testing.T) {
+ baseUnit := fmt.Sprintf("resticprofile-%s@profile-%s", tc.config.SubTitle, tc.config.Title)
+ serviceFile := baseUnit + ".service"
+
+ err := Generate(tc.config)
+ require.NoError(t, err)
+
+ readCfg, err := Read(serviceFile, tc.config.UnitType)
+ require.NoError(t, err)
+ assert.NotNil(t, readCfg)
+
+ home, err := os.UserHomeDir()
+ require.NoError(t, err)
+
+ expected := &Config{
+ Title: tc.config.Title,
+ SubTitle: tc.config.SubTitle,
+ JobDescription: tc.config.JobDescription,
+ WorkingDirectory: tc.config.WorkingDirectory,
+ CommandLine: tc.config.CommandLine,
+ UnitType: tc.config.UnitType,
+ Environment: append(tc.config.Environment, "HOME="+home),
+ Schedules: tc.config.Schedules,
+ Priority: tc.config.Priority,
+ }
+ assert.Equal(t, expected, readCfg)
+ })
+ }
+}
diff --git a/term/term.go b/term/term.go
index 2ea2457e..f5debb69 100644
--- a/term/term.go
+++ b/term/term.go
@@ -98,7 +98,7 @@ func OsStdoutTerminalSize() (width, height int) {
}
func fdToInt(fd uintptr) int {
- return int(fd) //nolint:gosec
+ return int(fd)
}
type LockedWriter struct {