From e3174ad26b836eae9aab77825afe14e25da66977 Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 10 Mar 2025 20:47:38 +0000 Subject: [PATCH 01/33] refactoring handler darwin --- constants/value.go | 8 +- {schedule => darwin}/calendar_interval.go | 6 +- darwin/calendar_interval_test.go | 111 ++++++++++++++++++ darwin/launchd.go | 100 ++++++++++++++++ darwin/process_type.go | 25 ++++ darwin/session_type.go | 29 +++++ schedule/tree_darwin.go => darwin/tree.go | 2 +- .../tree_test.go | 2 +- schedule/calendar_interval_test.go | 55 --------- schedule/handler_darwin.go | 59 ++++------ schedule/handler_darwin_test.go | 70 ++--------- schedule/job.go | 8 +- schedule/permission.go | 53 +++++++-- schedule/permission_test.go | 44 +++++-- schedule/permission_unix.go | 22 ++++ schedule/permission_windows.go | 17 ++- 16 files changed, 420 insertions(+), 191 deletions(-) rename {schedule => darwin}/calendar_interval.go (96%) create mode 100644 darwin/calendar_interval_test.go create mode 100644 darwin/launchd.go create mode 100644 darwin/process_type.go create mode 100644 darwin/session_type.go rename schedule/tree_darwin.go => darwin/tree.go (99%) rename schedule/tree_darwin_test.go => darwin/tree_test.go (99%) delete mode 100644 schedule/calendar_interval_test.go diff --git a/constants/value.go b/constants/value.go index aeb255ed..830e810c 100644 --- a/constants/value.go +++ b/constants/value.go @@ -2,12 +2,14 @@ package constants // Parameter values const ( + SchedulePermissionAuto = "auto" + SchedulePermissionSystem = "system" SchedulePermissionUser = "user" SchedulePermissionUserLoggedOn = "user_logged_on" SchedulePermissionUserLoggedIn = "user_logged_in" - SchedulePermissionSystem = "system" - SchedulePriorityBackground = "background" - SchedulePriorityStandard = "standard" + + SchedulePriorityBackground = "background" + SchedulePriorityStandard = "standard" VerbosityNone = 0 VerbosityLevel1 = 1 diff --git a/schedule/calendar_interval.go b/darwin/calendar_interval.go similarity index 96% rename from schedule/calendar_interval.go rename to darwin/calendar_interval.go index bd5f7bfd..7c9b525f 100644 --- a/schedule/calendar_interval.go +++ b/darwin/calendar_interval.go @@ -1,6 +1,6 @@ //go:build darwin -package schedule +package darwin import "github.com/creativeprojects/resticprofile/calendar" @@ -64,7 +64,7 @@ func (c *CalendarInterval) clone() *CalendarInterval { // Day = 1 // // Total of 1 rule -func getCalendarIntervalsFromSchedules(schedules []*calendar.Event) []CalendarInterval { +func GetCalendarIntervalsFromSchedules(schedules []*calendar.Event) []CalendarInterval { entries := make([]CalendarInterval, 0, len(schedules)) for _, schedule := range schedules { entries = append(entries, getCalendarIntervalsFromScheduleTree(generateTreeOfSchedules(schedule))...) @@ -116,7 +116,7 @@ func setCalendarIntervalValueFromType(entry *CalendarInterval, value int, typeVa // 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 { +func ParseCalendarIntervals(intervals []CalendarInterval) []string { event := calendar.NewEvent(func(e *calendar.Event) { _ = e.Second.AddValue(0) }) diff --git a/darwin/calendar_interval_test.go b/darwin/calendar_interval_test.go new file mode 100644 index 00000000..84b2f9d5 --- /dev/null +++ b/darwin/calendar_interval_test.go @@ -0,0 +1,111 @@ +//go:build darwin + +package darwin + +import ( + "testing" + + "github.com/creativeprojects/resticprofile/calendar" + "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) + }) + } +} + +func TestGetCalendarIntervalsFromScheduleTree(t *testing.T) { + testData := []struct { + input string + expected []CalendarInterval + }{ + {"*-*-*", []CalendarInterval{ + {"Hour": 0, "Minute": 0}, + }}, + {"*:0,30", []CalendarInterval{ + {"Minute": 0}, + {"Minute": 30}, + }}, + {"0,12:20", []CalendarInterval{ + {"Hour": 0, "Minute": 20}, + {"Hour": 12, "Minute": 20}, + }}, + {"0,12:20,40", []CalendarInterval{ + {"Hour": 0, "Minute": 20}, + {"Hour": 0, "Minute": 40}, + {"Hour": 12, "Minute": 20}, + {"Hour": 12, "Minute": 40}, + }}, + {"Mon..Fri *-*-* *:0,30:00", []CalendarInterval{ + {"Weekday": 1, "Minute": 0}, + {"Weekday": 1, "Minute": 30}, + {"Weekday": 2, "Minute": 0}, + {"Weekday": 2, "Minute": 30}, + {"Weekday": 3, "Minute": 0}, + {"Weekday": 3, "Minute": 30}, + {"Weekday": 4, "Minute": 0}, + {"Weekday": 4, "Minute": 30}, + {"Weekday": 5, "Minute": 0}, + {"Weekday": 5, "Minute": 30}, + }}, + // First sunday of the month at 3:30am + {"Sun *-*-01..06 03:30:00", []CalendarInterval{ + {"Day": 1, "Weekday": 0, "Hour": 3, "Minute": 30}, + {"Day": 2, "Weekday": 0, "Hour": 3, "Minute": 30}, + {"Day": 3, "Weekday": 0, "Hour": 3, "Minute": 30}, + {"Day": 4, "Weekday": 0, "Hour": 3, "Minute": 30}, + {"Day": 5, "Weekday": 0, "Hour": 3, "Minute": 30}, + {"Day": 6, "Weekday": 0, "Hour": 3, "Minute": 30}, + }}, + } + + for _, testItem := range testData { + t.Run(testItem.input, func(t *testing.T) { + event := calendar.NewEvent() + err := event.Parse(testItem.input) + assert.NoError(t, err) + assert.ElementsMatch(t, testItem.expected, getCalendarIntervalsFromScheduleTree(generateTreeOfSchedules(event))) + }) + } +} diff --git a/darwin/launchd.go b/darwin/launchd.go new file mode 100644 index 00000000..17030463 --- /dev/null +++ b/darwin/launchd.go @@ -0,0 +1,100 @@ +//go:build darwin + +package darwin + +// LaunchdJob is an agent definition for launchd +// Documentation found from man launchd.plist(5) +type LaunchdJob struct { + // This required key uniquely identifies the job to launchd. + Label string `plist:"Label"` + // This key maps to the first argument of execv(3) and indicates the absolute path to the executable for the job. If this key is missing, then the first + // element of the array of strings provided to the ProgramArguments will be used instead. This key is required in the absence of the ProgramArguments and + // BundleProgram keys. + Program string `plist:"Program"` + // This key maps to the second argument of execvp(3) and specifies the argument vector to be passed to the job when a process is spawned. This key is + // required in the absence of the Program key. IMPORTANT: Many people are confused by this key. Please read execvp(3) very carefully! + // + // NOTE: The Program key must be an absolute path. Previous versions of launchd did not enforce this requirement but failed to run the job. In the absence + // of the Program key, the first element of the ProgramArguments array may be either an absolute path, or a relative path which is resolved using + // _PATH_STDPATH. + ProgramArguments []string `plist:"ProgramArguments"` + // This optional key is used to specify additional environmental variables to be set before running the job. Each key in the dictionary is the name of an + // environment variable, with the corresponding value being a string representing the desired value. NOTE: Values other than strings will be ignored. + EnvironmentVariables map[string]string `plist:"EnvironmentVariables,omitempty"` + // This optional key specifies that the given path should be mapped to the job's stdin(4), and that the contents of that file will be readable from the + // job's stdin(4). If the file does not exist, no data will be delivered to the process' stdin(4). + StandardInPath string `plist:"StandardInPath,omitempty"` + // This optional key specifies that the given path should be mapped to the job's stdout(4), and that any writes to the job's stdout(4) will go to the given + // file. If the file does not exist, it will be created with writable permissions and ownership reflecting the user and/or group specified as the UserName + // and/or GroupName, respectively (if set) and permissions reflecting the umask(2) specified by the Umask key, if set. + StandardOutPath string `plist:"StandardOutPath,omitempty"` + // This optional key specifies that the given path should be mapped to the job's stderr(4), and that any writes to the job's stderr(4) will go to the given + // file. Note that this file is opened as readable and writable as mandated by the POSIX specification for unclear reasons. If the file does not exist, it + // will be created with ownership reflecting the user and/or group specified as the UserName and/or GroupName, respectively (if set) and permissions + // reflecting the umask(2) specified by the Umask key, if set. + StandardErrorPath string `plist:"StandardErrorPath,omitempty"` + // This optional key is used to specify a directory to chdir(2) to before running the job. + WorkingDirectory string `plist:"WorkingDirectory"` + // This optional key causes the job to be started every calendar interval as specified. Missing arguments are considered to be wildcard. The semantics are + // similar to crontab(5) in how firing dates are specified. Multiple dictionaries may be specified in an array to schedule multiple calendar intervals. + // + // Unlike cron which skips job invocations when the computer is asleep, launchd will start the job the next time the computer wakes up. If multiple + // intervals transpire before the computer is woken, those events will be coalesced into one event upon wake from sleep. + // + // Note that StartInterval and StartCalendarInterval are not aware of each other. They are evaluated completely independently by the system. + // + // Minute + // The minute (0-59) on which this job will be run. + // + // Hour + // The hour (0-23) on which this job will be run. + // + // Day + // The day of the month (1-31) on which this job will be run. + // + // Weekday + // The weekday on which this job will be run (0 and 7 are Sunday). If both Day and Weekday are specificed, then the job will be started if either one + // matches the current date. + // + // Month + // The month (1-12) on which this job will be run. + StartCalendarInterval []CalendarInterval `plist:"StartCalendarInterval,omitempty"` + // ProcessType + // This optional key describes, at a high level, the intended purpose of the job. The system will apply resource limits based on what kind of job it is. If + // left unspecified, the system will apply light resource limits to the job, throttling its CPU usage and I/O bandwidth. This classification is preferable + // to using the HardResourceLimits, SoftResourceLimits and Nice keys. The following are valid values: + // + // Background + // Background jobs are generally processes that do work that was not directly requested by the user. The resource limits applied to Background jobs + // are intended to prevent them from disrupting the user experience. + // + // Standard + // Standard jobs are equivalent to no ProcessType being set. + // + // Adaptive + // Adaptive jobs move between the Background and Interactive classifications based on activity over XPC connections. See xpc_transaction_begin(3) for + // details. + // + // Interactive + // Interactive jobs run with the same resource limitations as apps, that is to say, none. Interactive jobs are critical to maintaining a responsive + // user experience, and this key should only be used if an app's ability to be responsive depends on it, and cannot be made Adaptive. + ProcessType ProcessType `plist:"ProcessType"` + // This optional key specifies whether the kernel should consider this daemon to be low priority when doing filesystem I/O. + LowPriorityIO bool `plist:"LowPriorityIO"` + // This optional key specifies whether the kernel should consider this daemon to be low priority when doing filesystem I/O when + // the process is throttled with the Darwin-background classification. + LowPriorityBackgroundIO bool `plist:"LowPriorityBackgroundIO"` + // This optional key specifies what nice(3) value should be applied to the daemon. + Nice int `plist:"Nice"` + // Aqua: + // a GUI agent; has access to all the GUI services + // LoginWindow: + // pre-login agent; runs in the login window context + // Background: + // runs in the parent context of the user + // StandardIO: + // runs only in non-GUI login session (e.g. SSH sessions) + // System: + // runs in the system context + LimitLoadToSessionType SessionType `plist:"LimitLoadToSessionType"` +} diff --git a/darwin/process_type.go b/darwin/process_type.go new file mode 100644 index 00000000..179d61d6 --- /dev/null +++ b/darwin/process_type.go @@ -0,0 +1,25 @@ +//go:build darwin + +package darwin + +import "github.com/creativeprojects/resticprofile/constants" + +type ProcessType string + +const ( + ProcessTypeBackground ProcessType = "Background" + ProcessTypeStandard ProcessType = "Standard" +) + +func NewProcessType(schedulePriority string) ProcessType { + switch schedulePriority { + case constants.SchedulePriorityBackground: + return ProcessTypeBackground + + case constants.SchedulePriorityStandard: + return ProcessTypeStandard + + default: + return "" + } +} diff --git a/darwin/session_type.go b/darwin/session_type.go new file mode 100644 index 00000000..8ee13874 --- /dev/null +++ b/darwin/session_type.go @@ -0,0 +1,29 @@ +//go:build darwin + +package darwin + +import "github.com/creativeprojects/resticprofile/constants" + +type SessionType string + +const ( + SessionTypeGUI SessionType = "Aqua" + SessionTypeBackground SessionType = "Background" +) + +func NewSessionType(permission string) SessionType { + switch permission { + case constants.SchedulePermissionSystem: + return SessionTypeBackground + + case constants.SchedulePermissionUser: + return SessionTypeBackground + + case constants.SchedulePermissionUserLoggedOn, constants.SchedulePermissionUserLoggedIn: + return SessionTypeGUI + + default: + // this was the only option available before 0.30.0 + return SessionTypeGUI + } +} diff --git a/schedule/tree_darwin.go b/darwin/tree.go similarity index 99% rename from schedule/tree_darwin.go rename to darwin/tree.go index 89dcd204..f513305b 100644 --- a/schedule/tree_darwin.go +++ b/darwin/tree.go @@ -1,6 +1,6 @@ //go:build darwin -package schedule +package darwin import "github.com/creativeprojects/resticprofile/calendar" diff --git a/schedule/tree_darwin_test.go b/darwin/tree_test.go similarity index 99% rename from schedule/tree_darwin_test.go rename to darwin/tree_test.go index 25d6f597..7862c948 100644 --- a/schedule/tree_darwin_test.go +++ b/darwin/tree_test.go @@ -1,6 +1,6 @@ //go:build darwin -package schedule +package darwin import ( "testing" diff --git a/schedule/calendar_interval_test.go b/schedule/calendar_interval_test.go deleted file mode 100644 index 8936de14..00000000 --- a/schedule/calendar_interval_test.go +++ /dev/null @@ -1,55 +0,0 @@ -//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/handler_darwin.go b/schedule/handler_darwin.go index 94a00c21..d1db55a9 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -16,6 +16,7 @@ import ( "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/darwin" "github.com/creativeprojects/resticprofile/term" "github.com/creativeprojects/resticprofile/util" "github.com/spf13/afero" @@ -45,28 +46,6 @@ const ( codeServiceNotFound = 113 ) -// LaunchJob is an agent definition for launchd -type LaunchdJob struct { - Label string `plist:"Label"` - Program string `plist:"Program"` - ProgramArguments []string `plist:"ProgramArguments"` - EnvironmentVariables map[string]string `plist:"EnvironmentVariables,omitempty"` - StandardInPath string `plist:"StandardInPath,omitempty"` - StandardOutPath string `plist:"StandardOutPath,omitempty"` - StandardErrorPath string `plist:"StandardErrorPath,omitempty"` - WorkingDirectory string `plist:"WorkingDirectory"` - StartInterval int `plist:"StartInterval,omitempty"` - StartCalendarInterval []CalendarInterval `plist:"StartCalendarInterval,omitempty"` - ProcessType string `plist:"ProcessType"` - LowPriorityIO bool `plist:"LowPriorityIO"` - Nice int `plist:"Nice"` -} - -var priorityValues = map[string]string{ - constants.SchedulePriorityBackground: "Background", - constants.SchedulePriorityStandard: "Standard", -} - type HandlerLaunchd struct { config SchedulerConfig fs afero.Fs @@ -222,7 +201,7 @@ func (h *HandlerLaunchd) Scheduled(profileName string) ([]Config, error) { return jobs, nil } -func (h *HandlerLaunchd) getLaunchdJob(job *Config, schedules []*calendar.Event) *LaunchdJob { +func (h *HandlerLaunchd) getLaunchdJob(job *Config, schedules []*calendar.Event) *darwin.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 @@ -241,18 +220,20 @@ func (h *HandlerLaunchd) getLaunchdJob(job *Config, schedules []*calendar.Event) 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, + launchdJob := &darwin.LaunchdJob{ + Label: name, + Program: job.Command, + ProgramArguments: append([]string{job.Command, "--no-prio"}, job.Arguments.RawArgs()...), + StandardOutPath: logfile, + StandardErrorPath: logfile, + WorkingDirectory: job.WorkingDirectory, + StartCalendarInterval: darwin.GetCalendarIntervalsFromSchedules(schedules), + EnvironmentVariables: env.ValuesAsMap(), + Nice: nice, + ProcessType: darwin.NewProcessType(job.GetPriority()), + LowPriorityIO: lowPriorityIO, + LowPriorityBackgroundIO: lowPriorityIO, + LimitLoadToSessionType: darwin.NewSessionType(job.Permission), } return launchdJob } @@ -315,12 +296,12 @@ func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { ConfigFile: args.ConfigFile(), Arguments: args, WorkingDirectory: launchdJob.WorkingDirectory, - Schedules: parseCalendarIntervals(launchdJob.StartCalendarInterval), + Schedules: darwin.ParseCalendarIntervals(launchdJob.StartCalendarInterval), } return job, nil } -func (h *HandlerLaunchd) createPlistFile(launchdJob *LaunchdJob, permission string) (string, error) { +func (h *HandlerLaunchd) createPlistFile(launchdJob *darwin.LaunchdJob, permission string) (string, error) { filename, err := getFilename(launchdJob.Label, permission) if err != nil { return "", err @@ -344,7 +325,7 @@ func (h *HandlerLaunchd) createPlistFile(launchdJob *LaunchdJob, permission stri return filename, nil } -func (h *HandlerLaunchd) readPlistFile(filename string) (*LaunchdJob, error) { +func (h *HandlerLaunchd) readPlistFile(filename string) (*darwin.LaunchdJob, error) { file, err := h.fs.Open(filename) if err != nil { return nil, err @@ -352,7 +333,7 @@ func (h *HandlerLaunchd) readPlistFile(filename string) (*LaunchdJob, error) { defer file.Close() decoder := plist.NewDecoder(file) - launchdJob := new(LaunchdJob) + launchdJob := new(darwin.LaunchdJob) err = decoder.Decode(launchdJob) if err != nil { return nil, err diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 6ad95ac0..84dfe07e 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -10,6 +10,7 @@ import ( "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/darwin" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,10 +25,12 @@ func TestHandlerCrond(t *testing.T) { func TestPListEncoderWithCalendarInterval(t *testing.T) { expected := ` -Day1Hour0` - entry := newCalendarInterval() - setCalendarIntervalValueFromType(entry, 1, calendar.TypeDay) - setCalendarIntervalValueFromType(entry, 0, calendar.TypeHour) +Day1Hour0` + event := calendar.NewEvent(func(e *calendar.Event) { + _ = e.Day.AddValue(1) + _ = e.Hour.AddValue(0) + }) + entry := darwin.GetCalendarIntervalsFromSchedules([]*calendar.Event{event}) buffer := &bytes.Buffer{} encoder := plist.NewEncoder(buffer) err := encoder.Encode(entry) @@ -35,61 +38,6 @@ func TestPListEncoderWithCalendarInterval(t *testing.T) { assert.Equal(t, expected, buffer.String()) } -func TestGetCalendarIntervalsFromScheduleTree(t *testing.T) { - testData := []struct { - input string - expected []CalendarInterval - }{ - {"*-*-*", []CalendarInterval{ - {"Hour": 0, "Minute": 0}, - }}, - {"*:0,30", []CalendarInterval{ - {"Minute": 0}, - {"Minute": 30}, - }}, - {"0,12:20", []CalendarInterval{ - {"Hour": 0, "Minute": 20}, - {"Hour": 12, "Minute": 20}, - }}, - {"0,12:20,40", []CalendarInterval{ - {"Hour": 0, "Minute": 20}, - {"Hour": 0, "Minute": 40}, - {"Hour": 12, "Minute": 20}, - {"Hour": 12, "Minute": 40}, - }}, - {"Mon..Fri *-*-* *:0,30:00", []CalendarInterval{ - {"Weekday": 1, "Minute": 0}, - {"Weekday": 1, "Minute": 30}, - {"Weekday": 2, "Minute": 0}, - {"Weekday": 2, "Minute": 30}, - {"Weekday": 3, "Minute": 0}, - {"Weekday": 3, "Minute": 30}, - {"Weekday": 4, "Minute": 0}, - {"Weekday": 4, "Minute": 30}, - {"Weekday": 5, "Minute": 0}, - {"Weekday": 5, "Minute": 30}, - }}, - // First sunday of the month at 3:30am - {"Sun *-*-01..06 03:30:00", []CalendarInterval{ - {"Day": 1, "Weekday": 0, "Hour": 3, "Minute": 30}, - {"Day": 2, "Weekday": 0, "Hour": 3, "Minute": 30}, - {"Day": 3, "Weekday": 0, "Hour": 3, "Minute": 30}, - {"Day": 4, "Weekday": 0, "Hour": 3, "Minute": 30}, - {"Day": 5, "Weekday": 0, "Hour": 3, "Minute": 30}, - {"Day": 6, "Weekday": 0, "Hour": 3, "Minute": 30}, - }}, - } - - for _, testItem := range testData { - t.Run(testItem.input, func(t *testing.T) { - event := calendar.NewEvent() - err := event.Parse(testItem.input) - assert.NoError(t, err) - assert.ElementsMatch(t, testItem.expected, getCalendarIntervalsFromScheduleTree(generateTreeOfSchedules(event))) - }) - } -} - func TestParseStatus(t *testing.T) { status := `{ "StandardOutPath" = "local.resticprofile.self.check.log"; @@ -158,7 +106,7 @@ func TestCreateUserPlist(t *testing.T) { handler := NewHandler(SchedulerLaunchd{}).(*HandlerLaunchd) handler.fs = afero.NewMemMapFs() - launchdJob := &LaunchdJob{ + launchdJob := &darwin.LaunchdJob{ Label: "TestCreateSystemPlist", } filename, err := handler.createPlistFile(launchdJob, "user") @@ -172,7 +120,7 @@ func TestCreateSystemPlist(t *testing.T) { handler := NewHandler(SchedulerLaunchd{}).(*HandlerLaunchd) handler.fs = afero.NewMemMapFs() - launchdJob := &LaunchdJob{ + launchdJob := &darwin.LaunchdJob{ Label: "TestCreateSystemPlist", } filename, err := handler.createPlistFile(launchdJob, "system") diff --git a/schedule/job.go b/schedule/job.go index 7d3f51f6..5f0370c4 100644 --- a/schedule/job.go +++ b/schedule/job.go @@ -12,8 +12,9 @@ var ErrJobCanBeRemovedOnly = errors.New("this job is marked for removal only and // Job scheduler type Job struct { - config *Config - handler Handler + config *Config + handler Handler + resolvedPermission Permission } func NewJob(handler Handler, config *Config) *Job { @@ -42,8 +43,7 @@ func (j *Job) Create() error { } permission := getSchedulePermission(j.config.Permission) - ok := checkPermission(permission) - if !ok { + if ok := checkPermission(permission); !ok { return permissionError("create") } diff --git a/schedule/permission.go b/schedule/permission.go index 7ae90020..40fe2191 100644 --- a/schedule/permission.go +++ b/schedule/permission.go @@ -5,21 +5,56 @@ import ( "github.com/creativeprojects/resticprofile/constants" ) -// Permission is either system or user -type Permission string +type Permission int -// Permission const ( - PermissionUser Permission = "user" - PermissionSystem Permission = "system" + PermissionAuto Permission = iota + PermissionSystem + PermissionUserBackground + PermissionUserLoggedOn ) -// String returns either "user" or "system" +func PermissionFromConfig(permission string) Permission { + switch permission { + case constants.SchedulePermissionSystem: + return PermissionSystem + + case constants.SchedulePermissionUser: + return PermissionUserBackground + + case constants.SchedulePermissionUserLoggedIn, constants.SchedulePermissionUserLoggedOn: + return PermissionUserLoggedOn + + default: + return PermissionAuto + } +} + func (p Permission) String() string { - if p == PermissionSystem { + switch p { + case PermissionAuto: + return constants.SchedulePermissionAuto + + case PermissionSystem: return constants.SchedulePermissionSystem + + case PermissionUserBackground: + return constants.SchedulePermissionUser + + case PermissionUserLoggedOn: + return constants.SchedulePermissionUserLoggedOn + + default: + return "" } - return constants.SchedulePermissionUser +} + +func (p Permission) Resolve() Permission { + permission, safe := p.Detect() + if !safe { + clog.Warningf("you have not specified the permission for your schedule (\"system\", \"user\" or \"user_logged_on\"): assuming %q", permission.String()) + } + return permission } // getSchedulePermission returns the permission defined from the configuration, @@ -28,7 +63,7 @@ func (p Permission) String() string { func getSchedulePermission(permission string) string { permission, safe := detectSchedulePermission(permission) if !safe { - clog.Warningf("you have not specified the permission for your schedule (\"system\" or \"user\"): assuming %q", permission) + clog.Warningf("you have not specified the permission for your schedule (\"system\", \"user\" or \"user_logged_on\"): assuming %q", permission) } return permission } diff --git a/schedule/permission_test.go b/schedule/permission_test.go index 083251ce..b28583fd 100644 --- a/schedule/permission_test.go +++ b/schedule/permission_test.go @@ -1,27 +1,17 @@ package schedule import ( - "runtime" "testing" - "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/platform" "github.com/stretchr/testify/assert" ) -func TestPermissionUserString(t *testing.T) { - permission := PermissionUser - assert.Equal(t, constants.SchedulePermissionUser, permission.String()) -} - -func TestPermissionSystemString(t *testing.T) { - permission := PermissionSystem - assert.Equal(t, constants.SchedulePermissionSystem, permission.String()) -} - func TestDetectSchedulePermissionOnWindows(t *testing.T) { - if runtime.GOOS != "windows" { + if !platform.IsWindows() { t.Skip() } + fixtures := []struct { input string expected string @@ -42,3 +32,31 @@ func TestDetectSchedulePermissionOnWindows(t *testing.T) { }) } } + +func TestDetectPermission(t *testing.T) { + fixtures := []struct { + input string + expected string + safe bool + active bool + }{ + {"", "system", true, platform.IsWindows()}, + {"something", "system", true, platform.IsWindows()}, + {"", "user_logged_on", platform.IsDarwin(), !platform.IsWindows()}, + {"something", "user_logged_on", platform.IsDarwin(), !platform.IsWindows()}, + {"system", "system", true, true}, + {"user", "user", true, true}, + {"user_logged_on", "user_logged_on", true, true}, + {"user_logged_in", "user_logged_on", true, true}, // I did the typo as I was writing the doc, so let's add it here :) + } + for _, fixture := range fixtures { + if !fixture.active { + continue + } + t.Run(fixture.input, func(t *testing.T) { + perm, safe := PermissionFromConfig(fixture.input).Detect() + assert.Equal(t, fixture.expected, perm.String()) + assert.Equal(t, fixture.safe, safe) + }) + } +} diff --git a/schedule/permission_unix.go b/schedule/permission_unix.go index 5967a2c5..a9e71055 100644 --- a/schedule/permission_unix.go +++ b/schedule/permission_unix.go @@ -8,8 +8,30 @@ import ( "runtime" "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/platform" ) +// Detect returns the permission defined from the configuration, +// or the best guess considering the current user permission. +// safe specifies whether a guess may lead to a too broad or too narrow file access permission. +func (p Permission) Detect() (Permission, bool) { + switch p { + case PermissionSystem, PermissionUserBackground, PermissionUserLoggedOn: + // well defined + return p, true + + default: + // best guess is depending on the user being root or not: + detected := PermissionUserLoggedOn // sane default EXCEPT for cron + if os.Geteuid() == 0 { + detected = PermissionSystem + } + // darwin can backup protected files without the need of a system task + // otherwise guess based on UID is never safe + return detected, platform.IsDarwin() + } +} + // detectSchedulePermission returns the permission defined from the configuration, // or the best guess considering the current user permission. // safe specifies whether a guess may lead to a too broad or too narrow file access permission. diff --git a/schedule/permission_windows.go b/schedule/permission_windows.go index 93f0df14..8288114c 100644 --- a/schedule/permission_windows.go +++ b/schedule/permission_windows.go @@ -8,6 +8,19 @@ import ( "github.com/creativeprojects/resticprofile/constants" ) +// Detect returns the permission defined from the configuration, +// or the best guess considering the current user permission. +// safe specifies whether a guess may lead to a too broad or too narrow file access permission. +func (p Permission) Detect() (Permission, bool) { + switch p { + case PermissionAuto: + return PermissionSystem, true + + default: + return p, true + } +} + // detectSchedulePermission returns the permission defined from the configuration, // or the best guess considering the current user permission. // safe specifies whether a guess may lead to a too broad or too narrow file access permission. @@ -23,11 +36,11 @@ func detectSchedulePermission(permission string) (detected string, safe bool) { // checkPermission returns true if the user is allowed to access the job. // This is always true on Windows -func checkPermission(permission string) bool { +func checkPermission(_ string) bool { return true } // permissionError is not used in Windows -func permissionError(string) error { +func permissionError(_ string) error { return errors.New("computer says no") } From a6f7741af2ed9c3b92d8be6422dca035fa5e2823 Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 11 Mar 2025 22:00:24 +0000 Subject: [PATCH 02/33] refactoring string permission into an enum move detectSchedulePermission to the handler which broke many tests using the mock handler --- schedule/handler.go | 8 ++- schedule/handler_crond.go | 25 ++++++++- schedule/handler_crond_test.go | 4 +- schedule/handler_darwin.go | 37 ++++++++++---- schedule/handler_darwin_test.go | 6 +-- schedule/handler_systemd.go | 28 ++++++++-- schedule/handler_systemd_test.go | 6 +-- schedule/handler_windows.go | 21 ++++++-- schedule/job.go | 37 ++++++++++---- schedule/job_test.go | 2 +- schedule/mocks/Handler.go | 80 ++++++++++++++++++++++++----- schedule/permission.go | 20 -------- schedule/permission_darwin.go | 25 +++++++++ schedule/permission_other.go | 25 +++++++++ schedule/permission_test.go | 88 ++++++++++---------------------- schedule/permission_unix.go | 73 -------------------------- schedule/permission_windows.go | 41 +-------------- schedule_jobs_test.go | 6 +-- 18 files changed, 286 insertions(+), 246 deletions(-) create mode 100644 schedule/permission_darwin.go create mode 100644 schedule/permission_other.go delete mode 100644 schedule/permission_unix.go diff --git a/schedule/handler.go b/schedule/handler.go index 6a34a429..b1bed88f 100644 --- a/schedule/handler.go +++ b/schedule/handler.go @@ -14,11 +14,15 @@ type Handler interface { ParseSchedules(schedules []string) ([]*calendar.Event, 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 + CreateJob(job *Config, schedules []*calendar.Event, permission Permission) error + RemoveJob(job *Config, permission Permission) error DisplayJobStatus(job *Config) error // Scheduled can return configs at the same time as an error: it means some configs are fine but some others cannot be loaded properly Scheduled(profileName string) ([]Config, error) + // DetectSchedulePermission returns the permission defined from the configuration, + // or the best guess considering the current user permission. + // safe specifies whether a guess may lead to a too broad or too narrow file access permission. + DetectSchedulePermission(permission Permission) (Permission, bool) } // 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 13ee0f7d..e073980e 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -1,6 +1,7 @@ package schedule import ( + "os" "slices" "github.com/creativeprojects/resticprofile/calendar" @@ -66,7 +67,7 @@ func (h *HandlerCrond) DisplayStatus(profileName string) error { } // CreateJob is creating the crontab -func (h *HandlerCrond) CreateJob(job *Config, schedules []*calendar.Event, permission string) error { +func (h *HandlerCrond) CreateJob(job *Config, schedules []*calendar.Event, permission Permission) error { entries := make([]crond.Entry, len(schedules)) for i, event := range schedules { entries[i] = crond.NewEntry( @@ -92,7 +93,7 @@ func (h *HandlerCrond) CreateJob(job *Config, schedules []*calendar.Event, permi return nil } -func (h *HandlerCrond) RemoveJob(job *Config, permission string) error { +func (h *HandlerCrond) RemoveJob(job *Config, permission Permission) error { entries := []crond.Entry{ crond.NewEntry( calendar.NewEvent(), @@ -157,6 +158,26 @@ func (h *HandlerCrond) Scheduled(profileName string) ([]Config, error) { return configs, configsErr } +// DetectSchedulePermission returns the permission defined from the configuration, +// or the best guess considering the current user permission. +// safe specifies whether a guess may lead to a too broad or too narrow file access permission. +func (h *HandlerCrond) DetectSchedulePermission(p Permission) (Permission, bool) { + switch p { + case PermissionSystem, PermissionUserBackground, PermissionUserLoggedOn: + // well defined + return p, true + + default: + // best guess is depending on the user being root or not: + detected := PermissionUserBackground // sane default + if os.Geteuid() == 0 { + detected = PermissionSystem + } + // guess based on UID is never safe + return detected, false + } +} + // init registers HandlerCrond func init() { AddHandlerProvider(func(config SchedulerConfig, fallback bool) Handler { diff --git a/schedule/handler_crond_test.go b/schedule/handler_crond_test.go index c6493bfc..2c1d5847 100644 --- a/schedule/handler_crond_test.go +++ b/schedule/handler_crond_test.go @@ -60,7 +60,7 @@ func TestReadingCrondScheduled(t *testing.T) { for _, testCase := range testCases { expectedJobs = append(expectedJobs, testCase.job) - err := handler.CreateJob(&testCase.job, testCase.schedules, testCase.job.Permission) + err := handler.CreateJob(&testCase.job, testCase.schedules, PermissionFromConfig(testCase.job.Permission)) require.NoError(t, err) } @@ -71,7 +71,7 @@ func TestReadingCrondScheduled(t *testing.T) { // now delete all schedules for _, testCase := range testCases { - err := handler.RemoveJob(&testCase.job, testCase.job.Permission) + err := handler.RemoveJob(&testCase.job, PermissionFromConfig(testCase.job.Permission)) require.NoError(t, err) } diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index d1db55a9..79f17f3b 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -80,7 +80,7 @@ func (h *HandlerLaunchd) DisplayStatus(profileName string) error { } // CreateJob creates a plist file and registers it with launchd -func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, permission string) error { +func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, permission Permission) error { filename, err := h.createPlistFile(h.getLaunchdJob(job, schedules), permission) if err != nil { if filename != "" { @@ -114,7 +114,7 @@ func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, per } // RemoveJob stops and unloads the agent from launchd, then removes the configuration file -func (h *HandlerLaunchd) RemoveJob(job *Config, permission string) error { +func (h *HandlerLaunchd) RemoveJob(job *Config, permission Permission) error { name := getJobName(job.ProfileName, job.CommandName) filename, err := getFilename(name, permission) if err != nil { @@ -148,9 +148,8 @@ func (h *HandlerLaunchd) RemoveJob(job *Config, permission string) error { } func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error { - permission := getSchedulePermission(job.Permission) - ok := checkPermission(permission) - if !ok { + permission, _ := h.DetectSchedulePermission(PermissionFromConfig(job.Permission)) + if ok := permission.Check(); !ok { return permissionError("view") } cmd := exec.Command(launchctlBin, launchdList, getJobName(job.ProfileName, job.CommandName)) @@ -258,6 +257,26 @@ func (h *HandlerLaunchd) getScheduledJob(profileName, permission string) []Confi return jobs } +// DetectSchedulePermission returns the permission defined from the configuration, +// or the best guess considering the current user permission. +// safe specifies whether a guess may lead to a too broad or too narrow file access permission. +func (h *HandlerLaunchd) DetectSchedulePermission(permission Permission) (Permission, bool) { + switch permission { + case PermissionSystem, PermissionUserBackground, PermissionUserLoggedOn: + // well defined + return permission, true + + default: + // best guess is depending on the user being root or not: + detected := PermissionUserLoggedOn // sane default + if os.Geteuid() == 0 { + detected = PermissionSystem + } + // darwin can backup protected files without the need of a system task + return detected, true + } +} + func getSchedulePattern(profileName, permission string) string { pattern := "%s%s.*%s" if permission == constants.SchedulePermissionSystem { @@ -301,12 +320,12 @@ func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { return job, nil } -func (h *HandlerLaunchd) createPlistFile(launchdJob *darwin.LaunchdJob, permission string) (string, error) { +func (h *HandlerLaunchd) createPlistFile(launchdJob *darwin.LaunchdJob, permission Permission) (string, error) { filename, err := getFilename(launchdJob.Label, permission) if err != nil { return "", err } - if permission != constants.SchedulePermissionSystem { + if permission != PermissionSystem { // in some very recent installations of macOS, the user's LaunchAgents folder may not exist _ = h.fs.MkdirAll(path.Dir(filename), 0o700) } @@ -349,8 +368,8 @@ func getJobName(profileName, command string) string { return fmt.Sprintf("%s%s.%s", namePrefix, strings.ToLower(profileName), command) } -func getFilename(name, permission string) (string, error) { - if permission == constants.SchedulePermissionSystem { +func getFilename(name string, permission Permission) (string, error) { + if permission == PermissionSystem { return path.Join(GlobalDaemons, name+daemonExtension), nil } home, err := os.UserHomeDir() diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 84dfe07e..01c2ec10 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -109,7 +109,7 @@ func TestCreateUserPlist(t *testing.T) { launchdJob := &darwin.LaunchdJob{ Label: "TestCreateSystemPlist", } - filename, err := handler.createPlistFile(launchdJob, "user") + filename, err := handler.createPlistFile(launchdJob, PermissionUserBackground) require.NoError(t, err) _, err = handler.fs.Stat(filename) @@ -123,7 +123,7 @@ func TestCreateSystemPlist(t *testing.T) { launchdJob := &darwin.LaunchdJob{ Label: "TestCreateSystemPlist", } - filename, err := handler.createPlistFile(launchdJob, "system") + filename, err := handler.createPlistFile(launchdJob, PermissionSystem) require.NoError(t, err) _, err = handler.fs.Stat(filename) @@ -188,7 +188,7 @@ func TestReadingLaunchdScheduled(t *testing.T) { for _, testCase := range testCases { expectedJobs = append(expectedJobs, testCase.job) - _, err := handler.createPlistFile(handler.getLaunchdJob(&testCase.job, testCase.schedules), testCase.job.Permission) + _, err := handler.createPlistFile(handler.getLaunchdJob(&testCase.job, testCase.schedules), PermissionFromConfig(testCase.job.Permission)) require.NoError(t, err) } diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index 79b40c82..853bb398 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -103,7 +103,7 @@ func (h *HandlerSystemd) DisplayStatus(profileName string) error { } // CreateJob is creating the systemd unit and activating it -func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, permission string) error { +func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, permission Permission) error { unitType := systemd.UserUnit if os.Geteuid() == 0 { // user has sudoed already @@ -166,7 +166,7 @@ func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, per } // RemoveJob is disabling the systemd unit and deleting the timer and service files -func (h *HandlerSystemd) RemoveJob(job *Config, permission string) error { +func (h *HandlerSystemd) RemoveJob(job *Config, permission Permission) error { unitType := systemd.UserUnit if os.Geteuid() == 0 { // user has sudoed already @@ -232,9 +232,9 @@ func (h *HandlerSystemd) RemoveJob(job *Config, permission string) error { 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) + permission, _ := h.DetectSchedulePermission(PermissionFromConfig(job.Permission)) systemdType := systemd.UserUnit - if permission == constants.SchedulePermissionSystem { + if permission == PermissionSystem || permission == PermissionUserBackground { systemdType = systemd.SystemUnit } unitLoaded, err := unitLoaded(serviceName, systemdType) @@ -269,6 +269,26 @@ func (h *HandlerSystemd) Scheduled(profileName string) ([]Config, error) { return configs, nil } +// detectSchedulePermission returns the permission defined from the configuration, +// or the best guess considering the current user permission. +// safe specifies whether a guess may lead to a too broad or too narrow file access permission. +func (h *HandlerSystemd) DetectSchedulePermission(p Permission) (Permission, bool) { + switch p { + case PermissionSystem, PermissionUserBackground, PermissionUserLoggedOn: + // well defined + return p, true + + default: + // best guess is depending on the user being root or not: + detected := PermissionUserLoggedOn // sane default + if os.Geteuid() == 0 { + detected = PermissionSystem + } + // guess based on UID is never safe + return detected, false + } +} + var ( _ Handler = &HandlerSystemd{} ) diff --git a/schedule/handler_systemd_test.go b/schedule/handler_systemd_test.go index 078e392b..4ecf5a18 100644 --- a/schedule/handler_systemd_test.go +++ b/schedule/handler_systemd_test.go @@ -57,11 +57,11 @@ func TestReadingSystemdScheduled(t *testing.T) { expectedJobs := []Config{} for _, testCase := range testCases { job := testCase.job - err := handler.CreateJob(&job, testCase.schedules, schedulePermission) + err := handler.CreateJob(&job, testCase.schedules, PermissionFromConfig(schedulePermission)) toRemove := &job t.Cleanup(func() { - _ = handler.RemoveJob(toRemove, schedulePermission) + _ = handler.RemoveJob(toRemove, PermissionFromConfig(schedulePermission)) }) require.NoError(t, err) @@ -85,7 +85,7 @@ func TestReadingSystemdScheduled(t *testing.T) { // now delete all schedules for _, testCase := range testCases { - err := handler.RemoveJob(&testCase.job, testCase.job.Permission) + err := handler.RemoveJob(&testCase.job, PermissionFromConfig(testCase.job.Permission)) require.NoError(t, err) } diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index 6e5feb50..30489c5e 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -46,12 +46,12 @@ func (h *HandlerWindows) DisplayStatus(profileName string) error { } // CreateJob is creating the task scheduler job. -func (h *HandlerWindows) CreateJob(job *Config, schedules []*calendar.Event, permission string) error { +func (h *HandlerWindows) CreateJob(job *Config, schedules []*calendar.Event, permission Permission) error { // default permission will be system perm := schtasks.SystemAccount - if permission == constants.SchedulePermissionUser { + if permission == PermissionUserBackground { perm = schtasks.UserAccount - } else if permission == constants.SchedulePermissionUserLoggedOn || permission == constants.SchedulePermissionUserLoggedIn { + } else if permission == PermissionUserLoggedOn { perm = schtasks.UserLoggedOnAccount } jobConfig := &schtasks.Config{ @@ -70,7 +70,7 @@ func (h *HandlerWindows) CreateJob(job *Config, schedules []*calendar.Event, per } // RemoveJob is deleting the task scheduler job -func (h *HandlerWindows) RemoveJob(job *Config, permission string) error { +func (h *HandlerWindows) RemoveJob(job *Config, permission Permission) error { err := schtasks.Delete(job.ProfileName, job.CommandName) if err != nil { if errors.Is(err, schtasks.ErrNotRegistered) { @@ -116,6 +116,19 @@ func (h *HandlerWindows) Scheduled(profileName string) ([]Config, error) { return configs, nil } +// detectSchedulePermission returns the permission defined from the configuration, +// or the best guess considering the current user permission. +// safe specifies whether a guess may lead to a too broad or too narrow file access permission. +func (h *HandlerWindows) DetectSchedulePermission(permission Permission) (Permission, bool) { + switch permission { + case PermissionAuto: + return PermissionSystem, true + + default: + return permission, true + } +} + // init registers HandlerWindows func init() { AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { diff --git a/schedule/job.go b/schedule/job.go index 5f0370c4..b5ea143e 100644 --- a/schedule/job.go +++ b/schedule/job.go @@ -2,6 +2,9 @@ package schedule import ( "errors" + "fmt" + + "github.com/creativeprojects/clog" ) // @@ -32,8 +35,8 @@ func NewJob(handler Handler, config *Config) *Job { // Accessible checks if the current user is permitted to access the job func (j *Job) Accessible() bool { - permission, _ := detectSchedulePermission(j.config.Permission) - return checkPermission(permission) + permission, _ := j.handler.DetectSchedulePermission(PermissionFromConfig(j.config.Permission)) + return permission.Check() } // Create a new job @@ -42,8 +45,8 @@ func (j *Job) Create() error { return ErrJobCanBeRemovedOnly } - permission := getSchedulePermission(j.config.Permission) - if ok := checkPermission(permission); !ok { + permission := j.getSchedulePermission(PermissionFromConfig(j.config.Permission)) + if ok := permission.Check(); !ok { return permissionError("create") } @@ -65,14 +68,13 @@ func (j *Job) Create() error { // Remove a job func (j *Job) Remove() error { - var permission string + permission := PermissionFromConfig(j.config.Permission) if j.RemoveOnly() { - permission, _ = detectSchedulePermission(j.config.Permission) // silent call for possibly non-existent job + permission, _ = j.handler.DetectSchedulePermission(permission) // silent call for possibly non-existent job } else { - permission = getSchedulePermission(j.config.Permission) + permission = j.getSchedulePermission(permission) } - ok := checkPermission(permission) - if !ok { + if ok := permission.Check(); !ok { return permissionError("remove") } @@ -99,3 +101,20 @@ func (j *Job) Status() error { } return nil } + +// getSchedulePermission returns the permission defined from the configuration, +// or the best guess considering the current user permission. +// If the permission can only be guessed, this method will also display a warning +func (j *Job) getSchedulePermission(permission Permission) Permission { + permission, safe := j.handler.DetectSchedulePermission(permission) + if !safe { + clog.Warningf("you have not specified the permission for your schedule (\"system\", \"user\" or \"user_logged_on\"): assuming %q", permission.String()) + } + return permission +} + +// permissionError display a permission denied message to the user. +// permissionError is not used in Windows. +func permissionError(action string) error { + return fmt.Errorf("user is not allowed to %s a system job: please restart resticprofile as root (with sudo)", action) +} diff --git a/schedule/job_test.go b/schedule/job_test.go index 2cf0ae78..3fcd2f18 100644 --- a/schedule/job_test.go +++ b/schedule/job_test.go @@ -61,7 +61,7 @@ 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!")) + handler.EXPECT().CreateJob(mock.AnythingOfType("*schedule.Config"), []*calendar.Event{}, schedule.PermissionUserBackground).Return(errors.New("test!")) job := schedule.NewJob(handler, &schedule.Config{ ProfileName: "profile", diff --git a/schedule/mocks/Handler.go b/schedule/mocks/Handler.go index b748db6c..579acf0a 100644 --- a/schedule/mocks/Handler.go +++ b/schedule/mocks/Handler.go @@ -55,7 +55,7 @@ func (_c *Handler_Close_Call) RunAndReturn(run func()) *Handler_Close_Call { } // CreateJob provides a mock function with given fields: job, schedules, permission -func (_m *Handler) CreateJob(job *schedule.Config, schedules []*calendar.Event, permission string) error { +func (_m *Handler) CreateJob(job *schedule.Config, schedules []*calendar.Event, permission schedule.Permission) error { ret := _m.Called(job, schedules, permission) if len(ret) == 0 { @@ -63,7 +63,7 @@ func (_m *Handler) CreateJob(job *schedule.Config, schedules []*calendar.Event, } var r0 error - if rf, ok := ret.Get(0).(func(*schedule.Config, []*calendar.Event, string) error); ok { + if rf, ok := ret.Get(0).(func(*schedule.Config, []*calendar.Event, schedule.Permission) error); ok { r0 = rf(job, schedules, permission) } else { r0 = ret.Error(0) @@ -80,14 +80,14 @@ type Handler_CreateJob_Call struct { // CreateJob is a helper method to define mock.On call // - job *schedule.Config // - schedules []*calendar.Event -// - permission string +// - permission schedule.Permission func (_e *Handler_Expecter) CreateJob(job interface{}, schedules interface{}, permission interface{}) *Handler_CreateJob_Call { return &Handler_CreateJob_Call{Call: _e.mock.On("CreateJob", job, schedules, permission)} } -func (_c *Handler_CreateJob_Call) Run(run func(job *schedule.Config, schedules []*calendar.Event, permission string)) *Handler_CreateJob_Call { +func (_c *Handler_CreateJob_Call) Run(run func(job *schedule.Config, schedules []*calendar.Event, permission schedule.Permission)) *Handler_CreateJob_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*schedule.Config), args[1].([]*calendar.Event), args[2].(string)) + run(args[0].(*schedule.Config), args[1].([]*calendar.Event), args[2].(schedule.Permission)) }) return _c } @@ -97,7 +97,63 @@ func (_c *Handler_CreateJob_Call) Return(_a0 error) *Handler_CreateJob_Call { return _c } -func (_c *Handler_CreateJob_Call) RunAndReturn(run func(*schedule.Config, []*calendar.Event, string) error) *Handler_CreateJob_Call { +func (_c *Handler_CreateJob_Call) RunAndReturn(run func(*schedule.Config, []*calendar.Event, schedule.Permission) error) *Handler_CreateJob_Call { + _c.Call.Return(run) + return _c +} + +// DetectSchedulePermission provides a mock function with given fields: permission +func (_m *Handler) DetectSchedulePermission(permission schedule.Permission) (schedule.Permission, bool) { + ret := _m.Called(permission) + + if len(ret) == 0 { + panic("no return value specified for DetectSchedulePermission") + } + + var r0 schedule.Permission + var r1 bool + if rf, ok := ret.Get(0).(func(schedule.Permission) (schedule.Permission, bool)); ok { + return rf(permission) + } + if rf, ok := ret.Get(0).(func(schedule.Permission) schedule.Permission); ok { + r0 = rf(permission) + } else { + r0 = ret.Get(0).(schedule.Permission) + } + + if rf, ok := ret.Get(1).(func(schedule.Permission) bool); ok { + r1 = rf(permission) + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} + +// Handler_DetectSchedulePermission_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetectSchedulePermission' +type Handler_DetectSchedulePermission_Call struct { + *mock.Call +} + +// DetectSchedulePermission is a helper method to define mock.On call +// - permission schedule.Permission +func (_e *Handler_Expecter) DetectSchedulePermission(permission interface{}) *Handler_DetectSchedulePermission_Call { + return &Handler_DetectSchedulePermission_Call{Call: _e.mock.On("DetectSchedulePermission", permission)} +} + +func (_c *Handler_DetectSchedulePermission_Call) Run(run func(permission schedule.Permission)) *Handler_DetectSchedulePermission_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(schedule.Permission)) + }) + return _c +} + +func (_c *Handler_DetectSchedulePermission_Call) Return(_a0 schedule.Permission, _a1 bool) *Handler_DetectSchedulePermission_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Handler_DetectSchedulePermission_Call) RunAndReturn(run func(schedule.Permission) (schedule.Permission, bool)) *Handler_DetectSchedulePermission_Call { _c.Call.Return(run) return _c } @@ -346,7 +402,7 @@ func (_c *Handler_ParseSchedules_Call) RunAndReturn(run func([]string) ([]*calen } // RemoveJob provides a mock function with given fields: job, permission -func (_m *Handler) RemoveJob(job *schedule.Config, permission string) error { +func (_m *Handler) RemoveJob(job *schedule.Config, permission schedule.Permission) error { ret := _m.Called(job, permission) if len(ret) == 0 { @@ -354,7 +410,7 @@ func (_m *Handler) RemoveJob(job *schedule.Config, permission string) error { } var r0 error - if rf, ok := ret.Get(0).(func(*schedule.Config, string) error); ok { + if rf, ok := ret.Get(0).(func(*schedule.Config, schedule.Permission) error); ok { r0 = rf(job, permission) } else { r0 = ret.Error(0) @@ -370,14 +426,14 @@ type Handler_RemoveJob_Call struct { // RemoveJob is a helper method to define mock.On call // - job *schedule.Config -// - permission string +// - permission schedule.Permission func (_e *Handler_Expecter) RemoveJob(job interface{}, permission interface{}) *Handler_RemoveJob_Call { return &Handler_RemoveJob_Call{Call: _e.mock.On("RemoveJob", job, permission)} } -func (_c *Handler_RemoveJob_Call) Run(run func(job *schedule.Config, permission string)) *Handler_RemoveJob_Call { +func (_c *Handler_RemoveJob_Call) Run(run func(job *schedule.Config, permission schedule.Permission)) *Handler_RemoveJob_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*schedule.Config), args[1].(string)) + run(args[0].(*schedule.Config), args[1].(schedule.Permission)) }) return _c } @@ -387,7 +443,7 @@ func (_c *Handler_RemoveJob_Call) Return(_a0 error) *Handler_RemoveJob_Call { return _c } -func (_c *Handler_RemoveJob_Call) RunAndReturn(run func(*schedule.Config, string) error) *Handler_RemoveJob_Call { +func (_c *Handler_RemoveJob_Call) RunAndReturn(run func(*schedule.Config, schedule.Permission) error) *Handler_RemoveJob_Call { _c.Call.Return(run) return _c } diff --git a/schedule/permission.go b/schedule/permission.go index 40fe2191..c424c767 100644 --- a/schedule/permission.go +++ b/schedule/permission.go @@ -1,7 +1,6 @@ package schedule import ( - "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/constants" ) @@ -48,22 +47,3 @@ func (p Permission) String() string { return "" } } - -func (p Permission) Resolve() Permission { - permission, safe := p.Detect() - if !safe { - clog.Warningf("you have not specified the permission for your schedule (\"system\", \"user\" or \"user_logged_on\"): assuming %q", permission.String()) - } - return permission -} - -// getSchedulePermission returns the permission defined from the configuration, -// or the best guess considering the current user permission. -// If the permission can only be guessed, this method will also display a warning -func getSchedulePermission(permission string) string { - permission, safe := detectSchedulePermission(permission) - if !safe { - clog.Warningf("you have not specified the permission for your schedule (\"system\", \"user\" or \"user_logged_on\"): assuming %q", permission) - } - return permission -} diff --git a/schedule/permission_darwin.go b/schedule/permission_darwin.go new file mode 100644 index 00000000..7888eb25 --- /dev/null +++ b/schedule/permission_darwin.go @@ -0,0 +1,25 @@ +//go:build darwin + +package schedule + +import ( + "os" +) + +// Check returns true if the user is allowed to access the job. +func (p Permission) Check() bool { + switch p { + case PermissionUserLoggedOn, PermissionUserBackground: + // user mode is always available + return true + + default: + if os.Geteuid() == 0 { + // user has sudoed + return true + } + // last case is system (or undefined) + no sudo + return false + + } +} diff --git a/schedule/permission_other.go b/schedule/permission_other.go new file mode 100644 index 00000000..869eebbc --- /dev/null +++ b/schedule/permission_other.go @@ -0,0 +1,25 @@ +//go:build !windows && !darwin + +package schedule + +import ( + "os" +) + +// Check returns true if the user is allowed to access the job. +func (p Permission) Check() bool { + switch p { + case PermissionUserLoggedOn: + // user mode is always available + return true + + default: + if os.Geteuid() == 0 { + // user has sudoed + return true + } + // last case is system (or undefined) + no sudo + return false + + } +} diff --git a/schedule/permission_test.go b/schedule/permission_test.go index b28583fd..643f5641 100644 --- a/schedule/permission_test.go +++ b/schedule/permission_test.go @@ -1,62 +1,30 @@ package schedule -import ( - "testing" - - "github.com/creativeprojects/resticprofile/platform" - "github.com/stretchr/testify/assert" -) - -func TestDetectSchedulePermissionOnWindows(t *testing.T) { - if !platform.IsWindows() { - t.Skip() - } - - fixtures := []struct { - input string - expected string - safe bool - }{ - {"", "system", true}, - {"something", "system", true}, - {"system", "system", true}, - {"user", "user", true}, - {"user_logged_on", "user_logged_on", true}, - {"user_logged_in", "user_logged_on", true}, // I did the typo as I was writing the doc, so let's add it here :) - } - for _, fixture := range fixtures { - t.Run(fixture.input, func(t *testing.T) { - perm, safe := detectSchedulePermission(fixture.input) - assert.Equal(t, fixture.expected, perm) - assert.Equal(t, fixture.safe, safe) - }) - } -} - -func TestDetectPermission(t *testing.T) { - fixtures := []struct { - input string - expected string - safe bool - active bool - }{ - {"", "system", true, platform.IsWindows()}, - {"something", "system", true, platform.IsWindows()}, - {"", "user_logged_on", platform.IsDarwin(), !platform.IsWindows()}, - {"something", "user_logged_on", platform.IsDarwin(), !platform.IsWindows()}, - {"system", "system", true, true}, - {"user", "user", true, true}, - {"user_logged_on", "user_logged_on", true, true}, - {"user_logged_in", "user_logged_on", true, true}, // I did the typo as I was writing the doc, so let's add it here :) - } - for _, fixture := range fixtures { - if !fixture.active { - continue - } - t.Run(fixture.input, func(t *testing.T) { - perm, safe := PermissionFromConfig(fixture.input).Detect() - assert.Equal(t, fixture.expected, perm.String()) - assert.Equal(t, fixture.safe, safe) - }) - } -} +// TODO rewrite this test! +// func TestDetectPermission(t *testing.T) { +// fixtures := []struct { +// input string +// expected string +// safe bool +// active bool +// }{ +// {"", "system", true, platform.IsWindows()}, +// {"something", "system", true, platform.IsWindows()}, +// {"", "user_logged_on", platform.IsDarwin(), !platform.IsWindows()}, +// {"something", "user_logged_on", platform.IsDarwin(), !platform.IsWindows()}, +// {"system", "system", true, true}, +// {"user", "user", true, true}, +// {"user_logged_on", "user_logged_on", true, true}, +// {"user_logged_in", "user_logged_on", true, true}, // I did the typo as I was writing the doc, so let's add it here :) +// } +// for _, fixture := range fixtures { +// if !fixture.active { +// continue +// } +// t.Run(fixture.input, func(t *testing.T) { +// perm, safe := PermissionFromConfig(fixture.input).Detect() +// assert.Equal(t, fixture.expected, perm.String()) +// assert.Equal(t, fixture.safe, safe) +// }) +// } +// } diff --git a/schedule/permission_unix.go b/schedule/permission_unix.go deleted file mode 100644 index a9e71055..00000000 --- a/schedule/permission_unix.go +++ /dev/null @@ -1,73 +0,0 @@ -//go:build !windows - -package schedule - -import ( - "fmt" - "os" - "runtime" - - "github.com/creativeprojects/resticprofile/constants" - "github.com/creativeprojects/resticprofile/platform" -) - -// Detect returns the permission defined from the configuration, -// or the best guess considering the current user permission. -// safe specifies whether a guess may lead to a too broad or too narrow file access permission. -func (p Permission) Detect() (Permission, bool) { - switch p { - case PermissionSystem, PermissionUserBackground, PermissionUserLoggedOn: - // well defined - return p, true - - default: - // best guess is depending on the user being root or not: - detected := PermissionUserLoggedOn // sane default EXCEPT for cron - if os.Geteuid() == 0 { - detected = PermissionSystem - } - // darwin can backup protected files without the need of a system task - // otherwise guess based on UID is never safe - return detected, platform.IsDarwin() - } -} - -// detectSchedulePermission returns the permission defined from the configuration, -// or the best guess considering the current user permission. -// safe specifies whether a guess may lead to a too broad or too narrow file access permission. -func detectSchedulePermission(permission string) (detected string, safe bool) { - if permission == constants.SchedulePermissionSystem || - permission == constants.SchedulePermissionUser { - // well defined - return permission, true - } - // best guess is depending on the user being root or not: - if os.Geteuid() == 0 { - detected = constants.SchedulePermissionSystem - } else { - detected = constants.SchedulePermissionUser - } - // darwin can backup protected files without the need of a system task - // otherwise guess based on UID is never safe - safe = runtime.GOOS == "darwin" - - return -} - -// checkPermission returns true if the user is allowed to access the job. -func checkPermission(permission string) bool { - if permission == constants.SchedulePermissionUser { - // user mode is always available - return true - } - if os.Geteuid() == 0 { - // user has sudoed - return true - } - // last case is system (or undefined) + no sudo - return false -} - -func permissionError(action string) error { - return fmt.Errorf("user is not allowed to %s a system job: please restart resticprofile as root (with sudo)", action) -} diff --git a/schedule/permission_windows.go b/schedule/permission_windows.go index 8288114c..698f1607 100644 --- a/schedule/permission_windows.go +++ b/schedule/permission_windows.go @@ -2,45 +2,8 @@ package schedule -import ( - "errors" - - "github.com/creativeprojects/resticprofile/constants" -) - -// Detect returns the permission defined from the configuration, -// or the best guess considering the current user permission. -// safe specifies whether a guess may lead to a too broad or too narrow file access permission. -func (p Permission) Detect() (Permission, bool) { - switch p { - case PermissionAuto: - return PermissionSystem, true - - default: - return p, true - } -} - -// detectSchedulePermission returns the permission defined from the configuration, -// or the best guess considering the current user permission. -// safe specifies whether a guess may lead to a too broad or too narrow file access permission. -func detectSchedulePermission(permission string) (detected string, safe bool) { - if permission == constants.SchedulePermissionUser { - return constants.SchedulePermissionUser, true - } - if permission == constants.SchedulePermissionUserLoggedOn || permission == constants.SchedulePermissionUserLoggedIn { - return constants.SchedulePermissionUserLoggedOn, true - } - return constants.SchedulePermissionSystem, true -} - -// checkPermission returns true if the user is allowed to access the job. +// Check returns true if the user is allowed to access the job. // This is always true on Windows -func checkPermission(_ string) bool { +func (p Permission) Check() bool { return true } - -// permissionError is not used in Windows -func permissionError(_ string) error { - return errors.New("computer says no") -} diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go index 96439671..e28f4301 100644 --- a/schedule_jobs_test.go +++ b/schedule_jobs_test.go @@ -41,7 +41,7 @@ func TestSimpleScheduleJob(t *testing.T) { mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("[]*calendar.Event"), mock.AnythingOfType("string")). - RunAndReturn(func(scheduleConfig *schedule.Config, events []*calendar.Event, permission string) error { + RunAndReturn(func(scheduleConfig *schedule.Config, events []*calendar.Event, permission schedule.Permission) error { assert.Equal(t, []string{"--no-ansi", "--config", `config file`, "run-schedule", "backup@profile"}, scheduleConfig.Arguments.RawArgs()) assert.Equal(t, `--no-ansi --config "config file" run-schedule backup@profile`, scheduleConfig.Arguments.String()) return nil @@ -90,7 +90,7 @@ func TestRemoveJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). - RunAndReturn(func(scheduleConfig *schedule.Config, user string) error { + RunAndReturn(func(scheduleConfig *schedule.Config, _ schedule.Permission) error { assert.Equal(t, "profile", scheduleConfig.ProfileName) assert.Equal(t, "backup", scheduleConfig.CommandName) return nil @@ -108,7 +108,7 @@ func TestRemoveJobNoConfig(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). - RunAndReturn(func(scheduleConfig *schedule.Config, user string) error { + RunAndReturn(func(scheduleConfig *schedule.Config, _ schedule.Permission) error { assert.Equal(t, "profile", scheduleConfig.ProfileName) assert.Equal(t, "backup", scheduleConfig.CommandName) return nil From f478aa1899adee5796223574ecc278cfce0aece7 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 12 Mar 2025 20:56:27 +0000 Subject: [PATCH 03/33] fix tests --- schedule/job_test.go | 11 ++++++++--- schedule_jobs_test.go | 46 ++++++++++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/schedule/job_test.go b/schedule/job_test.go index 3fcd2f18..ecb71fac 100644 --- a/schedule/job_test.go +++ b/schedule/job_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/creativeprojects/resticprofile/calendar" + "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/schedule" "github.com/creativeprojects/resticprofile/schedule/mocks" "github.com/stretchr/testify/mock" @@ -13,15 +14,16 @@ import ( func TestCreateJobHappyPath(t *testing.T) { handler := mocks.NewHandler(t) + handler.EXPECT().DetectSchedulePermission(schedule.PermissionUserBackground).Return(schedule.PermissionUserBackground, true) 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) + handler.EXPECT().CreateJob(mock.AnythingOfType("*schedule.Config"), []*calendar.Event{}, schedule.PermissionUserBackground).Return(nil) job := schedule.NewJob(handler, &schedule.Config{ ProfileName: "profile", CommandName: "backup", Schedules: []string{}, - Permission: "user", + Permission: constants.SchedulePermissionUser, }) err := job.Create() @@ -30,6 +32,7 @@ func TestCreateJobHappyPath(t *testing.T) { func TestCreateJobErrorParseSchedules(t *testing.T) { handler := mocks.NewHandler(t) + handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(nil) handler.EXPECT().ParseSchedules([]string{}).Return(nil, errors.New("test!")) @@ -45,6 +48,7 @@ func TestCreateJobErrorParseSchedules(t *testing.T) { func TestCreateJobErrorDisplaySchedules(t *testing.T) { handler := mocks.NewHandler(t) + handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(errors.New("test!")) job := schedule.NewJob(handler, &schedule.Config{ @@ -59,6 +63,7 @@ func TestCreateJobErrorDisplaySchedules(t *testing.T) { func TestCreateJobErrorCreate(t *testing.T) { handler := mocks.NewHandler(t) + handler.EXPECT().DetectSchedulePermission(schedule.PermissionUserBackground).Return(schedule.PermissionUserBackground, true) 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{}, schedule.PermissionUserBackground).Return(errors.New("test!")) @@ -67,7 +72,7 @@ func TestCreateJobErrorCreate(t *testing.T) { ProfileName: "profile", CommandName: "backup", Schedules: []string{}, - Permission: "user", + Permission: constants.SchedulePermissionUser, }) err := job.Create() diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go index e28f4301..545254df 100644 --- a/schedule_jobs_test.go +++ b/schedule_jobs_test.go @@ -6,6 +6,7 @@ import ( "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/config" + "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/schedule" "github.com/creativeprojects/resticprofile/schedule/mocks" "github.com/stretchr/testify/assert" @@ -35,12 +36,13 @@ func TestSimpleScheduleJob(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() + handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil) handler.EXPECT().DisplaySchedules("profile", "backup", []string{"sched"}).Return(nil) handler.EXPECT().CreateJob( mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("[]*calendar.Event"), - mock.AnythingOfType("string")). + schedule.PermissionUserBackground). RunAndReturn(func(scheduleConfig *schedule.Config, events []*calendar.Event, permission schedule.Permission) error { assert.Equal(t, []string{"--no-ansi", "--config", `config file`, "run-schedule", "backup@profile"}, scheduleConfig.Arguments.RawArgs()) assert.Equal(t, `--no-ansi --config "config file" run-schedule backup@profile`, scheduleConfig.Arguments.String()) @@ -59,12 +61,13 @@ func TestFailScheduleJob(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() + handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil) handler.EXPECT().DisplaySchedules("profile", "backup", []string{"sched"}).Return(nil) handler.EXPECT().CreateJob( mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("[]*calendar.Event"), - mock.AnythingOfType("string")). + schedule.PermissionUserBackground). Return(errors.New("error creating job")) scheduleConfig := configForJob("backup", "sched") @@ -89,7 +92,8 @@ func TestRemoveJob(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() - handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). + handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). RunAndReturn(func(scheduleConfig *schedule.Config, _ schedule.Permission) error { assert.Equal(t, "profile", scheduleConfig.ProfileName) assert.Equal(t, "backup", scheduleConfig.CommandName) @@ -107,7 +111,8 @@ func TestRemoveJobNoConfig(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() - handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). + handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). RunAndReturn(func(scheduleConfig *schedule.Config, _ schedule.Permission) error { assert.Equal(t, "profile", scheduleConfig.ProfileName) assert.Equal(t, "backup", scheduleConfig.CommandName) @@ -125,7 +130,8 @@ func TestFailRemoveJob(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() - handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). + handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). Return(errors.New("error removing job")) scheduleConfig := configForJob("backup", "sched") @@ -139,7 +145,8 @@ func TestNoFailRemoveUnknownJob(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() - handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). + handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). Return(schedule.ErrScheduledJobNotFound) scheduleConfig := configForJob("backup", "sched") @@ -153,7 +160,8 @@ func TestNoFailRemoveUnknownRemoveOnlyJob(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() - handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). + handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). Return(schedule.ErrScheduledJobNotFound) scheduleConfig := configForJob("backup") @@ -208,14 +216,14 @@ func TestRemoveScheduledJobs(t *testing.T) { fromConfigFile string scheduledConfigs []schedule.Config removedConfigs []schedule.Config - permission string + permission schedule.Permission }{ { removeProfileName: "profile_no_config", fromConfigFile: "configFile", scheduledConfigs: []schedule.Config{}, removedConfigs: []schedule.Config{}, - permission: "user", + permission: schedule.PermissionUserBackground, }, { removeProfileName: "profile_one_config_to_remove", @@ -225,7 +233,7 @@ func TestRemoveScheduledJobs(t *testing.T) { ProfileName: "profile_one_config_to_remove", CommandName: "backup", ConfigFile: "configFile", - Permission: "user", + Permission: constants.SchedulePermissionUser, }, }, removedConfigs: []schedule.Config{ @@ -233,10 +241,10 @@ func TestRemoveScheduledJobs(t *testing.T) { ProfileName: "profile_one_config_to_remove", CommandName: "backup", ConfigFile: "configFile", - Permission: "user", + Permission: constants.SchedulePermissionUser, }, }, - permission: "user", + permission: schedule.PermissionUserBackground, }, { removeProfileName: "profile_different_config_file", @@ -246,11 +254,11 @@ func TestRemoveScheduledJobs(t *testing.T) { ProfileName: "profile_different_config_file", CommandName: "backup", ConfigFile: "other_configFile", - Permission: "user", + Permission: constants.SchedulePermissionUser, }, }, removedConfigs: []schedule.Config{}, - permission: "user", + permission: schedule.PermissionUserBackground, }, } @@ -263,6 +271,7 @@ func TestRemoveScheduledJobs(t *testing.T) { handler.EXPECT().Scheduled(tc.removeProfileName).Return(tc.scheduledConfigs, nil) for _, cfg := range tc.removedConfigs { handler.EXPECT().RemoveJob(&cfg, tc.permission).Return(nil) + handler.EXPECT().DetectSchedulePermission(tc.permission).Return(tc.permission, true) } err := removeScheduledJobs(handler, tc.fromConfigFile, tc.removeProfileName) @@ -283,15 +292,16 @@ func TestFailRemoveScheduledJobs(t *testing.T) { ProfileName: "profile_to_remove", CommandName: "backup", ConfigFile: "configFile", - Permission: "user", + Permission: constants.SchedulePermissionUser, }, }, nil) handler.EXPECT().RemoveJob(&schedule.Config{ ProfileName: "profile_to_remove", CommandName: "backup", ConfigFile: "configFile", - Permission: "user", - }, "user").Return(errors.New("impossible")) + Permission: constants.SchedulePermissionUser, + }, schedule.PermissionUserBackground).Return(errors.New("impossible")) + handler.EXPECT().DetectSchedulePermission(schedule.PermissionUserBackground).Return(schedule.PermissionUserBackground, true) err := removeScheduledJobs(handler, "configFile", "profile_to_remove") assert.Error(t, err) @@ -378,7 +388,7 @@ func TestFailStatusScheduledJobs(t *testing.T) { ProfileName: "profile_name", CommandName: "backup", ConfigFile: "configFile", - Permission: "user", + Permission: constants.SchedulePermissionUser, }, }, nil) handler.EXPECT().DisplaySchedules("profile_name", "backup", []string(nil)).Return(errors.New("impossible")) From e789d0df2a55847139fb25526974321eb9576073 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 14 Mar 2025 19:41:48 +0000 Subject: [PATCH 04/33] trying to use bootstrap commands --- examples/dev.yaml | 23 +++++++-------- schedule/handler_darwin.go | 60 ++++++++++++++++++++++++-------------- 2 files changed, 49 insertions(+), 34 deletions(-) diff --git a/examples/dev.yaml b/examples/dev.yaml index 170bdf2f..ba8f6269 100644 --- a/examples/dev.yaml +++ b/examples/dev.yaml @@ -113,9 +113,8 @@ self: - "/**/.git/" schedule: - "*:00,30" - schedule-permission: user + schedule-permission: user_logged_on schedule-log: "_schedule-log.txt" - schedule-ignore-on-battery: true schedule-after-network-online: true skip-if-unchanged: true @@ -126,20 +125,20 @@ self: - "echo restic stderr = ${RESTIC_STDERR}" check: read-data-subset: "$DOW/7" - schedule: - - "*:15" + # schedule: + # - "*:15" retention: after-backup: true keep-last: 30 group-by: host - forget: - schedule: "weekly" - schedule-priority: background - copy: - initialize: true - snapshot: latest - schedule: - - "*:45" + # forget: + # schedule: "weekly" + # schedule-priority: background + # copy: + # initialize: true + # snapshot: latest + # schedule: + # - "*:45" snapshots: host: true run-before: diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 79f17f3b..94771bc2 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -8,7 +8,6 @@ import ( "os/exec" "path" "regexp" - "slices" "sort" "strings" "text/tabwriter" @@ -28,16 +27,18 @@ import ( // Default paths for launchd files const ( - launchdBin = "launchd" - launchctlBin = "launchctl" - launchdStart = "start" - launchdStop = "stop" - launchdLoad = "load" - launchdUnload = "unload" - launchdList = "list" - UserAgentPath = "Library/LaunchAgents" - GlobalAgentPath = "/Library/LaunchAgents" - GlobalDaemons = "/Library/LaunchDaemons" + launchdBin = "launchd" + launchctlBin = "launchctl" + launchdStart = "start" + launchdStop = "stop" + launchdLoad = "load" + launchdUnload = "unload" + launchdBootstrap = "bootstrap" + launchdBootout = "bootout" + launchdList = "list" + UserAgentPath = "Library/LaunchAgents" + GlobalAgentPath = "/Library/LaunchAgents" + GlobalDaemons = "/Library/LaunchDaemons" namePrefix = "local.resticprofile." // namePrefix is the prefix used for all launchd job labels managed by resticprofile agentExtension = ".agent.plist" @@ -90,7 +91,7 @@ func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, per } // load the service - cmd := exec.Command(launchctlBin, launchdLoad, filename) + cmd := exec.Command(launchctlBin, launchdBootstrap, domainTarget(permission), filename) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err = cmd.Run() @@ -124,15 +125,15 @@ func (h *HandlerLaunchd) RemoveJob(job *Config, permission Permission) error { if _, err := os.Stat(filename); err != nil && os.IsNotExist(err) { return ErrScheduledJobNotFound } - // stop the service in case it's already running - stop := exec.Command(launchctlBin, launchdStop, name) - stop.Stdout = os.Stdout - stop.Stderr = os.Stderr - // keep going if there's an error here - _ = stop.Run() + // // stop the service in case it's already running + // stop := exec.Command(launchctlBin, launchdStop, name) + // stop.Stdout = os.Stdout + // stop.Stderr = os.Stderr + // // keep going if there's an error here + // _ = stop.Run() // unload the service - unload := exec.Command(launchctlBin, launchdUnload, filename) + unload := exec.Command(launchctlBin, launchdBootout, domainTarget(permission)+"/"+getJobName(job.ProfileName, job.CommandName)) unload.Stdout = os.Stdout unload.Stderr = os.Stderr err = unload.Run() @@ -168,9 +169,9 @@ 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 - } + // if slices.Contains([]string{"LimitLoadToSessionType", "OnDemand"}, key) { + // continue + // } keys = append(keys, key) } sort.Strings(keys) @@ -392,6 +393,21 @@ func parseStatus(status string) map[string]string { return output } +func domainTarget(permission Permission) string { + switch permission { + case PermissionSystem: + return "system" + case PermissionUserLoggedOn: + return fmt.Sprintf("gui/%d", os.Getuid()) + + case PermissionUserBackground: + return fmt.Sprintf("user/%d", os.Getuid()) + + default: + return "" + } +} + // init registers HandlerLaunchd func init() { AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { From 4ae52b3cf774a3860844d5b67ae307f75c60c17c Mon Sep 17 00:00:00 2001 From: Fred Date: Sat, 15 Mar 2025 14:44:51 +0000 Subject: [PATCH 05/33] investigations --- commands.go | 19 +++++++++++++++++++ examples/dev.yaml | 2 +- schedule/handler_darwin.go | 22 +--------------------- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/commands.go b/commands.go index e97a210e..49ae8c15 100644 --- a/commands.go +++ b/commands.go @@ -22,6 +22,7 @@ import ( "github.com/creativeprojects/resticprofile/remote" "github.com/creativeprojects/resticprofile/term" "github.com/creativeprojects/resticprofile/win" + "github.com/distatus/battery" ) var ( @@ -157,6 +158,13 @@ func getOwnCommands() []ownCommand { needConfiguration: false, hide: true, }, + { + name: "battery", + description: "check battery status", + action: batteryCommand, + needConfiguration: false, + hide: true, + }, } } @@ -362,3 +370,14 @@ func elevated() error { return nil } + +func batteryCommand(_ io.Writer, _ commandContext) error { + all, err := battery.GetAll() + if err != nil { + clog.Errorf("loading battery information: %s", err) + } + for _, batt := range all { + clog.Infof("%+v", *batt) + } + return nil +} diff --git a/examples/dev.yaml b/examples/dev.yaml index ba8f6269..5ef7b6dc 100644 --- a/examples/dev.yaml +++ b/examples/dev.yaml @@ -112,7 +112,7 @@ self: exclude: - "/**/.git/" schedule: - - "*:00,30" + - "*:00,30,50,55" schedule-permission: user_logged_on schedule-log: "_schedule-log.txt" schedule-after-network-online: true diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 94771bc2..6149a400 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -90,7 +90,6 @@ func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, per return err } - // load the service cmd := exec.Command(launchctlBin, launchdBootstrap, domainTarget(permission), filename) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -99,18 +98,6 @@ func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, per return err } - if _, start := job.GetFlag("start"); start { - name := getJobName(job.ProfileName, job.CommandName) - - // 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 } @@ -125,15 +112,8 @@ func (h *HandlerLaunchd) RemoveJob(job *Config, permission Permission) error { if _, err := os.Stat(filename); err != nil && os.IsNotExist(err) { return ErrScheduledJobNotFound } - // // stop the service in case it's already running - // stop := exec.Command(launchctlBin, launchdStop, name) - // stop.Stdout = os.Stdout - // stop.Stderr = os.Stderr - // // keep going if there's an error here - // _ = stop.Run() - // unload the service - unload := exec.Command(launchctlBin, launchdBootout, domainTarget(permission)+"/"+getJobName(job.ProfileName, job.CommandName)) + unload := exec.Command(launchctlBin, launchdBootout, domainTarget(permission)+"/"+name) unload.Stdout = os.Stdout unload.Stderr = os.Stderr err = unload.Run() From 2426fddcc6a46b23ebe4f2c68b3f7d8285989269 Mon Sep 17 00:00:00 2001 From: Fred Date: Sat, 15 Mar 2025 19:30:47 +0000 Subject: [PATCH 06/33] fix permissions --- examples/dev.yaml | 5 +++-- schedule/handler_darwin.go | 36 +++++++++++++++++++++++++----------- schedule/job.go | 5 ++--- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/examples/dev.yaml b/examples/dev.yaml index 5ef7b6dc..4ff4abb7 100644 --- a/examples/dev.yaml +++ b/examples/dev.yaml @@ -125,8 +125,9 @@ self: - "echo restic stderr = ${RESTIC_STDERR}" check: read-data-subset: "$DOW/7" - # schedule: - # - "*:15" + schedule: + - "*:05,10,15,20,25,35" + schedule-permission: user retention: after-backup: true keep-last: 30 diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 6149a400..b2af4cc0 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -27,12 +27,7 @@ import ( // Default paths for launchd files const ( - launchdBin = "launchd" - launchctlBin = "launchctl" - launchdStart = "start" - launchdStop = "stop" - launchdLoad = "load" - launchdUnload = "unload" + launchctlBin = "/bin/launchctl" launchdBootstrap = "bootstrap" launchdBootout = "bootout" launchdList = "list" @@ -54,7 +49,7 @@ type HandlerLaunchd struct { // Init verifies launchd is available on this system func (h *HandlerLaunchd) Init() error { - return lookupBinary("launchd", launchdBin) + return lookupBinary("launchd", launchctlBin) } // Close does nothing with launchd @@ -90,7 +85,7 @@ func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, per return err } - cmd := exec.Command(launchctlBin, launchdBootstrap, domainTarget(permission), filename) + cmd := launchctlCommand(launchdBootstrap, domainTarget(permission), filename) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err = cmd.Run() @@ -113,7 +108,7 @@ func (h *HandlerLaunchd) RemoveJob(job *Config, permission Permission) error { return ErrScheduledJobNotFound } - unload := exec.Command(launchctlBin, launchdBootout, domainTarget(permission)+"/"+name) + unload := launchctlCommand(launchdBootout, domainTarget(permission)+"/"+name) unload.Stdout = os.Stdout unload.Stderr = os.Stderr err = unload.Run() @@ -133,7 +128,7 @@ func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error { if ok := permission.Check(); !ok { return permissionError("view") } - cmd := exec.Command(launchctlBin, launchdList, getJobName(job.ProfileName, job.CommandName)) + cmd := launchctlCommand(launchdList, getJobName(job.ProfileName, job.CommandName)) output, err := cmd.Output() if cmd.ProcessState.ExitCode() == codeServiceNotFound { return ErrScheduledJobNotFound @@ -231,7 +226,9 @@ func (h *HandlerLaunchd) getScheduledJob(profileName, permission string) []Confi continue } if job != nil { - job.Permission = permission + if job.Permission == "" { + job.Permission = permission + } jobs = append(jobs, *job) } } @@ -288,6 +285,17 @@ func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { if err != nil { return nil, fmt.Errorf("error reading plist file: %w", err) } + + permission := "" + switch launchdJob.LimitLoadToSessionType { + case "", "Aqua": + permission = constants.SchedulePermissionUserLoggedOn + case "Background": + permission = constants.SchedulePermissionUser + case "System": + permission = constants.SchedulePermissionSystem + } + args := NewCommandArguments(launchdJob.ProgramArguments[2:]) // first is binary, second is --no-prio job := &Config{ ProfileName: profileName, @@ -297,6 +305,7 @@ func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { Arguments: args, WorkingDirectory: launchdJob.WorkingDirectory, Schedules: darwin.ParseCalendarIntervals(launchdJob.StartCalendarInterval), + Permission: permission, } return job, nil } @@ -388,6 +397,11 @@ func domainTarget(permission Permission) string { } } +func launchctlCommand(arg ...string) *exec.Cmd { + clog.Debugf("running command: '%s %s'", launchctlBin, strings.Join(arg, " ")) + return exec.Command(launchctlBin, arg...) +} + // init registers HandlerLaunchd func init() { AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { diff --git a/schedule/job.go b/schedule/job.go index b5ea143e..49c491b7 100644 --- a/schedule/job.go +++ b/schedule/job.go @@ -15,9 +15,8 @@ var ErrJobCanBeRemovedOnly = errors.New("this job is marked for removal only and // Job scheduler type Job struct { - config *Config - handler Handler - resolvedPermission Permission + config *Config + handler Handler } func NewJob(handler Handler, config *Config) *Job { From 3aff71d7b2e0e3e49c60c3bc48f748bc450d903a Mon Sep 17 00:00:00 2001 From: Fred Date: Sat, 15 Mar 2025 23:59:46 +0000 Subject: [PATCH 07/33] fix launchctl system job --- darwin/launchd.go | 2 +- darwin/session_type.go | 7 +++++-- examples/dev.yaml | 6 +++--- schedule/handler_darwin.go | 19 ++++++++++++++----- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/darwin/launchd.go b/darwin/launchd.go index 17030463..0afc8362 100644 --- a/darwin/launchd.go +++ b/darwin/launchd.go @@ -96,5 +96,5 @@ type LaunchdJob struct { // runs only in non-GUI login session (e.g. SSH sessions) // System: // runs in the system context - LimitLoadToSessionType SessionType `plist:"LimitLoadToSessionType"` + LimitLoadToSessionType SessionType `plist:"LimitLoadToSessionType,omitempty"` } diff --git a/darwin/session_type.go b/darwin/session_type.go index 8ee13874..9f96c2d7 100644 --- a/darwin/session_type.go +++ b/darwin/session_type.go @@ -7,14 +7,17 @@ import "github.com/creativeprojects/resticprofile/constants" type SessionType string const ( + SessionTypeDefault SessionType = "" SessionTypeGUI SessionType = "Aqua" SessionTypeBackground SessionType = "Background" + SessionTypeStandardIO SessionType = "StandardIO" + SessionTypeSystem SessionType = "System" ) func NewSessionType(permission string) SessionType { switch permission { case constants.SchedulePermissionSystem: - return SessionTypeBackground + return SessionTypeSystem case constants.SchedulePermissionUser: return SessionTypeBackground @@ -24,6 +27,6 @@ func NewSessionType(permission string) SessionType { default: // this was the only option available before 0.30.0 - return SessionTypeGUI + return SessionTypeDefault } } diff --git a/examples/dev.yaml b/examples/dev.yaml index 4ff4abb7..fa9ae0ed 100644 --- a/examples/dev.yaml +++ b/examples/dev.yaml @@ -132,9 +132,9 @@ self: after-backup: true keep-last: 30 group-by: host - # forget: - # schedule: "weekly" - # schedule-priority: background + forget: + schedule: "weekly" + schedule-permission: system # copy: # initialize: true # snapshot: latest diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index b2af4cc0..6075abc4 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -9,6 +9,7 @@ import ( "path" "regexp" "sort" + "strconv" "strings" "text/tabwriter" @@ -288,11 +289,11 @@ func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { permission := "" switch launchdJob.LimitLoadToSessionType { - case "", "Aqua": + case darwin.SessionTypeDefault, darwin.SessionTypeGUI: permission = constants.SchedulePermissionUserLoggedOn - case "Background": + case darwin.SessionTypeBackground: permission = constants.SchedulePermissionUser - case "System": + case darwin.SessionTypeSystem: permission = constants.SchedulePermissionSystem } @@ -383,14 +384,22 @@ func parseStatus(status string) map[string]string { } func domainTarget(permission Permission) string { + // after a sudo, macOs returns the root user on both os.Getuid() and os.Geteuid() + // to detect the logged on user after a sudo, we need to use the environment variable + uid := os.Getuid() + if userid, sudo := os.LookupEnv("SUDO_UID"); sudo { + if temp, err := strconv.Atoi(userid); err == nil { + uid = temp + } + } switch permission { case PermissionSystem: return "system" case PermissionUserLoggedOn: - return fmt.Sprintf("gui/%d", os.Getuid()) + return fmt.Sprintf("gui/%d", uid) case PermissionUserBackground: - return fmt.Sprintf("user/%d", os.Getuid()) + return fmt.Sprintf("user/%d", uid) default: return "" From ace0195dd85766eb4055fea5b62b91081c04eedb Mon Sep 17 00:00:00 2001 From: Fred Date: Sun, 16 Mar 2025 14:35:45 +0000 Subject: [PATCH 08/33] parse launchctl print --- schedule/handler_darwin.go | 27 +++++++++++++++++++++++++++ schedule/handler_darwin_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 6075abc4..73841acf 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -3,11 +3,13 @@ package schedule import ( + "bytes" "fmt" "os" "os/exec" "path" "regexp" + "slices" "sort" "strconv" "strings" @@ -411,6 +413,31 @@ func launchctlCommand(arg ...string) *exec.Cmd { return exec.Command(launchctlBin, arg...) } +type keyValue struct { + key string + value string +} + +func parsePrint(output []byte) ([]keyValue, error) { + keys := []string{"state"} + info := make([]keyValue, 0, 10) + lines := bytes.Split(output, []byte{'\n'}) + for _, line := range lines { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + if key, value, found := bytes.Cut(line, []byte{'='}); found { + strKey := string(bytes.TrimSpace(key)) + strValue := string(bytes.TrimSpace(value)) + if slices.Contains(keys, strKey) { + info = append(info, keyValue{strKey, strValue}) + } + } + } + return info, nil +} + // init registers HandlerLaunchd func init() { AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 01c2ec10..f2d0d8de 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -197,3 +197,36 @@ func TestReadingLaunchdScheduled(t *testing.T) { assert.ElementsMatch(t, expectedJobs, scheduled) } + +func TestDisplayStatusOnGUIAgent(t *testing.T) { + output, err := os.ReadFile("print_gui.txt") + require.NoError(t, err) + + info, err := parsePrint(output) + require.NoError(t, err) + assert.ElementsMatch(t, info, []keyValue{ + {"state", "not running"}, + }) +} + +func TestDisplayStatusOnUserAgent(t *testing.T) { + output, err := os.ReadFile("print_user.txt") + require.NoError(t, err) + + info, err := parsePrint(output) + require.NoError(t, err) + assert.ElementsMatch(t, info, []keyValue{ + {"state", "not running"}, + }) +} + +func TestDisplayStatusOnSystemAgent(t *testing.T) { + output, err := os.ReadFile("print_system.txt") + require.NoError(t, err) + + info, err := parsePrint(output) + require.NoError(t, err) + assert.ElementsMatch(t, info, []keyValue{ + {"state", "not running"}, + }) +} From a0b4d1a05f16b3cdbdde9516d23e317aa958c2dc Mon Sep 17 00:00:00 2001 From: Fred Date: Sun, 16 Mar 2025 18:13:31 +0000 Subject: [PATCH 09/33] print schedule status --- darwin/user.go | 38 ++++++++++++++++++++ schedule/handler_darwin.go | 63 +++++++++++++-------------------- schedule/handler_darwin_test.go | 47 ++++++++++++------------ 3 files changed, 84 insertions(+), 64 deletions(-) create mode 100644 darwin/user.go diff --git a/darwin/user.go b/darwin/user.go new file mode 100644 index 00000000..e5d7f74a --- /dev/null +++ b/darwin/user.go @@ -0,0 +1,38 @@ +package darwin + +import ( + "os" + "os/user" + "strconv" +) + +type User struct { + Uid int + Gid int + SudoRoot bool +} + +func CurrentUser() User { + sudoed := false + uid := os.Getuid() + gid := os.Getgid() + if uid == 0 { + // after a sudo, macOs returns the root user on both os.Getuid() and os.Geteuid() + // to detect the logged on user after a sudo, we need to use the environment variable + if userid, sudo := os.LookupEnv("SUDO_UID"); sudo { + if temp, err := strconv.Atoi(userid); err == nil { + uid = temp + sudoed = true + } + } + current, err := user.LookupId(strconv.Itoa(uid)) + if err == nil { + gid, _ = strconv.Atoi(current.Gid) + } + } + return User{ + Uid: uid, + Gid: gid, + SudoRoot: sudoed, + } +} diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 73841acf..0960c0fb 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -9,9 +9,6 @@ import ( "os/exec" "path" "regexp" - "slices" - "sort" - "strconv" "strings" "text/tabwriter" @@ -34,6 +31,7 @@ const ( launchdBootstrap = "bootstrap" launchdBootout = "bootout" launchdList = "list" + launchdPrint = "print" UserAgentPath = "Library/LaunchAgents" GlobalAgentPath = "/Library/LaunchAgents" GlobalDaemons = "/Library/LaunchDaemons" @@ -128,10 +126,10 @@ func (h *HandlerLaunchd) RemoveJob(job *Config, permission Permission) error { func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error { permission, _ := h.DetectSchedulePermission(PermissionFromConfig(job.Permission)) - if ok := permission.Check(); !ok { - return permissionError("view") - } - cmd := launchctlCommand(launchdList, getJobName(job.ProfileName, job.CommandName)) + // if ok := permission.Check(); !ok { + // return permissionError("view") + // } + cmd := launchctlCommand(launchdPrint, domainTarget(permission)+"/"+getJobName(job.ProfileName, job.CommandName)) output, err := cmd.Output() if cmd.ProcessState.ExitCode() == codeServiceNotFound { return ErrScheduledJobNotFound @@ -139,20 +137,12 @@ func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error { if err != nil { return err } - status := parseStatus(string(output)) + status := parsePrint(output) if len(status) == 0 { // output was not parsed, it could mean output format has changed - fmt.Println(string(output)) - } - // 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) + clog.Warning("output of 'launchctl print' was either empty or using an incompatible format") } - sort.Strings(keys) + keys := []string{"service", "domain", "program", "working directory", "stdout path", "stderr path", "state", "runs", "last exit code"} writer := tabwriter.NewWriter(term.GetOutput(), 0, 0, 0, ' ', tabwriter.AlignRight) for _, key := range keys { fmt.Fprintf(writer, "%s:\t %s\n", spacedTitle(key), status[key]) @@ -328,6 +318,12 @@ func (h *HandlerLaunchd) createPlistFile(launchdJob *darwin.LaunchdJob, permissi } defer file.Close() + // if running using sudo, a user task file would end up being owned by root + user := darwin.CurrentUser() + if permission != PermissionSystem && user.SudoRoot { + _ = h.fs.Chown(filename, user.Uid, user.Gid) + } + encoder := plist.NewEncoder(file) encoder.Indent("\t") err = encoder.Encode(launchdJob) @@ -386,22 +382,14 @@ func parseStatus(status string) map[string]string { } func domainTarget(permission Permission) string { - // after a sudo, macOs returns the root user on both os.Getuid() and os.Geteuid() - // to detect the logged on user after a sudo, we need to use the environment variable - uid := os.Getuid() - if userid, sudo := os.LookupEnv("SUDO_UID"); sudo { - if temp, err := strconv.Atoi(userid); err == nil { - uid = temp - } - } switch permission { case PermissionSystem: return "system" case PermissionUserLoggedOn: - return fmt.Sprintf("gui/%d", uid) + return fmt.Sprintf("gui/%d", darwin.CurrentUser().Uid) case PermissionUserBackground: - return fmt.Sprintf("user/%d", uid) + return fmt.Sprintf("user/%d", darwin.CurrentUser().Uid) default: return "" @@ -413,29 +401,26 @@ func launchctlCommand(arg ...string) *exec.Cmd { return exec.Command(launchctlBin, arg...) } -type keyValue struct { - key string - value string -} - -func parsePrint(output []byte) ([]keyValue, error) { - keys := []string{"state"} - info := make([]keyValue, 0, 10) +func parsePrint(output []byte) map[string]string { + info := make(map[string]string, 10) lines := bytes.Split(output, []byte{'\n'}) for _, line := range lines { line = bytes.TrimSpace(line) if len(line) == 0 { continue } + if bytes.Contains(line, []byte("=>")) { + continue + } if key, value, found := bytes.Cut(line, []byte{'='}); found { strKey := string(bytes.TrimSpace(key)) strValue := string(bytes.TrimSpace(value)) - if slices.Contains(keys, strKey) { - info = append(info, keyValue{strKey, strValue}) + if strValue != "{" { + info[strKey] = strValue } } } - return info, nil + return info } // init registers HandlerLaunchd diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index f2d0d8de..80bfb7b3 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -5,7 +5,10 @@ package schedule import ( "bytes" "fmt" + "maps" "os" + "slices" + "strings" "testing" "github.com/creativeprojects/resticprofile/calendar" @@ -198,35 +201,29 @@ func TestReadingLaunchdScheduled(t *testing.T) { assert.ElementsMatch(t, expectedJobs, scheduled) } -func TestDisplayStatusOnGUIAgent(t *testing.T) { - output, err := os.ReadFile("print_gui.txt") - require.NoError(t, err) +func TestParsePrint(t *testing.T) { + t.Parallel() + files := []string{"print_gui.txt", "print_user.txt", "print_system.txt"} - info, err := parsePrint(output) - require.NoError(t, err) - assert.ElementsMatch(t, info, []keyValue{ - {"state", "not running"}, - }) -} + for _, filename := range files { + t.Run(filename, func(t *testing.T) { + t.Parallel() -func TestDisplayStatusOnUserAgent(t *testing.T) { - output, err := os.ReadFile("print_user.txt") - require.NoError(t, err) + output, err := os.ReadFile(filename) + require.NoError(t, err) - info, err := parsePrint(output) - require.NoError(t, err) - assert.ElementsMatch(t, info, []keyValue{ - {"state", "not running"}, - }) + info := parsePrint(output) + assertMapHasKeys(t, info, []string{"service", "domain", "program", "working directory", "stdout path", "stderr path", "state", "runs", "last exit code"}) + }) + } } -func TestDisplayStatusOnSystemAgent(t *testing.T) { - output, err := os.ReadFile("print_system.txt") - require.NoError(t, err) +func assertMapHasKeys(t *testing.T, source map[string]string, keys []string) { + t.Helper() - info, err := parsePrint(output) - require.NoError(t, err) - assert.ElementsMatch(t, info, []keyValue{ - {"state", "not running"}, - }) + for _, key := range keys { + if _, found := source[key]; !found { + t.Errorf("key %q not found in map, available keys are: %s", key, strings.Join(slices.Collect(maps.Keys(source)), ", ")) + } + } } From e2ec6f7aea54a4714a5a91a9ba236c39a4421e14 Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 17 Mar 2025 16:24:46 +0000 Subject: [PATCH 10/33] remove agent before re-creating --- .gitignore | 1 + darwin/user.go | 2 + darwin/user_test.go | 17 ++++ schedule/handler_darwin.go | 75 +++++++++++------ schedule/handler_darwin_test.go | 139 ++++++++++++++++++++++---------- schedule/spaced_title.go | 31 ------- schedule/spaced_title_test.go | 28 ------- 7 files changed, 167 insertions(+), 126 deletions(-) create mode 100644 darwin/user_test.go delete mode 100644 schedule/spaced_title.go delete mode 100644 schedule/spaced_title_test.go diff --git a/.gitignore b/.gitignore index 4f448356..a05a8f54 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .DS_Store /examples/private +/examples/*-log.txt /build/restic* /build/rclone* /dist diff --git a/darwin/user.go b/darwin/user.go index e5d7f74a..694f90b9 100644 --- a/darwin/user.go +++ b/darwin/user.go @@ -1,3 +1,5 @@ +//go:build darwin + package darwin import ( diff --git a/darwin/user_test.go b/darwin/user_test.go new file mode 100644 index 00000000..43500cc1 --- /dev/null +++ b/darwin/user_test.go @@ -0,0 +1,17 @@ +//go:build darwin + +package darwin + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUserHasUidGid(t *testing.T) { + user := CurrentUser() + // it is very unlikely anyone would run the tests using sudo :D + assert.False(t, user.SudoRoot) + assert.Greater(t, user.Uid, 500) + assert.Greater(t, user.Gid, 0) +} diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 0960c0fb..47e29f39 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -8,7 +8,6 @@ import ( "os" "os/exec" "path" - "regexp" "strings" "text/tabwriter" @@ -78,6 +77,16 @@ func (h *HandlerLaunchd) DisplayStatus(profileName string) error { // CreateJob creates a plist file and registers it with launchd func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, permission Permission) error { + exists, err := isServiceRegistered(domainTarget(permission), getJobName(job.ProfileName, job.CommandName)) + if err != nil { + return fmt.Errorf("error listing service: %w", err) + } + if exists { + err := h.RemoveJob(job, permission) + if err != nil { + return fmt.Errorf("error removing existing job before re-creating: %w", err) + } + } filename, err := h.createPlistFile(h.getLaunchdJob(job, schedules), permission) if err != nil { if filename != "" { @@ -126,9 +135,6 @@ func (h *HandlerLaunchd) RemoveJob(job *Config, permission Permission) error { func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error { permission, _ := h.DetectSchedulePermission(PermissionFromConfig(job.Permission)) - // if ok := permission.Check(); !ok { - // return permissionError("view") - // } cmd := launchctlCommand(launchdPrint, domainTarget(permission)+"/"+getJobName(job.ProfileName, job.CommandName)) output, err := cmd.Output() if cmd.ProcessState.ExitCode() == codeServiceNotFound { @@ -137,16 +143,18 @@ func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error { if err != nil { return err } - status := parsePrint(output) + status := parsePrintStatus(output) if len(status) == 0 { // output was not parsed, it could mean output format has changed clog.Warning("output of 'launchctl print' was either empty or using an incompatible format") } keys := []string{"service", "domain", "program", "working directory", "stdout path", "stderr path", "state", "runs", "last exit code"} - writer := tabwriter.NewWriter(term.GetOutput(), 0, 0, 0, ' ', tabwriter.AlignRight) + writer := tabwriter.NewWriter(term.GetOutput(), 1, 1, 1, ' ', tabwriter.AlignRight) for _, key := range keys { - fmt.Fprintf(writer, "%s:\t %s\n", spacedTitle(key), status[key]) + key, value := presentStatus(key, status[key]) + fmt.Fprintf(writer, "%s:\t %s\n", key, value) } + fmt.Fprintf(writer, "* :\t since last (re)schedule or last reboot\n") writer.Flush() fmt.Println("") @@ -304,13 +312,17 @@ func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { } func (h *HandlerLaunchd) createPlistFile(launchdJob *darwin.LaunchdJob, permission Permission) (string, error) { + user := darwin.CurrentUser() filename, err := getFilename(launchdJob.Label, permission) if err != nil { return "", err } if permission != PermissionSystem { // in some very recent installations of macOS, the user's LaunchAgents folder may not exist - _ = h.fs.MkdirAll(path.Dir(filename), 0o700) + dir := path.Dir(filename) + _ = h.fs.MkdirAll(dir, 0o700) + // if running using sudo, the directory would end up being owned by root + _ = h.fs.Chown(dir, user.Uid, user.Gid) } file, err := h.fs.Create(filename) if err != nil { @@ -318,9 +330,8 @@ func (h *HandlerLaunchd) createPlistFile(launchdJob *darwin.LaunchdJob, permissi } defer file.Close() - // if running using sudo, a user task file would end up being owned by root - user := darwin.CurrentUser() if permission != PermissionSystem && user.SudoRoot { + // if running using sudo, a user task file would end up being owned by root _ = h.fs.Chown(filename, user.Uid, user.Gid) } @@ -368,19 +379,6 @@ func getFilename(name string, permission Permission) (string, error) { return path.Join(home, UserAgentPath, name+agentExtension), nil } -func parseStatus(status string) map[string]string { - expr := regexp.MustCompile(`^\s*"(\w+)"\s*=\s*(.*);$`) - lines := strings.Split(status, "\n") - output := make(map[string]string, len(lines)) - for _, line := range lines { - match := expr.FindStringSubmatch(line) - if len(match) == 3 { - output[match[1]] = strings.Trim(match[2], "\"") - } - } - return output -} - func domainTarget(permission Permission) string { switch permission { case PermissionSystem: @@ -401,7 +399,7 @@ func launchctlCommand(arg ...string) *exec.Cmd { return exec.Command(launchctlBin, arg...) } -func parsePrint(output []byte) map[string]string { +func parsePrintStatus(output []byte) map[string]string { info := make(map[string]string, 10) lines := bytes.Split(output, []byte{'\n'}) for _, line := range lines { @@ -423,6 +421,35 @@ func parsePrint(output []byte) map[string]string { return info } +func presentStatus(key, value string) (string, string) { + switch key { + case "domain": + key = "permission" + if strings.HasPrefix(value, "gui") { + value = "user logged on" + } else if strings.HasPrefix(value, "user") { + value = "user" + } + return key, value + case "runs", "last exit code": + return key + " (*)", value + default: + return key, value + } +} + +func isServiceRegistered(domain, name string) (bool, error) { + cmd := launchctlCommand(launchdPrint, fmt.Sprintf("%s/%s", domain, name)) + err := cmd.Run() + if cmd.ProcessState.ExitCode() == codeServiceNotFound { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + // init registers HandlerLaunchd func init() { AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 80bfb7b3..8c9b207c 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -41,39 +41,6 @@ func TestPListEncoderWithCalendarInterval(t *testing.T) { assert.Equal(t, expected, buffer.String()) } -func TestParseStatus(t *testing.T) { - status := `{ - "StandardOutPath" = "local.resticprofile.self.check.log"; - "LimitLoadToSessionType" = "Aqua"; - "StandardErrorPath" = "local.resticprofile.self.check.log"; - "Label" = "local.resticprofile.self.check"; - "OnDemand" = true; - "LastExitStatus" = 0; - "Program" = "/Users/go/src/github.com/creativeprojects/resticprofile/resticprofile"; - "ProgramArguments" = ( - "/Users/go/src/github.com/creativeprojects/resticprofile/resticprofile"; - "--no-ansi"; - "--config"; - "examples/dev.yaml"; - "--name"; - "self"; - "check"; - ); -};` - expected := map[string]string{ - "StandardOutPath": "local.resticprofile.self.check.log", - "LimitLoadToSessionType": "Aqua", - "StandardErrorPath": "local.resticprofile.self.check.log", - "Label": "local.resticprofile.self.check", - "OnDemand": "true", - "LastExitStatus": "0", - "Program": "/Users/go/src/github.com/creativeprojects/resticprofile/resticprofile", - } - - output := parseStatus(status) - assert.Equal(t, expected, output) -} - func TestHandlerInstanceDefault(t *testing.T) { handler := NewHandler(SchedulerDefaultOS{}) assert.NotNil(t, handler) @@ -202,20 +169,89 @@ func TestReadingLaunchdScheduled(t *testing.T) { } func TestParsePrint(t *testing.T) { - t.Parallel() - files := []string{"print_gui.txt", "print_user.txt", "print_system.txt"} + const launchctlOutput = `user/503/local.resticprofile.self.check = { + active count = 0 + path = /Users/cp/Library/LaunchAgents/local.resticprofile.self.check.agent.plist + type = LaunchAgent + state = not running - for _, filename := range files { - t.Run(filename, func(t *testing.T) { - t.Parallel() + program = /Users/cp/go/src/github.com/creativeprojects/resticprofile/resticprofile + arguments = { + /Users/cp/go/src/github.com/creativeprojects/resticprofile/resticprofile + --no-prio + --no-ansi + --config + examples/dev.yaml + run-schedule + check@self + } - output, err := os.ReadFile(filename) - require.NoError(t, err) + working directory = /Users/cp/go/src/github.com/creativeprojects/resticprofile - info := parsePrint(output) - assertMapHasKeys(t, info, []string{"service", "domain", "program", "working directory", "stdout path", "stderr path", "state", "runs", "last exit code"}) - }) + stdout path = local.resticprofile.self.check.log + stderr path = local.resticprofile.self.check.log + default environment = { + PATH => /usr/bin:/bin:/usr/sbin:/sbin + } + + environment = { + PATH => /usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/Users/cp/go/bin + RESTICPROFILE_SCHEDULE_ID => examples/dev.yaml:check@self + XPC_SERVICE_NAME => local.resticprofile.self.check + } + + domain = user/503 + asid = 100008 + minimum runtime = 10 + exit timeout = 5 + nice = 0 + runs = 1 + last exit code = 0 + + event triggers = { + local.resticprofile.self.check.267436470 => { + keepalive = 0 + service = local.resticprofile.self.check + stream = com.apple.launchd.calendarinterval + monitor = com.apple.UserEventAgent-Aqua + descriptor = { + "Minute" => 5 + } + } + } + + event channels = { + "com.apple.launchd.calendarinterval" = { + port = 0xc1851 + active = 0 + managed = 1 + reset = 0 + hide = 0 + watching = 1 + } } + + spawn type = daemon (3) + jetsam priority = 40 + jetsam memory limit (active) = (unlimited) + jetsam memory limit (inactive) = (unlimited) + jetsamproperties category = daemon + jetsam thread limit = 32 + cpumon = default + probabilistic guard malloc policy = { + activation rate = 1/1000 + sample rate = 1/0 + } + + properties = +} +` + + t.Parallel() + + info := parsePrintStatus([]byte(launchctlOutput)) + assertMapHasKeys(t, info, []string{"service", "domain", "program", "working directory", "stdout path", "stderr path", "state", "runs", "last exit code"}) + } func assertMapHasKeys(t *testing.T, source map[string]string, keys []string) { @@ -227,3 +263,20 @@ func assertMapHasKeys(t *testing.T, source map[string]string, keys []string) { } } } + +func TestIsServiceRegistered(t *testing.T) { + services := []struct { + domain string + name string + isRegistered bool + }{ + {"system", "service.that.surely.does.not.exist", false}, + {"system", "com.apple.fseventsd", true}, + } + + for _, service := range services { + registered, err := isServiceRegistered(service.domain, service.name) + require.NoError(t, err) + assert.Equal(t, service.isRegistered, registered) + } +} diff --git a/schedule/spaced_title.go b/schedule/spaced_title.go deleted file mode 100644 index 70b0507d..00000000 --- a/schedule/spaced_title.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build darwin - -package schedule - -import "strings" - -// spacedTitle adds spaces before capital letters in a string, except for the first character -// or when the capital letter follows a space. For example, "ThisIsATest" becomes "This Is A Test". -// If the input is empty or contains no capital letters, it is returned unchanged. -// This function is only used by the launchd handler on macOS. -// -// Example: -// -// spacedTitle("ResticProfile") // Returns "Restic Profile" -// spacedTitle("ABC") // Returns "A B C" -// spacedTitle("already spaced") // Returns "already spaced" -func spacedTitle(title string) string { - if title == "" { - return title - } - 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 deleted file mode 100644 index 18537e81..00000000 --- a/schedule/spaced_title_test.go +++ /dev/null @@ -1,28 +0,0 @@ -//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"}, - {"iPhone", "i Phone"}, - {"iOS15Device", "i O S15 Device"}, - {"user@Home", "user@ Home"}, - } - - for _, test := range tests { - result := spacedTitle(test.input) - if result != test.expected { - t.Errorf("spacedTitle(%q) = %q; expected %q", test.input, result, test.expected) - } - } -} From 5c5523a35908e6f49fc81f207c077238dc896a6c Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 17 Mar 2025 19:32:14 +0000 Subject: [PATCH 11/33] update documentation with new features on launchd --- commands.go | 19 --- darwin/session_type.go | 13 +++ darwin/session_type_test.go | 26 +++++ docs/content/schedules/_index.md | 98 ++++++++-------- docs/content/schedules/cron.md | 184 ++++++++++++++++++++++++++++++ docs/content/schedules/launchd.md | 37 ++---- examples/dev.yaml | 5 +- schedule/config.go | 4 +- schedule/config_test.go | 12 +- schedule/handler_darwin.go | 33 +++--- schedule/handler_darwin_test.go | 15 ++- 11 files changed, 325 insertions(+), 121 deletions(-) create mode 100644 darwin/session_type_test.go create mode 100644 docs/content/schedules/cron.md diff --git a/commands.go b/commands.go index 49ae8c15..e97a210e 100644 --- a/commands.go +++ b/commands.go @@ -22,7 +22,6 @@ import ( "github.com/creativeprojects/resticprofile/remote" "github.com/creativeprojects/resticprofile/term" "github.com/creativeprojects/resticprofile/win" - "github.com/distatus/battery" ) var ( @@ -158,13 +157,6 @@ func getOwnCommands() []ownCommand { needConfiguration: false, hide: true, }, - { - name: "battery", - description: "check battery status", - action: batteryCommand, - needConfiguration: false, - hide: true, - }, } } @@ -370,14 +362,3 @@ func elevated() error { return nil } - -func batteryCommand(_ io.Writer, _ commandContext) error { - all, err := battery.GetAll() - if err != nil { - clog.Errorf("loading battery information: %s", err) - } - for _, batt := range all { - clog.Infof("%+v", *batt) - } - return nil -} diff --git a/darwin/session_type.go b/darwin/session_type.go index 9f96c2d7..6db4fa4e 100644 --- a/darwin/session_type.go +++ b/darwin/session_type.go @@ -30,3 +30,16 @@ func NewSessionType(permission string) SessionType { return SessionTypeDefault } } + +func (st SessionType) Permission() string { + permission := "" + switch st { + case SessionTypeDefault, SessionTypeGUI: + permission = constants.SchedulePermissionUserLoggedOn + case SessionTypeBackground: + permission = constants.SchedulePermissionUser + case SessionTypeSystem: + permission = constants.SchedulePermissionSystem + } + return permission +} diff --git a/darwin/session_type_test.go b/darwin/session_type_test.go new file mode 100644 index 00000000..3e6c9bc2 --- /dev/null +++ b/darwin/session_type_test.go @@ -0,0 +1,26 @@ +//go:build darwin + +package darwin + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSessionType(t *testing.T) { + fixtures := []struct { + permission string + expected string + }{ + {"", "user_logged_on"}, + {"invalid", "user_logged_on"}, + {"user_logged_on", "user_logged_on"}, + {"user", "user"}, + {"system", "system"}, + } + + for _, fixture := range fixtures { + assert.Equal(t, fixture.expected, NewSessionType(fixture.permission).Permission()) + } +} diff --git a/docs/content/schedules/_index.md b/docs/content/schedules/_index.md index 10b23844..983df5c1 100644 --- a/docs/content/schedules/_index.md +++ b/docs/content/schedules/_index.md @@ -6,25 +6,43 @@ weight = 4 +++ -resticprofile is capable of managing scheduled backups for you. Under the hood it's using: -- **launchd** on macOS X -- **Task Scheduler** on Windows -- **systemd** where available (Linux and other BSDs) -- **crond** as fallback (depends on the availability of a `crontab` binary) -- **crontab** files (low level, with (`*`) or without (`-`) user column) +## Scheduler -On unixes (except macOS) resticprofile is using **systemd** if available and falls back to **crond**. -On any OS a **crond** compatible scheduler can be used instead if configured in `global` / `scheduler`: +resticprofile manages scheduled backups using: +- **[launchd]({{% relref "/schedules/launchd" %}})** on macOS +- **[Task Scheduler]({{% relref "/schedules/task_scheduler" %}})** on Windows +- **[systemd]({{% relref "/schedules/systemd" %}})** on Linux and other BSDs +- **[crond]({{% relref "/schedules/cron" %}})** as a fallback (requires `crontab` binary) +- **[crontab]({{% relref "/schedules/cron" %}})** files (with or without a user column) + +On Unix systems (excluding macOS), resticprofile uses **systemd** if available, otherwise it falls back to **crond**. + +See [reference / global section]({{% relref "/reference/global" %}}) for scheduler configuration options. + +Each profile can be scheduled independently. Within each profile, these sections can be scheduled: +- **backup** +- **check** +- **forget** +- **prune** +- **copy** + +## Deprecation +Scheduling the `retention` section directly is **deprecated**. Use the `forget` section instead. + +The retention section should be associated with a `backup` section, not scheduled independently. {{< tabs groupid="config-with-json" >}} {{% tab title="toml" %}} ```toml -[global] - scheduler = "crond" - # scheduler = "crond:/usr/bin/crontab" - # scheduler = "crontab:*:/etc/cron.d/resticprofile" - # scheduler = "crontab:-:/var/spool/cron/crontabs/username" +[profile.retention] + # deprecated + schedule = "daily" + +# use the forget target instead +[profile.forget] + schedule = "daily" + ``` {{% /tab %}} @@ -32,22 +50,30 @@ On any OS a **crond** compatible scheduler can be used instead if configured in ```yaml --- -global: - scheduler: crond - # scheduler: "crond:/usr/bin/crontab" - # scheduler: "crontab:*:/etc/cron.d/resticprofile" - # scheduler: "crontab:-:/var/spool/cron/crontabs/username" +profile: + retention: + # deprecated + schedule: daily + + # use the forget target instead + forget: + schedule: daily ``` {{% /tab %}} {{% tab title="hcl" %}} ```hcl -"global" = { - "scheduler" = "crond" - # "scheduler" = "crond:/usr/bin/crontab" - # "scheduler" = "crontab:*:/etc/cron.d/resticprofile" - # "scheduler" = "crontab:-:/var/spool/cron/crontabs/username" +"profile" = { + "retention" = { + # deprecated + schedule = "daily" + } + + # use the forget target instead + "forget" = { + schedule = "daily" + } } ``` @@ -56,31 +82,13 @@ global: ```json { - "global": { - "scheduler": "crond" + "profile": { + "forget": { + "schedule": "daily" + } } } ``` {{% /tab %}} {{< /tabs >}} - -See also [reference / global section]({{% relref "/reference/global" %}}) for options on how to configure the scheduler. - - -Each profile can be scheduled independently (groups are not available for scheduling yet - it will be available in version '2' of the configuration file). - -These 5 profile sections are accepting a schedule configuration: -- backup -- check -- forget (version 0.11.0) -- prune (version 0.11.0) -- copy (version 0.16.0) - -which mean you can schedule `backup`, `forget`, `prune`, `check` and `copy` independently (I recommend using a [local lock]({{% relref "/usage/locks" %}}) in this case). - -## retention schedule is deprecated -Starting from version 0.11.0, directly scheduling the `retention` section is **deprecated**: Use the `forget` section for direct schedule instead. - -The retention section is designed to be associated with a `backup` section, not to be scheduled independently. - diff --git a/docs/content/schedules/cron.md b/docs/content/schedules/cron.md new file mode 100644 index 00000000..cc840858 --- /dev/null +++ b/docs/content/schedules/cron.md @@ -0,0 +1,184 @@ +--- +title: "Cron & compatible" +weight: 170 +--- + + +On any OS, use a **crond** compatible scheduler if configured in `global` / `scheduler`: + +{{< tabs groupid="config-with-json" >}} +{{% tab title="toml" %}} + +```toml +[global] + scheduler = "crond" +``` + +{{% /tab %}} +{{% tab title="yaml" %}} + +```yaml +--- +global: + scheduler: crond +``` + +{{% /tab %}} +{{% tab title="hcl" %}} + +```hcl +"global" = { + "scheduler" = "crond" +} +``` + +{{% /tab %}} +{{% tab title="json" %}} + +```json +{ + "global": { + "scheduler": "crond" + } +} +``` + +{{% /tab %}} +{{< /tabs >}} + + +This configuration uses the default `crontab` tool shipped with `crond`. + +You can specify the location of the `crontab` tool: + +{{< tabs groupid="config-with-json" >}} +{{% tab title="toml" %}} + +```toml +[global] + scheduler = "crond:/usr/bin/crontab" +``` + +{{% /tab %}} +{{% tab title="yaml" %}} + +```yaml +--- +global: + scheduler: crond:/usr/bin/crontab +``` + +{{% /tab %}} +{{% tab title="hcl" %}} + +```hcl +"global" = { + "scheduler" = "crond:/usr/bin/crontab" +} +``` + +{{% /tab %}} +{{% tab title="json" %}} + +```json +{ + "global": { + "scheduler": "crond:/usr/bin/crontab" + } +} +``` + +{{% /tab %}} +{{< /tabs >}} + + +## Crontab + +You can use a crontab file directly instead of the `crontab` tool: +* `crontab:*:filepath`: Use a crontab file `filepath` **with a user field** +* `crontab:-:filepath`: Use a crontab file `filepath` **without a user field** +### With user field + +{{< tabs groupid="config-with-json" >}} +{{% tab title="toml" %}} + +```toml +[global] + scheduler = "crontab:*:/etc/cron.d/resticprofile" +``` + +{{% /tab %}} +{{% tab title="yaml" %}} + +```yaml +--- +global: + scheduler: "crontab:*:/etc/cron.d/resticprofile" +``` + +{{% /tab %}} +{{% tab title="hcl" %}} + +```hcl +"global" = { + "scheduler" = "crontab:*:/etc/cron.d/resticprofile" +} +``` + +{{% /tab %}} +{{% tab title="json" %}} + +```json +{ + "global": { + "scheduler": "crontab:*:/etc/cron.d/resticprofile" + } +} +``` + +{{% /tab %}} +{{< /tabs >}} + + +### Without a user field + +{{< tabs groupid="config-with-json" >}} +{{% tab title="toml" %}} + +```toml +[global] + scheduler = "crontab:-:/var/spool/cron/crontabs/username" +``` + +{{% /tab %}} +{{% tab title="yaml" %}} + +```yaml +--- +global: + scheduler: "crontab:-:/var/spool/cron/crontabs/username" +``` + +{{% /tab %}} +{{% tab title="hcl" %}} + +```hcl +"global" = { + "scheduler" = "crontab:-:/var/spool/cron/crontabs/username" +} +``` + +{{% /tab %}} +{{% tab title="json" %}} + +```json +{ + "global": { + "scheduler": "crontab:-:/var/spool/cron/crontabs/username" + } +} +``` + +{{% /tab %}} +{{< /tabs >}} + diff --git a/docs/content/schedules/launchd.md b/docs/content/schedules/launchd.md index efbe319e..ddfff00e 100644 --- a/docs/content/schedules/launchd.md +++ b/docs/content/schedules/launchd.md @@ -1,41 +1,26 @@ --- -title: "Launchd" +title: "Launchd on macOS" weight: 110 --- +`launchd` is the service manager on macOS. resticprofile can schedule a profile using the `launchctl` tool. +## User permission +A user agent is generated when you set `schedule-permission` to `user` or `user_logged-on`. It consists of a `plist` file in `~/Library/LaunchAgents`. -`launchd` is the service manager on macOS. resticprofile can schedule a profile via a _user agent_ or a _daemon_ in launchd. +If you include specific files in your backup, like contacts or calendar, you need to grant more permissions to resticprofile and restic (a popup window will ask for permission). -## User agent - -A user agent is generated when you set `schedule-permission` to `user`. - -It consists of a `plist` file in the folder `~/Library/LaunchAgents`: - -A user agent **mostly** runs with the privileges of the user. But if you backup some specific files, like your contacts or your calendar for example, you will need to give more permissions to resticprofile **and** restic. - -For this to happen, you need to start the agent or daemon from a console window first (resticprofile will ask if you want to do so) - -If your profile is a backup profile called `remote`, the command to run manually is: +You can wait for the profile to start or start it manually. To start a backup profile called `remote` manually, use: ```shell -launchctl start local.resticprofile.remote.backup +/bin/launchctl start local.resticprofile.remote.backup ``` -Once you grant the permission, the background agents/daemon will be able to run normally. - -There's some information in this thread: https://github.com/restic/restic/issues/2051 - -*TODO: I'm going to try to compile a comprehensive how-to guide from all the information from the thread. Stay tuned!* - -### Special case of schedule-permission=user with sudo - -Please note if you schedule a user agent while running resticprofile with sudo: the user agent will be registered to the root user, and not your initial user context. It means you can only see it (`status`) and remove it (`unschedule`) via sudo. +Once you grant permission, the profile will run normally until you update resticprofile or restic. This is a macOS limitation. -## Daemon +## System permission -A launchd daemon is generated when you set `schedule-permission` to `system`. +A launchd daemon is generated when you set `schedule-permission` to `system`. It consists of a `plist` file in `/Library/LaunchDaemons`. -It consists of a `plist` file in the folder `/Library/LaunchDaemons`. You have to run resticprofile with sudo to `schedule`, check the `status` and `unschedule` the profile. +Run resticprofile with sudo to `schedule` and `unschedule` the profile. You can schedule and unschedule system and user profiles simultaneously using the `schedule --all` command. \ No newline at end of file diff --git a/examples/dev.yaml b/examples/dev.yaml index fa9ae0ed..c0c0b6c6 100644 --- a/examples/dev.yaml +++ b/examples/dev.yaml @@ -108,11 +108,12 @@ self: extended-status: false check-before: true no-error-on-warning: true - source: "{{ .CurrentDir }}" + source: + - "{{ .CurrentDir }}" exclude: - "/**/.git/" schedule: - - "*:00,30,50,55" + - "*:00,30,36,50,55" schedule-permission: user_logged_on schedule-log: "_schedule-log.txt" schedule-after-network-online: true diff --git a/schedule/config.go b/schedule/config.go index 2479a4d3..8a303004 100644 --- a/schedule/config.go +++ b/schedule/config.go @@ -46,9 +46,9 @@ func (s *Config) SetCommand(wd, command string, args []string) { // Priority is either "background" or "standard" func (s *Config) GetPriority() string { s.Priority = strings.ToLower(s.Priority) - // default value for priority is "background" + // default value for priority is "standard" if s.Priority != constants.SchedulePriorityBackground && s.Priority != constants.SchedulePriorityStandard { - s.Priority = constants.SchedulePriorityBackground + s.Priority = constants.SchedulePriorityStandard } return s.Priority } diff --git a/schedule/config_test.go b/schedule/config_test.go index ec7f8f6f..9f736b3c 100644 --- a/schedule/config_test.go +++ b/schedule/config_test.go @@ -36,28 +36,28 @@ func TestScheduleProperties(t *testing.T) { assert.ElementsMatch(t, []string{"1", "2"}, schedule.Arguments.RawArgs()) assert.Equal(t, `1 2`, schedule.Arguments.String()) assert.Equal(t, []string{"test=dev"}, schedule.Environment) - assert.Equal(t, "background", schedule.GetPriority()) // default value + assert.Equal(t, "standard", schedule.GetPriority()) // default value } func TestStandardPriority(t *testing.T) { schedule := Config{ - Priority: "standard", + Priority: "background", } - assert.Equal(t, "standard", schedule.GetPriority()) + assert.Equal(t, "background", schedule.GetPriority()) } func TestCaseInsensitivePriority(t *testing.T) { schedule := Config{ - Priority: "stANDard", + Priority: "backGROUNd", } - assert.Equal(t, "standard", schedule.GetPriority()) + assert.Equal(t, "background", schedule.GetPriority()) } func TestOtherPriority(t *testing.T) { schedule := Config{ Priority: "other", } - assert.Equal(t, "background", schedule.GetPriority()) // default value + assert.Equal(t, "standard", schedule.GetPriority()) // default value } func TestScheduleFlags(t *testing.T) { diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 47e29f39..02bec067 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -29,7 +29,6 @@ const ( launchctlBin = "/bin/launchctl" launchdBootstrap = "bootstrap" launchdBootout = "bootout" - launchdList = "list" launchdPrint = "print" UserAgentPath = "Library/LaunchAgents" GlobalAgentPath = "/Library/LaunchAgents" @@ -39,9 +38,11 @@ const ( agentExtension = ".agent.plist" daemonExtension = ".plist" - codeServiceNotFound = 113 + launchctlServiceNotFound = 113 ) +var launchctlPrintKeys = []string{"service", "domain", "program", "working directory", "stdout path", "stderr path", "state", "runs", "last exit code"} + type HandlerLaunchd struct { config SchedulerConfig fs afero.Fs @@ -137,7 +138,7 @@ func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error { permission, _ := h.DetectSchedulePermission(PermissionFromConfig(job.Permission)) cmd := launchctlCommand(launchdPrint, domainTarget(permission)+"/"+getJobName(job.ProfileName, job.CommandName)) output, err := cmd.Output() - if cmd.ProcessState.ExitCode() == codeServiceNotFound { + if cmd.ProcessState.ExitCode() == launchctlServiceNotFound { return ErrScheduledJobNotFound } if err != nil { @@ -148,10 +149,12 @@ func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error { // output was not parsed, it could mean output format has changed clog.Warning("output of 'launchctl print' was either empty or using an incompatible format") } - keys := []string{"service", "domain", "program", "working directory", "stdout path", "stderr path", "state", "runs", "last exit code"} writer := tabwriter.NewWriter(term.GetOutput(), 1, 1, 1, ' ', tabwriter.AlignRight) - for _, key := range keys { + for _, key := range launchctlPrintKeys { key, value := presentStatus(key, status[key]) + if len(value) == 0 { + continue + } fmt.Fprintf(writer, "%s:\t %s\n", key, value) } fmt.Fprintf(writer, "* :\t since last (re)schedule or last reboot\n") @@ -287,16 +290,6 @@ func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { return nil, fmt.Errorf("error reading plist file: %w", err) } - permission := "" - switch launchdJob.LimitLoadToSessionType { - case darwin.SessionTypeDefault, darwin.SessionTypeGUI: - permission = constants.SchedulePermissionUserLoggedOn - case darwin.SessionTypeBackground: - permission = constants.SchedulePermissionUser - case darwin.SessionTypeSystem: - permission = constants.SchedulePermissionSystem - } - args := NewCommandArguments(launchdJob.ProgramArguments[2:]) // first is binary, second is --no-prio job := &Config{ ProfileName: profileName, @@ -306,7 +299,7 @@ func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { Arguments: args, WorkingDirectory: launchdJob.WorkingDirectory, Schedules: darwin.ParseCalendarIntervals(launchdJob.StartCalendarInterval), - Permission: permission, + Permission: launchdJob.LimitLoadToSessionType.Permission(), } return job, nil } @@ -321,8 +314,10 @@ func (h *HandlerLaunchd) createPlistFile(launchdJob *darwin.LaunchdJob, permissi // in some very recent installations of macOS, the user's LaunchAgents folder may not exist dir := path.Dir(filename) _ = h.fs.MkdirAll(dir, 0o700) - // if running using sudo, the directory would end up being owned by root - _ = h.fs.Chown(dir, user.Uid, user.Gid) + if user.SudoRoot { + // if running using sudo, the directory would end up being owned by root + _ = h.fs.Chown(dir, user.Uid, user.Gid) + } } file, err := h.fs.Create(filename) if err != nil { @@ -441,7 +436,7 @@ func presentStatus(key, value string) (string, string) { func isServiceRegistered(domain, name string) (bool, error) { cmd := launchctlCommand(launchdPrint, fmt.Sprintf("%s/%s", domain, name)) err := cmd.Run() - if cmd.ProcessState.ExitCode() == codeServiceNotFound { + if cmd.ProcessState.ExitCode() == launchctlServiceNotFound { return false, nil } if err != nil { diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 8c9b207c..b9420e1a 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -130,7 +130,7 @@ func TestReadingLaunchdScheduled(t *testing.T) { Command: "/bin/resticprofile", Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "config file.yaml", "--name", "self", "backup"}), WorkingDirectory: "/resticprofile", - Permission: constants.SchedulePermissionSystem, + Permission: constants.SchedulePermissionUserLoggedOn, ConfigFile: "config file.yaml", Schedules: []string{"*-*-* *:00,30:00"}, }, @@ -250,7 +250,7 @@ func TestParsePrint(t *testing.T) { t.Parallel() info := parsePrintStatus([]byte(launchctlOutput)) - assertMapHasKeys(t, info, []string{"service", "domain", "program", "working directory", "stdout path", "stderr path", "state", "runs", "last exit code"}) + assertMapHasKeys(t, info, launchctlPrintKeys) } @@ -280,3 +280,14 @@ func TestIsServiceRegistered(t *testing.T) { assert.Equal(t, service.isRegistered, registered) } } + +func TestParsePrintSystemService(t *testing.T) { + // this test should tell us when the output format of the launchctl print command is changing + cmd := launchctlCommand(launchdPrint, "system/com.apple.fseventsd") + output, err := cmd.Output() + require.NoError(t, err) + + info := parsePrintStatus(output) + assert.Greater(t, len(info), 20) // keep a low number to avoid flaky test + assert.Equal(t, "system", info["domain"]) +} From 523076f51fcfdbb5b7b70e22e75dfec4e150be22 Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 17 Mar 2025 21:25:47 +0000 Subject: [PATCH 12/33] check permission on cron scheduler --- docs/content/schedules/commands.md | 45 +++++++------- docs/content/schedules/configuration.md | 79 +++++++++++++------------ examples/dev.yaml | 3 +- schedule/handler_crond.go | 8 +++ schedule/handler_crond_test.go | 2 + 5 files changed, 75 insertions(+), 62 deletions(-) diff --git a/docs/content/schedules/commands.md b/docs/content/schedules/commands.md index 5beb0ab1..507ae154 100644 --- a/docs/content/schedules/commands.md +++ b/docs/content/schedules/commands.md @@ -5,19 +5,20 @@ tags: ["v0.25.0", "v0.29.0", "v0.30.0"] --- -resticprofile accepts these internal commands: +resticprofile accepts these commands: - **schedule** - **unschedule** - **status** -These resticprofile commands either operate on the profile selected by `--name`, on the profiles selected by a group (before `v0.29.0`), on groups (from v0.29.0), or on all profiles when the flag `--all` is passed on the command line. +These commands operate on the profile or group selected by `--name`, or on all profiles when `--all` is passed. {{% notice style="warning" %}} -Before version `0.29.0`, the `--name` flag on a group was used to select all profiles in the group for scheduling them. It was similar to running the schedule commands on each profile individually. +Before version `0.29.0`, the `--name` flag on a group selected all profiles in the group for scheduling, similar to running the schedule command on each profile individually. -Version `0.29.0` introduced group scheduling: The group schedule works at the group level (and will run all profiles one by one when the group schedule is triggered). +Version `0.29.0` introduced group scheduling: The group schedule works at the group level and runs all profiles one by one when triggered. {{% /notice %}} + Examples: ```shell resticprofile --name profile schedule @@ -25,45 +26,47 @@ resticprofile --name group schedule resticprofile schedule --all ``` -Please note, schedules are always independent of each other no matter whether they have been created with `--all`, by group or from a single profile. +Schedules are always independent, regardless of whether they are created with `--all` or from a single profile. ### schedule command -Install all the schedules defined on the selected profile or profiles. +Install all schedules defined in the selected profile(s). + +Note: On systemd, you need to `start` the timer once to enable it. Otherwise, it will only be enabled on the next reboot. If you don't want to start (and enable) it now, pass the `--no-start` flag to the command. -Please note on systemd, we need to `start` the timer once to enable it. Otherwise, it will only be enabled on the next reboot. If you **don't want** to start (and enable) it now, pass the `--no-start` flag to the command line. +If you use the `--all` flag to schedule all profiles at once, use either `user` mode or `system` mode. Combining both will not schedule tasks properly: +- If the user is not privileged, only `user` tasks will be scheduled. +- If the user is privileged, all schedules will be `system` schedules. -Also, if you use the `--all` flag to schedule all your profiles at once, make sure you use only the `user` mode or `system` mode. A combination of both would not schedule the tasks properly: -- if the user is not privileged, only the `user` tasks will be scheduled -- if the user **is** privileged, **all schedules will end up as a `system` schedule** {{% notice style=tip %}} -Before version `v0.30.0` resticprofile **did not keep a state** of the schedule and unschedule commands. If you need to make many changes in your profiles (like moving, renaming, deleting etc.) **it was recommended to unschedule everything using the `--all` flag before changing your profiles.**. This is no longer the case since version `v0.30.0` +Before version `v0.30.0`, resticprofile did not keep a state of the schedule and unschedule commands. If you needed to make many changes to your profiles (e.g., moving, renaming, deleting), it was recommended to unschedule everything using the `--all` flag before making changes. This is no longer necessary since version `v0.30.0`. {{% /notice %}} ### unschedule command -Remove all the schedules defined on the selected profile, or all profiles via the `--all` flag. +Remove all schedules defined on the selected profile, or all profiles using the `--all` flag. -Before version `v0.30.0`, using the `--all` flag wasn't removing schedules on deleted (or renamed) profiles. +Before version `v0.30.0`, the `--all` flag didn't remove schedules on deleted or renamed profiles. > [!NOTE] -> The behavior of the `unschedule` command has changed in version `v0.30.0`: +> The behavior of the `unschedule` command changed in version `v0.30.0`: > -> it now deletes **any schedule associated with the profile name, or any profile of the configuration file with `--all`** (even profiles deleted from the configuration file) +> It now deletes any schedule associated with the profile name, or any profile in the configuration file with `--all` (including deleted profiles). ### status command -Print the status on all the installed schedules of the selected profile or profiles. +Print the status of all installed schedules for the selected profile(s). -The display of the `status` command will be OS dependant. Please refer to the [examples]({{% relref "/schedules/examples" %}}) on which output you can expect from it. +The `status` command output depends on the OS. Refer to the [examples]({{% relref "/schedules/examples" %}}) for expected output. ### run-schedule command -This is the command that is used internally by the scheduler to tell resticprofile to execute in the context of a schedule. It means it will set the proper log output (`schedule-log`) and all other flags specific to the schedule. -If you're scheduling resticprofile manually you can use this command. It will execute the profile using all the `schedule-*` parameters defined in the profile. +This command is used by the scheduler to tell resticprofile to execute within a schedule. It sets the proper log output (`schedule-log`) and other schedule-specific flags. -This command is only taking one argument: name of the command to execute, followed by the profile name, both separated by a `@` sign. +If you're scheduling resticprofile manually, use this command. It executes the profile with all `schedule-*` parameters defined in the profile. + +This command takes one argument: the command name followed by the profile name, separated by an `@` sign. ```shell resticprofile run-schedule backup@profile @@ -71,4 +74,4 @@ resticprofile run-schedule backup@profile {{% notice info %}} For the `run-schedule` command, you cannot specify the profile name using the `--name` flag. -{{% /notice %}} +{{% /notice %}} \ No newline at end of file diff --git a/docs/content/schedules/configuration.md b/docs/content/schedules/configuration.md index e415faa4..f3d4444a 100644 --- a/docs/content/schedules/configuration.md +++ b/docs/content/schedules/configuration.md @@ -4,7 +4,7 @@ weight: 10 --- -The schedule configuration consists of a few parameters which can be added on each profile: +The schedule configuration includes several parameters that can be added to each profile: {{< tabs groupid="config-with-json" >}} {{% tab title="toml" %}} @@ -69,79 +69,80 @@ profile: {{< /tabs >}} - ### schedule-permission -`schedule-permission` accepts three parameters: `system`, `user` or `user_logged_on`: +`schedule-permission` accepts three parameters: `system`, `user`, or `user_logged_on`: -* `system`: if you need to access some system or protected files. You will need to run resticprofile with `sudo` on unixes and with elevated prompt on Windows (please note on Windows resticprofile will ask you for elevated permissions automatically if needed). +* `system`: Access system or protected files. Run resticprofile with `sudo` on Unix and with elevated prompt on Windows. On Windows, resticprofile will automatically request elevated permissions if needed. -* `user`: your backup will be running using your current user permissions on files. This is fine if you're only saving your documents (or any other file inside your profile). Please note on **systemd** that the schedule **will only run when your user is logged in**. This mode will ask you for your user password on Windows. +* `user`: Run the backup using current user permissions. Suitable for saving documents or files within your profile. **This mode runs even when the user is not logged on**. It will ask for your user password on Windows. -* `user_logged_on`: **For Windows only** - This gives the same permissions as `user`. This mode is not asking for your user password but will only run while the user is logged on. +* `user_logged_on`: **Not for crond** - Provides the same permissions as `user`, but runs only when the user is logged on. On Windows, it does not ask for your user password. -* *empty*: resticprofile will try its best guess based on how you started it (with sudo or as a normal user). The fallback is `system` on Windows, and `user` on the other platforms. +* *empty*: resticprofile will guess based on how it was started (with sudo or as a normal user). The fallback is `system` on Windows and `user_logged_in` on other platforms. +#### Changing schedule-permission -#### Changing schedule-permission from user to system, or system to user +To change the permission of a schedule, unschedule the profile first. -If you need to change the permission of a schedule, **please be sure to `unschedule` the profile before**. +Follow this order: -This order is important: +- Unschedule the job first. Resticprofile does not track how your profile was installed, so you must remove the schedule first. +- Change your permission (user to system, or system to user). +- Schedule your updated profile. -- `unschedule` the job first. resticprofile does **not keep track of how your profile was installed**, so you have to remove the schedule first -- now you can change your permission (`user` to `system`, or `system` to `user`) -- `schedule` your updated profile ### schedule-lock-mode -Starting from version 0.14.0, `schedule-lock-mode` accepts 3 values: -- `default`: Wait on acquiring a lock for the time duration set in `schedule-lock-wait`, before failing a schedule. - Behaves like `fail` when `schedule-lock-wait` is "0" or not specified. -- `fail`: Any lock failure causes a schedule to abort immediately. -- `ignore`: Skip resticprofile locks. restic locks are not skipped and can abort the schedule. +`schedule-lock-mode` accepts 3 values: +- `default`: Waits for the duration set in `schedule-lock-wait` before failing a schedule. Acts like `fail` if `schedule-lock-wait` is "0" or not specified. +- `fail`: Any lock failure immediately aborts the schedule. +- `ignore`: Skips resticprofile locks. Restic locks are not skipped and can abort the schedule. ### schedule-lock-wait -Sets the amount of time to wait for a resticprofile and restic lock to become available. Is only used when `schedule-lock-mode` is unset or `default`. +Sets the wait time for a resticprofile and restic lock to become available. Used only when `schedule-lock-mode` is unset or `default`. + ### schedule-log `schedule-log` can be used in two ways: -- Allow to redirect all output from resticprofile **and restic** to a file. The parameter should point to a file (`/path/to/file`) -- Redirects all resticprofile log entries to the syslog server. In that case the parameter is a URL like: `udp://server:514` or `tcp://127.0.0.1:514` +- Redirect all output from resticprofile **and restic** to a file. The parameter should point to a file (`/path/to/file`). +- Redirect all resticprofile log entries to the syslog server. In this case, the parameter is a URL like `udp://server:514` or `tcp://127.0.0.1:514`. -If there's no server answering on the port specified, resticprofile will send the logs to the default output instead. +If no server responds on the specified port, resticprofile will send the logs to the default output instead. -### schedule-priority (systemd and launchd only) -Starting from version 0.11.0, `schedule-priority` accepts two values: -- `background`: the process shouldn't be noticeable when working on the machine at the same time (this is the default) -- `standard`: the process should get the same priority as any other process on the machine (but it won't run faster if you're not using the machine at the same time) +### schedule-priority -`schedule-priority` is not available for windows task scheduler, nor crond +`schedule-priority` accepts two values: +- `background`: The process runs unnoticed while you work. +- `standard`: The process gets the same priority as other processes (won't run faster if the machine is idle). + +`schedule-priority` is not available for crond. ### schedule -The `schedule` parameter accepts many forms of input from the [systemd calendar event](https://www.freedesktop.org/software/systemd/man/systemd.time.html#Calendar%20Events) type. This is by far the easiest to use: **It is the same format used to schedule on macOS and Windows**. +The `schedule` parameter accepts various forms of input from the [systemd calendar event](https://www.freedesktop.org/software/systemd/man/systemd.time.html#Calendar%20Events) type. This format is the same used to schedule on macOS and Windows. -The most general form is: +The general form is: ``` weekdays year-month-day hour:minute:second ``` -- use `*` to mean any -- use `,` to separate multiple entries -- use `..` for a range +- Use `*` to mean any +- Use `,` to separate multiple entries +- Use `..` for a range -**limitations**: -- the divider (`/`), the `~` and timezones are not (yet?) supported on macOS and Windows. -- the `year` and `second` fields have no effect on macOS. They do have limited availability on Windows (they don't make much sense anyway). +**Limitations**: +- The divider (`/`), the `~`, and timezones are not supported on macOS and Windows. +- The `year` and `second` fields have no effect on macOS and limited availability on Windows. Here are a few examples (taken from the systemd documentation): ``` -On the left is the user input, on the right is the full format understood by the system + +The user input is on the left, and the system's full format is on the right. Sat,Thu,Mon..Wed,Sat..Sun → Mon..Thu,Sat,Sun *-*-* 00:00:00 Mon,Sun 12-*-* 2,1:23 → Mon,Sun 2012-*-* 01,02:23:00 @@ -171,15 +172,15 @@ Wed..Sat,Tue 12-10-15 1:2:3 → Tue..Sat 2012-10-15 01:02:03 annually → *-01-01 00:00:00 ``` -The `schedule` can be a string or an array of string (to allow for multiple schedules) +The `schedule` can be a string or an array of strings (to allow for multiple schedules). ### schedule-ignore-on-battery -If set to `true` the schedule won't start if the system is running on battery (even if the charge is still at 100%) +If set to `true`, the schedule won't start if the system is running on battery (even if the charge is at 100%). ### schedule-ignore-on-battery-less-than -If set to a number, the schedule won't start if the system is running on battery and the charge (in %) is less or equal than the number specified. +If set to a number, the schedule won't start if the system is running on battery and the charge is less than or equal to the specified number. ## Example diff --git a/examples/dev.yaml b/examples/dev.yaml index c0c0b6c6..5c929dd4 100644 --- a/examples/dev.yaml +++ b/examples/dev.yaml @@ -7,8 +7,7 @@ global: initialize: false priority: low prevent-sleep: true - # restic-binary: ~/fake_restic - # legacy-arguments: true + scheduler: "crontab:*:crontab" group-continue-on-error: true restic-lock-retry-after: "1m" # log: "_global.txt" diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index e073980e..4d24eaf2 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -4,6 +4,7 @@ import ( "os" "slices" + "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/crond" @@ -37,6 +38,7 @@ func NewHandlerCrond(config SchedulerConfig) *HandlerCrond { // Init verifies crond is available on this system func (h *HandlerCrond) Init() error { + clog.Debug("using cron scheduler") if len(h.config.CrontabFile) > 0 { return nil } @@ -137,6 +139,11 @@ func (h *HandlerCrond) Scheduled(profileName string) ([]Config, error) { profileName := entry.ProfileName() commandName := entry.CommandName() configFile := entry.ConfigFile() + permission := constants.SchedulePermissionUser + if entry.User() == "root" { + permission = constants.SchedulePermissionSystem + } + if index := slices.IndexFunc(configs, func(cfg Config) bool { return cfg.ProfileName == profileName && cfg.CommandName == commandName && cfg.ConfigFile == configFile }); index >= 0 { @@ -152,6 +159,7 @@ func (h *HandlerCrond) Scheduled(profileName string) ([]Config, error) { Command: args[0], Arguments: NewCommandArguments(args[1:]), WorkingDirectory: entry.WorkDir(), + Permission: permission, }) } } diff --git a/schedule/handler_crond_test.go b/schedule/handler_crond_test.go index 2c1d5847..e837700a 100644 --- a/schedule/handler_crond_test.go +++ b/schedule/handler_crond_test.go @@ -29,6 +29,7 @@ func TestReadingCrondScheduled(t *testing.T) { WorkingDirectory: "/resticprofile", Schedules: []string{"*-*-* *:00:00"}, ConfigFile: "examples/dev.yaml", + Permission: "user", }, schedules: []*calendar.Event{ hourly, @@ -43,6 +44,7 @@ func TestReadingCrondScheduled(t *testing.T) { WorkingDirectory: "/resticprofile", Schedules: []string{"*-*-* *:00:00"}, ConfigFile: "config file.yaml", + Permission: "user", }, schedules: []*calendar.Event{ hourly, From 3db6a86163c5655cc7fa071dd82bad679cc95115 Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 17 Mar 2025 21:45:45 +0000 Subject: [PATCH 13/33] add user permission on test profile --- commands_schedule_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/commands_schedule_test.go b/commands_schedule_test.go index 2ae5353a..b0cbdec0 100644 --- a/commands_schedule_test.go +++ b/commands_schedule_test.go @@ -40,11 +40,13 @@ profiles: profile-schedule-inline: backup: schedule: "*:00,30" + schedule-permission: "user" profile-schedule-struct: backup: schedule: at: "*:20,50" + permission: "user" ` From 223be775c0ad4f843014a5ec8aa8108ad6b4ef02 Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 18 Mar 2025 17:08:56 +0000 Subject: [PATCH 14/33] move permission check to handler (as crond is different than systemd) --- schedule/handler.go | 2 ++ schedule/handler_crond.go | 21 +++++++++++++++- schedule/handler_darwin.go | 18 +++++++++++++ schedule/handler_systemd.go | 18 +++++++++++++ schedule/handler_windows.go | 6 +++++ schedule/job.go | 6 ++--- schedule/job_test.go | 4 +++ schedule/mocks/Handler.go | 46 ++++++++++++++++++++++++++++++++++ schedule/permission_darwin.go | 25 ------------------ schedule/permission_other.go | 25 ------------------ schedule/permission_windows.go | 9 ------- schedule_jobs_test.go | 9 +++++++ 12 files changed, 126 insertions(+), 63 deletions(-) delete mode 100644 schedule/permission_darwin.go delete mode 100644 schedule/permission_other.go delete mode 100644 schedule/permission_windows.go diff --git a/schedule/handler.go b/schedule/handler.go index b1bed88f..89d34607 100644 --- a/schedule/handler.go +++ b/schedule/handler.go @@ -23,6 +23,8 @@ type Handler interface { // or the best guess considering the current user permission. // safe specifies whether a guess may lead to a too broad or too narrow file access permission. DetectSchedulePermission(permission Permission) (Permission, bool) + // CheckPermission returns true if the user is allowed to access the job. + CheckPermission(p Permission) bool } // 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 4d24eaf2..373c798c 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -38,10 +38,11 @@ func NewHandlerCrond(config SchedulerConfig) *HandlerCrond { // Init verifies crond is available on this system func (h *HandlerCrond) Init() error { - clog.Debug("using cron scheduler") if len(h.config.CrontabFile) > 0 { + clog.Debugf("using file %q as cron scheduler", h.config.CrontabFile) return nil } + clog.Debug("using standard cron scheduler") return lookupBinary("crond", h.config.CrontabBinary) } @@ -186,6 +187,24 @@ func (h *HandlerCrond) DetectSchedulePermission(p Permission) (Permission, bool) } } +// CheckPermission returns true if the user is allowed to access the job. +func (h *HandlerCrond) CheckPermission(p Permission) bool { + switch p { + case PermissionUserLoggedOn, PermissionUserBackground: + // user mode is always available + return true + + default: + if os.Geteuid() == 0 { + // user has sudoed + return true + } + // last case is system (or undefined) + no sudo + return false + + } +} + // init registers HandlerCrond func init() { AddHandlerProvider(func(config SchedulerConfig, fallback bool) Handler { diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 02bec067..99fe0abd 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -259,6 +259,24 @@ func (h *HandlerLaunchd) DetectSchedulePermission(permission Permission) (Permis } } +// CheckPermission returns true if the user is allowed to access the job. +func (h *HandlerLaunchd) CheckPermission(p Permission) bool { + switch p { + case PermissionUserLoggedOn, PermissionUserBackground: + // user mode is always available + return true + + default: + if os.Geteuid() == 0 { + // user has sudoed + return true + } + // last case is system (or undefined) + no sudo + return false + + } +} + func getSchedulePattern(profileName, permission string) string { pattern := "%s%s.*%s" if permission == constants.SchedulePermissionSystem { diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index 853bb398..fcd2b0ff 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -289,6 +289,24 @@ func (h *HandlerSystemd) DetectSchedulePermission(p Permission) (Permission, boo } } +// CheckPermission returns true if the user is allowed to access the job. +func (h *HandlerSystemd) CheckPermission(p Permission) bool { + switch p { + case PermissionUserLoggedOn: + // user mode is always available + return true + + default: + if os.Geteuid() == 0 { + // user has sudoed + return true + } + // last case is system (or undefined) + no sudo + return false + + } +} + var ( _ Handler = &HandlerSystemd{} ) diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index 30489c5e..d8a0b8a5 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -129,6 +129,12 @@ func (h *HandlerWindows) DetectSchedulePermission(permission Permission) (Permis } } +// CheckPermission returns true if the user is allowed to access the job. +// This is always true on Windows. +func (h *HandlerWindows) CheckPermission(p Permission) bool { + return true +} + // init registers HandlerWindows func init() { AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { diff --git a/schedule/job.go b/schedule/job.go index 49c491b7..96ee254e 100644 --- a/schedule/job.go +++ b/schedule/job.go @@ -35,7 +35,7 @@ func NewJob(handler Handler, config *Config) *Job { // Accessible checks if the current user is permitted to access the job func (j *Job) Accessible() bool { permission, _ := j.handler.DetectSchedulePermission(PermissionFromConfig(j.config.Permission)) - return permission.Check() + return j.handler.CheckPermission(permission) } // Create a new job @@ -45,7 +45,7 @@ func (j *Job) Create() error { } permission := j.getSchedulePermission(PermissionFromConfig(j.config.Permission)) - if ok := permission.Check(); !ok { + if ok := j.handler.CheckPermission(permission); !ok { return permissionError("create") } @@ -73,7 +73,7 @@ func (j *Job) Remove() error { } else { permission = j.getSchedulePermission(permission) } - if ok := permission.Check(); !ok { + if ok := j.handler.CheckPermission(permission); !ok { return permissionError("remove") } diff --git a/schedule/job_test.go b/schedule/job_test.go index ecb71fac..423a0194 100644 --- a/schedule/job_test.go +++ b/schedule/job_test.go @@ -15,6 +15,7 @@ import ( func TestCreateJobHappyPath(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().DetectSchedulePermission(schedule.PermissionUserBackground).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) 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{}, schedule.PermissionUserBackground).Return(nil) @@ -33,6 +34,7 @@ func TestCreateJobHappyPath(t *testing.T) { func TestCreateJobErrorParseSchedules(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(nil) handler.EXPECT().ParseSchedules([]string{}).Return(nil, errors.New("test!")) @@ -49,6 +51,7 @@ func TestCreateJobErrorParseSchedules(t *testing.T) { func TestCreateJobErrorDisplaySchedules(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(errors.New("test!")) job := schedule.NewJob(handler, &schedule.Config{ @@ -64,6 +67,7 @@ func TestCreateJobErrorDisplaySchedules(t *testing.T) { func TestCreateJobErrorCreate(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().DetectSchedulePermission(schedule.PermissionUserBackground).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) 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{}, schedule.PermissionUserBackground).Return(errors.New("test!")) diff --git a/schedule/mocks/Handler.go b/schedule/mocks/Handler.go index 579acf0a..e8335532 100644 --- a/schedule/mocks/Handler.go +++ b/schedule/mocks/Handler.go @@ -22,6 +22,52 @@ func (_m *Handler) EXPECT() *Handler_Expecter { return &Handler_Expecter{mock: &_m.Mock} } +// CheckPermission provides a mock function with given fields: p +func (_m *Handler) CheckPermission(p schedule.Permission) bool { + ret := _m.Called(p) + + if len(ret) == 0 { + panic("no return value specified for CheckPermission") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(schedule.Permission) bool); ok { + r0 = rf(p) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// Handler_CheckPermission_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckPermission' +type Handler_CheckPermission_Call struct { + *mock.Call +} + +// CheckPermission is a helper method to define mock.On call +// - p schedule.Permission +func (_e *Handler_Expecter) CheckPermission(p interface{}) *Handler_CheckPermission_Call { + return &Handler_CheckPermission_Call{Call: _e.mock.On("CheckPermission", p)} +} + +func (_c *Handler_CheckPermission_Call) Run(run func(p schedule.Permission)) *Handler_CheckPermission_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(schedule.Permission)) + }) + return _c +} + +func (_c *Handler_CheckPermission_Call) Return(_a0 bool) *Handler_CheckPermission_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Handler_CheckPermission_Call) RunAndReturn(run func(schedule.Permission) bool) *Handler_CheckPermission_Call { + _c.Call.Return(run) + return _c +} + // Close provides a mock function with no fields func (_m *Handler) Close() { _m.Called() diff --git a/schedule/permission_darwin.go b/schedule/permission_darwin.go deleted file mode 100644 index 7888eb25..00000000 --- a/schedule/permission_darwin.go +++ /dev/null @@ -1,25 +0,0 @@ -//go:build darwin - -package schedule - -import ( - "os" -) - -// Check returns true if the user is allowed to access the job. -func (p Permission) Check() bool { - switch p { - case PermissionUserLoggedOn, PermissionUserBackground: - // user mode is always available - return true - - default: - if os.Geteuid() == 0 { - // user has sudoed - return true - } - // last case is system (or undefined) + no sudo - return false - - } -} diff --git a/schedule/permission_other.go b/schedule/permission_other.go deleted file mode 100644 index 869eebbc..00000000 --- a/schedule/permission_other.go +++ /dev/null @@ -1,25 +0,0 @@ -//go:build !windows && !darwin - -package schedule - -import ( - "os" -) - -// Check returns true if the user is allowed to access the job. -func (p Permission) Check() bool { - switch p { - case PermissionUserLoggedOn: - // user mode is always available - return true - - default: - if os.Geteuid() == 0 { - // user has sudoed - return true - } - // last case is system (or undefined) + no sudo - return false - - } -} diff --git a/schedule/permission_windows.go b/schedule/permission_windows.go deleted file mode 100644 index 698f1607..00000000 --- a/schedule/permission_windows.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build windows - -package schedule - -// Check returns true if the user is allowed to access the job. -// This is always true on Windows -func (p Permission) Check() bool { - return true -} diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go index 545254df..1297bc08 100644 --- a/schedule_jobs_test.go +++ b/schedule_jobs_test.go @@ -37,6 +37,7 @@ func TestSimpleScheduleJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil) handler.EXPECT().DisplaySchedules("profile", "backup", []string{"sched"}).Return(nil) handler.EXPECT().CreateJob( @@ -62,6 +63,7 @@ func TestFailScheduleJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil) handler.EXPECT().DisplaySchedules("profile", "backup", []string{"sched"}).Return(nil) handler.EXPECT().CreateJob( @@ -93,6 +95,7 @@ func TestRemoveJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). RunAndReturn(func(scheduleConfig *schedule.Config, _ schedule.Permission) error { assert.Equal(t, "profile", scheduleConfig.ProfileName) @@ -112,6 +115,7 @@ func TestRemoveJobNoConfig(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). RunAndReturn(func(scheduleConfig *schedule.Config, _ schedule.Permission) error { assert.Equal(t, "profile", scheduleConfig.ProfileName) @@ -131,6 +135,7 @@ func TestFailRemoveJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). Return(errors.New("error removing job")) @@ -146,6 +151,7 @@ func TestNoFailRemoveUnknownJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). Return(schedule.ErrScheduledJobNotFound) @@ -161,6 +167,7 @@ func TestNoFailRemoveUnknownRemoveOnlyJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). Return(schedule.ErrScheduledJobNotFound) @@ -272,6 +279,7 @@ func TestRemoveScheduledJobs(t *testing.T) { for _, cfg := range tc.removedConfigs { handler.EXPECT().RemoveJob(&cfg, tc.permission).Return(nil) handler.EXPECT().DetectSchedulePermission(tc.permission).Return(tc.permission, true) + handler.EXPECT().CheckPermission(tc.permission).Return(true) } err := removeScheduledJobs(handler, tc.fromConfigFile, tc.removeProfileName) @@ -302,6 +310,7 @@ func TestFailRemoveScheduledJobs(t *testing.T) { Permission: constants.SchedulePermissionUser, }, schedule.PermissionUserBackground).Return(errors.New("impossible")) handler.EXPECT().DetectSchedulePermission(schedule.PermissionUserBackground).Return(schedule.PermissionUserBackground, true) + handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) err := removeScheduledJobs(handler, "configFile", "profile_to_remove") assert.Error(t, err) From 09e6f797ebc5f613f47446a0ad302d3c506e8c01 Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 18 Mar 2025 17:22:42 +0000 Subject: [PATCH 15/33] add tests on DetectSchedulePermission --- schedule/handler_crond_test.go | 27 +++++++++++++++++++++++++++ schedule/handler_darwin_test.go | 27 +++++++++++++++++++++++++++ schedule/handler_systemd_test.go | 27 +++++++++++++++++++++++++++ schedule/handler_windows_test.go | 27 +++++++++++++++++++++++++++ 4 files changed, 108 insertions(+) diff --git a/schedule/handler_crond_test.go b/schedule/handler_crond_test.go index e837700a..85a64934 100644 --- a/schedule/handler_crond_test.go +++ b/schedule/handler_crond_test.go @@ -81,3 +81,30 @@ func TestReadingCrondScheduled(t *testing.T) { require.NoError(t, err) assert.Empty(t, scheduled) } + +func TestDetectPermissionCrond(t *testing.T) { + t.Parallel() + + fixtures := []struct { + input string + expected string + safe bool + }{ + {"", "user", false}, + {"something", "user", false}, + {"system", "system", true}, + {"user", "user", true}, + {"user_logged_on", "user_logged_on", true}, + {"user_logged_in", "user_logged_on", true}, // I did the typo as I was writing the doc, so let's add it here :) + } + for _, fixture := range fixtures { + t.Run(fixture.input, func(t *testing.T) { + t.Parallel() + + handler := NewHandler(SchedulerCrond{}).(*HandlerCrond) + perm, safe := handler.DetectSchedulePermission(PermissionFromConfig(fixture.input)) + assert.Equal(t, fixture.expected, perm.String()) + assert.Equal(t, fixture.safe, safe) + }) + } +} diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index b9420e1a..1a826bcb 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -291,3 +291,30 @@ func TestParsePrintSystemService(t *testing.T) { assert.Greater(t, len(info), 20) // keep a low number to avoid flaky test assert.Equal(t, "system", info["domain"]) } + +func TestDetectPermissionLaunchd(t *testing.T) { + t.Parallel() + + fixtures := []struct { + input string + expected string + safe bool + }{ + {"", "user_logged_on", true}, + {"something", "user_logged_on", true}, + {"system", "system", true}, + {"user", "user", true}, + {"user_logged_on", "user_logged_on", true}, + {"user_logged_in", "user_logged_on", true}, // I did the typo as I was writing the doc, so let's add it here :) + } + for _, fixture := range fixtures { + t.Run(fixture.input, func(t *testing.T) { + t.Parallel() + + handler := NewHandler(SchedulerLaunchd{}).(*HandlerLaunchd) + perm, safe := handler.DetectSchedulePermission(PermissionFromConfig(fixture.input)) + assert.Equal(t, fixture.expected, perm.String()) + assert.Equal(t, fixture.safe, safe) + }) + } +} diff --git a/schedule/handler_systemd_test.go b/schedule/handler_systemd_test.go index 4ecf5a18..54fa99c0 100644 --- a/schedule/handler_systemd_test.go +++ b/schedule/handler_systemd_test.go @@ -93,3 +93,30 @@ func TestReadingSystemdScheduled(t *testing.T) { require.NoError(t, err) assert.Empty(t, scheduled) } + +func TestDetectPermissionSystemd(t *testing.T) { + t.Parallel() + + fixtures := []struct { + input string + expected string + safe bool + }{ + {"", "user_logged_on", false}, + {"something", "user_logged_on", false}, + {"system", "system", true}, + {"user", "user", true}, + {"user_logged_on", "user_logged_on", true}, + {"user_logged_in", "user_logged_on", true}, // I did the typo as I was writing the doc, so let's add it here :) + } + for _, fixture := range fixtures { + t.Run(fixture.input, func(t *testing.T) { + t.Parallel() + + handler := NewHandler(SchedulerSystemd{}).(*HandlerSystemd) + perm, safe := handler.DetectSchedulePermission(PermissionFromConfig(fixture.input)) + assert.Equal(t, fixture.expected, perm.String()) + assert.Equal(t, fixture.safe, safe) + }) + } +} diff --git a/schedule/handler_windows_test.go b/schedule/handler_windows_test.go index c235c9e1..e3feda37 100644 --- a/schedule/handler_windows_test.go +++ b/schedule/handler_windows_test.go @@ -17,3 +17,30 @@ func TestHandlerDefaultOS(t *testing.T) { handler := NewHandler(SchedulerDefaultOS{}) assert.IsType(t, &HandlerWindows{}, handler) } + +func TestDetectPermissionTaskScheduler(t *testing.T) { + t.Parallel() + + fixtures := []struct { + input string + expected string + safe bool + }{ + {"", "system", true}, + {"something", "system", true}, + {"system", "system", true}, + {"user", "user", true}, + {"user_logged_on", "user_logged_on", true}, + {"user_logged_in", "user_logged_on", true}, // I did the typo as I was writing the doc, so let's add it here :) + } + for _, fixture := range fixtures { + t.Run(fixture.input, func(t *testing.T) { + t.Parallel() + + handler := NewHandler(SchedulerWindows{}).(*HandlerWindows) + perm, safe := handler.DetectSchedulePermission(PermissionFromConfig(fixture.input)) + assert.Equal(t, fixture.expected, perm.String()) + assert.Equal(t, fixture.safe, safe) + }) + } +} From 6001fad72228318bb81a6f02e531517a1ad08f2b Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 18 Mar 2025 17:52:56 +0000 Subject: [PATCH 16/33] docs: inform we can set the cron user manually in config --- .gitignore | 1 + docs/content/schedules/cron.md | 3 ++- examples/dev.yaml | 2 +- schedule/permission_test.go | 30 ------------------------------ 4 files changed, 4 insertions(+), 32 deletions(-) delete mode 100644 schedule/permission_test.go diff --git a/.gitignore b/.gitignore index a05a8f54..bc254330 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /build/restic* /build/rclone* /dist +/crontab # binary /resticprofile* diff --git a/docs/content/schedules/cron.md b/docs/content/schedules/cron.md index cc840858..fe76eabd 100644 --- a/docs/content/schedules/cron.md +++ b/docs/content/schedules/cron.md @@ -95,7 +95,8 @@ global: ## Crontab You can use a crontab file directly instead of the `crontab` tool: -* `crontab:*:filepath`: Use a crontab file `filepath` **with a user field** +* `crontab:*:filepath`: Use a crontab file `filepath` **with a user field** filled in automatically +* `crontab:username:filepath`: Use a crontab file `filepath` **with a user field** always set to `username` * `crontab:-:filepath`: Use a crontab file `filepath` **without a user field** ### With user field diff --git a/examples/dev.yaml b/examples/dev.yaml index 5c929dd4..d69fdd54 100644 --- a/examples/dev.yaml +++ b/examples/dev.yaml @@ -7,7 +7,7 @@ global: initialize: false priority: low prevent-sleep: true - scheduler: "crontab:*:crontab" + scheduler: "crontab:me:crontab" group-continue-on-error: true restic-lock-retry-after: "1m" # log: "_global.txt" diff --git a/schedule/permission_test.go b/schedule/permission_test.go deleted file mode 100644 index 643f5641..00000000 --- a/schedule/permission_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package schedule - -// TODO rewrite this test! -// func TestDetectPermission(t *testing.T) { -// fixtures := []struct { -// input string -// expected string -// safe bool -// active bool -// }{ -// {"", "system", true, platform.IsWindows()}, -// {"something", "system", true, platform.IsWindows()}, -// {"", "user_logged_on", platform.IsDarwin(), !platform.IsWindows()}, -// {"something", "user_logged_on", platform.IsDarwin(), !platform.IsWindows()}, -// {"system", "system", true, true}, -// {"user", "user", true, true}, -// {"user_logged_on", "user_logged_on", true, true}, -// {"user_logged_in", "user_logged_on", true, true}, // I did the typo as I was writing the doc, so let's add it here :) -// } -// for _, fixture := range fixtures { -// if !fixture.active { -// continue -// } -// t.Run(fixture.input, func(t *testing.T) { -// perm, safe := PermissionFromConfig(fixture.input).Detect() -// assert.Equal(t, fixture.expected, perm.String()) -// assert.Equal(t, fixture.safe, safe) -// }) -// } -// } From f0002b42209861835d2d1f1903814afbce7b36fb Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 18 Mar 2025 18:11:21 +0000 Subject: [PATCH 17/33] detect user after sudo --- crond/crontab.go | 8 ++++---- examples/dev.yaml | 2 +- schedule/handler_darwin.go | 7 ++++--- {darwin => user}/user.go | 16 ++++++++++------ user/user_other.go | 35 +++++++++++++++++++++++++++++++++++ {darwin => user}/user_test.go | 7 +++---- 6 files changed, 57 insertions(+), 18 deletions(-) rename {darwin => user}/user.go (72%) create mode 100644 user/user_other.go rename {darwin => user}/user_test.go (82%) diff --git a/crond/crontab.go b/crond/crontab.go index 636384ac..8d3ae52d 100644 --- a/crond/crontab.go +++ b/crond/crontab.go @@ -4,10 +4,10 @@ import ( "errors" "fmt" "io" - "os/user" "regexp" "strings" + "github.com/creativeprojects/resticprofile/user" "github.com/spf13/afero" ) @@ -172,9 +172,9 @@ func (c *Crontab) LoadCurrent() (content string, err error) { func (c *Crontab) username() string { if len(c.user) == 0 { - if current, err := user.Current(); err == nil { - c.user = current.Username - } + current := user.Current() + c.user = current.Username + if len(c.user) == 0 || strings.ContainsAny(c.user, "\t \n\r") { c.user = "root" } diff --git a/examples/dev.yaml b/examples/dev.yaml index d69fdd54..5c929dd4 100644 --- a/examples/dev.yaml +++ b/examples/dev.yaml @@ -7,7 +7,7 @@ global: initialize: false priority: low prevent-sleep: true - scheduler: "crontab:me:crontab" + scheduler: "crontab:*:crontab" group-continue-on-error: true restic-lock-retry-after: "1m" # log: "_global.txt" diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 99fe0abd..027b249c 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -16,6 +16,7 @@ import ( "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/darwin" "github.com/creativeprojects/resticprofile/term" + "github.com/creativeprojects/resticprofile/user" "github.com/creativeprojects/resticprofile/util" "github.com/spf13/afero" "howett.net/plist" @@ -323,7 +324,7 @@ func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { } func (h *HandlerLaunchd) createPlistFile(launchdJob *darwin.LaunchdJob, permission Permission) (string, error) { - user := darwin.CurrentUser() + user := user.Current() filename, err := getFilename(launchdJob.Label, permission) if err != nil { return "", err @@ -397,10 +398,10 @@ func domainTarget(permission Permission) string { case PermissionSystem: return "system" case PermissionUserLoggedOn: - return fmt.Sprintf("gui/%d", darwin.CurrentUser().Uid) + return fmt.Sprintf("gui/%d", user.Current().Uid) case PermissionUserBackground: - return fmt.Sprintf("user/%d", darwin.CurrentUser().Uid) + return fmt.Sprintf("user/%d", user.Current().Uid) default: return "" diff --git a/darwin/user.go b/user/user.go similarity index 72% rename from darwin/user.go rename to user/user.go index 694f90b9..faa1857c 100644 --- a/darwin/user.go +++ b/user/user.go @@ -1,6 +1,6 @@ //go:build darwin -package darwin +package user import ( "os" @@ -11,10 +11,12 @@ import ( type User struct { Uid int Gid int + Username string SudoRoot bool } -func CurrentUser() User { +func Current() User { + username := "" sudoed := false uid := os.Getuid() gid := os.Getgid() @@ -27,14 +29,16 @@ func CurrentUser() User { sudoed = true } } - current, err := user.LookupId(strconv.Itoa(uid)) - if err == nil { - gid, _ = strconv.Atoi(current.Gid) - } + } + current, err := user.LookupId(strconv.Itoa(uid)) + if err == nil { + gid, _ = strconv.Atoi(current.Gid) + username = current.Username } return User{ Uid: uid, Gid: gid, + Username: username, SudoRoot: sudoed, } } diff --git a/user/user_other.go b/user/user_other.go new file mode 100644 index 00000000..524ed5a0 --- /dev/null +++ b/user/user_other.go @@ -0,0 +1,35 @@ +//go:build !darwin + +package user + +import ( + "os" + "os/user" + "strconv" +) + +type User struct { + Uid int + Gid int + Username string + SudoRoot bool +} + +func Current() User { + username := "" + uid := os.Getuid() + gid := os.Getgid() + + current, err := user.Current() + if err == nil { + uid, _ = strconv.Atoi(current.Uid) + gid, _ = strconv.Atoi(current.Gid) + username = current.Username + } + return User{ + Uid: uid, + Gid: gid, + Username: username, + SudoRoot: false, + } +} diff --git a/darwin/user_test.go b/user/user_test.go similarity index 82% rename from darwin/user_test.go rename to user/user_test.go index 43500cc1..aae7106d 100644 --- a/darwin/user_test.go +++ b/user/user_test.go @@ -1,6 +1,4 @@ -//go:build darwin - -package darwin +package user import ( "testing" @@ -9,9 +7,10 @@ import ( ) func TestUserHasUidGid(t *testing.T) { - user := CurrentUser() + user := Current() // it is very unlikely anyone would run the tests using sudo :D assert.False(t, user.SudoRoot) assert.Greater(t, user.Uid, 500) assert.Greater(t, user.Gid, 0) + t.Logf("%+v", user) } From 593536cfc9ba8bb78bd9b520cbea5bd867cebcab Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 18 Mar 2025 20:48:11 +0000 Subject: [PATCH 18/33] fix getting username on windows --- user/user.go | 44 -------------------------------------------- user/user_other.go | 19 ++++++++++++++----- user/user_test.go | 12 +++++++++--- user/user_windows.go | 28 ++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 52 deletions(-) delete mode 100644 user/user.go create mode 100644 user/user_windows.go diff --git a/user/user.go b/user/user.go deleted file mode 100644 index faa1857c..00000000 --- a/user/user.go +++ /dev/null @@ -1,44 +0,0 @@ -//go:build darwin - -package user - -import ( - "os" - "os/user" - "strconv" -) - -type User struct { - Uid int - Gid int - Username string - SudoRoot bool -} - -func Current() User { - username := "" - sudoed := false - uid := os.Getuid() - gid := os.Getgid() - if uid == 0 { - // after a sudo, macOs returns the root user on both os.Getuid() and os.Geteuid() - // to detect the logged on user after a sudo, we need to use the environment variable - if userid, sudo := os.LookupEnv("SUDO_UID"); sudo { - if temp, err := strconv.Atoi(userid); err == nil { - uid = temp - sudoed = true - } - } - } - current, err := user.LookupId(strconv.Itoa(uid)) - if err == nil { - gid, _ = strconv.Atoi(current.Gid) - username = current.Username - } - return User{ - Uid: uid, - Gid: gid, - Username: username, - SudoRoot: sudoed, - } -} diff --git a/user/user_other.go b/user/user_other.go index 524ed5a0..9081de7e 100644 --- a/user/user_other.go +++ b/user/user_other.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !windows package user @@ -17,12 +17,21 @@ type User struct { func Current() User { username := "" + sudoed := false uid := os.Getuid() gid := os.Getgid() - - current, err := user.Current() + if uid == 0 { + // after a sudo, both os.Getuid() and os.Geteuid() return 0 (the root user) + // to detect the logged on user after a sudo, we need to use the environment variable + if userid, sudo := os.LookupEnv("SUDO_UID"); sudo { + if temp, err := strconv.Atoi(userid); err == nil { + uid = temp + sudoed = true + } + } + } + current, err := user.LookupId(strconv.Itoa(uid)) if err == nil { - uid, _ = strconv.Atoi(current.Uid) gid, _ = strconv.Atoi(current.Gid) username = current.Username } @@ -30,6 +39,6 @@ func Current() User { Uid: uid, Gid: gid, Username: username, - SudoRoot: false, + SudoRoot: sudoed, } } diff --git a/user/user_test.go b/user/user_test.go index aae7106d..38c43b2e 100644 --- a/user/user_test.go +++ b/user/user_test.go @@ -3,14 +3,20 @@ package user import ( "testing" + "github.com/creativeprojects/resticprofile/platform" "github.com/stretchr/testify/assert" ) -func TestUserHasUidGid(t *testing.T) { +func TestCurrentUser(t *testing.T) { user := Current() // it is very unlikely anyone would run the tests using sudo :D assert.False(t, user.SudoRoot) - assert.Greater(t, user.Uid, 500) - assert.Greater(t, user.Gid, 0) + + assert.NotEmpty(t, user.Username) + + if !platform.IsWindows() { + assert.Greater(t, user.Uid, 500) + assert.Greater(t, user.Gid, 0) + } t.Logf("%+v", user) } diff --git a/user/user_windows.go b/user/user_windows.go new file mode 100644 index 00000000..47406e14 --- /dev/null +++ b/user/user_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package user + +import ( + "os/user" +) + +type User struct { + Uid int + Gid int + Username string + SudoRoot bool +} + +func Current() User { + username := "" + current, err := user.Current() + if err == nil { + username = current.Username + } + return User{ + Uid: -1, + Gid: -1, + Username: username, + SudoRoot: false, + } +} From 904115048a8ad266c4af78b40e56aca4912df24e Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 19 Mar 2025 15:16:22 +0000 Subject: [PATCH 19/33] use root user on system jobs --- schedule/handler_crond.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index 373c798c..4f2af22c 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -82,7 +82,11 @@ func (h *HandlerCrond) CreateJob(job *Config, schedules []*calendar.Event, permi job.WorkingDirectory, ) if h.config.Username != "" { - entries[i] = entries[i].WithUser(h.config.Username) + if permission == PermissionSystem { + entries[i] = entries[i].WithUser("root") + } else { + entries[i] = entries[i].WithUser(h.config.Username) + } } } crontab := crond.NewCrontab(entries). From 0f423d52f219a0d8920f2d1f647f967adb1cceaf Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 19 Mar 2025 18:07:09 +0000 Subject: [PATCH 20/33] docs: explain how systemd services are generated --- Makefile | 2 +- docs/content/schedules/systemd.md | 95 ++++++++++++++++--------------- examples/dev.yaml | 2 +- examples/linux.yaml | 20 ++++--- schedule/handler_crond.go | 2 +- schedule/handler_systemd.go | 24 ++++---- schedule/job.go | 4 +- schedule/schedules.go | 2 +- schedule/schedules_test.go | 2 +- 9 files changed, 80 insertions(+), 73 deletions(-) diff --git a/Makefile b/Makefile index 1e0d4a12..fffffbc5 100644 --- a/Makefile +++ b/Makefile @@ -194,7 +194,7 @@ ${TMP_MOUNT_DARWIN}: # Mount tmpfs on linux ${TMP_MOUNT_LINUX}: mkdir -p ${TMP_MOUNT_LINUX} - sudo mount -t tmpfs -o "rw,relatime,size=2097152k" tmpfs ${TMP_MOUNT_LINUX} + sudo mount -t tmpfs -o "rw,relatime,size=2097152k,uid=`id -u`,gid=`id -g`" tmpfs ${TMP_MOUNT_LINUX} rest-server: @echo "[*] $@" diff --git a/docs/content/schedules/systemd.md b/docs/content/schedules/systemd.md index 14cb0b03..3de4a01a 100644 --- a/docs/content/schedules/systemd.md +++ b/docs/content/schedules/systemd.md @@ -5,23 +5,17 @@ tags: ["v0.25.0", "v0.29.0"] --- - -**systemd** is a common service manager in use by many Linux distributions. -resticprofile has the ability to create systemd timer and service files. -systemd can be used in place of cron to schedule backups. +**systemd** is a common service manager used by many Linux distributions. resticprofile can create systemd timer and service files. User systemd units are created under the user's systemd profile (`~/.config/systemd/user`). -System units are created in `/etc/systemd/system` +System units are created in `/etc/systemd/system`. ## systemd calendars -resticprofile uses systemd -[OnCalendar](https://www.freedesktop.org/software/systemd/man/systemd.time.html#Calendar%20Events) -format to schedule events. +resticprofile uses the systemd [OnCalendar](https://www.freedesktop.org/software/systemd/man/systemd.time.html#Calendar%20Events) format to schedule events. -Testing systemd calendars can be done with the systemd-analyze application. -systemd-analyze will display when the next trigger will happen: +Test systemd calendars with [systemd-analyze](https://www.freedesktop.org/software/systemd/man/latest/systemd-analyze.html#systemd-analyze%20calendar%20EXPRESSION...). It will display the next trigger time: ```shell systemd-analyze calendar 'daily' @@ -35,26 +29,51 @@ Normalized form: *-*-* 00:00:00 ## First time schedule -When you schedule a profile with the `schedule` command, under the hood resticprofile will -- create the unit file (of type `notify`) -- create the timer file -- run `systemctl daemon-reload` (only if `schedule-permission` is set to `system`) -- run `systemctl enable` -- run `systemctl start` +When you schedule a profile with the `schedule` command, resticprofile will: +- Create the unit file (type [notify](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Type=)) +- Create the timer file +- Run `systemctl daemon-reload` (if `schedule-permission` is set to `system`) +- Run `systemctl enable` +- Run `systemctl start` + +## Priority and CPU scheduling + +resticprofile allows you to set the `nice` value, CPU scheduling policy, and IO nice values for the systemd service. This works properly for resticprofile >= 0.29.0. + +| systemd unit option | resticprofile option | +|----------------------|----------------------| +| CPUSchedulingPolicy | Set to `idle` if `priority` is `background`, otherwise defaults to standard policy | +| Nice | `nice` from `global` section | +| IOSchedulingClass | `ionice-class` from `global` section | +| IOSchedulingPriority | `ionice-level` from `global` section | + +{{% notice note %}} +When setting `CPUSchedulingPolicy` to `idle` (by setting `priority` to `background`), the backup might never execute if all your CPU cores are always busy. +{{% /notice %}} + + +## Permission + +Until version v0.30.0, the `user` permission was actually `user_logged_on` unless you activated [lingering](https://wiki.archlinux.org/title/Systemd/User#Automatic_start-up_of_systemd_user_instances) for the user. + +This is now fixed: + +| Permission | Type of unit | Without lingering | With lingering | +|--------------------|-------------------------------------------|----------------------------------|---------------------| +| **system** | system service | can run any time | can run any time | +| **user** | system service with User= field defined | can run any time | can run any time | +| **user_logged_on** | user service | runs only when user is logged on | can run any time | + + ## Run after the network is up -Specifying the profile option `schedule-after-network-online: true` means that the scheduled services will wait -for a network connection before running. -This is done via an [After=network-online.target](https://systemd.io/NETWORK_ONLINE/) entry in the service. +Setting the profile option `schedule-after-network-online: true` ensures scheduled services wait for a network connection before running. This is achieved with an [After=network-online.target](https://systemd.io/NETWORK_ONLINE/) entry in the service. ## systemd drop-in files -It is possible to automatically populate `*.conf.d` -[drop-in files](https://www.freedesktop.org/software/systemd/man/latest/systemd-system.conf.html#main-conf) -for profiles, which allows easy overriding -of the generated services, without modifying the service templates. For example: +You can automatically populate `*.conf.d` [drop-in files](https://www.freedesktop.org/software/systemd/man/latest/systemd-system.conf.html#main-conf) for profiles, allowing easy overrides of generated services without [modifying service templates]({{% relref "/schedules/systemd/#how-to-change-the-default-systemd-unit-and-timer-file-using-a-template" %}}). For example: {{< tabs groupid="config-with-json" >}} {{% tab title="toml" %}} @@ -143,10 +162,7 @@ SetCredentialEncrypted=rclone.conf: \ rGvQzqQI7kNX+v7EPXj4B0tSUeBBJJCEu4mgajZNAhwHtbw== ``` -Generated with the following, see [systemd credentials docs](https://systemd.io/CREDENTIALS/) -for more details. This could allow, for example, -using a TPM-backed encrypted password, outside of the -resticprofile config itself +Generated with the following. See [systemd credentials docs](https://systemd.io/CREDENTIALS/) for more details. This allows using a TPM-backed encrypted password outside the resticprofile config. ```shell systemd-ask-password -n | sudo systemd-creds encrypt --name=restic-repo-password -p - - @@ -159,29 +175,13 @@ pass = $(systemd-ask-password -n "smb restic user password" | rclone obscure -) EOF ``` -## Priority and CPU scheduling - -resticprofile allows you to set the `nice` value, the CPU scheduling policy and IO nice values for the systemd service. -This is only working properly for resticprofile >= 0.29.0. - -| systemd unit option | resticprofile option | -|----------------------|----------------------| -| CPUSchedulingPolicy | set to `idle` if schedule `priority` = `background` , otherwise default to standard policy | -| Nice | `nice` from `global` section | -| IOSchedulingClass | `ionice-class` from `global` section | -| IOSchedulingPriority | `ionice-level` from `global` section | - -{{% notice note %}} -When setting the `CPUSchedulingPolicy` to `idle` (by setting `priority` to `background`), the backup might never execute if all your CPU cores are always busy. -{{% /notice %}} - ## How to change the default systemd unit and timer file using a template -By default, an opinionated systemd unit and timer are automatically generated by resticprofile. +By default, resticprofile automatically generates a systemd unit and timer. -Since version 0.16.0, you now can describe your own templates if you need to add things in it (typically like sending an email on failure). +You can create custom templates to add features (e.g., sending an email on failure). -The format used is a [go template](https://pkg.go.dev/text/template) and you need to specify your own unit and/or timer file in the global section of the configuration (it will apply to all your profiles): +The format is a [Go template](https://pkg.go.dev/text/template). Specify your custom unit and/or timer file in the global section of the configuration to apply it to all profiles: {{< tabs groupid="config-with-json" >}} {{% tab title="toml" %}} @@ -227,7 +227,8 @@ global: {{% /tab %}} {{< /tabs >}} -Here are the defaults if you don't specify your own (which I recommend to use as a starting point for your own templates) + +Here are the defaults if you don't specify your own. I recommend using them as a starting point for your templates. ### Default unit file diff --git a/examples/dev.yaml b/examples/dev.yaml index 5c929dd4..8da576b9 100644 --- a/examples/dev.yaml +++ b/examples/dev.yaml @@ -7,7 +7,7 @@ global: initialize: false priority: low prevent-sleep: true - scheduler: "crontab:*:crontab" + # scheduler: "crontab:*:crontab" group-continue-on-error: true restic-lock-retry-after: "1m" # log: "_global.txt" diff --git a/examples/linux.yaml b/examples/linux.yaml index 0f61ab20..eeb67ce2 100644 --- a/examples/linux.yaml +++ b/examples/linux.yaml @@ -11,8 +11,8 @@ global: ionice: true ionice-class: 3 ionice-level: 7 - nice: 19 - scheduler: crontab:*:/tmp/crontab + # nice: 19 + # scheduler: crontab:*:/tmp/crontab default: password-file: key @@ -102,14 +102,18 @@ self: extended-status: true source: ./ # schedule: - # at: "*:15" + # at: "*:15,20,25" # permission: system - copy: - initialize: true - schedule-permission: user + # check: + # schedule-permission: user + # schedule: + # - "*:15" + # - "*:45" + forget: + schedule-permission: user_logged_on schedule: - - "*:15" - - "*:45" + - "*:10" + - "*:40" src: inherit: default diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index 4f2af22c..4e8afaa8 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -39,7 +39,7 @@ func NewHandlerCrond(config SchedulerConfig) *HandlerCrond { // Init verifies crond is available on this system func (h *HandlerCrond) Init() error { if len(h.config.CrontabFile) > 0 { - clog.Debugf("using file %q as cron scheduler", h.config.CrontabFile) + clog.Debugf("using %q file as cron scheduler", h.config.CrontabFile) return nil } clog.Debug("using standard cron scheduler") diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index fcd2b0ff..8726ad87 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -38,8 +38,8 @@ const ( ) var ( - journalctlBinary = "journalctl" - systemctlBinary = "systemctl" + journalctlBinary = "/usr/bin/journalctl" + systemctlBinary = "/usr/bin/systemctl" ) // HandlerSystemd is a handler to schedule tasks using systemd @@ -234,7 +234,8 @@ func (h *HandlerSystemd) DisplayJobStatus(job *Config) error { timerName := systemd.GetTimerFile(job.ProfileName, job.CommandName) permission, _ := h.DetectSchedulePermission(PermissionFromConfig(job.Permission)) systemdType := systemd.UserUnit - if permission == PermissionSystem || permission == PermissionUserBackground { + if permission == PermissionSystem { + // if permission == PermissionSystem || permission == PermissionUserBackground { systemdType = systemd.SystemUnit } unitLoaded, err := unitLoaded(serviceName, systemdType) @@ -318,9 +319,8 @@ func getSystemdStatus(profile string, unitType systemd.UnitType) (string, error) if unitType == systemd.UserUnit { args = append(args, flagUserUnit) } - clog.Debugf("starting command \"%s %s\"", systemctlBinary, strings.Join(args, " ")) buffer := &strings.Builder{} - cmd := exec.Command(systemctlBinary, args...) + cmd := systemctlCommand(args...) cmd.Stdout = buffer cmd.Stderr = buffer err := cmd.Run() @@ -338,8 +338,7 @@ func runSystemctlCommand(timerName, command string, unitType systemd.UnitType, s args = append(args, flagNoPager) args = append(args, command, timerName) - clog.Debugf("starting command \"%s %s\"", systemctlBinary, strings.Join(args, " ")) - cmd := exec.Command(systemctlBinary, args...) + cmd := systemctlCommand(args...) if !silent { cmd.Stdout = term.GetOutput() cmd.Stderr = term.GetErrorOutput() @@ -378,8 +377,7 @@ func runSystemctlReload(unitType systemd.UnitType) error { if unitType == systemd.UserUnit { args = append(args, flagUserUnit) } - clog.Debugf("starting command \"%s %s\"", systemctlBinary, strings.Join(args, " ")) - cmd := exec.Command(systemctlBinary, args...) + cmd := systemctlCommand(args...) cmd.Stdout = term.GetOutput() cmd.Stderr = term.GetErrorOutput() err := cmd.Run() @@ -400,10 +398,9 @@ func listUnits(profile string, unitType systemd.UnitType) ([]SystemdUnit, error) } 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 := systemctlCommand(args...) cmd.Stdout = stdout cmd.Stderr = stderr err := cmd.Run() @@ -481,6 +478,11 @@ func toScheduleConfig(systemdConfig systemd.Config, unitType systemd.UnitType) C return cfg } +func systemctlCommand(args ...string) *exec.Cmd { + clog.Debugf("starting command \"%s %s\"", systemctlBinary, strings.Join(args, " ")) + return exec.Command(systemctlBinary, args...) +} + // init registers HandlerSystemd func init() { AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { diff --git a/schedule/job.go b/schedule/job.go index 96ee254e..cbc45140 100644 --- a/schedule/job.go +++ b/schedule/job.go @@ -92,11 +92,11 @@ func (j *Job) Status() error { } if err := j.handler.DisplaySchedules(j.config.ProfileName, j.config.CommandName, j.config.Schedules); err != nil { - return err + return fmt.Errorf("cannot display schedules: %w", err) } if err := j.handler.DisplayJobStatus(j.config); err != nil { - return err + return fmt.Errorf("cannot display status: %w", err) } return nil } diff --git a/schedule/schedules.go b/schedule/schedules.go index 582aeafc..2d0132a5 100644 --- a/schedule/schedules.go +++ b/schedule/schedules.go @@ -61,7 +61,7 @@ func displaySystemdSchedules(profile, command string, schedules []string) error return errors.New("empty schedule") } displayHeader(profile, command, index+1, len(schedules)) - cmd := exec.Command("systemd-analyze", "calendar", schedule) + cmd := exec.Command("/usr/bin/systemd-analyze", "calendar", schedule) cmd.Stdout = term.GetOutput() cmd.Stderr = term.GetErrorOutput() err := cmd.Run() diff --git a/schedule/schedules_test.go b/schedule/schedules_test.go index 3277a7ca..ec6b330e 100644 --- a/schedule/schedules_test.go +++ b/schedule/schedules_test.go @@ -70,7 +70,7 @@ func TestDisplaySystemdSchedulesWithEmpty(t *testing.T) { } func TestDisplaySystemdSchedules(t *testing.T) { - _, err := exec.LookPath("systemd-analyze") + _, err := exec.LookPath("/usr/bin/systemd-analyze") if err != nil { t.Skip("systemd-analyze not available") } From a986823961c7a29ce9ecd30b1cabe1d2523f60ca Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 19 Mar 2025 21:34:16 +0000 Subject: [PATCH 21/33] generate systemd service with User field --- systemd/generate.go | 33 +++++++++++++++++++++------------ systemd/generate_test.go | 35 +++++++++++++++++++++++++++++++++++ systemd/read.go | 1 + systemd/read_test.go | 15 +++++++++++++++ user/user.go | 9 +++++++++ user/user_other.go | 11 +++-------- user/user_systemd.go | 14 ++++++++++++++ user/user_test.go | 6 ++++++ user/user_windows.go | 11 +++-------- 9 files changed, 107 insertions(+), 28 deletions(-) create mode 100644 user/user.go create mode 100644 user/user_systemd.go diff --git a/systemd/generate.go b/systemd/generate.go index d44912da..b22f19dd 100644 --- a/systemd/generate.go +++ b/systemd/generate.go @@ -7,13 +7,13 @@ import ( "fmt" "io" "os" - "os/user" "path/filepath" "slices" "strings" "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/user" "github.com/creativeprojects/resticprofile/util/collect" "github.com/creativeprojects/resticprofile/util/templates" "github.com/spf13/afero" @@ -38,6 +38,8 @@ ExecStart={{ .CommandLine }} {{ if .IOSchedulingClass }}IOSchedulingClass={{ .IOSchedulingClass }} {{ end -}} {{ if .IOSchedulingPriority }}IOSchedulingPriority={{ .IOSchedulingPriority }} +{{ end -}} + {{ if .User }}User={{ .User }} {{ end -}} {{ range .Environment -}} Environment="{{ . }}" @@ -85,6 +87,7 @@ type templateInfo struct { CPUSchedulingPolicy string IOSchedulingClass int IOSchedulingPriority int + User string } // Config for generating systemd unit and timer files @@ -107,6 +110,7 @@ type Config struct { CPUSchedulingPolicy string IOSchedulingClass int IOSchedulingPriority int + User string } func init() { @@ -128,13 +132,20 @@ func Generate(config Config) error { } environment := slices.Clone(config.Environment) - // add $HOME to the environment variables (as a fallback if not defined in profile) - if home, err := os.UserHomeDir(); err == nil { - environment = append(environment, fmt.Sprintf("HOME=%s", home)) - } - // also add $SUDO_USER to env variables - if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { - environment = append(environment, fmt.Sprintf("SUDO_USER=%s", sudoUser)) + if config.User != "" { + // resticprofile will start under config.User (user background mode) + u := user.Current() + if u.HomeDir != "" { + environment = append(environment, fmt.Sprintf("HOME=%s", u.HomeDir)) + } + } else { + // running resticprofile as root + if home, err := os.UserHomeDir(); err == nil { + environment = append(environment, fmt.Sprintf("HOME=%s", home)) + } + if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { + environment = append(environment, fmt.Sprintf("SUDO_USER=%s", sudoUser)) + } } policy := "" @@ -156,6 +167,7 @@ func Generate(config Config) error { CPUSchedulingPolicy: policy, IOSchedulingClass: config.IOSchedulingClass, IOSchedulingPriority: config.IOSchedulingPriority, + User: config.User, } var data bytes.Buffer @@ -231,10 +243,7 @@ func GetTimerFile(profileName, commandName string) string { // GetUserDir returns the default directory where systemd stores user units func GetUserDir() (string, error) { - u, err := user.Current() - if err != nil { - return "", err - } + u := user.Current() systemdUserDir := filepath.Join(u.HomeDir, ".config", "systemd", "user") if err := fs.MkdirAll(systemdUserDir, 0o700); err != nil { diff --git a/systemd/generate_test.go b/systemd/generate_test.go index 5ce024b9..cd5ee21f 100644 --- a/systemd/generate_test.go +++ b/systemd/generate_test.go @@ -560,6 +560,41 @@ func TestGeneratePriorityFields(t *testing.T) { } } +func TestGenerateUserField(t *testing.T) { + fs = afero.NewMemMapFs() + systemdDir := GetSystemDir() + serviceFile := filepath.Join(systemdDir, "resticprofile-backup@profile-name.service") + + err := Generate(Config{ + JobDescription: "Test", + CommandLine: "resticprofile", + WorkingDirectory: "/tmp", + Title: "name", + SubTitle: "backup", + UnitType: SystemUnit, + User: "user", + }) + require.NoError(t, err) + + contents, err := afero.ReadFile(fs, serviceFile) + require.NoError(t, err) + + homeDir, err := os.UserHomeDir() + require.NoError(t, err) + + expected := `[Unit] +Description=Test + +[Service] +Type=notify +WorkingDirectory=/tmp +ExecStart=resticprofile +User=user +Environment="HOME=%s" +` + assert.Equal(t, fmt.Sprintf(expected, homeDir), string(contents)) +} + func assertNoFileExists(t *testing.T, filename string) { t.Helper() exists, err := afero.Exists(fs, filename) diff --git a/systemd/read.go b/systemd/read.go index 00a3b572..9dd057be 100644 --- a/systemd/read.go +++ b/systemd/read.go @@ -53,6 +53,7 @@ func Read(unit string, unitType UnitType) (*Config, error) { IOSchedulingPriority: getIntegerValue(serviceSections, "Service", "IOSchedulingPriority"), Schedules: getValues(timerSections, "Timer", "OnCalendar"), Priority: getPriority(getSingleValue(serviceSections, "Service", "CPUSchedulingPolicy")), + User: getSingleValue(serviceSections, "Service", "User"), } return cfg, nil } diff --git a/systemd/read_test.go b/systemd/read_test.go index 4e74beda..a9d6b977 100644 --- a/systemd/read_test.go +++ b/systemd/read_test.go @@ -108,6 +108,20 @@ func TestReadSystemUnit(t *testing.T) { }, }, }, + { + config: Config{ + CommandLine: "/bin/resticprofile --no-ansi --config profiles.yaml run-schedule check@profile3", + WorkingDirectory: "/workdir", + Title: "profile3", + SubTitle: "forget", + JobDescription: "job description", + TimerDescription: "", + Schedules: []string{"monthly"}, + UnitType: SystemUnit, + Priority: "standard", + User: "me", + }, + }, } fs = afero.NewMemMapFs() @@ -137,6 +151,7 @@ func TestReadSystemUnit(t *testing.T) { Environment: append(tc.config.Environment, "HOME="+home), Schedules: tc.config.Schedules, Priority: tc.config.Priority, + User: tc.config.User, } assert.Equal(t, expected, readCfg) }) diff --git a/user/user.go b/user/user.go new file mode 100644 index 00000000..35b319a0 --- /dev/null +++ b/user/user.go @@ -0,0 +1,9 @@ +package user + +type User struct { + Uid int + Gid int + Username string + HomeDir string + SudoRoot bool +} diff --git a/user/user_other.go b/user/user_other.go index 9081de7e..7d7575e0 100644 --- a/user/user_other.go +++ b/user/user_other.go @@ -8,15 +8,8 @@ import ( "strconv" ) -type User struct { - Uid int - Gid int - Username string - SudoRoot bool -} - func Current() User { - username := "" + var username, homedir string sudoed := false uid := os.Getuid() gid := os.Getgid() @@ -34,11 +27,13 @@ func Current() User { if err == nil { gid, _ = strconv.Atoi(current.Gid) username = current.Username + homedir = current.HomeDir } return User{ Uid: uid, Gid: gid, Username: username, + HomeDir: homedir, SudoRoot: sudoed, } } diff --git a/user/user_systemd.go b/user/user_systemd.go new file mode 100644 index 00000000..45d4cf83 --- /dev/null +++ b/user/user_systemd.go @@ -0,0 +1,14 @@ +//go:build !windows && !darwin + +package user + +import ( + "errors" + "fmt" + "os" +) + +func (u User) HasLingering() bool { + _, err := os.Stat(fmt.Sprintf("/var/lib/systemd/linger/%s", u.Username)) + return err == nil || !errors.Is(err, os.ErrNotExist) +} diff --git a/user/user_test.go b/user/user_test.go index 38c43b2e..7d23def2 100644 --- a/user/user_test.go +++ b/user/user_test.go @@ -1,10 +1,12 @@ package user import ( + "os" "testing" "github.com/creativeprojects/resticprofile/platform" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCurrentUser(t *testing.T) { @@ -14,6 +16,10 @@ func TestCurrentUser(t *testing.T) { assert.NotEmpty(t, user.Username) + homeDir, err := os.UserHomeDir() + require.NoError(t, err) + assert.Equal(t, homeDir, user.HomeDir) + if !platform.IsWindows() { assert.Greater(t, user.Uid, 500) assert.Greater(t, user.Gid, 0) diff --git a/user/user_windows.go b/user/user_windows.go index 47406e14..b2611e9a 100644 --- a/user/user_windows.go +++ b/user/user_windows.go @@ -6,23 +6,18 @@ import ( "os/user" ) -type User struct { - Uid int - Gid int - Username string - SudoRoot bool -} - func Current() User { - username := "" + var username, homedir string current, err := user.Current() if err == nil { username = current.Username + homedir = current.HomeDir } return User{ Uid: -1, Gid: -1, Username: username, + HomeDir: homedir, SudoRoot: false, } } From 8712bcde55a045db8fe0e56fa487e8c94558e40b Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 24 Mar 2025 21:19:51 +0000 Subject: [PATCH 22/33] don't display epoch when there's no next run --- schedule/schedules.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/schedule/schedules.go b/schedule/schedules.go index 2d0132a5..4b0cd5f3 100644 --- a/schedule/schedules.go +++ b/schedule/schedules.go @@ -48,6 +48,10 @@ func displayParsedSchedules(profile, command string, events []*calendar.Event) { next := event.Next(now) term.Printf(" Original form: %s\n", event.Input()) term.Printf("Normalized form: %s\n", event.String()) + if next.IsZero() { + term.Print(" Next elapse: Never\n") + continue + } term.Printf(" Next elapse: %s\n", next.Format(time.UnixDate)) term.Printf(" (in UTC): %s\n", next.UTC().Format(time.UnixDate)) term.Printf(" From now: %s left\n", next.Sub(now)) From 51be91bd0092478b0cf9630f860dca91d76baf9d Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 24 Mar 2025 21:59:52 +0000 Subject: [PATCH 23/33] fix wording --- examples/dev.yaml | 7 ++++--- schtasks/taskscheduler.go | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/dev.yaml b/examples/dev.yaml index 8da576b9..71eaccb9 100644 --- a/examples/dev.yaml +++ b/examples/dev.yaml @@ -113,6 +113,7 @@ self: - "/**/.git/" schedule: - "*:00,30,36,50,55" + - "2020-01-01" schedule-permission: user_logged_on schedule-log: "_schedule-log.txt" schedule-after-network-online: true @@ -132,9 +133,9 @@ self: after-backup: true keep-last: 30 group-by: host - forget: - schedule: "weekly" - schedule-permission: system + # forget: + # schedule: "weekly" + # schedule-permission: system # copy: # initialize: true # snapshot: latest diff --git a/schtasks/taskscheduler.go b/schtasks/taskscheduler.go index 61cf7c8e..b6b8e912 100644 --- a/schtasks/taskscheduler.go +++ b/schtasks/taskscheduler.go @@ -48,7 +48,7 @@ func Create(config *Config, schedules []*calendar.Event, permission Permission) clog.Debugf("task %q already exists: deleting before creating", taskPath) _, err = deleteTask(taskPath) if err != nil { - return fmt.Errorf("cannot delete existing task for replacing it: %w", err) + return fmt.Errorf("cannot delete existing task to replace it: %w", err) } } task := createTaskDefinition(config, schedules) From 39fc1b2fd3f0a1a068b4668cd19669782560ec24 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 27 Mar 2025 16:37:37 +0000 Subject: [PATCH 24/33] detect systemd service type (system or user) --- examples/linux.yaml | 1 + schedule/handler_systemd.go | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/linux.yaml b/examples/linux.yaml index eeb67ce2..da175d95 100644 --- a/examples/linux.yaml +++ b/examples/linux.yaml @@ -112,6 +112,7 @@ self: forget: schedule-permission: user_logged_on schedule: + - "2020-01-01" - "*:10" - "*:40" diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index 8726ad87..a1512f6c 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -457,9 +457,12 @@ func toScheduleConfig(systemdConfig systemd.Config, unitType systemd.UnitType) C } args := NewCommandArguments(cmdLine[1:]) - permission := constants.SchedulePermissionUser + permission := constants.SchedulePermissionUserLoggedOn if unitType == systemd.SystemUnit { permission = constants.SchedulePermissionSystem + if systemdConfig.User != "" { + permission = constants.SchedulePermissionUser + } } cfg := Config{ From 212917de93e5b3699d3b2cc7de2f7822ff0699ed Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 27 Mar 2025 18:37:55 +0000 Subject: [PATCH 25/33] systemd unit type system for background user --- examples/linux.yaml | 18 +++---- examples/sample.service | 7 ++- go.sum | 29 ++--------- schedule/handler_systemd.go | 83 +++++++++++++++++++++----------- schedule/handler_systemd_test.go | 2 +- schedule_jobs.go | 1 + 6 files changed, 73 insertions(+), 67 deletions(-) diff --git a/examples/linux.yaml b/examples/linux.yaml index da175d95..be4d36aa 100644 --- a/examples/linux.yaml +++ b/examples/linux.yaml @@ -6,7 +6,7 @@ global: default-command: snapshots initialize: false priority: low - systemd-unit-template: sample.service + # systemd-unit-template: sample.service prevent-sleep: false ionice: true ionice-class: 3 @@ -101,14 +101,14 @@ self: backup: extended-status: true source: ./ - # schedule: - # at: "*:15,20,25" - # permission: system - # check: - # schedule-permission: user - # schedule: - # - "*:15" - # - "*:45" + schedule: + at: "*:15,20,25" + permission: system + check: + schedule-permission: user + schedule: + - "*:15" + - "*:45" forget: schedule-permission: user_logged_on schedule: diff --git a/examples/sample.service b/examples/sample.service index 747ccd21..7d803a23 100644 --- a/examples/sample.service +++ b/examples/sample.service @@ -1,7 +1,8 @@ [Unit] Description={{ .JobDescription }} OnFailure=unit-status-mail@%n.service - +{{ if .AfterNetworkOnline }}After=network-online.target +{{ end }} [Service] Type=notify WorkingDirectory={{ .WorkingDirectory }} @@ -13,7 +14,9 @@ ExecStart={{ .CommandLine }} {{ if .IOSchedulingClass }}IOSchedulingClass={{ .IOSchedulingClass }} {{ end -}} {{ if .IOSchedulingPriority }}IOSchedulingPriority={{ .IOSchedulingPriority }} +{{ end -}} + {{ if .User }}User={{ .User }} {{ end -}} {{ range .Environment -}} Environment="{{ . }}" -{{ end -}} +{{ end -}} \ No newline at end of file diff --git a/go.sum b/go.sum index f82cf2cd..d824a92c 100644 --- a/go.sum +++ b/go.sum @@ -39,9 +39,8 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= @@ -62,14 +61,12 @@ github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= -github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/lufia/plan9stats v0.0.0-20250303091104-876f3ea5145d h1:fjMbDVUGsMQiVZnSQsmouYJvMdwsGiDipOZoN66v844= github.com/lufia/plan9stats v0.0.0-20250303091104-876f3ea5145d/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mackerelio/go-osstat v0.2.5 h1:+MqTbZUhoIt4m8qzkVoXUJg1EuifwlAJSk4Yl2GXh+o= @@ -92,8 +89,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= @@ -134,12 +129,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= -github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= -github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo= -github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= @@ -153,12 +144,8 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= -golang.org/x/exp v0.0.0-20250215185904-eff6e970281f h1:oFMYAjX0867ZD2jcNiLBrI9BdpmEkvPyi5YrBGXbamg= -golang.org/x/exp v0.0.0-20250215185904-eff6e970281f/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -167,8 +154,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= -golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -178,23 +163,15 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= -golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index a1512f6c..3dff99ea 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -18,6 +18,7 @@ import ( "github.com/creativeprojects/resticprofile/shell" "github.com/creativeprojects/resticprofile/systemd" "github.com/creativeprojects/resticprofile/term" + "github.com/creativeprojects/resticprofile/user" ) const ( @@ -104,14 +105,10 @@ func (h *HandlerSystemd) DisplayStatus(profileName string) error { // CreateJob is creating the systemd unit and activating it func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, permission Permission) error { - unitType := systemd.UserUnit - if os.Geteuid() == 0 { - // user has sudoed already - unitType = systemd.SystemUnit - } + unitType, user := permissionToSystemd(permission) if unitType == systemd.UserUnit && job.AfterNetworkOnline { - return fmt.Errorf("after-network-online only available for \"system\" permission schedules") + return fmt.Errorf("after-network-online is not available for \"user_logged_on\" permission schedules") } err := systemd.Generate(systemd.Config{ @@ -132,6 +129,7 @@ func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, per Nice: h.config.Nice, IOSchedulingClass: h.config.IONiceClass, IOSchedulingPriority: h.config.IONiceLevel, + User: user, }) if err != nil { return err @@ -167,12 +165,8 @@ func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, per // RemoveJob is disabling the systemd unit and deleting the timer and service files func (h *HandlerSystemd) RemoveJob(job *Config, permission Permission) error { - unitType := systemd.UserUnit - if os.Geteuid() == 0 { - // user has sudoed already - unitType = systemd.SystemUnit - } var err error + unitType, _ := permissionToSystemd(permission) serviceFile := systemd.GetServiceFile(job.ProfileName, job.CommandName) unitLoaded, err := unitLoaded(serviceFile, unitType) if err != nil { @@ -234,8 +228,7 @@ func (h *HandlerSystemd) DisplayJobStatus(job *Config) error { timerName := systemd.GetTimerFile(job.ProfileName, job.CommandName) permission, _ := h.DetectSchedulePermission(PermissionFromConfig(job.Permission)) systemdType := systemd.UserUnit - if permission == PermissionSystem { - // if permission == PermissionSystem || permission == PermissionUserBackground { + if permission == PermissionSystem || permission == PermissionUserBackground { systemdType = systemd.SystemUnit } unitLoaded, err := unitLoaded(serviceName, systemdType) @@ -312,12 +305,32 @@ var ( _ Handler = &HandlerSystemd{} ) +func permissionToSystemd(permission Permission) (systemd.UnitType, string) { + switch permission { + case PermissionSystem: + return systemd.SystemUnit, "" + + case PermissionUserBackground: + return systemd.SystemUnit, user.Current().Username + + case PermissionUserLoggedOn: + return systemd.UserUnit, "" + + default: + unitType := systemd.UserUnit + if os.Geteuid() == 0 { + unitType = systemd.SystemUnit + } + return unitType, "" + } +} + // getSystemdStatus displays the status of all the timers installed on that profile func getSystemdStatus(profile string, unitType systemd.UnitType) (string, error) { timerName := fmt.Sprintf("resticprofile-*@profile-%s.timer", profile) args := []string{"list-timers", "--all", flagNoPager, timerName} if unitType == systemd.UserUnit { - args = append(args, flagUserUnit) + args = append(args, getUserFlags()...) } buffer := &strings.Builder{} cmd := systemctlCommand(args...) @@ -333,7 +346,7 @@ func runSystemctlCommand(timerName, command string, unitType systemd.UnitType, s } args := make([]string, 0, 3) if unitType == systemd.UserUnit { - args = append(args, flagUserUnit) + args = append(args, getUserFlags()...) } args = append(args, flagNoPager) args = append(args, command, timerName) @@ -361,7 +374,7 @@ func runJournalCtlCommand(timerName string, unitType systemd.UnitType) error { timerName = strings.TrimSuffix(timerName, ".timer") args := []string{"--since", "1 month ago", flagNoPager, "--priority", "warning", "--unit", timerName} if unitType == systemd.UserUnit { - args = append(args, flagUserUnit) + args = append(args, getUserFlags()...) } clog.Debugf("starting command \"%s %s\"", journalctlBinary, strings.Join(args, " ")) cmd := exec.Command(journalctlBinary, args...) @@ -375,7 +388,7 @@ func runJournalCtlCommand(timerName string, unitType systemd.UnitType) error { func runSystemctlReload(unitType systemd.UnitType) error { args := []string{systemctlReload} if unitType == systemd.UserUnit { - args = append(args, flagUserUnit) + args = append(args, getUserFlags()...) } cmd := systemctlCommand(args...) cmd.Stdout = term.GetOutput() @@ -394,7 +407,7 @@ func listUnits(profile string, unitType systemd.UnitType) ([]SystemdUnit, error) 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, getUserFlags()...) } args = append(args, pattern) @@ -416,6 +429,14 @@ func listUnits(profile string, unitType systemd.UnitType) ([]SystemdUnit, error) return units, err } +func getUserFlags() []string { + currentUser := user.Current() + if !currentUser.SudoRoot { + return []string{flagUserUnit} + } + return []string{flagUserUnit, "-M", currentUser.Username + "@"} +} + func unitLoaded(serviceName string, unitType systemd.UnitType) (bool, error) { units, err := listUnits("", unitType) if err != nil { @@ -444,12 +465,12 @@ func getConfigs(profileName string, unitType systemd.UnitType) ([]Config, error) if cfg == nil { continue } - configs = append(configs, toScheduleConfig(*cfg, unitType)) + configs = append(configs, toScheduleConfig(*cfg)) } return configs, nil } -func toScheduleConfig(systemdConfig systemd.Config, unitType systemd.UnitType) Config { +func toScheduleConfig(systemdConfig systemd.Config) Config { var command string cmdLine := shell.SplitArguments(systemdConfig.CommandLine) if len(cmdLine) > 0 { @@ -457,14 +478,6 @@ func toScheduleConfig(systemdConfig systemd.Config, unitType systemd.UnitType) C } args := NewCommandArguments(cmdLine[1:]) - permission := constants.SchedulePermissionUserLoggedOn - if unitType == systemd.SystemUnit { - permission = constants.SchedulePermissionSystem - if systemdConfig.User != "" { - permission = constants.SchedulePermissionUser - } - } - cfg := Config{ ConfigFile: args.ConfigFile(), ProfileName: systemdConfig.Title, @@ -474,13 +487,25 @@ func toScheduleConfig(systemdConfig systemd.Config, unitType systemd.UnitType) C Arguments: args.Trim([]string{"--no-prio"}), JobDescription: systemdConfig.JobDescription, Environment: systemdConfig.Environment, - Permission: permission, + Permission: systemdConfigPermission(systemdConfig), Schedules: systemdConfig.Schedules, Priority: systemdConfig.Priority, } return cfg } +func systemdConfigPermission(systemdConfig systemd.Config) string { + switch systemdConfig.UnitType { + case systemd.SystemUnit: + if systemdConfig.User != "" { + return constants.SchedulePermissionUser + } + return constants.SchedulePermissionSystem + default: + return constants.SchedulePermissionUserLoggedOn + } +} + func systemctlCommand(args ...string) *exec.Cmd { clog.Debugf("starting command \"%s %s\"", systemctlBinary, strings.Join(args, " ")) return exec.Command(systemctlBinary, args...) diff --git a/schedule/handler_systemd_test.go b/schedule/handler_systemd_test.go index 54fa99c0..c881b6a6 100644 --- a/schedule/handler_systemd_test.go +++ b/schedule/handler_systemd_test.go @@ -16,7 +16,7 @@ func TestReadingSystemdScheduled(t *testing.T) { event := calendar.NewEvent() require.NoError(t, event.Parse("2020-01-01")) - schedulePermission := constants.SchedulePermissionUser + schedulePermission := constants.SchedulePermissionUserLoggedOn testCases := []struct { job Config diff --git a/schedule_jobs.go b/schedule_jobs.go index 353076bb..a74e9ed8 100644 --- a/schedule_jobs.go +++ b/schedule_jobs.go @@ -124,6 +124,7 @@ func removeScheduledJobs(handler schedule.Handler, configFile, profileName strin err = job.Remove() if err != nil { errs = errors.Join(errs, fmt.Errorf("%s/%s: %w", cfg.ProfileName, cfg.CommandName, err)) + continue } clog.Infof("scheduled job %s/%s removed", cfg.ProfileName, cfg.CommandName) } From 8b27b43bdb09d4ec6362c79ecb5bbc9080038e51 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 27 Mar 2025 19:00:18 +0000 Subject: [PATCH 26/33] add a few new tests --- codecov.yml | 1 + darwin/process_type_test.go | 42 ++++++++++++ schedule/handler_systemd.go | 10 +-- schedule/handler_systemd_test.go | 108 +++++++++++++++++++++++++++++++ user/user_test.go | 3 +- 5 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 darwin/process_type_test.go diff --git a/codecov.yml b/codecov.yml index ee41c391..95e1c9d2 100644 --- a/codecov.yml +++ b/codecov.yml @@ -12,6 +12,7 @@ ignore: - syslog.go - syslog_windows.go - schtasks/permission.go + - user/user_systemd.go - "**/mocks/*.go" codecov: diff --git a/darwin/process_type_test.go b/darwin/process_type_test.go new file mode 100644 index 00000000..e6e627c4 --- /dev/null +++ b/darwin/process_type_test.go @@ -0,0 +1,42 @@ +//go:build darwin + +package darwin + +import ( + "testing" + + "github.com/creativeprojects/resticprofile/constants" +) + +func TestNewProcessType(t *testing.T) { + tests := []struct { + name string + schedulePriority string + expected ProcessType + }{ + { + name: "Background priority", + schedulePriority: constants.SchedulePriorityBackground, + expected: ProcessTypeBackground, + }, + { + name: "Standard priority", + schedulePriority: constants.SchedulePriorityStandard, + expected: ProcessTypeStandard, + }, + { + name: "Unknown priority", + schedulePriority: "Unknown", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NewProcessType(tt.schedulePriority) + if result != tt.expected { + t.Errorf("NewProcessType(%q) = %q; want %q", tt.schedulePriority, result, tt.expected) + } + }) + } +} diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index 3dff99ea..fd4b0856 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -105,7 +105,7 @@ func (h *HandlerSystemd) DisplayStatus(profileName string) error { // CreateJob is creating the systemd unit and activating it func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, permission Permission) error { - unitType, user := permissionToSystemd(permission) + unitType, user := permissionToSystemd(user.Current(), permission) if unitType == systemd.UserUnit && job.AfterNetworkOnline { return fmt.Errorf("after-network-online is not available for \"user_logged_on\" permission schedules") @@ -166,7 +166,7 @@ func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, per // RemoveJob is disabling the systemd unit and deleting the timer and service files func (h *HandlerSystemd) RemoveJob(job *Config, permission Permission) error { var err error - unitType, _ := permissionToSystemd(permission) + unitType, _ := permissionToSystemd(user.Current(), permission) serviceFile := systemd.GetServiceFile(job.ProfileName, job.CommandName) unitLoaded, err := unitLoaded(serviceFile, unitType) if err != nil { @@ -305,20 +305,20 @@ var ( _ Handler = &HandlerSystemd{} ) -func permissionToSystemd(permission Permission) (systemd.UnitType, string) { +func permissionToSystemd(user user.User, permission Permission) (systemd.UnitType, string) { switch permission { case PermissionSystem: return systemd.SystemUnit, "" case PermissionUserBackground: - return systemd.SystemUnit, user.Current().Username + return systemd.SystemUnit, user.Username case PermissionUserLoggedOn: return systemd.UserUnit, "" default: unitType := systemd.UserUnit - if os.Geteuid() == 0 { + if user.Uid == 0 { unitType = systemd.SystemUnit } return unitType, "" diff --git a/schedule/handler_systemd_test.go b/schedule/handler_systemd_test.go index c881b6a6..1306d1bf 100644 --- a/schedule/handler_systemd_test.go +++ b/schedule/handler_systemd_test.go @@ -8,6 +8,8 @@ import ( "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/systemd" + "github.com/creativeprojects/resticprofile/user" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -120,3 +122,109 @@ func TestDetectPermissionSystemd(t *testing.T) { }) } } +func TestSystemdConfigPermission(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + config systemd.Config + expected string + }{ + { + name: "SystemUnit with User", + config: systemd.Config{ + UnitType: systemd.SystemUnit, + User: "testuser", + }, + expected: constants.SchedulePermissionUser, + }, + { + name: "SystemUnit without User", + config: systemd.Config{ + UnitType: systemd.SystemUnit, + User: "", + }, + expected: constants.SchedulePermissionSystem, + }, + { + name: "Default case (UserUnit)", + config: systemd.Config{ + UnitType: systemd.UserUnit, + }, + expected: constants.SchedulePermissionUserLoggedOn, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + result := systemdConfigPermission(testCase.config) + assert.Equal(t, testCase.expected, result) + }) + } +} +func TestPermissionToSystemd(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + permission Permission + isRoot bool + expected systemd.UnitType + expectedUser string + }{ + { + name: "PermissionSystem", + permission: PermissionSystem, + isRoot: false, + expected: systemd.SystemUnit, + expectedUser: "", + }, + { + name: "PermissionUserBackground", + permission: PermissionUserBackground, + isRoot: false, + expected: systemd.SystemUnit, + expectedUser: "testuser", + }, + { + name: "PermissionUserLoggedOn", + permission: PermissionUserLoggedOn, + isRoot: false, + expected: systemd.UserUnit, + expectedUser: "", + }, + { + name: "Default case as non-root", + permission: PermissionFromConfig("unknown"), + isRoot: false, + expected: systemd.UserUnit, + expectedUser: "", + }, + { + name: "Default case as root", + permission: PermissionFromConfig("unknown"), + isRoot: true, + expected: systemd.SystemUnit, + expectedUser: "", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + currentUser := user.User{ + Uid: 1000, + Gid: 1000, + Username: "testuser", + } + if testCase.isRoot { + currentUser.Uid = 0 + } + + unitType, user := permissionToSystemd(currentUser, testCase.permission) + assert.Equal(t, testCase.expected, unitType) + assert.Equal(t, testCase.expectedUser, user) + }) + } +} diff --git a/user/user_test.go b/user/user_test.go index 7d23def2..4ca17d12 100644 --- a/user/user_test.go +++ b/user/user_test.go @@ -11,8 +11,7 @@ import ( func TestCurrentUser(t *testing.T) { user := Current() - // it is very unlikely anyone would run the tests using sudo :D - assert.False(t, user.SudoRoot) + assert.Equal(t, os.Geteuid() == 0, user.SudoRoot) assert.NotEmpty(t, user.Username) From 6335fcc23097ec5cd2eb68e6a3b481785f9c6490 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 27 Mar 2025 20:58:23 +0000 Subject: [PATCH 27/33] simplify String method --- schedule/permission.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/schedule/permission.go b/schedule/permission.go index c424c767..56d5b478 100644 --- a/schedule/permission.go +++ b/schedule/permission.go @@ -31,8 +31,6 @@ func PermissionFromConfig(permission string) Permission { func (p Permission) String() string { switch p { - case PermissionAuto: - return constants.SchedulePermissionAuto case PermissionSystem: return constants.SchedulePermissionSystem @@ -44,6 +42,6 @@ func (p Permission) String() string { return constants.SchedulePermissionUserLoggedOn default: - return "" + return constants.SchedulePermissionAuto } } From 0dcacaf3a3ea0f9362abf5a22f0603f7da605bbe Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 27 Mar 2025 21:15:03 +0000 Subject: [PATCH 28/33] add user parameter to CheckPermission --- schedule/handler.go | 3 +- schedule/handler_crond.go | 6 ++-- schedule/handler_crond_test.go | 66 +++++++++++++++++++++++++++++++++- schedule/handler_darwin.go | 4 +-- schedule/handler_systemd.go | 5 ++- schedule/handler_windows.go | 3 +- schedule/job.go | 7 ++-- schedule/job_test.go | 8 ++--- schedule/mocks/Handler.go | 23 ++++++------ schedule_jobs_test.go | 18 +++++----- 10 files changed, 106 insertions(+), 37 deletions(-) diff --git a/schedule/handler.go b/schedule/handler.go index 89d34607..48ffeccd 100644 --- a/schedule/handler.go +++ b/schedule/handler.go @@ -5,6 +5,7 @@ import ( "os/exec" "github.com/creativeprojects/resticprofile/calendar" + "github.com/creativeprojects/resticprofile/user" ) // Handler interface for the scheduling software available on the system @@ -24,7 +25,7 @@ type Handler interface { // safe specifies whether a guess may lead to a too broad or too narrow file access permission. DetectSchedulePermission(permission Permission) (Permission, bool) // CheckPermission returns true if the user is allowed to access the job. - CheckPermission(p Permission) bool + CheckPermission(user user.User, p Permission) bool } // 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 4e8afaa8..767daf22 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -10,6 +10,7 @@ import ( "github.com/creativeprojects/resticprofile/crond" "github.com/creativeprojects/resticprofile/platform" "github.com/creativeprojects/resticprofile/shell" + "github.com/creativeprojects/resticprofile/user" "github.com/spf13/afero" ) @@ -192,15 +193,14 @@ func (h *HandlerCrond) DetectSchedulePermission(p Permission) (Permission, bool) } // CheckPermission returns true if the user is allowed to access the job. -func (h *HandlerCrond) CheckPermission(p Permission) bool { +func (h *HandlerCrond) CheckPermission(user user.User, p Permission) bool { switch p { case PermissionUserLoggedOn, PermissionUserBackground: // user mode is always available return true default: - if os.Geteuid() == 0 { - // user has sudoed + if user.SudoRoot || user.Uid == 0 { return true } // last case is system (or undefined) + no sudo diff --git a/schedule/handler_crond_test.go b/schedule/handler_crond_test.go index 85a64934..a8f83d3a 100644 --- a/schedule/handler_crond_test.go +++ b/schedule/handler_crond_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/creativeprojects/resticprofile/calendar" + "github.com/creativeprojects/resticprofile/user" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -44,7 +45,7 @@ func TestReadingCrondScheduled(t *testing.T) { WorkingDirectory: "/resticprofile", Schedules: []string{"*-*-* *:00:00"}, ConfigFile: "config file.yaml", - Permission: "user", + Permission: "system", }, schedules: []*calendar.Event{ hourly, @@ -55,6 +56,7 @@ func TestReadingCrondScheduled(t *testing.T) { tempFile := filepath.Join(t.TempDir(), "crontab") handler := NewHandler(SchedulerCrond{ CrontabFile: tempFile, + Username: "*", }).(*HandlerCrond) handler.fs = afero.NewMemMapFs() @@ -108,3 +110,65 @@ func TestDetectPermissionCrond(t *testing.T) { }) } } + +func TestCheckPermission(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + permission Permission + euid int + expected bool + }{ + { + name: "PermissionUserLoggedOn", + permission: PermissionUserLoggedOn, + euid: 1000, // non-root user + expected: true, + }, + { + name: "PermissionUserBackground", + permission: PermissionUserBackground, + euid: 1000, // non-root user + expected: true, + }, + { + name: "PermissionSystem as root", + permission: PermissionSystem, + euid: 0, // root user + expected: true, + }, + { + name: "PermissionSystem as non-root", + permission: PermissionSystem, + euid: 1000, // non-root user + expected: false, + }, + { + name: "Undefined permission as root", + permission: PermissionFromConfig("undefined"), + euid: 0, // root user + expected: true, + }, + { + name: "Undefined permission as non-root", + permission: PermissionFromConfig("undefined"), + euid: 1000, // non-root user + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + user := user.User{ + Uid: tc.euid, + } + + handler := NewHandler(SchedulerCrond{}).(*HandlerCrond) + result := handler.CheckPermission(user, tc.permission) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 027b249c..6cee9221 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -261,14 +261,14 @@ func (h *HandlerLaunchd) DetectSchedulePermission(permission Permission) (Permis } // CheckPermission returns true if the user is allowed to access the job. -func (h *HandlerLaunchd) CheckPermission(p Permission) bool { +func (h *HandlerLaunchd) CheckPermission(user user.User, p Permission) bool { switch p { case PermissionUserLoggedOn, PermissionUserBackground: // user mode is always available return true default: - if os.Geteuid() == 0 { + if user.SudoRoot || user.Uid == 0 { // user has sudoed return true } diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index fd4b0856..c0023b34 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -284,15 +284,14 @@ func (h *HandlerSystemd) DetectSchedulePermission(p Permission) (Permission, boo } // CheckPermission returns true if the user is allowed to access the job. -func (h *HandlerSystemd) CheckPermission(p Permission) bool { +func (h *HandlerSystemd) CheckPermission(user user.User, p Permission) bool { switch p { case PermissionUserLoggedOn: // user mode is always available return true default: - if os.Geteuid() == 0 { - // user has sudoed + if user.SudoRoot || user.Uid == 0 { return true } // last case is system (or undefined) + no sudo diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index d8a0b8a5..c4a0a196 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -9,6 +9,7 @@ import ( "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/schtasks" "github.com/creativeprojects/resticprofile/shell" + "github.com/creativeprojects/resticprofile/user" ) // HandlerWindows is using windows task manager @@ -131,7 +132,7 @@ func (h *HandlerWindows) DetectSchedulePermission(permission Permission) (Permis // CheckPermission returns true if the user is allowed to access the job. // This is always true on Windows. -func (h *HandlerWindows) CheckPermission(p Permission) bool { +func (h *HandlerWindows) CheckPermission(_ user.User, _ Permission) bool { return true } diff --git a/schedule/job.go b/schedule/job.go index cbc45140..6f603b37 100644 --- a/schedule/job.go +++ b/schedule/job.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/creativeprojects/clog" + "github.com/creativeprojects/resticprofile/user" ) // @@ -35,7 +36,7 @@ func NewJob(handler Handler, config *Config) *Job { // Accessible checks if the current user is permitted to access the job func (j *Job) Accessible() bool { permission, _ := j.handler.DetectSchedulePermission(PermissionFromConfig(j.config.Permission)) - return j.handler.CheckPermission(permission) + return j.handler.CheckPermission(user.Current(), permission) } // Create a new job @@ -45,7 +46,7 @@ func (j *Job) Create() error { } permission := j.getSchedulePermission(PermissionFromConfig(j.config.Permission)) - if ok := j.handler.CheckPermission(permission); !ok { + if ok := j.handler.CheckPermission(user.Current(), permission); !ok { return permissionError("create") } @@ -73,7 +74,7 @@ func (j *Job) Remove() error { } else { permission = j.getSchedulePermission(permission) } - if ok := j.handler.CheckPermission(permission); !ok { + if ok := j.handler.CheckPermission(user.Current(), permission); !ok { return permissionError("remove") } diff --git a/schedule/job_test.go b/schedule/job_test.go index 423a0194..6e34cdfc 100644 --- a/schedule/job_test.go +++ b/schedule/job_test.go @@ -15,7 +15,7 @@ import ( func TestCreateJobHappyPath(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().DetectSchedulePermission(schedule.PermissionUserBackground).Return(schedule.PermissionUserBackground, true) - handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) 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{}, schedule.PermissionUserBackground).Return(nil) @@ -34,7 +34,7 @@ func TestCreateJobHappyPath(t *testing.T) { func TestCreateJobErrorParseSchedules(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) - handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(nil) handler.EXPECT().ParseSchedules([]string{}).Return(nil, errors.New("test!")) @@ -51,7 +51,7 @@ func TestCreateJobErrorParseSchedules(t *testing.T) { func TestCreateJobErrorDisplaySchedules(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) - handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(errors.New("test!")) job := schedule.NewJob(handler, &schedule.Config{ @@ -67,7 +67,7 @@ func TestCreateJobErrorDisplaySchedules(t *testing.T) { func TestCreateJobErrorCreate(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().DetectSchedulePermission(schedule.PermissionUserBackground).Return(schedule.PermissionUserBackground, true) - handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) 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{}, schedule.PermissionUserBackground).Return(errors.New("test!")) diff --git a/schedule/mocks/Handler.go b/schedule/mocks/Handler.go index e8335532..03cdd64e 100644 --- a/schedule/mocks/Handler.go +++ b/schedule/mocks/Handler.go @@ -7,6 +7,8 @@ import ( mock "github.com/stretchr/testify/mock" schedule "github.com/creativeprojects/resticprofile/schedule" + + user "github.com/creativeprojects/resticprofile/user" ) // Handler is an autogenerated mock type for the Handler type @@ -22,17 +24,17 @@ func (_m *Handler) EXPECT() *Handler_Expecter { return &Handler_Expecter{mock: &_m.Mock} } -// CheckPermission provides a mock function with given fields: p -func (_m *Handler) CheckPermission(p schedule.Permission) bool { - ret := _m.Called(p) +// CheckPermission provides a mock function with given fields: _a0, p +func (_m *Handler) CheckPermission(_a0 user.User, p schedule.Permission) bool { + ret := _m.Called(_a0, p) if len(ret) == 0 { panic("no return value specified for CheckPermission") } var r0 bool - if rf, ok := ret.Get(0).(func(schedule.Permission) bool); ok { - r0 = rf(p) + if rf, ok := ret.Get(0).(func(user.User, schedule.Permission) bool); ok { + r0 = rf(_a0, p) } else { r0 = ret.Get(0).(bool) } @@ -46,14 +48,15 @@ type Handler_CheckPermission_Call struct { } // CheckPermission is a helper method to define mock.On call +// - _a0 user.User // - p schedule.Permission -func (_e *Handler_Expecter) CheckPermission(p interface{}) *Handler_CheckPermission_Call { - return &Handler_CheckPermission_Call{Call: _e.mock.On("CheckPermission", p)} +func (_e *Handler_Expecter) CheckPermission(_a0 interface{}, p interface{}) *Handler_CheckPermission_Call { + return &Handler_CheckPermission_Call{Call: _e.mock.On("CheckPermission", _a0, p)} } -func (_c *Handler_CheckPermission_Call) Run(run func(p schedule.Permission)) *Handler_CheckPermission_Call { +func (_c *Handler_CheckPermission_Call) Run(run func(_a0 user.User, p schedule.Permission)) *Handler_CheckPermission_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(schedule.Permission)) + run(args[0].(user.User), args[1].(schedule.Permission)) }) return _c } @@ -63,7 +66,7 @@ func (_c *Handler_CheckPermission_Call) Return(_a0 bool) *Handler_CheckPermissio return _c } -func (_c *Handler_CheckPermission_Call) RunAndReturn(run func(schedule.Permission) bool) *Handler_CheckPermission_Call { +func (_c *Handler_CheckPermission_Call) RunAndReturn(run func(user.User, schedule.Permission) bool) *Handler_CheckPermission_Call { _c.Call.Return(run) return _c } diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go index 1297bc08..0e54f4ad 100644 --- a/schedule_jobs_test.go +++ b/schedule_jobs_test.go @@ -37,7 +37,7 @@ func TestSimpleScheduleJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) - handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil) handler.EXPECT().DisplaySchedules("profile", "backup", []string{"sched"}).Return(nil) handler.EXPECT().CreateJob( @@ -63,7 +63,7 @@ func TestFailScheduleJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) - handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil) handler.EXPECT().DisplaySchedules("profile", "backup", []string{"sched"}).Return(nil) handler.EXPECT().CreateJob( @@ -95,7 +95,7 @@ func TestRemoveJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) - handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). RunAndReturn(func(scheduleConfig *schedule.Config, _ schedule.Permission) error { assert.Equal(t, "profile", scheduleConfig.ProfileName) @@ -115,7 +115,7 @@ func TestRemoveJobNoConfig(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) - handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). RunAndReturn(func(scheduleConfig *schedule.Config, _ schedule.Permission) error { assert.Equal(t, "profile", scheduleConfig.ProfileName) @@ -135,7 +135,7 @@ func TestFailRemoveJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) - handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). Return(errors.New("error removing job")) @@ -151,7 +151,7 @@ func TestNoFailRemoveUnknownJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) - handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). Return(schedule.ErrScheduledJobNotFound) @@ -167,7 +167,7 @@ func TestNoFailRemoveUnknownRemoveOnlyJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, true) - handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). Return(schedule.ErrScheduledJobNotFound) @@ -279,7 +279,7 @@ func TestRemoveScheduledJobs(t *testing.T) { for _, cfg := range tc.removedConfigs { handler.EXPECT().RemoveJob(&cfg, tc.permission).Return(nil) handler.EXPECT().DetectSchedulePermission(tc.permission).Return(tc.permission, true) - handler.EXPECT().CheckPermission(tc.permission).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, tc.permission).Return(true) } err := removeScheduledJobs(handler, tc.fromConfigFile, tc.removeProfileName) @@ -310,7 +310,7 @@ func TestFailRemoveScheduledJobs(t *testing.T) { Permission: constants.SchedulePermissionUser, }, schedule.PermissionUserBackground).Return(errors.New("impossible")) handler.EXPECT().DetectSchedulePermission(schedule.PermissionUserBackground).Return(schedule.PermissionUserBackground, true) - handler.EXPECT().CheckPermission(schedule.PermissionUserBackground).Return(true) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) err := removeScheduledJobs(handler, "configFile", "profile_to_remove") assert.Error(t, err) From 3d8f5db0f5e3fc68d2fd1a74350f0ac69788b0ef Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 27 Mar 2025 21:58:06 +0000 Subject: [PATCH 29/33] remove support for crond on windows --- crond/crontab.go | 2 ++ crond/crontab_test.go | 2 ++ crond/entry.go | 2 ++ crond/entry_test.go | 2 ++ crond/io.go | 2 ++ crond/parse_event.go | 2 ++ crond/parse_event_test.go | 2 ++ schedule/handler_crond.go | 2 ++ schedule/handler_crond_test.go | 2 ++ 9 files changed, 18 insertions(+) diff --git a/crond/crontab.go b/crond/crontab.go index 8d3ae52d..933abab3 100644 --- a/crond/crontab.go +++ b/crond/crontab.go @@ -1,3 +1,5 @@ +//go:build !windows + package crond import ( diff --git a/crond/crontab_test.go b/crond/crontab_test.go index c07d95cf..4dfac189 100644 --- a/crond/crontab_test.go +++ b/crond/crontab_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package crond import ( diff --git a/crond/entry.go b/crond/entry.go index 8c696dc7..c0187d9d 100644 --- a/crond/entry.go +++ b/crond/entry.go @@ -1,3 +1,5 @@ +//go:build !windows + package crond import ( diff --git a/crond/entry_test.go b/crond/entry_test.go index 221bb1fb..a840c1a5 100644 --- a/crond/entry_test.go +++ b/crond/entry_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package crond import ( diff --git a/crond/io.go b/crond/io.go index 4b7fae77..4761e507 100644 --- a/crond/io.go +++ b/crond/io.go @@ -1,3 +1,5 @@ +//go:build !windows + package crond import ( diff --git a/crond/parse_event.go b/crond/parse_event.go index 0abc0f20..31355ed9 100644 --- a/crond/parse_event.go +++ b/crond/parse_event.go @@ -1,3 +1,5 @@ +//go:build !windows + package crond import ( diff --git a/crond/parse_event_test.go b/crond/parse_event_test.go index 442fc87a..0b3bc068 100644 --- a/crond/parse_event_test.go +++ b/crond/parse_event_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package crond import ( diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index 767daf22..13471091 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -1,3 +1,5 @@ +//go:build !windows + package schedule import ( diff --git a/schedule/handler_crond_test.go b/schedule/handler_crond_test.go index a8f83d3a..87b3ec0a 100644 --- a/schedule/handler_crond_test.go +++ b/schedule/handler_crond_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package schedule import ( From 49a9801e520928796adfda45d08a45ae921799b9 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 27 Mar 2025 22:12:38 +0000 Subject: [PATCH 30/33] fix tests references to crond for windows --- commands_schedule_test.go | 2 ++ docs/content/schedules/cron.md | 6 +++++- schedule/handler_windows_test.go | 9 +++++---- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/commands_schedule_test.go b/commands_schedule_test.go index b0cbdec0..daf3ba7d 100644 --- a/commands_schedule_test.go +++ b/commands_schedule_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package main import ( diff --git a/docs/content/schedules/cron.md b/docs/content/schedules/cron.md index fe76eabd..e946c003 100644 --- a/docs/content/schedules/cron.md +++ b/docs/content/schedules/cron.md @@ -4,7 +4,11 @@ weight: 170 --- -On any OS, use a **crond** compatible scheduler if configured in `global` / `scheduler`: +On non-Windows OS, use a **crond**-compatible scheduler if specified in `global`/`scheduler`: + +{{% notice style="warning" title="Windows No Longer Supported" %}} +Crond support on Windows has been removed due to significant issues in previous versions. +{{% /notice %}} {{< tabs groupid="config-with-json" >}} {{% tab title="toml" %}} diff --git a/schedule/handler_windows_test.go b/schedule/handler_windows_test.go index e3feda37..0a084ed5 100644 --- a/schedule/handler_windows_test.go +++ b/schedule/handler_windows_test.go @@ -8,10 +8,11 @@ import ( "github.com/stretchr/testify/assert" ) -func TestHandlerCrond(t *testing.T) { - handler := NewHandler(SchedulerCrond{}) - assert.IsType(t, &HandlerCrond{}, handler) -} +// Support for Windows removed as it was broken +// func TestHandlerCrond(t *testing.T) { +// handler := NewHandler(SchedulerCrond{}) +// assert.IsType(t, &HandlerCrond{}, handler) +// } func TestHandlerDefaultOS(t *testing.T) { handler := NewHandler(SchedulerDefaultOS{}) From fe28b9c10819a6cd4db7b716c6986427f70b67e9 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 27 Mar 2025 22:25:32 +0000 Subject: [PATCH 31/33] avoid checking links not already available :facepalm --- .github/workflows/doc.yml | 2 +- .github/workflows/release-doc.yml | 2 +- Makefile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 81e32d24..beec2b66 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -55,7 +55,7 @@ jobs: with: url: "https://${{ env.BRANCH_NAME }}.resticprofile.pages.dev/" pages_path: ./public/ - cmd_params: '--exclude="(linux.die.net|stackoverflow.com|scoop.sh)" --buffer-size=8192 --max-connections=10 --color=always --skip-tls-verification --header="User-Agent:curl/7.54.0" --timeout=20' + cmd_params: '--exclude="(linux\.die\.net|stackoverflow\.com|scoop\.sh|0-18)" --buffer-size=8192 --max-connections=10 --color=always --skip-tls-verification --header="User-Agent:curl/7.54.0" --timeout=20' - name: Publish to pages.dev continue-on-error: true # secrets are not set for PRs from forks diff --git a/.github/workflows/release-doc.yml b/.github/workflows/release-doc.yml index 5a1d9928..6e84bc97 100644 --- a/.github/workflows/release-doc.yml +++ b/.github/workflows/release-doc.yml @@ -49,7 +49,7 @@ jobs: with: url: https://creativeprojects.github.io/resticprofile/ pages_path: ./www/ - cmd_params: '--exclude="(linux.die.net|stackoverflow.com|scoop.sh)" --buffer-size=8192 --max-connections-per-host=5 --rate-limit=5 --timeout=20 --header="User-Agent:curl/7.54.0" --skip-tls-verification' + cmd_params: '--exclude="(linux\.die\.net|stackoverflow\.com|scoop\.sh|0-18)" --buffer-size=8192 --max-connections-per-host=5 --rate-limit=5 --timeout=20 --header="User-Agent:curl/7.54.0" --skip-tls-verification' - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 diff --git a/Makefile b/Makefile index 19ba41c3..b956cbe9 100644 --- a/Makefile +++ b/Makefile @@ -298,7 +298,7 @@ checkdoc: .PHONY: checklinks checklinks: @echo "[*] $@" - muffet -b 8192 --max-connections=10 --exclude="(linux.die.net|stackoverflow.com|scoop.sh)" http://localhost:1313/resticprofile/ + muffet -b 8192 --max-connections=10 --exclude="(linux\.die\.net|stackoverflow\.com|scoop\.sh|0-18)" http://localhost:1313/resticprofile/ .PHONY: lint lint: $(GOBIN)/golangci-lint From 1286704ff7ebc496d16e6a9cbb45164224c449b2 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 28 Mar 2025 10:23:30 +0000 Subject: [PATCH 32/33] nitpick comments --- docs/content/schedules/launchd.md | 2 +- examples/linux.yaml | 6 +-- schedule/handler_systemd.go | 80 ++++++++++++++++++++++++------- schedule/handler_systemd_test.go | 20 ++++++++ schedule/handler_windows.go | 2 +- schedule/schedules.go | 19 -------- schedule/schedules_test.go | 33 ------------- 7 files changed, 89 insertions(+), 73 deletions(-) diff --git a/docs/content/schedules/launchd.md b/docs/content/schedules/launchd.md index ddfff00e..45e9f046 100644 --- a/docs/content/schedules/launchd.md +++ b/docs/content/schedules/launchd.md @@ -7,7 +7,7 @@ weight: 110 ## User permission -A user agent is generated when you set `schedule-permission` to `user` or `user_logged-on`. It consists of a `plist` file in `~/Library/LaunchAgents`. +A user agent is generated when you set `schedule-permission` to `user` or `user_logged_on`. It consists of a `plist` file in `~/Library/LaunchAgents`. If you include specific files in your backup, like contacts or calendar, you need to grant more permissions to resticprofile and restic (a popup window will ask for permission). diff --git a/examples/linux.yaml b/examples/linux.yaml index be4d36aa..925c7a4e 100644 --- a/examples/linux.yaml +++ b/examples/linux.yaml @@ -102,8 +102,8 @@ self: extended-status: true source: ./ schedule: - at: "*:15,20,25" - permission: system + at: "*:15,20,25" + permission: system check: schedule-permission: user schedule: @@ -145,7 +145,7 @@ src: tag: - test - dev - + stdin: inherit: default backup: diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index c0023b34..a5b86c9e 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -5,6 +5,7 @@ package schedule import ( "bytes" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -15,6 +16,7 @@ import ( "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/platform" "github.com/creativeprojects/resticprofile/shell" "github.com/creativeprojects/resticprofile/systemd" "github.com/creativeprojects/resticprofile/term" @@ -39,8 +41,9 @@ const ( ) var ( - journalctlBinary = "/usr/bin/journalctl" - systemctlBinary = "/usr/bin/systemctl" + journalctlBinary = "journalctl" + systemctlBinary = "systemctl" + analyzeBinary = "systemd-analyze" ) // HandlerSystemd is a handler to schedule tasks using systemd @@ -332,10 +335,13 @@ func getSystemdStatus(profile string, unitType systemd.UnitType) (string, error) args = append(args, getUserFlags()...) } buffer := &strings.Builder{} - cmd := systemctlCommand(args...) + cmd, err := systemctlCommand(args...) + if err != nil { + return "", err + } cmd.Stdout = buffer cmd.Stderr = buffer - err := cmd.Run() + err = cmd.Run() return buffer.String(), err } @@ -350,12 +356,15 @@ func runSystemctlCommand(timerName, command string, unitType systemd.UnitType, s args = append(args, flagNoPager) args = append(args, command, timerName) - cmd := systemctlCommand(args...) + cmd, err := systemctlCommand(args...) + if err != nil { + return err + } if !silent { cmd.Stdout = term.GetOutput() cmd.Stderr = term.GetErrorOutput() } - err := cmd.Run() + err = cmd.Run() if command == systemctlStatus && cmd.ProcessState.ExitCode() == codeStatusUnitNotFound { return ErrScheduledJobNotFound } @@ -375,11 +384,16 @@ func runJournalCtlCommand(timerName string, unitType systemd.UnitType) error { if unitType == systemd.UserUnit { args = append(args, getUserFlags()...) } - clog.Debugf("starting command \"%s %s\"", journalctlBinary, strings.Join(args, " ")) - cmd := exec.Command(journalctlBinary, args...) + + binary, err := exec.LookPath(journalctlBinary) + if err != nil { + return fmt.Errorf("cannot find %q: %w", journalctlBinary, err) + } + clog.Debugf("starting command \"%s %s\"", binary, strings.Join(args, " ")) + cmd := exec.Command(binary, args...) cmd.Stdout = term.GetOutput() cmd.Stderr = term.GetErrorOutput() - err := cmd.Run() + err = cmd.Run() fmt.Println("") return err } @@ -389,10 +403,13 @@ func runSystemctlReload(unitType systemd.UnitType) error { if unitType == systemd.UserUnit { args = append(args, getUserFlags()...) } - cmd := systemctlCommand(args...) + cmd, err := systemctlCommand(args...) + if err != nil { + return err + } cmd.Stdout = term.GetOutput() cmd.Stderr = term.GetErrorOutput() - err := cmd.Run() + err = cmd.Run() if err != nil { return err } @@ -412,10 +429,13 @@ func listUnits(profile string, unitType systemd.UnitType) ([]SystemdUnit, error) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - cmd := systemctlCommand(args...) + cmd, err := systemctlCommand(args...) + if err != nil { + return nil, err + } cmd.Stdout = stdout cmd.Stderr = stderr - err := cmd.Run() + err = cmd.Run() if err != nil { return nil, fmt.Errorf("error running command: %w\n%s", err, stderr.String()) } @@ -505,9 +525,37 @@ func systemdConfigPermission(systemdConfig systemd.Config) string { } } -func systemctlCommand(args ...string) *exec.Cmd { - clog.Debugf("starting command \"%s %s\"", systemctlBinary, strings.Join(args, " ")) - return exec.Command(systemctlBinary, args...) +func systemctlCommand(args ...string) (*exec.Cmd, error) { + binary, err := exec.LookPath(systemctlBinary) + if err != nil { + return nil, fmt.Errorf("cannot find %q: %w", systemctlBinary, err) + } + clog.Debugf("starting command \"%s %s\"", binary, strings.Join(args, " ")) + return exec.Command(binary, args...), nil +} + +func displaySystemdSchedules(profile, command string, schedules []string) error { + binary, err := exec.LookPath(analyzeBinary) + if err != nil { + return fmt.Errorf("cannot find %q: %w", analyzeBinary, err) + } + + for index, schedule := range schedules { + if schedule == "" { + return errors.New("empty schedule") + } + displayHeader(profile, command, index+1, len(schedules)) + + cmd := exec.Command(binary, "calendar", schedule) + cmd.Stdout = term.GetOutput() + cmd.Stderr = term.GetErrorOutput() + err = cmd.Run() + if err != nil { + return err + } + } + term.Print(platform.LineSeparator) + return nil } // init registers HandlerSystemd diff --git a/schedule/handler_systemd_test.go b/schedule/handler_systemd_test.go index 1306d1bf..e413e5cd 100644 --- a/schedule/handler_systemd_test.go +++ b/schedule/handler_systemd_test.go @@ -3,12 +3,14 @@ package schedule import ( + "bytes" "os" "testing" "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/systemd" + "github.com/creativeprojects/resticprofile/term" "github.com/creativeprojects/resticprofile/user" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -228,3 +230,21 @@ func TestPermissionToSystemd(t *testing.T) { }) } } + +func TestDisplaySystemdSchedulesWithEmpty(t *testing.T) { + err := displaySystemdSchedules("profile", "command", []string{""}) + require.Error(t, err) +} + +func TestDisplaySystemdSchedules(t *testing.T) { + buffer := &bytes.Buffer{} + term.SetOutput(buffer) + defer term.SetOutput(os.Stdout) + + err := displaySystemdSchedules("profile", "command", []string{"daily"}) + require.NoError(t, err) + + output := buffer.String() + assert.Contains(t, output, "Original form: daily") + assert.Contains(t, output, "Normalized form: *-*-* 00:00:00") +} diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index c4a0a196..8189080b 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -71,7 +71,7 @@ func (h *HandlerWindows) CreateJob(job *Config, schedules []*calendar.Event, per } // RemoveJob is deleting the task scheduler job -func (h *HandlerWindows) RemoveJob(job *Config, permission Permission) error { +func (h *HandlerWindows) RemoveJob(job *Config, _ Permission) error { err := schtasks.Delete(job.ProfileName, job.CommandName) if err != nil { if errors.Is(err, schtasks.ErrNotRegistered) { diff --git a/schedule/schedules.go b/schedule/schedules.go index 4b0cd5f3..edb68e27 100644 --- a/schedule/schedules.go +++ b/schedule/schedules.go @@ -3,7 +3,6 @@ package schedule import ( "errors" "fmt" - "os/exec" "strings" "time" @@ -58,21 +57,3 @@ func displayParsedSchedules(profile, command string, events []*calendar.Event) { } term.Print(platform.LineSeparator) } - -func displaySystemdSchedules(profile, command string, schedules []string) error { - for index, schedule := range schedules { - if schedule == "" { - return errors.New("empty schedule") - } - displayHeader(profile, command, index+1, len(schedules)) - cmd := exec.Command("/usr/bin/systemd-analyze", "calendar", schedule) - cmd.Stdout = term.GetOutput() - cmd.Stderr = term.GetErrorOutput() - err := cmd.Run() - if err != nil { - return err - } - } - term.Print(platform.LineSeparator) - return nil -} diff --git a/schedule/schedules_test.go b/schedule/schedules_test.go index ec6b330e..ca6e1735 100644 --- a/schedule/schedules_test.go +++ b/schedule/schedules_test.go @@ -3,10 +3,8 @@ package schedule import ( "bytes" "os" - "os/exec" "testing" - "github.com/creativeprojects/resticprofile/platform" "github.com/creativeprojects/resticprofile/term" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -63,34 +61,3 @@ func TestDisplayParseSchedulesIndexAndTotal(t *testing.T) { assert.Contains(t, output, "schedule 2/3") assert.Contains(t, output, "schedule 3/3") } - -func TestDisplaySystemdSchedulesWithEmpty(t *testing.T) { - err := displaySystemdSchedules("profile", "command", []string{""}) - require.Error(t, err) -} - -func TestDisplaySystemdSchedules(t *testing.T) { - _, err := exec.LookPath("/usr/bin/systemd-analyze") - if err != nil { - t.Skip("systemd-analyze not available") - } - - buffer := &bytes.Buffer{} - term.SetOutput(buffer) - defer term.SetOutput(os.Stdout) - - err = displaySystemdSchedules("profile", "command", []string{"daily"}) - require.NoError(t, err) - - output := buffer.String() - assert.Contains(t, output, "Original form: daily") - assert.Contains(t, output, "Normalized form: *-*-* 00:00:00") -} - -func TestDisplaySystemdSchedulesError(t *testing.T) { - if !platform.IsWindows() && !platform.IsDarwin() { - t.Skip() - } - err := displaySystemdSchedules("profile", "command", []string{"daily"}) - require.Error(t, err) -} From 659e422abff2de3144d0777c2e841ec277a00e05 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 28 Mar 2025 10:43:15 +0000 Subject: [PATCH 33/33] add a few more tests --- schedule/handler_darwin_test.go | 46 +++++++++++++++++++++++++++++++++ schedule/schedules.go | 2 +- schedule/schedules_test.go | 13 ++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 1a826bcb..75dcfb09 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -14,6 +14,7 @@ import ( "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/darwin" + "github.com/creativeprojects/resticprofile/user" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -318,3 +319,48 @@ func TestDetectPermissionLaunchd(t *testing.T) { }) } } + +func TestPresentStatus(t *testing.T) { + tests := []struct { + key string + value string + expectedKey string + expectedValue string + }{ + {"domain", "gui/501", "permission", "user logged on"}, + {"domain", "user/501", "permission", "user"}, + {"domain", "system", "permission", "system"}, + {"runs", "5", "runs (*)", "5"}, + {"last exit code", "0", "last exit code (*)", "0"}, + {"state", "running", "state", "running"}, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%s=%s", test.key, test.value), func(t *testing.T) { + actualKey, actualValue := presentStatus(test.key, test.value) + assert.Equal(t, test.expectedKey, actualKey) + assert.Equal(t, test.expectedValue, actualValue) + }) + } +} +func TestDomainTarget(t *testing.T) { + t.Parallel() + + currentUid := user.Current().Uid + tests := []struct { + permission Permission + expected string + }{ + {PermissionSystem, "system"}, + {PermissionUserLoggedOn, fmt.Sprintf("gui/%d", currentUid)}, + {PermissionUserBackground, fmt.Sprintf("user/%d", currentUid)}, + {PermissionFromConfig("unknown"), ""}, + } + + for _, test := range tests { + t.Run(test.permission.String(), func(t *testing.T) { + actual := domainTarget(test.permission) + assert.Equal(t, test.expected, actual) + }) + } +} diff --git a/schedule/schedules.go b/schedule/schedules.go index edb68e27..7daf73cb 100644 --- a/schedule/schedules.go +++ b/schedule/schedules.go @@ -48,7 +48,7 @@ func displayParsedSchedules(profile, command string, events []*calendar.Event) { term.Printf(" Original form: %s\n", event.Input()) term.Printf("Normalized form: %s\n", event.String()) if next.IsZero() { - term.Print(" Next elapse: Never\n") + term.Print(" Next elapse: never\n") continue } term.Printf(" Next elapse: %s\n", next.Format(time.UnixDate)) diff --git a/schedule/schedules_test.go b/schedule/schedules_test.go index ca6e1735..1c014fef 100644 --- a/schedule/schedules_test.go +++ b/schedule/schedules_test.go @@ -47,6 +47,19 @@ func TestDisplayParseSchedules(t *testing.T) { assert.Contains(t, output, "Normalized form: *-*-* 00:00:00\n") } +func TestDisplayParseSchedulesWillNeverRun(t *testing.T) { + events, err := parseSchedules([]string{"2020-01-01"}) + require.NoError(t, err) + + buffer := &bytes.Buffer{} + term.SetOutput(buffer) + defer term.SetOutput(os.Stdout) + + displayParsedSchedules("profile", "command", events) + output := buffer.String() + assert.Contains(t, output, "Next elapse: never\n") +} + func TestDisplayParseSchedulesIndexAndTotal(t *testing.T) { events, err := parseSchedules([]string{"daily", "monthly", "yearly"}) require.NoError(t, err)