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 {