Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions config/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}
Expand Down
5 changes: 3 additions & 2 deletions config/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"`
}
Expand All @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion config/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"`
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
79 changes: 79 additions & 0 deletions config/schedule_after_login_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
61 changes: 61 additions & 0 deletions crond/crontab.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 42 additions & 0 deletions crond/crontab_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 27 additions & 2 deletions crond/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type Entry struct {
commandLine string
workDir string
user string
atReboot bool
}

// NewEntry creates a new crontab entry
Expand All @@ -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)
Expand All @@ -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)
}
Expand Down
17 changes: 17 additions & 0 deletions crond/entry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions darwin/launchd.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ type LaunchdJob struct {
// Month <integer>
// 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
Expand Down
Loading
Loading