diff --git a/config/group.go b/config/group.go index c0c6306e2..c425d0e51 100644 --- a/config/group.go +++ b/config/group.go @@ -26,7 +26,7 @@ func NewGroup(c *Config, name string) (g *Group) { func (g *Group) ResolveConfiguration() { global := g.config.mustGetGlobalSection() for command, cfg := range g.CommandSchedules { - if cfg.HasSchedules() { + if cfg.HasTriggers() { cfg.init(global.ScheduleDefaults) cfg.origin = ScheduleOrigin(g.Name, command, ScheduleOriginGroup) } else { @@ -38,7 +38,7 @@ func (g *Group) ResolveConfiguration() { func (g *Group) Schedules() map[string]*Schedule { schedules := make(map[string]*Schedule) for command, cfg := range g.CommandSchedules { - if cfg.HasSchedules() { + if cfg.HasTriggers() { schedules[command] = NewSchedule(g.config, cfg) } } diff --git a/config/profile.go b/config/profile.go index 64837e9df..93f1f984c 100644 --- a/config/profile.go +++ b/config/profile.go @@ -325,6 +325,7 @@ type ScheduleBaseSection struct { ScheduleIgnoreOnBattery maybe.Bool `mapstructure:"schedule-ignore-on-battery" show:"noshow" default:"false" description:"Don't start this schedule when running on battery"` ScheduleIgnoreOnBatteryLessThan int `mapstructure:"schedule-ignore-on-battery-less-than" show:"noshow" default:"" examples:"20;33;50;75" description:"Don't start this schedule when running on battery and the state of charge is less than this percentage"` ScheduleAfterNetworkOnline maybe.Bool `mapstructure:"schedule-after-network-online" show:"noshow" description:"Don't start this schedule when the network is offline (supported in \"systemd\")"` + ScheduleAfterLogin maybe.Bool `mapstructure:"schedule-after-login" show:"noshow" description:"Start this schedule after the user logs in. Requires the \"user_logged_on\" permission (supported in \"systemd\", \"launchd\", Windows Task Scheduler and as \"@reboot\" in \"crond\")"` ScheduleHideWindow maybe.Bool `mapstructure:"schedule-hide-window" show:"noshow" default:"false" description:"Hide schedule window when running in foreground (Windows only)"` ScheduleStartWhenAvailable maybe.Bool `mapstructure:"schedule-start-when-available" show:"noshow" default:"false" description:"Start the task as soon as possible after a scheduled start is missed (Windows only)"` } @@ -337,12 +338,12 @@ func (s *ScheduleBaseSection) resolve(profile *Profile) { if s == nil || !profile.hasConfig() { return } - if config := newScheduleConfig(profile, s); config.HasSchedules() { + if config := newScheduleConfig(profile, s); config.HasTriggers() { s.scheduleConfig = config } } -func (s *ScheduleBaseSection) HasSchedule() bool { return s.scheduleConfig.HasSchedules() } +func (s *ScheduleBaseSection) HasSchedule() bool { return s.scheduleConfig.HasTriggers() } func (s *ScheduleBaseSection) getScheduleConfig(p *Profile, command string) *ScheduleConfig { if s.scheduleConfig != nil && p != nil { diff --git a/config/schedule.go b/config/schedule.go index 59dce5974..ec572ce3b 100644 --- a/config/schedule.go +++ b/config/schedule.go @@ -45,6 +45,7 @@ type ScheduleBaseConfig struct { IgnoreOnBattery maybe.Bool `mapstructure:"ignore-on-battery" default:"false" description:"Don't start this schedule when running on battery"` IgnoreOnBatteryLessThan int `mapstructure:"ignore-on-battery-less-than" default:"" examples:"20;33;50;75" description:"Don't start this schedule when running on battery and the state of charge is less than this percentage"` AfterNetworkOnline maybe.Bool `mapstructure:"after-network-online" description:"Don't start this schedule when the network is offline (supported in \"systemd\")"` + AfterLogin maybe.Bool `mapstructure:"after-login" description:"Start this schedule after the user logs in. Requires the \"user_logged_on\" permission. Maps to systemd \"OnStartupSec\", launchd \"RunAtLoad\", Windows logon trigger and cron \"@reboot\" (which is at boot, not login)"` SystemdDropInFiles []string `mapstructure:"systemd-drop-in-files" default:"" description:"Files containing systemd drop-in (override) files - see https://creativeprojects.github.io/resticprofile/schedules/systemd/"` HideWindow maybe.Bool `mapstructure:"hide-window" default:"false" description:"Hide schedule window when running in foreground (Windows only)"` StartWhenAvailable maybe.Bool `mapstructure:"start-when-available" default:"false" description:"Start the task as soon as possible after a scheduled start is missed (Windows only)"` @@ -95,6 +96,9 @@ func (s *ScheduleBaseConfig) init(defaults *ScheduleBaseConfig) { if !s.AfterNetworkOnline.HasValue() { s.AfterNetworkOnline = defaults.AfterNetworkOnline } + if !s.AfterLogin.HasValue() { + s.AfterLogin = defaults.AfterLogin + } if s.SystemdDropInFiles == nil { s.SystemdDropInFiles = slices.Clone(defaults.SystemdDropInFiles) } @@ -119,6 +123,7 @@ func (s *ScheduleBaseConfig) applyOverrides(section *ScheduleBaseSection) { s.EnvCapture = slices.Clone(section.ScheduleEnvCapture) s.IgnoreOnBattery = section.ScheduleIgnoreOnBattery s.AfterNetworkOnline = section.ScheduleAfterNetworkOnline + s.AfterLogin = section.ScheduleAfterLogin s.HideWindow = section.ScheduleHideWindow s.StartWhenAvailable = section.ScheduleStartWhenAvailable // re-init with defaults @@ -230,7 +235,7 @@ func newScheduleConfig(profile *Profile, section *ScheduleBaseSection) (s *Sched } // init - if s.HasSchedules() { + if s.HasTriggers() { s.applyOverrides(section) } else { s = nil @@ -257,6 +262,16 @@ func (s *ScheduleConfig) HasSchedules() bool { return len(s.Schedules) > 0 } +// HasTriggers returns true if the schedule has at least one trigger, either a +// calendar time (see HasSchedules) or an event-based trigger such as after-login. +// The func is nil tolerant and returns false for a nil ScheduleConfig. +func (s *ScheduleConfig) HasTriggers() bool { + if s == nil { + return false + } + return s.HasSchedules() || s.AfterLogin.IsTrue() +} + func (s *ScheduleConfig) ScheduleOrigin() ScheduleConfigOrigin { return s.origin } diff --git a/config/schedule_after_login_test.go b/config/schedule_after_login_test.go new file mode 100644 index 000000000..9e98c0f00 --- /dev/null +++ b/config/schedule_after_login_test.go @@ -0,0 +1,79 @@ +package config + +import ( + "strings" + "testing" + + "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/util/maybe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHasTriggers(t *testing.T) { + t.Run("nil is false", func(t *testing.T) { + var sc *ScheduleConfig + assert.False(t, sc.HasTriggers()) + }) + + t.Run("calendar schedule has triggers", func(t *testing.T) { + sc := &ScheduleConfig{Schedules: []string{"daily"}} + assert.True(t, sc.HasSchedules()) + assert.True(t, sc.HasTriggers()) + }) + + t.Run("after-login only has triggers but no schedules", func(t *testing.T) { + sc := &ScheduleConfig{} + sc.AfterLogin = maybe.True() + assert.False(t, sc.HasSchedules()) + assert.True(t, sc.HasTriggers()) + }) + + t.Run("empty has no triggers", func(t *testing.T) { + sc := &ScheduleConfig{} + assert.False(t, sc.HasTriggers()) + }) +} + +func TestAfterLoginScheduleNotDropped(t *testing.T) { + profile := func(t *testing.T, config string) *Profile { + t.Helper() + if !strings.Contains(config, "[default") { + config += "\n[default]" + } + profile, err := getResolvedProfile("toml", config, "default") + require.NoError(t, err) + require.NotNil(t, profile) + return profile + } + + t.Run("struct form with after-login only", func(t *testing.T) { + p := profile(t, ` + [default.backup] + schedule-permission = "user_logged_on" + [default.backup.schedule] + after-login = true + `) + schedules := p.Schedules() + require.Contains(t, schedules, "backup") + sc := schedules["backup"] + assert.True(t, sc.AfterLogin.IsTrue()) + assert.False(t, sc.HasSchedules()) + assert.True(t, sc.HasTriggers()) + assert.Equal(t, constants.SchedulePermissionUserLoggedOn, sc.Permission) + }) + + t.Run("flat schedule-after-login override coexists with at times", func(t *testing.T) { + p := profile(t, ` + [default.backup] + schedule = "daily" + schedule-after-login = true + `) + schedules := p.Schedules() + require.Contains(t, schedules, "backup") + sc := schedules["backup"] + assert.True(t, sc.AfterLogin.IsTrue()) + assert.Equal(t, []string{"daily"}, sc.Schedules) + assert.True(t, sc.HasTriggers()) + }) +} diff --git a/crond/crontab.go b/crond/crontab.go index 5c2f58035..981336460 100644 --- a/crond/crontab.go +++ b/crond/crontab.go @@ -55,9 +55,39 @@ const ( indexCount ) +const rebootExp = `^(@reboot)` + +const ( + rebootIndexWhole int = iota + rebootIndexReboot + rebootIndexUser + rebootIndexWorkdir + rebootIndexCommandLine + rebootIndexConfigFile + rebootIndexProfileName + rebootIndexOtherFlags + rebootIndexCommandName + rebootLegacyIndexCount +) + +const ( + rebootRSIndexWhole int = iota + rebootRSIndexReboot + rebootRSIndexUser + rebootRSIndexWorkdir + rebootRSIndexCommandLine + rebootRSIndexConfigFile + rebootRSIndexCommandName + rebootRSIndexProfileName + rebootRSIndexCount +) + var ( legacyPattern = regexp.MustCompile(timeExp + userExp + workDirExp + legacyExp) runSchedulePattern = regexp.MustCompile(timeExp + userExp + dayOfWeekExtra + workDirExp + runScheduleExp) + + rebootLegacyPattern = regexp.MustCompile(rebootExp + userExp + workDirExp + legacyExp) + rebootRunSchedulePattern = regexp.MustCompile(rebootExp + userExp + workDirExp + runScheduleExp) ) var ( @@ -387,6 +417,37 @@ func parseEntry(line string) (*Entry, error) { // 00,15,30,45 * * * * test $(date '+\%w') -eq 2 || test $(date '+\%w') -eq 3 && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile // 00,15,30,45 * * * * test $(date '+\%w') -eq 1 && cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile + // @reboot entries (the cron equivalent of an "after login" trigger) have no time fields + if strings.HasPrefix(line, "@reboot") { + matches := rebootLegacyPattern.FindStringSubmatch(line) + if len(matches) == rebootLegacyIndexCount { + return &Entry{ + event: calendar.NewEvent(), + atReboot: true, + user: getUserValue(matches[rebootIndexUser]), + workDir: getWorkdirValue(matches[rebootIndexWorkdir]), + commandLine: matches[rebootIndexCommandLine], + configFile: matches[rebootIndexConfigFile], + profileName: matches[rebootIndexProfileName], + commandName: matches[rebootIndexCommandName], + }, nil + } + matches = rebootRunSchedulePattern.FindStringSubmatch(line) + if len(matches) == rebootRSIndexCount { + return &Entry{ + event: calendar.NewEvent(), + atReboot: true, + user: getUserValue(matches[rebootRSIndexUser]), + workDir: getWorkdirValue(matches[rebootRSIndexWorkdir]), + commandLine: matches[rebootRSIndexCommandLine], + configFile: matches[rebootRSIndexConfigFile], + commandName: matches[rebootRSIndexCommandName], + profileName: matches[rebootRSIndexProfileName], + }, nil + } + return nil, ErrEntryNoMatch + } + // try legacy pattern first matches := legacyPattern.FindStringSubmatch(line) if len(matches) == legacyIndexCount { diff --git a/crond/crontab_test.go b/crond/crontab_test.go index b75016339..dd2ce769f 100644 --- a/crond/crontab_test.go +++ b/crond/crontab_test.go @@ -473,6 +473,48 @@ func TestParseEntry(t *testing.T) { } } +func TestParseRebootEntry(t *testing.T) { + testData := []struct { + source string + expectEntry *Entry + }{ + { + source: "@reboot\t/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", atReboot: true, commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, + }, + { + source: "@reboot\tuser\t/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", user: "user", atReboot: true, commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, + }, + { + source: "@reboot\tcd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", atReboot: true, commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, + }, + { + source: "@reboot\t/home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup", + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", atReboot: true, commandLine: "/home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup"}, + }, + } + + for _, testRun := range testData { + t.Run("", func(t *testing.T) { + entry, err := parseEntry(testRun.source) + require.NoError(t, err) + require.NotNil(t, entry) + assert.True(t, entry.AtReboot()) + assert.Equal(t, testRun.expectEntry.CommandLine(), entry.CommandLine()) + assert.Equal(t, testRun.expectEntry.CommandName(), entry.CommandName()) + assert.Equal(t, testRun.expectEntry.ConfigFile(), entry.ConfigFile()) + assert.Equal(t, testRun.expectEntry.ProfileName(), entry.ProfileName()) + assert.Equal(t, testRun.expectEntry.User(), entry.User()) + assert.Equal(t, testRun.expectEntry.WorkDir(), entry.WorkDir()) + + // round-trip: the parsed entry must regenerate the same line + assert.Equal(t, testRun.source+"\n", entry.String()) + }) + } +} + func TestGetEntries(t *testing.T) { fs := afero.NewMemMapFs() file := "/var/spool/cron/crontabs/user" diff --git a/crond/entry.go b/crond/entry.go index bdad7c0f9..06e662962 100644 --- a/crond/entry.go +++ b/crond/entry.go @@ -19,6 +19,7 @@ type Entry struct { commandLine string workDir string user string + atReboot bool } // NewEntry creates a new crontab entry @@ -33,6 +34,23 @@ func NewEntry(event *calendar.Event, configFile, profileName, commandName, comma } } +// NewRebootEntry creates a new crontab entry that runs at startup (the cron "@reboot" special string). +// This is the best-effort equivalent of an "after login" trigger for cron, which has no login event. +func NewRebootEntry(configFile, profileName, commandName, commandLine, workDir string) Entry { + return Entry{ + event: calendar.NewEvent(), + configFile: configFile, + profileName: profileName, + commandName: commandName, + commandLine: commandLine, + workDir: workDir, + atReboot: true, + } +} + +// AtReboot returns true if this entry uses the "@reboot" cron special string +func (e Entry) AtReboot() bool { return e.atReboot } + // WithUser creates a new entry that adds a user that should run the command func (e Entry) WithUser(user string) Entry { e.user = strings.TrimSpace(user) @@ -50,11 +68,18 @@ func (e Entry) String() string { // The day of a command's execution can be specified by two fields — day of month, and day of week. // If both fields are restricted (ie, are not *), the command will be run when either field matches the current time. // For example, "30 4 1,15 * 5" would cause a command to be run at 4:30 am on the 1st and 15th of each month, plus every Friday. - minute, hour, dayOfMonth, month, dayOfWeek := "*", "*", "*", "*", "*" - dayTest, wd := "", "" + wd := "" if e.workDir != "" { wd = fmt.Sprintf("cd %s && ", e.workDir) } + if e.atReboot { + if e.HasUser() && !e.SkipUser() { + return fmt.Sprintf("@reboot\t%s\t%s%s\n", e.user, wd, e.commandLine) + } + return fmt.Sprintf("@reboot\t%s%s\n", wd, e.commandLine) + } + minute, hour, dayOfMonth, month, dayOfWeek := "*", "*", "*", "*", "*" + dayTest := "" if e.event.Minute.HasValue() { minute = formatRange(e.event.Minute.GetRanges(), twoDecimals) } diff --git a/crond/entry_test.go b/crond/entry_test.go index ffb1de36c..c8ec0d3ec 100644 --- a/crond/entry_test.go +++ b/crond/entry_test.go @@ -83,6 +83,23 @@ func TestEvents(t *testing.T) { } } +func TestRebootEntry(t *testing.T) { + entry := NewRebootEntry("config.yaml", "profile", "backup", "command line", "") + assert.True(t, entry.AtReboot()) + assert.Equal(t, "@reboot\tcommand line\n", entry.String()) +} + +func TestRebootEntryWithWorkdirAndUser(t *testing.T) { + entry := NewRebootEntry("config.yaml", "profile", "backup", "command line", "/workdir") + assert.Equal(t, "@reboot\tcd /workdir && command line\n", entry.String()) + + entry = entry.WithUser("root") + assert.Equal(t, "@reboot\troot\tcd /workdir && command line\n", entry.String()) + + entry = entry.WithUser("-") + assert.Equal(t, "@reboot\tcd /workdir && command line\n", entry.String()) +} + func makeCommandLine(extra, commandLine string) string { if extra != "" { return extra + " && " + commandLine diff --git a/darwin/launchd.go b/darwin/launchd.go index 0afc8362b..588d1ccc8 100644 --- a/darwin/launchd.go +++ b/darwin/launchd.go @@ -59,6 +59,9 @@ type LaunchdJob struct { // Month // The month (1-12) on which this job will be run. StartCalendarInterval []CalendarInterval `plist:"StartCalendarInterval,omitempty"` + // This optional key controls whether the job is launched once at the time the job is loaded. + // For a user LaunchAgent this happens when the user logs in, providing an "after login" trigger. + RunAtLoad bool `plist:"RunAtLoad,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 diff --git a/docs/content/schedules/configuration.md b/docs/content/schedules/configuration.md index 60da4d9e2..7c5add0f1 100644 --- a/docs/content/schedules/configuration.md +++ b/docs/content/schedules/configuration.md @@ -209,8 +209,27 @@ For example, if a backup is scheduled for 3:00 AM but the computer is off, enabl Note: This option only works on Windows. +## schedule-after-login + +When set to `true`, the schedule runs after the user logs in, in addition to any `schedule` (`at`) times you may have configured. A schedule can have both calendar times and `after-login`, or `after-login` on its own. + +This option **requires** `schedule-permission` to be set to `user_logged_on`, as it only makes sense for an interactive user session. + +Each scheduler maps it to its native mechanism, and the fidelity of "after login" differs between platforms: + +| Scheduler | Native mechanism | Behaviour | +|------------------------|-------------------------------------------|---------------------------------------------------------| +| launchd (macOS) | `RunAtLoad` on a user LaunchAgent | At login (also runs once when the agent is first loaded) | +| Task Scheduler (Win) | logon trigger | At login | +| systemd | `OnStartupSec=0` on the user timer | When the user session (user manager) starts ≈ login | +| crond | `@reboot` | **At boot, not at login** (cron has no login event) | + +Note: because cron has no concept of a login, `crond` falls back to `@reboot`, which runs once when the cron daemon starts (typically at boot). + ## Example + + Here's an example of a scheduling configuration: {{< tabs groupid="config-with-json" >}} diff --git a/docs/content/schedules/cron.md b/docs/content/schedules/cron.md index e946c003b..b27336c97 100644 --- a/docs/content/schedules/cron.md +++ b/docs/content/schedules/cron.md @@ -6,6 +6,10 @@ weight: 170 On non-Windows OS, use a **crond**-compatible scheduler if specified in `global`/`scheduler`: +{{% notice style="note" title="after-login on cron" %}} +cron has no concept of a user login. When `schedule-after-login` is enabled, resticprofile adds a `@reboot` entry, which runs once when the cron daemon starts (typically at boot) rather than at login. +{{% /notice %}} + {{% notice style="warning" title="Windows No Longer Supported" %}} Crond support on Windows has been removed due to significant issues in previous versions. {{% /notice %}} diff --git a/docs/content/schedules/launchd.md b/docs/content/schedules/launchd.md index e2c5dde17..c7b50e43e 100644 --- a/docs/content/schedules/launchd.md +++ b/docs/content/schedules/launchd.md @@ -5,6 +5,10 @@ weight: 110 `launchd` is the service manager on macOS. resticprofile can schedule a profile using the `launchctl` tool. +## Run after login + +When `schedule-after-login` is enabled (with `schedule-permission: user_logged_on`), the generated user agent sets `RunAtLoad` to `true`, so the profile runs at login. Note that `RunAtLoad` also runs the job once when the agent is first loaded (when the schedule is created), not only on subsequent logins. + ## 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`. diff --git a/docs/content/schedules/systemd.md b/docs/content/schedules/systemd.md index 3e26f2ed3..8398dda52 100644 --- a/docs/content/schedules/systemd.md +++ b/docs/content/schedules/systemd.md @@ -82,6 +82,13 @@ loginctl enable-linger $USER 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. +## Run after login + +Setting the profile option `schedule-after-login: true` (which requires `schedule-permission: user_logged_on`) adds an `OnStartupSec=0` entry to the user timer. Because the timer runs in the per-user systemd manager, it starts when the user session starts, i.e. at login. This can be combined with `schedule` (`OnCalendar`) times. + +Note: if you use a [custom timer template](#how-to-change-the-default-systemd-unit-and-timer-file-using-a-template), add `{{ if .AfterLogin }}OnStartupSec=0{{ end }}` to keep this feature working. + + ## systemd drop-in files 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: @@ -275,6 +282,8 @@ Description={{ .TimerDescription }} {{ range .OnCalendar -}} OnCalendar={{ . }} {{ end -}} +{{ if .AfterLogin }}OnStartupSec=0 +{{ end -}} Unit={{ .SystemdProfile }} Persistent=true @@ -294,3 +303,5 @@ These are available for both the unit and timer templates: * SystemdProfile *string* * Nice *integer* * Environment *array of strings* +* AfterNetworkOnline *boolean* +* AfterLogin *boolean* diff --git a/docs/content/schedules/task_scheduler/index.md b/docs/content/schedules/task_scheduler/index.md index b953ff05b..aee5e1214 100644 --- a/docs/content/schedules/task_scheduler/index.md +++ b/docs/content/schedules/task_scheduler/index.md @@ -47,3 +47,18 @@ profile: ``` This sets the "Start the task as soon as possible after a scheduled start is missed" option in Windows Task Scheduler. + +## Run after login + +Enable `schedule-after-login` (with `schedule-permission: user_logged_on`) to add an "At log on" trigger to the task, so it runs when you log in. It can be combined with `schedule` times. + +```yaml +profile: + backup: + schedule-after-login: true + schedule-permission: user_logged_on +``` + +Note: when the task runs as the system account, the logon trigger applies to any user logging on. + +Note: the registered logon trigger is not reported back by `resticprofile status` because the underlying `schtasks /query` text output does not expose triggers; the task itself works normally. diff --git a/schedule/config.go b/schedule/config.go index 3f8f7abf6..b50f6b13b 100644 --- a/schedule/config.go +++ b/schedule/config.go @@ -23,6 +23,7 @@ type Config struct { ConfigFile string Flags map[string]string // flags added to the command line AfterNetworkOnline bool + AfterLogin bool SystemdDropInFiles []string Log string HideWindow bool diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index 48b9e31fd..b3d7d925b 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -75,17 +75,32 @@ func (h *HandlerCrond) DisplayStatus(profileName string) error { // CreateJob is creating the crontab 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( + if err := checkAfterLoginPermission(job, permission); err != nil { + return err + } + commandLine := job.Command + " " + job.Arguments.String() + entries := make([]crond.Entry, 0, len(schedules)+1) + for _, event := range schedules { + entries = append(entries, crond.NewEntry( event, job.ConfigFile, job.ProfileName, job.CommandName, - job.Command+" "+job.Arguments.String(), + commandLine, job.WorkingDirectory, - ) - + )) + } + if job.AfterLogin { + // cron has no login event: @reboot (at boot) is the documented best-effort equivalent + entries = append(entries, crond.NewRebootEntry( + job.ConfigFile, + job.ProfileName, + job.CommandName, + commandLine, + job.WorkingDirectory, + )) + } + for i := range entries { switch h.config.Username { case "", "-": // empty or "-" => do not set a user; let crond keep entries without a user field @@ -157,20 +172,29 @@ func (h *HandlerCrond) Scheduled(profileName string) ([]Config, error) { if index := slices.IndexFunc(configs, func(cfg Config) bool { return cfg.ProfileName == profileName && cfg.CommandName == commandName && cfg.ConfigFile == configFile }); index >= 0 { - configs[index].Schedules = append(configs[index].Schedules, entry.Event().String()) + if entry.AtReboot() { + configs[index].AfterLogin = true + } else { + configs[index].Schedules = append(configs[index].Schedules, entry.Event().String()) + } } else { commandLine := entry.CommandLine() args := shell.SplitArguments(commandLine) - configs = append(configs, Config{ + cfg := Config{ ProfileName: profileName, CommandName: commandName, ConfigFile: configFile, - Schedules: []string{entry.Event().String()}, Command: args[0], Arguments: NewCommandArguments(args[1:]), WorkingDirectory: entry.WorkDir(), Permission: permission, - }) + } + if entry.AtReboot() { + cfg.AfterLogin = true + } else { + cfg.Schedules = []string{entry.Event().String()} + } + configs = append(configs, cfg) } } return configs, configsErr diff --git a/schedule/handler_crond_test.go b/schedule/handler_crond_test.go index fe49a9fb0..29f6410c3 100644 --- a/schedule/handler_crond_test.go +++ b/schedule/handler_crond_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/creativeprojects/resticprofile/calendar" + "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/crond" "github.com/creativeprojects/resticprofile/user" "github.com/spf13/afero" @@ -93,6 +94,69 @@ func TestCreateReadDeleteCrondSchedules(t *testing.T) { assert.Empty(t, scheduled) } +func TestCreateReadDeleteCrondAfterLogin(t *testing.T) { + hourly := calendar.NewEvent(func(e *calendar.Event) { + e.Minute.MustAddValue(0) + e.Second.MustAddValue(0) + }) + + tempFile := filepath.Join(t.TempDir(), "crontab") + handler := NewHandler(SchedulerCrond{ + CrontabFile: tempFile, + Username: "*", + }).(*HandlerCrond) + handler.fs = afero.NewMemMapFs() + + // after-login only (no calendar schedule) + loginOnly := Config{ + ProfileName: "self", + CommandName: "backup", + Command: "/bin/resticprofile", + Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "run-schedule", "backup@self"}), + WorkingDirectory: "/resticprofile", + ConfigFile: "examples/dev.yaml", + Permission: constants.SchedulePermissionUserLoggedOn, + AfterLogin: true, + } + require.NoError(t, handler.CreateJob(&loginOnly, nil, PermissionUserLoggedOn)) + + // after-login combined with a calendar schedule + both := Config{ + ProfileName: "other", + CommandName: "check", + Command: "/bin/resticprofile", + Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "run-schedule", "check@other"}), + WorkingDirectory: "/resticprofile", + ConfigFile: "examples/dev.yaml", + Schedules: []string{"*-*-* *:00:00"}, + Permission: constants.SchedulePermissionUserLoggedOn, + AfterLogin: true, + } + require.NoError(t, handler.CreateJob(&both, []*calendar.Event{hourly}, PermissionUserLoggedOn)) + + scheduled, err := handler.Scheduled("") + require.NoError(t, err) + require.Len(t, scheduled, 2) + + byName := make(map[string]Config, len(scheduled)) + for _, cfg := range scheduled { + byName[cfg.ProfileName] = cfg + } + + require.Contains(t, byName, "self") + assert.True(t, byName["self"].AfterLogin) + assert.Empty(t, byName["self"].Schedules) + + require.Contains(t, byName, "other") + assert.True(t, byName["other"].AfterLogin) + assert.Equal(t, []string{"*-*-* *:00:00"}, byName["other"].Schedules) + + // after-login requires user_logged_on + require.ErrorContains(t, + handler.CreateJob(&loginOnly, nil, PermissionSystem), + "after-login") +} + func TestDetectPermissionCrond(t *testing.T) { t.Parallel() diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index c832c3b6b..8a981ae4c 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -80,6 +80,9 @@ func (h *HandlerLaunchd) DisplayStatus(profileName string) error { // CreateJob creates a plist file and registers it with launchd func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, permission Permission) error { + if err := checkAfterLoginPermission(job, permission); err != nil { + return err + } exists, err := isServiceRegistered(domainTarget(permission), getJobName(job.ProfileName, job.CommandName)) if err != nil { return fmt.Errorf("error listing service: %w", err) @@ -214,6 +217,7 @@ func (h *HandlerLaunchd) getLaunchdJob(job *Config, schedules []*calendar.Event) StandardErrorPath: logfile, WorkingDirectory: job.WorkingDirectory, StartCalendarInterval: darwin.GetCalendarIntervalsFromSchedules(schedules), + RunAtLoad: job.AfterLogin, EnvironmentVariables: env.ValuesAsMap(), Nice: nice, ProcessType: darwin.NewProcessType(job.GetPriority()), @@ -324,6 +328,7 @@ func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { WorkingDirectory: launchdJob.WorkingDirectory, Schedules: darwin.ParseCalendarIntervals(launchdJob.StartCalendarInterval), Permission: launchdJob.LimitLoadToSessionType.Permission(), + AfterLogin: launchdJob.RunAtLoad, } return job, nil } diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 15c49d5f1..9675004f7 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -68,6 +68,30 @@ func TestLaunchdJobPreservesEnv(t *testing.T) { } } +func TestLaunchdJobAfterLogin(t *testing.T) { + handler := NewHandler(SchedulerLaunchd{}).(*HandlerLaunchd) + + t.Run("after-login sets RunAtLoad", func(t *testing.T) { + cfg := &Config{ProfileName: "t", CommandName: "backup", AfterLogin: true} + launchdJob := handler.getLaunchdJob(cfg, []*calendar.Event{}) + assert.True(t, launchdJob.RunAtLoad) + }) + + t.Run("without after-login RunAtLoad is false", func(t *testing.T) { + cfg := &Config{ProfileName: "t", CommandName: "backup"} + launchdJob := handler.getLaunchdJob(cfg, []*calendar.Event{}) + assert.False(t, launchdJob.RunAtLoad) + }) +} + +func TestCreateJobAfterLoginRejectsNonLoggedOn(t *testing.T) { + handler := NewHandler(SchedulerLaunchd{}).(*HandlerLaunchd) + handler.fs = afero.NewMemMapFs() + job := &Config{ProfileName: "t", CommandName: "backup", AfterLogin: true} + err := handler.CreateJob(job, []*calendar.Event{}, PermissionSystem) + assert.ErrorContains(t, err, "after-login") +} + func TestCreateUserPlist(t *testing.T) { handler := NewHandler(SchedulerLaunchd{}).(*HandlerLaunchd) handler.fs = afero.NewMemMapFs() diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index c57667b5f..56679b6ad 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -129,6 +129,9 @@ func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, per if unitType == systemd.UserUnit && job.AfterNetworkOnline { return fmt.Errorf("after-network-online is not available for \"user_logged_on\" permission schedules") } + if err := checkAfterLoginPermission(job, permission); err != nil { + return err + } timerFile := systemd.GetTimerFile(job.ProfileName, job.CommandName) @@ -169,6 +172,7 @@ func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, per UnitFile: h.config.UnitTemplate, TimerFile: h.config.TimerTemplate, AfterNetworkOnline: job.AfterNetworkOnline, + AfterLogin: job.AfterLogin, DropInFiles: job.SystemdDropInFiles, Nice: h.config.Nice, IOSchedulingClass: h.config.IONiceClass, @@ -575,6 +579,7 @@ func toScheduleConfig(systemdConfig systemd.Config) Config { Permission: systemdConfigPermission(systemdConfig), Schedules: systemdConfig.Schedules, Priority: systemdConfig.Priority, + AfterLogin: systemdConfig.AfterLogin, } return cfg } diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index 54111f703..8e67c1a6d 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -50,6 +50,9 @@ func (h *HandlerWindows) DisplayStatus(profileName string) error { // CreateJob is creating the task scheduler job. func (h *HandlerWindows) CreateJob(job *Config, schedules []*calendar.Event, permission Permission) error { + if err := checkAfterLoginPermission(job, permission); err != nil { + return err + } // default permission will be system perm := schtasks.SystemAccount switch permission { @@ -86,6 +89,7 @@ func (h *HandlerWindows) CreateJob(job *Config, schedules []*calendar.Event, per JobDescription: job.JobDescription, RunLevel: job.RunLevel, StartWhenAvailable: job.StartWhenAvailable, + AfterLogin: job.AfterLogin, } err := schtasks.Create(jobConfig, schedules, perm) if err != nil { diff --git a/schedule/permission.go b/schedule/permission.go index 56d5b4786..3ff9eb186 100644 --- a/schedule/permission.go +++ b/schedule/permission.go @@ -1,6 +1,8 @@ package schedule import ( + "fmt" + "github.com/creativeprojects/resticprofile/constants" ) @@ -29,6 +31,18 @@ func PermissionFromConfig(permission string) Permission { } } +// checkAfterLoginPermission returns an error when the job requests an after-login +// trigger with a permission that cannot be tied to an interactive login session. +// after-login only makes sense for a logged-on user session, so it requires the +// "user_logged_on" permission on every scheduler. +func checkAfterLoginPermission(job *Config, permission Permission) error { + if job.AfterLogin && permission != PermissionUserLoggedOn { + return fmt.Errorf("after-login requires the %q permission, but the schedule resolves to %q", + constants.SchedulePermissionUserLoggedOn, permission.String()) + } + return nil +} + func (p Permission) String() string { switch p { diff --git a/schedule/permission_test.go b/schedule/permission_test.go new file mode 100644 index 000000000..cf5b9e3f3 --- /dev/null +++ b/schedule/permission_test.go @@ -0,0 +1,24 @@ +package schedule + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckAfterLoginPermission(t *testing.T) { + t.Run("no after-login is always allowed", func(t *testing.T) { + for _, p := range []Permission{PermissionAuto, PermissionSystem, PermissionUserBackground, PermissionUserLoggedOn} { + assert.NoError(t, checkAfterLoginPermission(&Config{AfterLogin: false}, p)) + } + }) + + t.Run("after-login requires user_logged_on", func(t *testing.T) { + require.NoError(t, checkAfterLoginPermission(&Config{AfterLogin: true}, PermissionUserLoggedOn)) + + for _, p := range []Permission{PermissionAuto, PermissionSystem, PermissionUserBackground} { + assert.Error(t, checkAfterLoginPermission(&Config{AfterLogin: true}, p), "permission %s should be rejected", p) + } + }) +} diff --git a/schedule_jobs.go b/schedule_jobs.go index ccf750d5c..3db4b5cf5 100644 --- a/schedule_jobs.go +++ b/schedule_jobs.go @@ -217,8 +217,8 @@ func statusScheduledJobs(handler schedule.Handler, configFile, profileName strin func scheduleToConfig(sched *config.Schedule) *schedule.Config { origin := sched.ScheduleOrigin() - if !sched.HasSchedules() { - // there's no schedule defined, so this record is for removal only + if !sched.HasTriggers() { + // there's no schedule or trigger defined, so this record is for removal only return schedule.NewRemoveOnlyConfig(origin.Name, origin.Command) } return &schedule.Config{ @@ -237,6 +237,7 @@ func scheduleToConfig(sched *config.Schedule) *schedule.Config { ConfigFile: sched.ConfigFile, Flags: sched.Flags, AfterNetworkOnline: sched.AfterNetworkOnline.IsTrue(), + AfterLogin: sched.AfterLogin.IsTrue(), SystemdDropInFiles: sched.SystemdDropInFiles, HideWindow: sched.HideWindow.IsTrue(), Log: sched.Log, diff --git a/schtasks/config.go b/schtasks/config.go index dc94d49fa..b38992cd4 100644 --- a/schtasks/config.go +++ b/schtasks/config.go @@ -11,4 +11,5 @@ type Config struct { JobDescription string RunLevel string StartWhenAvailable bool + AfterLogin bool } diff --git a/schtasks/task.go b/schtasks/task.go index 691ad6ce9..e14edf45f 100644 --- a/schtasks/task.go +++ b/schtasks/task.go @@ -127,6 +127,14 @@ func (t *Task) addTimeTrigger(triggerOnce time.Time) { t.Triggers.TimeTrigger = append(t.Triggers.TimeTrigger, timeTrigger) } +// addLogonTrigger adds a trigger that starts the task when the given user logs on. +func (t *Task) addLogonTrigger(userID string) { + logonTrigger := LogonTrigger{ + UserId: userID, + } + t.Triggers.LogonTrigger = append(t.Triggers.LogonTrigger, logonTrigger) +} + func (t *Task) addCalendarTrigger(trigger CalendarTrigger) { if t.Triggers.CalendarTrigger == nil { t.Triggers.CalendarTrigger = []CalendarTrigger{trigger} diff --git a/schtasks/task_test.go b/schtasks/task_test.go index 6489f6e16..5b076252f 100644 --- a/schtasks/task_test.go +++ b/schtasks/task_test.go @@ -3,6 +3,7 @@ package schtasks import ( + "bytes" "testing" "time" @@ -102,3 +103,25 @@ func TestConvertDaysOfMonth(t *testing.T) { }) } } + +func TestAddLogonTrigger(t *testing.T) { + t.Run("for a specific user", func(t *testing.T) { + task := NewTask() + task.addLogonTrigger("S-1-5-21-1234") + require.Len(t, task.Triggers.LogonTrigger, 1) + assert.Equal(t, "S-1-5-21-1234", task.Triggers.LogonTrigger[0].UserId) + + buffer := &bytes.Buffer{} + require.NoError(t, createTaskFile(task, buffer)) + assert.Contains(t, buffer.String(), "") + assert.Contains(t, buffer.String(), "S-1-5-21-1234") + }) + + t.Run("coexists with time triggers", func(t *testing.T) { + task := NewTask() + task.addTimeTrigger(time.Date(2020, 1, 2, 3, 4, 0, 0, time.UTC)) + task.addLogonTrigger("user") + assert.Len(t, task.Triggers.TimeTrigger, 1) + assert.Len(t, task.Triggers.LogonTrigger, 1) + }) +} diff --git a/schtasks/taskscheduler.go b/schtasks/taskscheduler.go index 3a44bea5b..0d0f5f077 100644 --- a/schtasks/taskscheduler.go +++ b/schtasks/taskscheduler.go @@ -89,6 +89,11 @@ func Create(config *Config, schedules []*calendar.Event, permission Permission) } } + if config.AfterLogin { + // add the logon trigger now that the principal (and its UserId) is resolved + task.addLogonTrigger(task.Principals.Principal.UserId) + } + file, err := os.CreateTemp("", "*.xml") if err != nil { return fmt.Errorf("cannot create XML task file: %w", err) diff --git a/schtasks/trigger.go b/schtasks/trigger.go index d28a4d04b..937c6d0d7 100644 --- a/schtasks/trigger.go +++ b/schtasks/trigger.go @@ -11,6 +11,16 @@ import ( type Triggers struct { CalendarTrigger []CalendarTrigger `xml:"CalendarTrigger,omitempty"` TimeTrigger []TimeTrigger `xml:"TimeTrigger,omitempty"` + LogonTrigger []LogonTrigger `xml:"LogonTrigger,omitempty"` +} + +// LogonTrigger starts the task when a user logs on. +// When UserId is empty the task triggers for any user logon, otherwise only for the specified user. +type LogonTrigger struct { + Enabled *bool `xml:"Enabled"` + UserId string `xml:"UserId,omitempty"` + Delay *period.Period `xml:"Delay,omitempty"` + StartBoundary *time.Time `xml:"StartBoundary,omitempty"` } type TimeTrigger struct { diff --git a/systemd/generate.go b/systemd/generate.go index 1277497b4..8a9933cc0 100644 --- a/systemd/generate.go +++ b/systemd/generate.go @@ -54,6 +54,8 @@ Description={{ .TimerDescription }} {{ range .OnCalendar -}} OnCalendar={{ . }} {{ end -}} +{{ if .AfterLogin }}OnStartupSec=0 +{{ end -}} Unit={{ .SystemdProfile }} Persistent=true @@ -84,6 +86,7 @@ type templateInfo struct { Nice int Environment []string AfterNetworkOnline bool + AfterLogin bool CPUSchedulingPolicy string IOSchedulingClass int IOSchedulingPriority int @@ -106,6 +109,7 @@ type Config struct { TimerFile string DropInFiles []string AfterNetworkOnline bool + AfterLogin bool Nice int CPUSchedulingPolicy string IOSchedulingClass int @@ -165,6 +169,7 @@ func (u Unit) Generate(config Config) error { CommandLine: config.CommandLine, OnCalendar: config.Schedules, AfterNetworkOnline: config.AfterNetworkOnline, + AfterLogin: config.AfterLogin, SystemdProfile: systemdProfile, Nice: config.Nice, Environment: environment, diff --git a/systemd/generate_test.go b/systemd/generate_test.go index 57998161f..9219f521c 100644 --- a/systemd/generate_test.go +++ b/systemd/generate_test.go @@ -90,6 +90,79 @@ Environment="HOME=%s" assert.Equal(t, fmt.Sprintf(expectedService, testSudoUser.UserHomeDir), string(service)) } +func TestGenerateUserUnitAfterLogin(t *testing.T) { + const expectedTimer = `[Unit] +Description=timer description + +[Timer] +OnCalendar=daily +OnStartupSec=0 +Unit=resticprofile-backup@profile-name.service +Persistent=true + +[Install] +WantedBy=timers.target +` + t.Parallel() + fs := afero.NewMemMapFs() + + systemdUserDir := filepath.Join(testStandardUser.UserHomeDir, ".config", "systemd", "user") + timerFile := filepath.Join(systemdUserDir, "resticprofile-backup@profile-name.timer") + + err := Unit{fs: fs, user: testStandardUser}.Generate(Config{ + CommandLine: "commandLine", + WorkingDirectory: "workdir", + Title: "name", + SubTitle: "backup", + JobDescription: "job description", + TimerDescription: "timer description", + Schedules: []string{"daily"}, + UnitType: UserUnit, + AfterLogin: true, + }) + require.NoError(t, err) + + timer, err := afero.ReadFile(fs, timerFile) + require.NoError(t, err) + assert.Equal(t, expectedTimer, string(timer)) +} + +func TestGenerateUserUnitAfterLoginOnly(t *testing.T) { + // a monotonic-only timer (OnStartupSec without OnCalendar) must be valid + const expectedTimer = `[Unit] +Description=timer description + +[Timer] +OnStartupSec=0 +Unit=resticprofile-backup@profile-name.service +Persistent=true + +[Install] +WantedBy=timers.target +` + t.Parallel() + fs := afero.NewMemMapFs() + + systemdUserDir := filepath.Join(testStandardUser.UserHomeDir, ".config", "systemd", "user") + timerFile := filepath.Join(systemdUserDir, "resticprofile-backup@profile-name.timer") + + err := Unit{fs: fs, user: testStandardUser}.Generate(Config{ + CommandLine: "commandLine", + WorkingDirectory: "workdir", + Title: "name", + SubTitle: "backup", + JobDescription: "job description", + TimerDescription: "timer description", + UnitType: UserUnit, + AfterLogin: true, + }) + require.NoError(t, err) + + timer, err := afero.ReadFile(fs, timerFile) + require.NoError(t, err) + assert.Equal(t, expectedTimer, string(timer)) +} + func TestGenerateUserUnit(t *testing.T) { const expectedService = `[Unit] Description=job description diff --git a/systemd/read.go b/systemd/read.go index 5abb5d96d..937187447 100644 --- a/systemd/read.go +++ b/systemd/read.go @@ -54,6 +54,7 @@ func (u Unit) Read(unit string, unitType UnitType) (*Config, error) { Schedules: getValues(timerSections, "Timer", "OnCalendar"), Priority: getPriority(getSingleValue(serviceSections, "Service", "CPUSchedulingPolicy")), User: getSingleValue(serviceSections, "Service", "User"), + AfterLogin: hasValue(timerSections, "Timer", "OnStartupSec"), } return cfg, nil } @@ -115,6 +116,11 @@ func getSingleValue(from map[string]map[string][]string, section, key string) st return "" } +// hasValue returns true if the given key exists in the section with at least one value. +func hasValue(from map[string]map[string][]string, section, key string) bool { + return len(getValues(from, section, key)) > 0 +} + func getValues(from map[string]map[string][]string, section, key string) []string { if section, found := from[section]; found { if values, found := section[key]; found { diff --git a/systemd/read_test.go b/systemd/read_test.go index a505b056b..fad4edf53 100644 --- a/systemd/read_test.go +++ b/systemd/read_test.go @@ -42,6 +42,34 @@ Persistent=true WantedBy=timers.target` ) +func TestReadUnitFileAfterLogin(t *testing.T) { + t.Parallel() + + const timerWithStartup = `[Unit] +Description=copy timer for profile self in examples/linux.yaml + +[Timer] +OnStartupSec=0 +Unit=resticprofile-copy@profile-self.service +Persistent=true + +[Install] +WantedBy=timers.target` + + fs := afero.NewMemMapFs() + unitFile := "resticprofile-copy@profile-self.service" + timerFile := "resticprofile-copy@profile-self.timer" + require.NoError(t, afero.WriteFile(fs, path.Join(systemdSystemDir, unitFile), []byte(testServiceUnit), 0o600)) + require.NoError(t, afero.WriteFile(fs, path.Join(systemdSystemDir, timerFile), []byte(timerWithStartup), 0o600)) + + unit := Unit{fs: fs, user: testSudoUser} + cfg, err := unit.Read(unitFile, SystemUnit) + require.NoError(t, err) + require.NotNil(t, cfg) + assert.True(t, cfg.AfterLogin) + assert.Empty(t, cfg.Schedules) +} + func TestReadUnitFile(t *testing.T) { t.Parallel()