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/.gitignore b/.gitignore index 4f448356..bc254330 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,11 @@ .DS_Store /examples/private +/examples/*-log.txt /build/restic* /build/rclone* /dist +/crontab # binary /resticprofile* diff --git a/Makefile b/Makefile index d112730d..b956cbe9 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 "[*] $@" @@ -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 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/commands_schedule_test.go b/commands_schedule_test.go index 2ae5353a..daf3ba7d 100644 --- a/commands_schedule_test.go +++ b/commands_schedule_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package main import ( @@ -40,11 +42,13 @@ profiles: profile-schedule-inline: backup: schedule: "*:00,30" + schedule-permission: "user" profile-schedule-struct: backup: schedule: at: "*:20,50" + permission: "user" ` 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/crond/crontab.go b/crond/crontab.go index 636384ac..933abab3 100644 --- a/crond/crontab.go +++ b/crond/crontab.go @@ -1,13 +1,15 @@ +//go:build !windows + package crond import ( "errors" "fmt" "io" - "os/user" "regexp" "strings" + "github.com/creativeprojects/resticprofile/user" "github.com/spf13/afero" ) @@ -172,9 +174,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/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/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..0afc8362 --- /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,omitempty"` +} 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/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/darwin/session_type.go b/darwin/session_type.go new file mode 100644 index 00000000..6db4fa4e --- /dev/null +++ b/darwin/session_type.go @@ -0,0 +1,45 @@ +//go:build darwin + +package darwin + +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 SessionTypeSystem + + case constants.SchedulePermissionUser: + return SessionTypeBackground + + case constants.SchedulePermissionUserLoggedOn, constants.SchedulePermissionUserLoggedIn: + return SessionTypeGUI + + default: + // this was the only option available before 0.30.0 + 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/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/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/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/docs/content/schedules/cron.md b/docs/content/schedules/cron.md new file mode 100644 index 00000000..e946c003 --- /dev/null +++ b/docs/content/schedules/cron.md @@ -0,0 +1,189 @@ +--- +title: "Cron & compatible" +weight: 170 +--- + + +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" %}} + +```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** 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 + +{{< 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..45e9f046 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/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 170bdf2f..71eaccb9 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" @@ -108,14 +107,15 @@ self: extended-status: false check-before: true no-error-on-warning: true - source: "{{ .CurrentDir }}" + source: + - "{{ .CurrentDir }}" exclude: - "/**/.git/" schedule: - - "*:00,30" - schedule-permission: user + - "*:00,30,36,50,55" + - "2020-01-01" + schedule-permission: user_logged_on schedule-log: "_schedule-log.txt" - schedule-ignore-on-battery: true schedule-after-network-online: true skip-if-unchanged: true @@ -127,19 +127,20 @@ self: check: read-data-subset: "$DOW/7" schedule: - - "*:15" + - "*:05,10,15,20,25,35" + schedule-permission: user 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-permission: system + # copy: + # initialize: true + # snapshot: latest + # schedule: + # - "*:45" snapshots: host: true run-before: diff --git a/examples/linux.yaml b/examples/linux.yaml index 0f61ab20..925c7a4e 100644 --- a/examples/linux.yaml +++ b/examples/linux.yaml @@ -6,13 +6,13 @@ 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 ionice-level: 7 - nice: 19 - scheduler: crontab:*:/tmp/crontab + # nice: 19 + # scheduler: crontab:*:/tmp/crontab default: password-file: key @@ -101,15 +101,20 @@ self: backup: extended-status: true source: ./ - # schedule: - # at: "*:15" - # permission: system - copy: - initialize: true + schedule: + at: "*:15,20,25" + permission: system + check: schedule-permission: user schedule: - "*:15" - "*:45" + forget: + schedule-permission: user_logged_on + schedule: + - "2020-01-01" + - "*:10" + - "*:40" src: inherit: default @@ -140,7 +145,7 @@ src: tag: - test - dev - + stdin: inherit: default backup: 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/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/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.go b/schedule/handler.go index 6a34a429..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 @@ -14,11 +15,17 @@ 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) + // CheckPermission returns true if the user is allowed to access the job. + 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 13ee0f7d..13471091 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -1,13 +1,18 @@ +//go:build !windows + package schedule import ( + "os" "slices" + "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/crond" "github.com/creativeprojects/resticprofile/platform" "github.com/creativeprojects/resticprofile/shell" + "github.com/creativeprojects/resticprofile/user" "github.com/spf13/afero" ) @@ -37,8 +42,10 @@ 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 %q file as cron scheduler", h.config.CrontabFile) return nil } + clog.Debug("using standard cron scheduler") return lookupBinary("crond", h.config.CrontabBinary) } @@ -66,7 +73,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( @@ -78,7 +85,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). @@ -92,7 +103,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(), @@ -136,6 +147,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 { @@ -151,12 +167,50 @@ func (h *HandlerCrond) Scheduled(profileName string) ([]Config, error) { Command: args[0], Arguments: NewCommandArguments(args[1:]), WorkingDirectory: entry.WorkDir(), + Permission: permission, }) } } 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 + } +} + +// CheckPermission returns true if the user is allowed to access the job. +func (h *HandlerCrond) CheckPermission(user user.User, p Permission) bool { + switch p { + case PermissionUserLoggedOn, PermissionUserBackground: + // user mode is always available + return true + + default: + if user.SudoRoot || user.Uid == 0 { + 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_crond_test.go b/schedule/handler_crond_test.go index c6493bfc..87b3ec0a 100644 --- a/schedule/handler_crond_test.go +++ b/schedule/handler_crond_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package schedule import ( @@ -5,6 +7,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" @@ -29,6 +32,7 @@ func TestReadingCrondScheduled(t *testing.T) { WorkingDirectory: "/resticprofile", Schedules: []string{"*-*-* *:00:00"}, ConfigFile: "examples/dev.yaml", + Permission: "user", }, schedules: []*calendar.Event{ hourly, @@ -43,6 +47,7 @@ func TestReadingCrondScheduled(t *testing.T) { WorkingDirectory: "/resticprofile", Schedules: []string{"*-*-* *:00:00"}, ConfigFile: "config file.yaml", + Permission: "system", }, schedules: []*calendar.Event{ hourly, @@ -53,6 +58,7 @@ func TestReadingCrondScheduled(t *testing.T) { tempFile := filepath.Join(t.TempDir(), "crontab") handler := NewHandler(SchedulerCrond{ CrontabFile: tempFile, + Username: "*", }).(*HandlerCrond) handler.fs = afero.NewMemMapFs() @@ -60,7 +66,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 +77,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) } @@ -79,3 +85,92 @@ 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) + }) + } +} + +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 94a00c21..6cee9221 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -3,20 +3,20 @@ package schedule import ( + "bytes" "fmt" "os" "os/exec" "path" - "regexp" - "slices" - "sort" "strings" "text/tabwriter" "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/darwin" "github.com/creativeprojects/resticprofile/term" + "github.com/creativeprojects/resticprofile/user" "github.com/creativeprojects/resticprofile/util" "github.com/spf13/afero" "howett.net/plist" @@ -27,45 +27,22 @@ 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" + launchctlBin = "/bin/launchctl" + launchdBootstrap = "bootstrap" + launchdBootout = "bootout" + launchdPrint = "print" + 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" daemonExtension = ".plist" - codeServiceNotFound = 113 + launchctlServiceNotFound = 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", -} +var launchctlPrintKeys = []string{"service", "domain", "program", "working directory", "stdout path", "stderr path", "state", "runs", "last exit code"} type HandlerLaunchd struct { config SchedulerConfig @@ -74,7 +51,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 @@ -101,7 +78,17 @@ 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 { + 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 != "" { @@ -110,8 +97,7 @@ func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, per return err } - // load the service - cmd := exec.Command(launchctlBin, launchdLoad, filename) + cmd := launchctlCommand(launchdBootstrap, domainTarget(permission), filename) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err = cmd.Run() @@ -119,23 +105,11 @@ 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 } // 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 { @@ -145,15 +119,8 @@ func (h *HandlerLaunchd) RemoveJob(job *Config, permission string) 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, launchdUnload, filename) + unload := launchctlCommand(launchdBootout, domainTarget(permission)+"/"+name) unload.Stdout = os.Stdout unload.Stderr = os.Stderr err = unload.Run() @@ -169,37 +136,29 @@ 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 { - return permissionError("view") - } - cmd := exec.Command(launchctlBin, launchdList, getJobName(job.ProfileName, job.CommandName)) + 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 { return err } - status := parseStatus(string(output)) + status := parsePrintStatus(output) if len(status) == 0 { // output was not parsed, it could mean output format has changed - fmt.Println(string(output)) + clog.Warning("output of 'launchctl print' was either empty or using an incompatible format") } - // order keys alphabetically - keys := make([]string, 0, len(status)) - for key := range status { - if slices.Contains([]string{"LimitLoadToSessionType", "OnDemand"}, key) { + writer := tabwriter.NewWriter(term.GetOutput(), 1, 1, 1, ' ', tabwriter.AlignRight) + for _, key := range launchctlPrintKeys { + key, value := presentStatus(key, status[key]) + if len(value) == 0 { continue } - keys = append(keys, key) - } - sort.Strings(keys) - writer := tabwriter.NewWriter(term.GetOutput(), 0, 0, 0, ' ', tabwriter.AlignRight) - for _, key := range keys { - fmt.Fprintf(writer, "%s:\t %s\n", spacedTitle(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("") @@ -222,7 +181,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 +200,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 } @@ -270,13 +231,53 @@ 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) } } 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 + } +} + +// CheckPermission returns true if the user is allowed to access the job. +func (h *HandlerLaunchd) CheckPermission(user user.User, p Permission) bool { + switch p { + case PermissionUserLoggedOn, PermissionUserBackground: + // user mode is always available + return true + + default: + if user.SudoRoot || user.Uid == 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 { @@ -307,6 +308,7 @@ func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { if err != nil { return nil, fmt.Errorf("error reading plist file: %w", err) } + args := NewCommandArguments(launchdJob.ProgramArguments[2:]) // first is binary, second is --no-prio job := &Config{ ProfileName: profileName, @@ -315,19 +317,26 @@ 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), + Permission: launchdJob.LimitLoadToSessionType.Permission(), } return job, nil } -func (h *HandlerLaunchd) createPlistFile(launchdJob *LaunchdJob, permission string) (string, error) { +func (h *HandlerLaunchd) createPlistFile(launchdJob *darwin.LaunchdJob, permission Permission) (string, error) { + user := user.Current() 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) + dir := path.Dir(filename) + _ = h.fs.MkdirAll(dir, 0o700) + 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 { @@ -335,6 +344,11 @@ func (h *HandlerLaunchd) createPlistFile(launchdJob *LaunchdJob, permission stri } defer file.Close() + 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) + } + encoder := plist.NewEncoder(file) encoder.Indent("\t") err = encoder.Encode(launchdJob) @@ -344,7 +358,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 +366,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 @@ -368,8 +382,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() @@ -379,17 +393,75 @@ func getFilename(name, permission string) (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)) +func domainTarget(permission Permission) string { + switch permission { + case PermissionSystem: + return "system" + case PermissionUserLoggedOn: + return fmt.Sprintf("gui/%d", user.Current().Uid) + + case PermissionUserBackground: + return fmt.Sprintf("user/%d", user.Current().Uid) + + default: + return "" + } +} + +func launchctlCommand(arg ...string) *exec.Cmd { + clog.Debugf("running command: '%s %s'", launchctlBin, strings.Join(arg, " ")) + return exec.Command(launchctlBin, arg...) +} + +func parsePrintStatus(output []byte) map[string]string { + info := make(map[string]string, 10) + lines := bytes.Split(output, []byte{'\n'}) for _, line := range lines { - match := expr.FindStringSubmatch(line) - if len(match) == 3 { - output[match[1]] = strings.Trim(match[2], "\"") + 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 strValue != "{" { + info[strKey] = strValue + } + } + } + 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() == launchctlServiceNotFound { + return false, nil + } + if err != nil { + return false, err } - return output + return true, nil } // init registers HandlerLaunchd diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 6ad95ac0..75dcfb09 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -5,11 +5,16 @@ package schedule import ( "bytes" "fmt" + "maps" "os" + "slices" + "strings" "testing" "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" @@ -24,10 +29,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,94 +42,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"; - "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) @@ -158,10 +77,10 @@ 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") + filename, err := handler.createPlistFile(launchdJob, PermissionUserBackground) require.NoError(t, err) _, err = handler.fs.Stat(filename) @@ -172,10 +91,10 @@ 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") + filename, err := handler.createPlistFile(launchdJob, PermissionSystem) require.NoError(t, err) _, err = handler.fs.Stat(filename) @@ -212,7 +131,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"}, }, @@ -240,7 +159,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) } @@ -249,3 +168,199 @@ func TestReadingLaunchdScheduled(t *testing.T) { assert.ElementsMatch(t, expectedJobs, scheduled) } + +func TestParsePrint(t *testing.T) { + 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 + + 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 + } + + working directory = /Users/cp/go/src/github.com/creativeprojects/resticprofile + + 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, launchctlPrintKeys) + +} + +func assertMapHasKeys(t *testing.T, source map[string]string, keys []string) { + t.Helper() + + 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)), ", ")) + } + } +} + +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) + } +} + +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"]) +} + +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) + }) + } +} + +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/handler_systemd.go b/schedule/handler_systemd.go index 79b40c82..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,9 +16,11 @@ 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" + "github.com/creativeprojects/resticprofile/user" ) const ( @@ -40,6 +43,7 @@ const ( var ( journalctlBinary = "journalctl" systemctlBinary = "systemctl" + analyzeBinary = "systemd-analyze" ) // HandlerSystemd is a handler to schedule tasks using systemd @@ -103,15 +107,11 @@ 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 { - unitType := systemd.UserUnit - if os.Geteuid() == 0 { - // user has sudoed already - unitType = systemd.SystemUnit - } +func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, permission Permission) error { + unitType, user := permissionToSystemd(user.Current(), 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 +132,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 @@ -166,13 +167,9 @@ 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 { - unitType := systemd.UserUnit - if os.Geteuid() == 0 { - // user has sudoed already - unitType = systemd.SystemUnit - } +func (h *HandlerSystemd) RemoveJob(job *Config, permission Permission) error { var err error + unitType, _ := permissionToSystemd(user.Current(), permission) serviceFile := systemd.GetServiceFile(job.ProfileName, job.CommandName) unitLoaded, err := unitLoaded(serviceFile, unitType) if err != nil { @@ -232,9 +229,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,23 +266,82 @@ 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 + } +} + +// CheckPermission returns true if the user is allowed to access the job. +func (h *HandlerSystemd) CheckPermission(user user.User, p Permission) bool { + switch p { + case PermissionUserLoggedOn: + // user mode is always available + return true + + default: + if user.SudoRoot || user.Uid == 0 { + return true + } + // last case is system (or undefined) + no sudo + return false + + } +} + var ( _ Handler = &HandlerSystemd{} ) +func permissionToSystemd(user user.User, permission Permission) (systemd.UnitType, string) { + switch permission { + case PermissionSystem: + return systemd.SystemUnit, "" + + case PermissionUserBackground: + return systemd.SystemUnit, user.Username + + case PermissionUserLoggedOn: + return systemd.UserUnit, "" + + default: + unitType := systemd.UserUnit + if user.Uid == 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()...) } - clog.Debugf("starting command \"%s %s\"", systemctlBinary, strings.Join(args, " ")) buffer := &strings.Builder{} - cmd := exec.Command(systemctlBinary, 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 } @@ -295,18 +351,20 @@ 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) - clog.Debugf("starting command \"%s %s\"", systemctlBinary, strings.Join(args, " ")) - cmd := exec.Command(systemctlBinary, 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 } @@ -324,13 +382,18 @@ 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...) + + 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 } @@ -338,13 +401,15 @@ 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, err := systemctlCommand(args...) + if err != nil { + return err } - clog.Debugf("starting command \"%s %s\"", systemctlBinary, strings.Join(args, " ")) - cmd := exec.Command(systemctlBinary, args...) cmd.Stdout = term.GetOutput() cmd.Stderr = term.GetErrorOutput() - err := cmd.Run() + err = cmd.Run() if err != nil { return err } @@ -358,17 +423,19 @@ 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) - clog.Debugf("starting command \"%s %s\"", systemctlBinary, strings.Join(args, " ")) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - cmd := exec.Command(systemctlBinary, 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()) } @@ -381,6 +448,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 { @@ -409,12 +484,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 { @@ -422,11 +497,6 @@ func toScheduleConfig(systemdConfig systemd.Config, unitType systemd.UnitType) C } args := NewCommandArguments(cmdLine[1:]) - permission := constants.SchedulePermissionUser - if unitType == systemd.SystemUnit { - permission = constants.SchedulePermissionSystem - } - cfg := Config{ ConfigFile: args.ConfigFile(), ProfileName: systemdConfig.Title, @@ -436,13 +506,58 @@ 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, 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 func init() { AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { diff --git a/schedule/handler_systemd_test.go b/schedule/handler_systemd_test.go index 078e392b..e413e5cd 100644 --- a/schedule/handler_systemd_test.go +++ b/schedule/handler_systemd_test.go @@ -3,11 +3,15 @@ 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" ) @@ -16,7 +20,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 @@ -57,11 +61,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 +89,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) } @@ -93,3 +97,154 @@ 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) + }) + } +} +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) + }) + } +} + +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 6e5feb50..8189080b 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 @@ -46,12 +47,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 +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 string) 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) { @@ -116,6 +117,25 @@ 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 + } +} + +// CheckPermission returns true if the user is allowed to access the job. +// This is always true on Windows. +func (h *HandlerWindows) CheckPermission(_ user.User, _ Permission) bool { + return true +} + // init registers HandlerWindows func init() { AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { diff --git a/schedule/handler_windows_test.go b/schedule/handler_windows_test.go index c235c9e1..0a084ed5 100644 --- a/schedule/handler_windows_test.go +++ b/schedule/handler_windows_test.go @@ -8,12 +8,40 @@ 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{}) 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) + }) + } +} diff --git a/schedule/job.go b/schedule/job.go index 7d3f51f6..6f603b37 100644 --- a/schedule/job.go +++ b/schedule/job.go @@ -2,6 +2,10 @@ package schedule import ( "errors" + "fmt" + + "github.com/creativeprojects/clog" + "github.com/creativeprojects/resticprofile/user" ) // @@ -31,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 j.handler.CheckPermission(user.Current(), permission) } // Create a new job @@ -41,9 +45,8 @@ func (j *Job) Create() error { return ErrJobCanBeRemovedOnly } - permission := getSchedulePermission(j.config.Permission) - ok := checkPermission(permission) - if !ok { + permission := j.getSchedulePermission(PermissionFromConfig(j.config.Permission)) + if ok := j.handler.CheckPermission(user.Current(), permission); !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 := j.handler.CheckPermission(user.Current(), permission); !ok { return permissionError("remove") } @@ -91,11 +93,28 @@ 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 } + +// 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..6e34cdfc 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,17 @@ import ( func TestCreateJobHappyPath(t *testing.T) { handler := mocks.NewHandler(t) + handler.EXPECT().DetectSchedulePermission(schedule.PermissionUserBackground).Return(schedule.PermissionUserBackground, 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{}, "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 +33,8 @@ 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(mock.Anything, schedule.PermissionUserBackground).Return(true) handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(nil) handler.EXPECT().ParseSchedules([]string{}).Return(nil, errors.New("test!")) @@ -45,6 +50,8 @@ 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(mock.Anything, schedule.PermissionUserBackground).Return(true) handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(errors.New("test!")) job := schedule.NewJob(handler, &schedule.Config{ @@ -59,15 +66,17 @@ 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(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{}, "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", CommandName: "backup", Schedules: []string{}, - Permission: "user", + Permission: constants.SchedulePermissionUser, }) err := job.Create() diff --git a/schedule/mocks/Handler.go b/schedule/mocks/Handler.go index b748db6c..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,6 +24,53 @@ func (_m *Handler) EXPECT() *Handler_Expecter { return &Handler_Expecter{mock: &_m.Mock} } +// 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(user.User, schedule.Permission) bool); ok { + r0 = rf(_a0, 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 +// - _a0 user.User +// - p schedule.Permission +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(_a0 user.User, p schedule.Permission)) *Handler_CheckPermission_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(user.User), args[1].(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(user.User, 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() @@ -55,7 +104,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 +112,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 +129,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 +146,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 +451,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 +459,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 +475,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 +492,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 7ae90020..56d5b478 100644 --- a/schedule/permission.go +++ b/schedule/permission.go @@ -1,34 +1,47 @@ package schedule import ( - "github.com/creativeprojects/clog" "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 (p Permission) String() string { - if p == PermissionSystem { - return constants.SchedulePermissionSystem +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 } - return constants.SchedulePermissionUser } -// 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\" or \"user\"): assuming %q", permission) +func (p Permission) String() string { + switch p { + + case PermissionSystem: + return constants.SchedulePermissionSystem + + case PermissionUserBackground: + return constants.SchedulePermissionUser + + case PermissionUserLoggedOn: + return constants.SchedulePermissionUserLoggedOn + + default: + return constants.SchedulePermissionAuto } - return permission } diff --git a/schedule/permission_test.go b/schedule/permission_test.go deleted file mode 100644 index 083251ce..00000000 --- a/schedule/permission_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package schedule - -import ( - "runtime" - "testing" - - "github.com/creativeprojects/resticprofile/constants" - "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" { - 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) - }) - } -} diff --git a/schedule/permission_unix.go b/schedule/permission_unix.go deleted file mode 100644 index 5967a2c5..00000000 --- a/schedule/permission_unix.go +++ /dev/null @@ -1,51 +0,0 @@ -//go:build !windows - -package schedule - -import ( - "fmt" - "os" - "runtime" - - "github.com/creativeprojects/resticprofile/constants" -) - -// 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 deleted file mode 100644 index 93f0df14..00000000 --- a/schedule/permission_windows.go +++ /dev/null @@ -1,33 +0,0 @@ -//go:build windows - -package schedule - -import ( - "errors" - - "github.com/creativeprojects/resticprofile/constants" -) - -// 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. -// This is always true on Windows -func checkPermission(permission string) bool { - return true -} - -// permissionError is not used in Windows -func permissionError(string) error { - return errors.New("computer says no") -} diff --git a/schedule/schedules.go b/schedule/schedules.go index 582aeafc..7daf73cb 100644 --- a/schedule/schedules.go +++ b/schedule/schedules.go @@ -3,7 +3,6 @@ package schedule import ( "errors" "fmt" - "os/exec" "strings" "time" @@ -48,27 +47,13 @@ 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)) } 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("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 3277a7ca..1c014fef 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" @@ -49,8 +47,8 @@ func TestDisplayParseSchedules(t *testing.T) { assert.Contains(t, output, "Normalized form: *-*-* 00:00:00\n") } -func TestDisplayParseSchedulesIndexAndTotal(t *testing.T) { - events, err := parseSchedules([]string{"daily", "monthly", "yearly"}) +func TestDisplayParseSchedulesWillNeverRun(t *testing.T) { + events, err := parseSchedules([]string{"2020-01-01"}) require.NoError(t, err) buffer := &bytes.Buffer{} @@ -59,38 +57,20 @@ func TestDisplayParseSchedulesIndexAndTotal(t *testing.T) { displayParsedSchedules("profile", "command", events) output := buffer.String() - assert.Contains(t, output, "schedule 1/3") - assert.Contains(t, output, "schedule 2/3") - assert.Contains(t, output, "schedule 3/3") + assert.Contains(t, output, "Next elapse: never\n") } -func TestDisplaySystemdSchedulesWithEmpty(t *testing.T) { - err := displaySystemdSchedules("profile", "command", []string{""}) - require.Error(t, err) -} - -func TestDisplaySystemdSchedules(t *testing.T) { - _, err := exec.LookPath("systemd-analyze") - if err != nil { - t.Skip("systemd-analyze not available") - } +func TestDisplayParseSchedulesIndexAndTotal(t *testing.T) { + events, err := parseSchedules([]string{"daily", "monthly", "yearly"}) + require.NoError(t, err) buffer := &bytes.Buffer{} term.SetOutput(buffer) defer term.SetOutput(os.Stdout) - err = displaySystemdSchedules("profile", "command", []string{"daily"}) - require.NoError(t, err) - + displayParsedSchedules("profile", "command", events) 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) + assert.Contains(t, output, "schedule 1/3") + assert.Contains(t, output, "schedule 2/3") + assert.Contains(t, output, "schedule 3/3") } 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) - } - } -} 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) } diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go index 96439671..0e54f4ad 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,13 +36,15 @@ 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().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( mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("[]*calendar.Event"), - mock.AnythingOfType("string")). - RunAndReturn(func(scheduleConfig *schedule.Config, events []*calendar.Event, permission string) error { + 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()) return nil @@ -59,12 +62,14 @@ 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().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( mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("[]*calendar.Event"), - mock.AnythingOfType("string")). + schedule.PermissionUserBackground). Return(errors.New("error creating job")) scheduleConfig := configForJob("backup", "sched") @@ -89,8 +94,10 @@ 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")). - RunAndReturn(func(scheduleConfig *schedule.Config, user string) error { + handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, 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) assert.Equal(t, "backup", scheduleConfig.CommandName) return nil @@ -107,8 +114,10 @@ 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")). - RunAndReturn(func(scheduleConfig *schedule.Config, user string) error { + handler.EXPECT().DetectSchedulePermission(schedule.PermissionAuto).Return(schedule.PermissionUserBackground, 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) assert.Equal(t, "backup", scheduleConfig.CommandName) return nil @@ -125,7 +134,9 @@ 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().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) + handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). Return(errors.New("error removing job")) scheduleConfig := configForJob("backup", "sched") @@ -139,7 +150,9 @@ 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().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) + handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). Return(schedule.ErrScheduledJobNotFound) scheduleConfig := configForJob("backup", "sched") @@ -153,7 +166,9 @@ 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().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) + handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), schedule.PermissionUserBackground). Return(schedule.ErrScheduledJobNotFound) scheduleConfig := configForJob("backup") @@ -208,14 +223,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 +240,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 +248,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 +261,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 +278,8 @@ 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) + handler.EXPECT().CheckPermission(mock.Anything, tc.permission).Return(true) } err := removeScheduledJobs(handler, tc.fromConfigFile, tc.removeProfileName) @@ -283,15 +300,17 @@ 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) + handler.EXPECT().CheckPermission(mock.Anything, schedule.PermissionUserBackground).Return(true) err := removeScheduledJobs(handler, "configFile", "profile_to_remove") assert.Error(t, err) @@ -378,7 +397,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")) 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) 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 new file mode 100644 index 00000000..7d7575e0 --- /dev/null +++ b/user/user_other.go @@ -0,0 +1,39 @@ +//go:build !windows + +package user + +import ( + "os" + "os/user" + "strconv" +) + +func Current() User { + var username, homedir string + sudoed := false + uid := os.Getuid() + gid := os.Getgid() + 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 { + 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 new file mode 100644 index 00000000..4ca17d12 --- /dev/null +++ b/user/user_test.go @@ -0,0 +1,27 @@ +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) { + user := Current() + assert.Equal(t, os.Geteuid() == 0, user.SudoRoot) + + 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) + } + t.Logf("%+v", user) +} diff --git a/user/user_windows.go b/user/user_windows.go new file mode 100644 index 00000000..b2611e9a --- /dev/null +++ b/user/user_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package user + +import ( + "os/user" +) + +func Current() User { + 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, + } +}