From 5b34d371f0da554765be12edb957faba6f9eefae Mon Sep 17 00:00:00 2001 From: Juergen Kellerer Date: Sun, 3 Mar 2024 19:55:33 +0100 Subject: [PATCH 1/5] schedule: added unified schedule config struct --- commands.go | 48 +++-- commands_test.go | 36 ++-- config/confidential.go | 11 ++ config/confidential_test.go | 30 +++ config/config.go | 102 +++++----- config/config_group_test.go | 6 +- config/config_schedule_test.go | 58 ------ config/config_test.go | 5 +- config/config_v1.go | 33 +--- config/global.go | 49 ++--- config/group.go | 36 +++- config/info.go | 16 +- config/info_customizer.go | 35 +++- config/jsonschema/schema.go | 35 +++- config/jsonschema/schema_test.go | 33 ++-- config/mocks/PropertyInfo.go | 45 +++++ config/profile.go | 126 +++++------- config/profile_test.go | 15 +- config/schedule.go | 328 ++++++++++++++++++++++++++++--- config/schedule_test.go | 175 ++++++++++++++--- constants/section.go | 2 +- main.go | 9 +- schedule_jobs.go | 11 +- schedule_jobs_test.go | 56 ++---- util/maybe/bool.go | 8 - util/maybe/bool_test.go | 16 ++ util/maybe/duration.go | 44 +++++ util/maybe/duration_test.go | 128 ++++++++++++ util/maybe/optional.go | 8 + 29 files changed, 1060 insertions(+), 444 deletions(-) delete mode 100644 config/config_schedule_test.go create mode 100644 util/maybe/duration.go create mode 100644 util/maybe/duration_test.go diff --git a/commands.go b/commands.go index 8cf5adce7..bea2b70a4 100644 --- a/commands.go +++ b/commands.go @@ -24,6 +24,7 @@ import ( "github.com/creativeprojects/resticprofile/term" "github.com/creativeprojects/resticprofile/util/templates" "github.com/creativeprojects/resticprofile/win" + "golang.org/x/exp/maps" ) var ( @@ -330,7 +331,7 @@ func showProfile(output io.Writer, ctx commandContext) error { _, _ = fmt.Fprintln(output) // Show schedules - showSchedules(output, profile.Schedules()) + showSchedules(output, maps.Values(profile.Schedules())) // Show deprecation notice displayProfileDeprecationNotices(profile) @@ -342,12 +343,13 @@ func showProfile(output io.Writer, ctx commandContext) error { } func showSchedules(output io.Writer, schedules []*config.Schedule) { + slices.SortFunc(schedules, config.CompareSchedules) for _, schedule := range schedules { - err := config.ShowStruct(output, schedule, "schedule "+schedule.CommandName+"@"+schedule.Profiles[0]) + err := config.ShowStruct(output, schedule.ScheduleConfig, fmt.Sprintf("schedule %s", schedule.ScheduleOrigin())) if err != nil { - fmt.Fprintln(output, err) + _, _ = fmt.Fprintln(output, err) } - fmt.Fprintln(output, "") + _, _ = fmt.Fprintln(output, "") } } @@ -552,7 +554,7 @@ func getScheduleJobs(c *config.Config, flags commandLineFlags) (schedule.Schedul return nil, nil, nil, fmt.Errorf("cannot load profile '%s': %w", flags.name, err) } - return schedule.NewSchedulerConfig(global), profile, profile.Schedules(), nil + return schedule.NewSchedulerConfig(global), profile, maps.Values(profile.Schedules()), nil } func requireScheduleJobs(schedules []*config.Schedule, flags commandLineFlags) error { @@ -572,12 +574,13 @@ func getRemovableScheduleJobs(c *config.Config, flags commandLineFlags) (schedul for _, command := range profile.SchedulableCommands() { declared := false for _, s := range schedules { - if declared = s.CommandName == command; declared { + if declared = s.ScheduleOrigin().Command == command; declared { break } } if !declared { - schedules = append(schedules, config.NewEmptySchedule(profile.Name, command)) + origin := config.ScheduleOrigin(profile.Name, command) + schedules = append(schedules, config.NewDefaultSchedule(c, origin)) } } @@ -609,13 +612,8 @@ func preRunSchedule(ctx *Context) error { return fmt.Errorf("cannot load profile '%s': %w", profileName, err) } // get the list of all scheduled commands to find the current command - schedules := profile.Schedules() - for _, schedule := range schedules { - if schedule.CommandName == ctx.command { - ctx.schedule = schedule - prepareScheduledProfile(ctx) - break - } + if ctx.schedule, ok = profile.Schedules()[ctx.command]; ok { + prepareScheduledProfile(ctx) } } return nil @@ -623,25 +621,23 @@ func preRunSchedule(ctx *Context) error { func prepareScheduledProfile(ctx *Context) { clog.Debugf("preparing scheduled profile %q", ctx.request.schedule) + s := ctx.schedule // log file - if len(ctx.schedule.Log) > 0 { - ctx.logTarget = ctx.schedule.Log + if len(s.Log) > 0 { + ctx.logTarget = s.Log } // battery - if ctx.schedule.IgnoreOnBatteryLessThan > 0 { - ctx.stopOnBattery = ctx.schedule.IgnoreOnBatteryLessThan - } else if ctx.schedule.IgnoreOnBattery { + if s.IgnoreOnBatteryLessThan > 0 && !s.IgnoreOnBattery.IsStrictlyFalse() { + ctx.stopOnBattery = s.IgnoreOnBatteryLessThan + } else if s.IgnoreOnBattery.IsTrue() { ctx.stopOnBattery = 100 } // lock - if ctx.schedule.GetLockWait() > 0 { - ctx.lockWait = ctx.schedule.LockWait - } - if ctx.schedule.GetLockMode() == config.ScheduleLockModeDefault { - if ctx.schedule.GetLockWait() > 0 { - ctx.lockWait = ctx.schedule.GetLockWait() + if s.GetLockMode() == config.ScheduleLockModeDefault { + if duration := s.GetLockWait(); duration > 0 { + ctx.lockWait = duration } - } else if ctx.schedule.GetLockMode() == config.ScheduleLockModeIgnore { + } else if s.GetLockMode() == config.ScheduleLockModeIgnore { ctx.noLock = true } } diff --git a/commands_test.go b/commands_test.go index 59136611f..f40fea459 100644 --- a/commands_test.go +++ b/commands_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/creativeprojects/resticprofile/config" + "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/schedule" "github.com/creativeprojects/resticprofile/util/collect" "github.com/stretchr/testify/assert" @@ -64,10 +65,11 @@ schedule = "daily" declaredCount := 0 for _, jobConfig := range schedules { - scheduler := schedule.NewScheduler(schedule.NewHandler(schedule.SchedulerDefaultOS{}), jobConfig.Profiles[0]) + configOrigin := jobConfig.ScheduleOrigin() + scheduler := schedule.NewScheduler(schedule.NewHandler(schedule.SchedulerDefaultOS{}), configOrigin.Name) defer func(s *schedule.Scheduler) { s.Close() }(scheduler) // Capture current ref to scheduler to be able to close it when function returns. - if jobConfig.CommandName == "check" { + if configOrigin.Command == constants.CommandCheck { assert.False(t, scheduler.NewJob(scheduleToConfig(jobConfig)).RemoveOnly()) declaredCount++ } else { @@ -114,7 +116,7 @@ schedule = "daily" assert.NotNil(t, profile) assert.NotEmpty(t, schedules) assert.Len(t, schedules, 1) - assert.Equal(t, "check", schedules[0].CommandName) + assert.Equal(t, "check", schedules[0].ScheduleOrigin().Command) } } @@ -274,28 +276,20 @@ func TestGenerateCommand(t *testing.T) { func TestShowSchedules(t *testing.T) { buffer := &bytes.Buffer{} + create := func(command string, at ...string) *config.Schedule { + origin := config.ScheduleOrigin("default", command) + return config.NewDefaultSchedule(nil, origin, at...) + } schedules := []*config.Schedule{ - { - Profiles: []string{"default"}, - CommandName: "check", - Schedules: []string{"weekly"}, - }, - { - Profiles: []string{"default"}, - CommandName: "backup", - Schedules: []string{"daily"}, - }, + create("check", "weekly"), + create("backup", "daily"), } expected := strings.TrimSpace(` -schedule check@default: - run: check - profiles: default - schedule: weekly - schedule backup@default: - run: backup - profiles: default - schedule: daily + at: daily + +schedule check@default: + at: weekly `) showSchedules(buffer, schedules) diff --git a/config/confidential.go b/config/confidential.go index f7ca6bceb..6f96d8ae6 100644 --- a/config/confidential.go +++ b/config/confidential.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "reflect" "regexp" @@ -25,6 +26,16 @@ func (c ConfidentialValue) String() string { return c.public } +func (c *ConfidentialValue) UnmarshalJSON(data []byte) (err error) { + err = json.Unmarshal(data, &c.confidential) + c.public = c.confidential + return +} + +func (c ConfidentialValue) MarshalJSON() ([]byte, error) { + return json.Marshal(c.Value()) +} + func (c *ConfidentialValue) IsConfidential() bool { return c.public != c.confidential } diff --git a/config/confidential_test.go b/config/confidential_test.go index 8acc2a0b7..26806ed91 100644 --- a/config/confidential_test.go +++ b/config/confidential_test.go @@ -2,6 +2,7 @@ package config import ( "bytes" + "encoding/json" "fmt" "reflect" "regexp" @@ -276,3 +277,32 @@ profile: assert.Equal(t, []string{"--repo=" + expectedSecret}, args.GetAll()) assert.Equal(t, []string{"--repo=" + expectedPublic}, result.GetAll()) } + +func TestConfidentialToJSON(t *testing.T) { + t.Run("marshal", func(t *testing.T) { + value := NewConfidentialValue("plain") + assert.False(t, value.IsConfidential()) + + binary, _ := json.Marshal(value) + assert.Equal(t, `"plain"`, string(binary)) + + value.hideValue() + assert.True(t, value.IsConfidential()) + + binary, _ = json.Marshal(value) + assert.Equal(t, `"plain"`, string(binary)) + }) + + t.Run("unmarshal", func(t *testing.T) { + value := NewConfidentialValue("") + value.hideValue() + assert.True(t, value.IsConfidential()) + + assert.NoError(t, json.Unmarshal([]byte(`"plain"`), &value)) + + // the confidential state is not marshalled for now + assert.False(t, value.IsConfidential()) + assert.Equal(t, "plain", value.Value()) + assert.Equal(t, "plain", value.String()) + }) +} diff --git a/config/config.go b/config/config.go index 22b66d22d..7c7c0ef6d 100644 --- a/config/config.go +++ b/config/config.go @@ -35,19 +35,23 @@ type Config struct { viper *viper.Viper mixinUses []map[string][]*mixinUse mixins map[string]*mixin - groups map[string]Group sourceTemplates *template.Template version Version issues struct { changedPaths map[string][]string // 'path' items that had been changed to absolute paths failedSection map[string]error // profile sections that failed to get parsed or resolved } + cached struct { + groups map[string]*Group + global *Global + } } var ( configOption = viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), maybe.BoolDecoder(), + maybe.DurationDecoder(), confidentialValueDecoder(), )) @@ -316,6 +320,10 @@ func (c *Config) reloadTemplates(data TemplateData) error { err = c.applyNonProfileMixins() } + // clear cached items + c.cached.groups = nil + c.cached.global = nil + return err } @@ -472,15 +480,25 @@ func (c *Config) GetGlobalSection() (*Global, error) { // So we need to fix all relative files rootPath := filepath.Dir(c.GetConfigFile()) if rootPath != "." { - rootPathMessage.Do(func() { - clog.Debugf("files in configuration are relative to %q", rootPath) - }) + rootPathMessage.Do(func() { clog.Debugf("files in configuration are relative to %q", rootPath) }) } global.SetRootPath(rootPath) return global, nil } +// mustGetGlobalSection returns a cached global configuration, panics if it can't be loaded (is for internal use) +func (c *Config) mustGetGlobalSection() *Global { + if c.cached.global == nil { + var err error + c.cached.global, err = c.GetGlobalSection() + if err != nil { + panic(fmt.Errorf("MustGetGlobalSection: %w", err)) + } + } + return c.cached.global +} + // HasProfileGroup returns true if the group of profiles exists in the configuration func (c *Config) HasProfileGroup(groupKey string) bool { if !c.IsSet(constants.SectionConfigurationGroups) { @@ -489,7 +507,7 @@ func (c *Config) HasProfileGroup(groupKey string) bool { if err := c.loadGroups(); err != nil { return false } - _, ok := c.groups[groupKey] + _, ok := c.cached.groups[groupKey] return ok } @@ -499,21 +517,35 @@ func (c *Config) GetProfileGroup(groupKey string) (*Group, error) { return nil, err } - group, ok := c.groups[groupKey] + group, ok := c.cached.groups[groupKey] if !ok { return nil, fmt.Errorf("group '%s' not found", groupKey) } - return &group, nil + return group, nil } // GetProfileGroups returns all groups from the configuration // // If the groups section does not exist, it returns an empty map -func (c *Config) GetProfileGroups() map[string]Group { +func (c *Config) GetProfileGroups() map[string]*Group { if err := c.loadGroups(); err != nil { clog.Errorf("failed loading groups: %s", err.Error()) } - return c.groups + return maps.Clone(c.cached.groups) +} + +func (c *Config) GetGroupNames() (names []string) { + if c.GetVersion() <= Version01 { + _ = c.loadGroupsV1() + names = maps.Keys(c.cached.groups) + } else { + if groups := c.viper.Sub(constants.SectionConfigurationGroups); groups != nil { + for name := range groups.AllSettings() { + names = append(names, name) + } + } + } + return } func (c *Config) loadGroups() (err error) { @@ -521,13 +553,14 @@ func (c *Config) loadGroups() (err error) { return c.loadGroupsV1() } - if c.groups == nil { - c.groups = map[string]Group{} - - if c.IsSet(constants.SectionConfigurationGroups) { - groups := map[string]Group{} - if err = c.unmarshalKey(constants.SectionConfigurationGroups, &groups); err == nil { - c.groups = groups + if c.cached.groups == nil { + c.cached.groups = make(map[string]*Group) + for _, name := range c.GetGroupNames() { + group := NewGroup(c, name) + err = c.unmarshalKey(c.flatKey(constants.SectionConfigurationGroups, name), group) + if err == nil { + group.ResolveConfiguration() + c.cached.groups[name] = group } } } @@ -668,43 +701,6 @@ func (c *Config) getProfilePath(key string) string { return c.flatKey(constants.SectionConfigurationProfiles, key) } -// GetSchedules loads all schedules from the configuration. -func (c *Config) GetSchedules() ([]*Schedule, error) { - if c.GetVersion() <= Version01 { - return c.getSchedulesV1() - } - return nil, nil -} - -// GetScheduleSections returns a list of schedules -func (c *Config) GetScheduleSections() (schedules map[string]Schedule, err error) { - c.requireMinVersion(Version02) - - schedules = map[string]Schedule{} - - if section := c.viper.Sub(constants.SectionConfigurationSchedules); section != nil { - for sectionKey := range section.AllSettings() { - var schedule Schedule - schedule, err = c.getSchedule(sectionKey) - if err != nil { - break - } - schedules[sectionKey] = schedule - } - } - - return -} - -func (c *Config) getSchedule(key string) (Schedule, error) { - schedule := Schedule{} - err := c.unmarshalKey(c.flatKey(constants.SectionConfigurationSchedules, key), &schedule) - if err != nil { - return schedule, err - } - return schedule, nil -} - // unmarshalConfig returns the decoder config options depending on the configuration version and format func (c *Config) unmarshalConfig() viper.DecoderConfigOption { if c.GetVersion() == Version01 { diff --git a/config/config_group_test.go b/config/config_group_test.go index 24be79bd9..f0f4d1265 100644 --- a/config/config_group_test.go +++ b/config/config_group_test.go @@ -212,7 +212,11 @@ groups: group, err := c.GetProfileGroup("test") require.NoError(t, err) - assert.Equal(t, &Group{Profiles: []string{"first", "second", "third"}}, group) + assert.Equal(t, &Group{ + config: c, + Name: "test", + Profiles: []string{"first", "second", "third"}, + }, group) _, err = c.GetProfileGroup("my-group") assert.Error(t, err) diff --git a/config/config_schedule_test.go b/config/config_schedule_test.go deleted file mode 100644 index a3a66a829..000000000 --- a/config/config_schedule_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package config - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetScheduleSections(t *testing.T) { - testData := []testTemplate{ - {FormatTOML, ` -version = 2 -[schedules] -[schedules.sname] -profiles="value" -schedule="daily" -`}, - {FormatJSON, ` -{ - "version": 2, - "schedules": { - "sname": { - "profiles": "value", - "schedule": "daily" - } - } -}`}, - {FormatYAML, `--- -version: 2 -schedules: - sname: - profiles: value - schedule: daily -`}, - } - - for _, testItem := range testData { - format := testItem.format - testConfig := testItem.config - t.Run(format, func(t *testing.T) { - c, err := Load(bytes.NewBufferString(testConfig), format) - require.NoError(t, err) - - schedules, err := c.GetScheduleSections() - require.NoError(t, err) - assert.NotEmpty(t, schedules) - assert.Equal(t, []string{"value"}, schedules["sname"].Profiles) - assert.Equal(t, []string{"daily"}, schedules["sname"].Schedules) - }) - } -} - -func TestGetScheduleSectionsOnV1(t *testing.T) { - c := newConfig("toml") - assert.Panics(t, func() { c.GetScheduleSections() }) -} diff --git a/config/config_test.go b/config/config_test.go index 0eafc5d72..4ac352ab0 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -610,11 +610,12 @@ profile: require.NoError(t, err) assert.NotNil(t, cfg) - schedules, err := cfg.GetSchedules() + profile, err := cfg.GetProfile("profile") + schedules := profile.Schedules() require.NoError(t, err) assert.Len(t, schedules, 1) - assert.Equal(t, []string{"daily"}, schedules[0].Schedules) + assert.Equal(t, []string{"daily"}, schedules["backup"].Schedules) } func TestRegressionInheritanceListMerging(t *testing.T) { diff --git a/config/config_v1.go b/config/config_v1.go index 81864a2ff..f611c79ed 100644 --- a/config/config_v1.go +++ b/config/config_v1.go @@ -30,12 +30,14 @@ var ( configOptionV1 = viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), maybe.BoolDecoder(), + maybe.DurationDecoder(), confidentialValueDecoder(), )) configOptionV1HCL = viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), maybe.BoolDecoder(), + maybe.DurationDecoder(), confidentialValueDecoder(), sliceOfMapsToMapHookFunc(), )) @@ -62,19 +64,18 @@ func (c *Config) getProfileNamesV1() (names []string) { func (c *Config) loadGroupsV1() (err error) { c.requireVersion(Version01) - if c.groups == nil { - c.groups = map[string]Group{} + if c.cached.groups == nil { + c.cached.groups = make(map[string]*Group) if c.IsSet(constants.SectionConfigurationGroups) { groups := map[string][]string{} if err = c.unmarshalKey(constants.SectionConfigurationGroups, &groups); err == nil { // fits previous version into new structure for groupName, group := range groups { - c.groups[groupName] = Group{ - Description: "", - Profiles: group, - ContinueOnError: maybe.Bool{}, - } + g := NewGroup(c, groupName) + g.Profiles = group + g.ResolveConfiguration() + c.cached.groups[groupName] = g } } } @@ -121,24 +122,6 @@ func (c *Config) getProfileV1(profileKey string) (profile *Profile, err error) { return profile, nil } -// getSchedulesV1 loads schedules from profiles -func (c *Config) getSchedulesV1() ([]*Schedule, error) { - profiles := c.GetProfileNames() - if len(profiles) == 0 { - return nil, nil - } - schedules := []*Schedule{} - for _, profileName := range profiles { - profile, err := c.GetProfile(profileName) - if err != nil { - return nil, fmt.Errorf("cannot load profile %q: %w", profileName, err) - } - profileSchedules := profile.Schedules() - schedules = append(schedules, profileSchedules...) - } - return schedules, nil -} - // unmarshalConfigV1 returns the viper.DecoderConfigOption to use for V1 configuration files func (c *Config) unmarshalConfigV1() viper.DecoderConfigOption { c.requireVersion(Version01) diff --git a/config/global.go b/config/global.go index 7e8b4db5f..e746bdc88 100644 --- a/config/global.go +++ b/config/global.go @@ -8,29 +8,31 @@ import ( // Global holds the configuration from the global section type Global struct { - IONice bool `mapstructure:"ionice" default:"false" description:"Enables setting the unix IO priority class and level for resticprofile and child processes (only on unix OS)."` - IONiceClass int `mapstructure:"ionice-class" default:"2" range:"[1:3]" description:"Sets the unix \"ionice-class\" to apply when \"ionice\" is enabled"` - IONiceLevel int `mapstructure:"ionice-level" default:"0" range:"[0:7]" description:"Sets the unix \"ionice-level\" to apply when \"ionice\" is enabled"` - Nice int `mapstructure:"nice" default:"0" range:"[-20:19]" description:"Sets the unix \"nice\" value for resticprofile and child processes (on any OS)"` - Priority string `mapstructure:"priority" default:"normal" enum:"idle;background;low;normal;high;highest" description:"Sets process priority class for resticprofile and child processes (on any OS)"` - DefaultCommand string `mapstructure:"default-command" default:"snapshots" description:"The restic or resticprofile command to use when no command was specified"` - Initialize bool `mapstructure:"initialize" default:"false" description:"Initialize a repository if missing"` - ResticBinary string `mapstructure:"restic-binary" description:"Full path of the restic executable (detected if not set)"` - ResticVersion string // not configurable at the moment. To be set after ResticBinary is known. - FilterResticFlags bool `mapstructure:"restic-arguments-filter" default:"true" description:"Remove unknown flags instead of passing all configured flags to restic"` - ResticLockRetryAfter time.Duration `mapstructure:"restic-lock-retry-after" default:"1m" description:"Time to wait before trying to get a lock on a restic repositoey - see https://creativeprojects.github.io/resticprofile/usage/locks/"` - ResticStaleLockAge time.Duration `mapstructure:"restic-stale-lock-age" default:"1h" description:"The age an unused lock on a restic repository must have at least before resiticprofile attempts to unlock - see https://creativeprojects.github.io/resticprofile/usage/locks/"` - ShellBinary []string `mapstructure:"shell" default:"auto" examples:"sh;bash;pwsh;powershell;cmd" description:"The shell that is used to run commands (default is OS specific)"` - MinMemory uint64 `mapstructure:"min-memory" default:"100" description:"Minimum available memory (in MB) required to run any commands - see https://creativeprojects.github.io/resticprofile/usage/memory/"` - Scheduler string `mapstructure:"scheduler" description:"Leave blank for the default scheduler or use \"crond\" to select cron on supported operating systems"` - Log string `mapstructure:"log" default:"" description:"Sets the default log destination to be used if not specified in '--log' or 'schedule-log' - see https://creativeprojects.github.io/resticprofile/configuration/logs/"` - LegacyArguments bool `mapstructure:"legacy-arguments" default:"false" deprecated:"0.20.0" description:"Legacy, broken arguments mode of resticprofile before version 0.15"` - SystemdUnitTemplate string `mapstructure:"systemd-unit-template" default:"" description:"File containing the go template to generate a systemd unit - see https://creativeprojects.github.io/resticprofile/schedules/systemd/"` - SystemdTimerTemplate string `mapstructure:"systemd-timer-template" default:"" description:"File containing the go template to generate a systemd timer - see https://creativeprojects.github.io/resticprofile/schedules/systemd/"` - SenderTimeout time.Duration `mapstructure:"send-timeout" default:"30s" examples:"15s;30s;2m30s" description:"Timeout when sending messages to a webhook - see https://creativeprojects.github.io/resticprofile/configuration/http_hooks/"` - CACertificates []string `mapstructure:"ca-certificates" description:"Path to PEM encoded certificates to trust in addition to system certificates when resticprofile sends to a webhook - see https://creativeprojects.github.io/resticprofile/configuration/http_hooks/"` - PreventSleep bool `mapstructure:"prevent-sleep" default:"false" description:"Prevent the system from sleeping while running commands - see https://creativeprojects.github.io/resticprofile/configuration/sleep/"` - GroupContinueOnError bool `mapstructure:"group-continue-on-error" default:"false" description:"Enable groups to continue with the next profile(s) instead of stopping at the first failure"` + IONice bool `mapstructure:"ionice" default:"false" description:"Enables setting the unix IO priority class and level for resticprofile and child processes (only on unix OS)."` + IONiceClass int `mapstructure:"ionice-class" default:"2" range:"[1:3]" description:"Sets the unix \"ionice-class\" to apply when \"ionice\" is enabled"` + IONiceLevel int `mapstructure:"ionice-level" default:"0" range:"[0:7]" description:"Sets the unix \"ionice-level\" to apply when \"ionice\" is enabled"` + Nice int `mapstructure:"nice" default:"0" range:"[-20:19]" description:"Sets the unix \"nice\" value for resticprofile and child processes (on any OS)"` + Priority string `mapstructure:"priority" default:"normal" enum:"idle;background;low;normal;high;highest" description:"Sets process priority class for resticprofile and child processes (on any OS)"` + DefaultCommand string `mapstructure:"default-command" default:"snapshots" description:"The restic or resticprofile command to use when no command was specified"` + Initialize bool `mapstructure:"initialize" default:"false" description:"Initialize a repository if missing"` + ResticBinary string `mapstructure:"restic-binary" description:"Full path of the restic executable (detected if not set)"` + ResticVersion string // not configurable at the moment. To be set after ResticBinary is known. + FilterResticFlags bool `mapstructure:"restic-arguments-filter" default:"true" description:"Remove unknown flags instead of passing all configured flags to restic"` + ResticLockRetryAfter time.Duration `mapstructure:"restic-lock-retry-after" default:"1m" description:"Time to wait before trying to get a lock on a restic repositoey - see https://creativeprojects.github.io/resticprofile/usage/locks/"` + ResticStaleLockAge time.Duration `mapstructure:"restic-stale-lock-age" default:"1h" description:"The age an unused lock on a restic repository must have at least before resiticprofile attempts to unlock - see https://creativeprojects.github.io/resticprofile/usage/locks/"` + ShellBinary []string `mapstructure:"shell" default:"auto" examples:"sh;bash;pwsh;powershell;cmd" description:"The shell that is used to run commands (default is OS specific)"` + MinMemory uint64 `mapstructure:"min-memory" default:"100" description:"Minimum available memory (in MB) required to run any commands - see https://creativeprojects.github.io/resticprofile/usage/memory/"` + Scheduler string `mapstructure:"scheduler" description:"Leave blank for the default scheduler or use \"crond\" to select cron on supported operating systems"` + ScheduleDefaults *ScheduleBaseConfig `mapstructure:"schedule-defaults" default:"" description:"Sets defaults for all schedules"` + Log string `mapstructure:"log" default:"" description:"Sets the default log destination to be used if not specified in '--log' or 'schedule-log' - see https://creativeprojects.github.io/resticprofile/configuration/logs/"` + LegacyArguments bool `mapstructure:"legacy-arguments" default:"false" deprecated:"0.20.0" description:"Legacy, broken arguments mode of resticprofile before version 0.15"` + SystemdUnitTemplate string `mapstructure:"systemd-unit-template" default:"" description:"File containing the go template to generate a systemd unit - see https://creativeprojects.github.io/resticprofile/schedules/systemd/"` + SystemdTimerTemplate string `mapstructure:"systemd-timer-template" default:"" description:"File containing the go template to generate a systemd timer - see https://creativeprojects.github.io/resticprofile/schedules/systemd/"` + SystemdDropInFiles []string `mapstructure:"systemd-drop-in-files" default:"" description:"Files containing systemd drop-in (override) files - see https://creativeprojects.github.io/resticprofile/schedules/systemd/"` + SenderTimeout time.Duration `mapstructure:"send-timeout" default:"30s" examples:"15s;30s;2m30s" description:"Timeout when sending messages to a webhook - see https://creativeprojects.github.io/resticprofile/configuration/http_hooks/"` + CACertificates []string `mapstructure:"ca-certificates" description:"Path to PEM encoded certificates to trust in addition to system certificates when resticprofile sends to a webhook - see https://creativeprojects.github.io/resticprofile/configuration/http_hooks/"` + PreventSleep bool `mapstructure:"prevent-sleep" default:"false" description:"Prevent the system from sleeping while running commands - see https://creativeprojects.github.io/resticprofile/configuration/sleep/"` + GroupContinueOnError bool `mapstructure:"group-continue-on-error" default:"false" description:"Enable groups to continue with the next profile(s) instead of stopping at the first failure"` } // NewGlobal instantiates a new Global with default values @@ -51,6 +53,7 @@ func NewGlobal() *Global { func (p *Global) SetRootPath(rootPath string) { p.ShellBinary = fixPaths(p.ShellBinary, expandEnv) p.ResticBinary = fixPath(p.ResticBinary, expandEnv) + p.Log = fixPath(p.Log, expandEnv, expandUserHome) p.SystemdUnitTemplate = fixPath(p.SystemdUnitTemplate, expandEnv, absolutePrefix(rootPath)) p.SystemdTimerTemplate = fixPath(p.SystemdTimerTemplate, expandEnv, absolutePrefix(rootPath)) diff --git a/config/group.go b/config/group.go index 9f8821ad4..59fe3fb19 100644 --- a/config/group.go +++ b/config/group.go @@ -4,7 +4,37 @@ import "github.com/creativeprojects/resticprofile/util/maybe" // Group of profiles type Group struct { - Description string `mapstructure:"description" description:"Describe the group"` - Profiles []string `mapstructure:"profiles" description:"Names of the profiles belonging to this group"` - ContinueOnError maybe.Bool `mapstructure:"continue-on-error" default:"auto" description:"Continue with the next profile on a failure, overrides \"global.group-continue-on-error\""` + config *Config + Name string `show:"noshow"` + Description string `mapstructure:"description" description:"Describe the group"` + Profiles []string `mapstructure:"profiles" description:"Names of the profiles belonging to this group"` + ContinueOnError maybe.Bool `mapstructure:"continue-on-error" default:"auto" description:"Continue with the next profile on a failure, overrides \"global.group-continue-on-error\""` + CommandSchedules map[string]ScheduleConfig `mapstructure:"schedules" description:"Allows to run the group on schedule for the specified command name."` } + +func NewGroup(c *Config, name string) (g *Group) { + g = &Group{ + Name: name, + config: c, + } + return +} + +func (g *Group) ResolveConfiguration() { + global := g.config.mustGetGlobalSection() + for command, cfg := range g.CommandSchedules { + cfg.init(global.ScheduleDefaults) + cfg.origin = ScheduleOrigin(g.Name, command, ScheduleOriginGroup) + } +} + +func (g *Group) Schedules() map[string]*Schedule { + schedules := make(map[string]*Schedule) + for command, cfg := range g.CommandSchedules { + schedules[command] = NewSchedule(g.config, &cfg) + } + return schedules +} + +// Implements Schedulable +var _ Schedulable = new(Group) diff --git a/config/info.go b/config/info.go index e1cdd9e6a..fa9775b57 100644 --- a/config/info.go +++ b/config/info.go @@ -81,6 +81,8 @@ type PropertyInfo interface { IsDeprecated() bool // IsSingle indicates that the property can be defined only once. IsSingle() bool + // IsSinglePropertySet indicates that a nested PropertySet can be defined only once (is implied with IsSingle). + IsSinglePropertySet() bool // IsMultiType indicates that more than one of CanBeString, CanBeNumeric, CanBeBool & CanBePropertySet returns true IsMultiType() bool // IsAnyType indicates that all of CanBeString, CanBeNumeric & CanBeBool return true @@ -196,7 +198,7 @@ type accessibleProperty interface { // basicPropertyInfo is the base for PropertyInfo implementations type basicPropertyInfo struct { mayString, mayNumber, mayBool, mayNil, mustInt bool - deprecated, required, single bool + deprecated, required, single, singleNested bool from, to *float64 fromExclusive, toExclusive bool name, format, pattern string @@ -209,6 +211,7 @@ func (b *basicPropertyInfo) Name() string { return b.name } func (b *basicPropertyInfo) IsDeprecated() bool { return b.deprecated } func (b *basicPropertyInfo) IsRequired() bool { return b.required } func (b *basicPropertyInfo) IsSingle() bool { return b.single } +func (b *basicPropertyInfo) IsSinglePropertySet() bool { return b.IsSingle() || b.singleNested } func (b *basicPropertyInfo) CanBeBool() bool { return b.mayBool } func (b *basicPropertyInfo) CanBeNil() bool { return b.mayNil } func (b *basicPropertyInfo) CanBeNumeric() bool { return b.mayNumber } @@ -583,6 +586,7 @@ var infoTypes struct { mixins, mixinUse, profile, + scheduleConfig, genericSection reflect.Type genericSectionNames []string } @@ -596,6 +600,7 @@ func init() { infoTypes.mixins = reflect.TypeOf(mixin{}) infoTypes.mixinUse = reflect.TypeOf(mixinUse{}) infoTypes.profile = reflect.TypeOf(profile) + infoTypes.scheduleConfig = reflect.TypeOf(ScheduleConfig{}) infoTypes.genericSection = reflect.TypeOf(GenericSection{}) infoTypes.genericSectionNames = maps.Keys(profile.OtherSections) } @@ -641,6 +646,15 @@ func NewMixinUseInfo() NamedPropertySet { } } +// NewScheduleConfigInfo returns structural information on the "schedule" config structure +func NewScheduleConfigInfo() NamedPropertySet { + return &namedPropertySet{ + name: constants.SectionConfigurationSchedule, + description: "schedule configuration structure", + propertySet: propertySetFromType(infoTypes.scheduleConfig), + } +} + // NewProfileInfo returns structural information on the "profile" config section func NewProfileInfo(withDefaultOptions bool) ProfileInfo { return NewProfileInfoForRestic(restic.AnyVersion, withDefaultOptions) diff --git a/config/info_customizer.go b/config/info_customizer.go index fdd3e034b..4f67aaa70 100644 --- a/config/info_customizer.go +++ b/config/info_customizer.go @@ -109,7 +109,22 @@ func init() { } }) - // Profile: special handling for ConfidentialValue + // Profile: special handling for ScheduleBaseSection.Schedule + registerPropertyInfoCustomizer(func(sectionName, propertyName string, property accessibleProperty) { + if field := property.field(); field != nil && propertyName == constants.SectionConfigurationSchedule { + if field.Type.Kind() == reflect.Interface { + basic := property.basic().resetTypeInfo() + basic.nested = NewScheduleConfigInfo() + basic.single = false + basic.singleNested = true + basic.mayString = true + basic.mayNil = true + } + } + return + }) + + // Profile or Group: special handling for ConfidentialValue confidentialType := reflect.TypeOf(ConfidentialValue{}) registerPropertyInfoCustomizer(func(sectionName, propertyName string, property accessibleProperty) { if field := property.field(); field != nil { @@ -124,7 +139,7 @@ func init() { } }) - // Profile: special handling for maybe.Bool + // Profile or Group: special handling for maybe.Bool maybeBoolType := reflect.TypeOf(maybe.Bool{}) registerPropertyInfoCustomizer(func(sectionName, propertyName string, property accessibleProperty) { if field := property.field(); field != nil { @@ -135,7 +150,21 @@ func init() { } }) - // Profile: deprecated sections (squash with deprecated, e.g. schedule in retention) + // Profile or Group: special handling for maybe.Duration + maybeDurationType := reflect.TypeOf(maybe.Duration{}) + registerPropertyInfoCustomizer(func(sectionName, propertyName string, property accessibleProperty) { + if field := property.field(); field != nil { + if util.ElementType(field.Type).AssignableTo(maybeDurationType) { + basic := property.basic().resetTypeInfo() + basic.mayNumber = true + basic.mustInt = true + basic.format = "duration" + basic.mayString = true + } + } + }) + + // Profile or Group: deprecated sections (squash with deprecated, e.g. schedule in retention) registerPropertyInfoCustomizer(func(sectionName, propertyName string, property accessibleProperty) { if field := property.sectionField(nil); field != nil { if _, deprecated := field.Tag.Lookup("deprecated"); deprecated { diff --git a/config/jsonschema/schema.go b/config/jsonschema/schema.go index 0bd2b0712..d979b66ee 100644 --- a/config/jsonschema/schema.go +++ b/config/jsonschema/schema.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "regexp" + "slices" "strings" "github.com/creativeprojects/resticprofile/config" @@ -151,29 +152,49 @@ func schemaForPropertySet(props config.PropertySet) (object *schemaObject) { } func schemaForPropertyInfo(info config.PropertyInfo) SchemaType { + if info == nil { + return nil + } + + // Special nested handling: treat nestedType separately from propertyType to fulfil IsSinglePropertySet when not IsSingle + enforceNestedSingle := info.IsSinglePropertySet() && !info.IsSingle() + // Detect item type of this property var propertyType, nestedType SchemaType if types, nestedIndex := typesFromPropertyInfo(info); len(types) > 0 { + // nested handling + if nestedIndex > -1 { + nestedType = types[nestedIndex] + if enforceNestedSingle { + types = slices.Delete(types, nestedIndex, nestedIndex+1) + } + } + // type list if len(types) > 1 { oneOf := info.IsSingle() propertyType = newSchemaTypeList(!oneOf, types...) - } else { + } else if len(types) > 0 { propertyType = types[0] } - if nestedIndex > -1 { - nestedType = types[nestedIndex] - } } else { return nil } - // Array or single type - if !info.IsSingle() { - // viper supports single elements for list types + // array of propertyType or single propertyType + if propertyType != nil && !info.IsSingle() { propertyType = newSchemaTypeList(true, propertyType, newSchemaArray(propertyType)) } + // re-add separated nestedType (nestedType or propertyType) + if nestedType != nil && enforceNestedSingle { + if propertyType == nil { + propertyType = nestedType + } else { + propertyType = newSchemaTypeList(true, nestedType, propertyType) + } + } + // Set basic info configureBasicInfo(propertyType, nestedType, info) diff --git a/config/jsonschema/schema_test.go b/config/jsonschema/schema_test.go index efd2644d3..8f0e374bb 100644 --- a/config/jsonschema/schema_test.go +++ b/config/jsonschema/schema_test.go @@ -235,21 +235,22 @@ func TestValueTypeConversion(t *testing.T) { } var propertyInfoDefaults = map[string]any{ - "CanBeNil": false, - "CanBeBool": false, - "CanBeNumeric": false, - "CanBeString": false, - "CanBePropertySet": false, - "IsDeprecated": false, - "IsSingle": false, - "IsMultiType": false, - "IsOption": false, - "IsRequired": false, - "Name": "", - "Description": "", - "DefaultValue": []string{""}, - "EnumValues": nil, - "ExampleValues": nil, + "CanBeNil": false, + "CanBeBool": false, + "CanBeNumeric": false, + "CanBeString": false, + "CanBePropertySet": false, + "IsDeprecated": false, + "IsSingle": false, + "IsSinglePropertySet": false, + "IsMultiType": false, + "IsOption": false, + "IsRequired": false, + "Name": "", + "Description": "", + "DefaultValue": []string{""}, + "EnumValues": nil, + "ExampleValues": nil, } var propertySetDefaults = map[string]any{ @@ -357,6 +358,7 @@ func TestSchemaForPropertySet(t *testing.T) { pi := new(mocks.PropertyInfo) stringProperty(pi, "", "") pi.EXPECT().IsSingle().Return(true) + pi.EXPECT().IsSinglePropertySet().Return(true) setupMock(t, &pi.Mock, propertyInfoDefaults) s := schemaForPropertySet(newMock(func(m *mocks.NamedPropertySet) { @@ -385,6 +387,7 @@ func TestSchemaForPropertySet(t *testing.T) { singleProperty := func(required bool) *mocks.PropertyInfo { pi := new(mocks.PropertyInfo) pi.EXPECT().IsSingle().Return(true) + pi.EXPECT().IsSinglePropertySet().Return(true) pi.EXPECT().IsRequired().Return(required) return pi } diff --git a/config/mocks/PropertyInfo.go b/config/mocks/PropertyInfo.go index 743f168dd..757cd1ece 100644 --- a/config/mocks/PropertyInfo.go +++ b/config/mocks/PropertyInfo.go @@ -748,6 +748,51 @@ func (_c *PropertyInfo_IsSingle_Call) RunAndReturn(run func() bool) *PropertyInf return _c } +// IsSinglePropertySet provides a mock function with given fields: +func (_m *PropertyInfo) IsSinglePropertySet() bool { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for IsSinglePropertySet") + } + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// PropertyInfo_IsSinglePropertySet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsSinglePropertySet' +type PropertyInfo_IsSinglePropertySet_Call struct { + *mock.Call +} + +// IsSinglePropertySet is a helper method to define mock.On call +func (_e *PropertyInfo_Expecter) IsSinglePropertySet() *PropertyInfo_IsSinglePropertySet_Call { + return &PropertyInfo_IsSinglePropertySet_Call{Call: _e.mock.On("IsSinglePropertySet")} +} + +func (_c *PropertyInfo_IsSinglePropertySet_Call) Run(run func()) *PropertyInfo_IsSinglePropertySet_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *PropertyInfo_IsSinglePropertySet_Call) Return(_a0 bool) *PropertyInfo_IsSinglePropertySet_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *PropertyInfo_IsSinglePropertySet_Call) RunAndReturn(run func() bool) *PropertyInfo_IsSinglePropertySet_Call { + _c.Call.Return(run) + return _c +} + // MustBeInteger provides a mock function with given fields: func (_m *PropertyInfo) MustBeInteger() bool { ret := _m.Called() diff --git a/config/profile.go b/config/profile.go index 7f347cf31..947561f68 100644 --- a/config/profile.go +++ b/config/profile.go @@ -5,13 +5,10 @@ import ( "os" "path/filepath" "reflect" - "slices" "sort" "strings" - "time" "github.com/Masterminds/semver/v3" - "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/restic" "github.com/creativeprojects/resticprofile/shell" @@ -29,11 +26,6 @@ type Empty interface { IsEmpty() bool } -// Scheduling provides access to schedule information inside a section -type Scheduling interface { - GetSchedule() *ScheduleBaseSection -} - // Monitoring provides access to http hooks inside a section type Monitoring interface { GetSendMonitoring() *SendMonitoringSections @@ -49,6 +41,11 @@ type OtherFlags interface { GetOtherFlags() map[string]any } +// scheduling provides access to schedule information inside a section +type scheduling interface { + getScheduleConfig(p *Profile, command string) *ScheduleConfig +} + // commandFlags allows sections to return flags directly type commandFlags interface { getCommandFlags(profile *Profile) *shell.Args @@ -185,6 +182,8 @@ type BackupSection struct { func (s *BackupSection) IsEmpty() bool { return s == nil } func (b *BackupSection) resolve(profile *Profile) { + b.ScheduleBaseSection.resolve(profile) + // Ensure UseStdin is set when Backup.StdinCommand is defined if len(b.StdinCommand) > 0 { b.UseStdin = true @@ -228,6 +227,8 @@ type RetentionSection struct { func (r *RetentionSection) IsEmpty() bool { return r == nil } func (r *RetentionSection) resolve(profile *Profile) { + r.ScheduleBaseSection.resolve(profile) + // Special cases of retention isSet := func(flags OtherFlags, name string) (found bool) { _, found = flags.GetOtherFlags()[name]; return } hasBackup := !profile.Backup.IsEmpty() @@ -288,27 +289,39 @@ func (s *SectionWithScheduleAndMonitoring) IsEmpty() bool { return s == nil } // ScheduleBaseSection contains the parameters for scheduling a command (backup, check, forget, etc.) type ScheduleBaseSection struct { - Schedule []string `mapstructure:"schedule" show:"noshow" examples:"hourly;daily;weekly;monthly;10:00,14:00,18:00,22:00;Wed,Fri 17:48;*-*-15 02:45;Mon..Fri 00:30" description:"Set the times at which the scheduled command is run (times are specified in systemd timer format)"` - SchedulePermission string `mapstructure:"schedule-permission" show:"noshow" default:"auto" enum:"auto;system;user;user_logged_on" description:"Specify whether the schedule runs with system or user privileges - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` - ScheduleLog string `mapstructure:"schedule-log" show:"noshow" examples:"/resticprofile.log;tcp://localhost:514" description:"Redirect the output into a log file or to syslog when running on schedule"` - SchedulePriority string `mapstructure:"schedule-priority" show:"noshow" default:"background" enum:"background;standard" description:"Set the priority at which the schedule is run"` - ScheduleLockMode string `mapstructure:"schedule-lock-mode" show:"noshow" default:"default" enum:"default;fail;ignore" description:"Specify how locks are used when running on schedule - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` - ScheduleLockWait time.Duration `mapstructure:"schedule-lock-wait" show:"noshow" examples:"150s;15m;30m;45m;1h;2h30m" description:"Set the maximum time to wait for acquiring locks when running on schedule"` - ScheduleEnvCapture []string `mapstructure:"schedule-capture-environment" show:"noshow" default:"RESTIC_*" description:"Set names (or glob expressions) of environment variables to capture during schedule creation. The captured environment is applied prior to \"profile.env\" when running the schedule. Whether capturing is supported depends on the type of scheduler being used (supported in \"systemd\" and \"launchd\")"` - ScheduleIgnoreOnBattery bool `mapstructure:"schedule-ignore-on-battery" show:"noshow" default:"false" description:"Don't schedule the start of this profile when running on battery"` - ScheduleIgnoreOnBatteryLessThan int `mapstructure:"schedule-ignore-on-battery-less-than" show:"noshow" default:"" description:"Don't schedule the start of this profile when running on battery, and the battery charge left is less than the value"` - ScheduleAfterNetworkOnline bool `mapstructure:"schedule-after-network-online" show:"noshow" description:"Don't schedule the start of this profile when the network is offline (supported in \"systemd\")."` + scheduleConfig *ScheduleConfig + Schedule any `mapstructure:"schedule" show:"noshow" examples:"hourly;daily;weekly;monthly;10:00,14:00,18:00,22:00;Wed,Fri 17:48;*-*-15 02:45;Mon..Fri 00:30" description:"Configures the schedule can be times in systemd timer format or a config structure"` + SchedulePermission string `mapstructure:"schedule-permission" show:"noshow" default:"auto" enum:"auto;system;user;user_logged_on" description:"Specify whether the schedule runs with system or user privileges - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` + ScheduleLog string `mapstructure:"schedule-log" show:"noshow" examples:"/resticprofile.log;tcp://localhost:514" description:"Redirect the output into a log file or to syslog when running on schedule"` + SchedulePriority string `mapstructure:"schedule-priority" show:"noshow" default:"background" enum:"background;standard" description:"Set the priority at which the schedule is run"` + ScheduleLockMode string `mapstructure:"schedule-lock-mode" show:"noshow" default:"default" enum:"default;fail;ignore" description:"Specify how locks are used when running on schedule - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` + ScheduleLockWait maybe.Duration `mapstructure:"schedule-lock-wait" show:"noshow" examples:"150s;15m;30m;45m;1h;2h30m" description:"Set the maximum time to wait for acquiring locks when running on schedule"` + ScheduleEnvCapture []string `mapstructure:"schedule-capture-environment" show:"noshow" default:"RESTIC_*" description:"Set names (or glob expressions) of environment variables to capture during schedule creation. The captured environment is applied prior to \"profile.env\" when running the schedule. Whether capturing is supported depends on the type of scheduler being used (supported in \"systemd\" and \"launchd\")"` + ScheduleIgnoreOnBattery maybe.Bool `mapstructure:"schedule-ignore-on-battery" show:"noshow" default:"false" description:"Don't schedule the start of this profile when running on battery"` + ScheduleIgnoreOnBatteryLessThan int `mapstructure:"schedule-ignore-on-battery-less-than" show:"noshow" default:"" description:"Don't schedule the start of this profile when running on battery, and the battery charge left is less than the value"` + ScheduleAfterNetworkOnline maybe.Bool `mapstructure:"schedule-after-network-online" show:"noshow" description:"Don't schedule the start of this profile when the network is offline (supported in \"systemd\")."` } func (s *ScheduleBaseSection) setRootPath(_ *Profile, _ string) { s.ScheduleLog = fixPath(s.ScheduleLog, expandEnv, expandUserHome) } -func (s *ScheduleBaseSection) GetSchedule() *ScheduleBaseSection { - if s != nil && s.ScheduleEnvCapture == nil { - s.ScheduleEnvCapture = []string{"RESTIC_*"} +func (s *ScheduleBaseSection) resolve(profile *Profile) { + if s == nil || profile.config == nil { + return + } + if config := newScheduleConfig(profile.config, s); config.HasSchedules() { + s.scheduleConfig = config + } +} + +func (s *ScheduleBaseSection) HasSchedule() bool { return s.scheduleConfig.HasSchedules() } + +func (s *ScheduleBaseSection) getScheduleConfig(p *Profile, command string) *ScheduleConfig { + if s.scheduleConfig != nil && p != nil { + s.scheduleConfig.origin = ScheduleOrigin(p.Name, command) } - return s + return s.scheduleConfig } // CopySection contains the destination parameters for a copy command @@ -328,6 +341,8 @@ type CopySection struct { func (s *CopySection) IsEmpty() bool { return s == nil } func (c *CopySection) resolve(p *Profile) { + c.ScheduleBaseSection.resolve(p) + c.Repository.setValue(fixPath(c.Repository.Value(), expandEnv, expandUserHome)) } @@ -763,10 +778,10 @@ func (p *Profile) GetRetentionFlags() *shell.Args { // HasDeprecatedRetentionSchedule indicates if there's one or more schedule parameters in the retention section, // which is deprecated as of 0.11.0 func (p *Profile) HasDeprecatedRetentionSchedule() bool { - return p.Retention != nil && len(p.Retention.Schedule) > 0 + return p.Retention != nil && p.Retention.HasSchedule() } -// GetBackupSource returns the directories to backup +// GetBackupSource returns the directories to back up func (p *Profile) GetBackupSource() []string { if p.Backup == nil { return nil @@ -813,7 +828,7 @@ func (p *Profile) AllSections() (sections map[string]any) { // SchedulableCommands returns all command names that could have a schedule func (p *Profile) SchedulableCommands() (commands []string) { - if commands = maps.Keys(GetDeclaredSectionsWith[Scheduling](p)); commands != nil { + if commands = maps.Keys(GetDeclaredSectionsWith[scheduling](p)); commands != nil { sort.Strings(commands) } return @@ -844,62 +859,20 @@ func (p *Profile) GetEnvironment(withOs bool) (env *util.Environment) { return } -// Schedules returns a slice of Schedule for all the commands that have a schedule configuration +// Schedules returns a map of command -> Schedule, for all the commands that have a schedule configuration // Only v1 configuration have schedules inside the profile -func (p *Profile) Schedules() []*Schedule { - // All SectionWithSchedule (backup, check, prune, etc) - sections := GetSectionsWith[Scheduling](p) - configs := make([]*Schedule, 0, len(sections)) +func (p *Profile) Schedules() map[string]*Schedule { + // All SectionWithSchedule (backup, check, prune, etc.) + sections := GetSectionsWith[scheduling](p) + schedules := make(map[string]*Schedule) for name, section := range sections { - if s := section.GetSchedule(); len(s.Schedule) > 0 { - var envValues []string - - if len(s.ScheduleEnvCapture) > 0 { - env := p.GetEnvironment(true) - - for index, key := range env.Names() { - matched := slices.ContainsFunc(s.ScheduleEnvCapture, func(pattern string) bool { - matched, err := filepath.Match(pattern, key) - if err != nil && index == 0 { - clog.Tracef("env not matched with invalid glob expression '%s': %s", pattern, err.Error()) - } - return matched - }) - if !matched { - env.Remove(key) - } - } - - envValues = env.Values() - } - - config := &Schedule{ - CommandName: name, - Group: "", - Profiles: []string{p.Name}, - Schedules: s.Schedule, - Permission: s.SchedulePermission, - Log: s.ScheduleLog, - Priority: s.SchedulePriority, - LockMode: s.ScheduleLockMode, - LockWait: s.ScheduleLockWait, - Environment: envValues, - IgnoreOnBattery: s.ScheduleIgnoreOnBattery, - IgnoreOnBatteryLessThan: s.ScheduleIgnoreOnBatteryLessThan, - AfterNetworkOnline: s.ScheduleAfterNetworkOnline, - SystemdDropInFiles: p.SystemdDropInFiles, - ConfigFile: p.config.configFile, - Flags: map[string]string{}, - } - - config.Init(p.config) - - configs = append(configs, config) + if config := section.getScheduleConfig(p, name); config != nil { + schedules[name] = newScheduleForProfile(p, config) } } - return configs + return schedules } func (p *Profile) GetRunShellCommandsSections(command string) (profileCommands RunShellCommandsSection, sectionCommands RunShellCommandsSection) { @@ -978,3 +951,6 @@ func addArgsFromOtherFlags(args *shell.Args, profile *Profile, section OtherFlag maps.Copy(aliases, argAliasesFromStruct(section)) addArgsFromMap(args, aliases, section.GetOtherFlags()) } + +// Implements Schedulable +var _ Schedulable = new(Profile) diff --git a/config/profile_test.go b/config/profile_test.go index b0f26deac..d1c3f8f3d 100644 --- a/config/profile_test.go +++ b/config/profile_test.go @@ -980,14 +980,15 @@ func TestSchedules(t *testing.T) { config := profile.Schedules() require.Len(t, config, 1) - schedule := config[0] - assert.Equal(t, command, schedule.CommandName) + schedule := config[command] + assert.Equal(t, command, schedule.ScheduleOrigin().Command) assert.Equal(t, []string{"@hourly"}, schedule.Schedules) assert.Equal(t, path.Join(constants.TemporaryDirMarker, "rp.log"), schedule.Log) assert.Equal(t, map[string]string{ - "RESTIC_VAR": "profile-only-value", - "RESTIC_ANY1": "xyz", - "RESTIC_ANY2": "123", + "RESTICPROFILE_SCHEDULE_ID": fmt.Sprintf(":%s@profile", command), + "RESTIC_VAR": "profile-only-value", + "RESTIC_ANY1": "xyz", + "RESTIC_ANY2": "123", }, util.NewDefaultEnvironment(schedule.Environment...).ValuesAsMap()) // Check that schedule is optional @@ -1098,12 +1099,12 @@ profile: format := testItem.format testConfig := testItem.config t.Run(format, func(t *testing.T) { - profile, err := getProfile(format, testConfig, "profile", "") + profile, err := getResolvedProfile(format, testConfig, "profile") require.NoError(t, err) assert.NotNil(t, profile) assert.NotNil(t, profile.Retention) - assert.NotEmpty(t, profile.Retention.Schedule) + assert.NotNil(t, profile.Retention.getScheduleConfig(nil, "")) assert.True(t, profile.HasDeprecatedRetentionSchedule()) }) } diff --git a/config/schedule.go b/config/schedule.go index 1b05be1d3..fe540402a 100644 --- a/config/schedule.go +++ b/config/schedule.go @@ -1,13 +1,22 @@ package config import ( + "encoding/json" + "fmt" + "os" "path" "path/filepath" + "regexp" + "slices" + "sort" "strings" "time" + "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/util" + "github.com/creativeprojects/resticprofile/util/maybe" + "github.com/spf13/cast" ) type ScheduleLockMode int8 @@ -22,36 +31,249 @@ const ( ScheduleLockModeIgnore = ScheduleLockMode(2) ) -// Schedule is an intermediary object between the configuration (v1, v2+) and the ScheduleConfig object used by the scheduler. -// The object is also used to display the scheduling configuration +// ScheduleBaseConfig is the base user configuration that could be shared across all schedules. +type ScheduleBaseConfig struct { + Permission string `mapstructure:"permission" show:"noshow" default:"auto" enum:"auto;system;user;user_logged_on" description:"Specify whether the schedule runs with system or user privileges - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` + Log string `mapstructure:"log" show:"noshow" examples:"/resticprofile.log;tcp://localhost:514" description:"Redirect the output into a log file or to syslog when running on schedule"` + Priority string `mapstructure:"priority" show:"noshow" default:"background" enum:"background;standard" description:"Set the priority at which the schedule is run"` + LockMode string `mapstructure:"lock-mode" show:"noshow" default:"default" enum:"default;fail;ignore" description:"Specify how locks are used when running on schedule - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` + LockWait maybe.Duration `mapstructure:"lock-wait" show:"noshow" examples:"150s;15m;30m;45m;1h;2h30m" description:"Set the maximum time to wait for acquiring locks when running on schedule"` + EnvCapture []string `mapstructure:"capture-environment" show:"noshow" default:"RESTIC_*" description:"Set names (or glob expressions) of environment variables to capture during schedule creation. The captured environment is applied prior to \"profile.env\" when running the schedule. Whether capturing is supported depends on the type of scheduler being used (supported in \"systemd\" and \"launchd\")"` + IgnoreOnBattery maybe.Bool `mapstructure:"ignore-on-battery" show:"noshow" default:"false" description:"Don't schedule the start of this profile when running on battery"` + IgnoreOnBatteryLessThan int `mapstructure:"ignore-on-battery-less-than" show:"noshow" default:"" description:"Don't schedule the start of this profile when running on battery, and the battery charge left is less than the value"` + AfterNetworkOnline maybe.Bool `mapstructure:"after-network-online" show:"noshow" description:"Don't schedule the start of this profile when the network is offline (supported in \"systemd\")."` +} + +// scheduleBaseConfigDefaults declares built-in scheduling defaults +var scheduleBaseConfigDefaults = ScheduleBaseConfig{ + Permission: "auto", + Priority: "background", + LockMode: "default", + EnvCapture: []string{"RESTIC_*"}, +} + +func (s *ScheduleBaseConfig) init(defaults *ScheduleBaseConfig) { + // defaults + if defaults == nil { + defaults = &scheduleBaseConfigDefaults + } + if s.Permission == "" { + s.Permission = defaults.Permission + } + if s.Log == "" { + s.Log = defaults.Log + } + if s.Priority == "" { + s.Priority = defaults.Priority + } + if s.LockMode == "" { + s.LockMode = defaults.LockMode + } + if !s.LockWait.HasValue() { + s.LockWait = defaults.LockWait + } + if s.EnvCapture == nil { + s.EnvCapture = slices.Clone(defaults.EnvCapture) + } + if !s.IgnoreOnBattery.HasValue() { + s.IgnoreOnBattery = defaults.IgnoreOnBattery + } + if !s.AfterNetworkOnline.HasValue() { + s.AfterNetworkOnline = defaults.AfterNetworkOnline + } +} + +func (s *ScheduleBaseConfig) applyOverrides(section *ScheduleBaseSection) { + // capture a copy of self as defaults + defaults := *s + // applying the settings of the section + s.Permission = section.SchedulePermission + s.Log = section.ScheduleLog + s.Priority = section.SchedulePriority + s.LockMode = section.ScheduleLockMode + s.LockWait = section.ScheduleLockWait + s.EnvCapture = section.ScheduleEnvCapture + s.IgnoreOnBattery = section.ScheduleIgnoreOnBattery + s.AfterNetworkOnline = section.ScheduleAfterNetworkOnline + // re-init with defaults + s.init(&defaults) +} + +type ScheduleOriginType int + +const ( + ScheduleOriginProfile ScheduleOriginType = iota + ScheduleOriginGroup +) + +type ScheduleConfigOrigin struct { + Type ScheduleOriginType + Name, Command string +} + +func (o ScheduleConfigOrigin) String() string { + kind := "" + if o.Type == ScheduleOriginGroup { + kind = "g:" + } + return fmt.Sprintf("%s%s@%s", kind, o.Command, o.Name) +} + +// ScheduleOrigin returns a origin for the specified name command and optional type (defaulting to ScheduleOriginProfile) +func ScheduleOrigin(name, command string, kind ...ScheduleOriginType) (s ScheduleConfigOrigin) { + s.Name = name + s.Command = command + if len(kind) == 1 { + s.Type = kind[0] + } + return +} + +// ScheduleConfig is the user configuration of a specific schedule bound to a command in a profile or group. +type ScheduleConfig struct { + origin ScheduleConfigOrigin `show:"noshow"` + Schedules []string `mapstructure:"at" examples:"hourly;daily;weekly;monthly;10:00,14:00,18:00,22:00;Wed,Fri 17:48;*-*-15 02:45;Mon..Fri 00:30" description:"Set the times at which the scheduled command is run (times are specified in systemd timer format)"` + ScheduleBaseConfig `mapstructure:",squash"` +} + +// NewDefaultScheduleConfig returns a new schedule configuration that is initialized with defaults +func NewDefaultScheduleConfig(config *Config, origin ScheduleConfigOrigin, schedules ...string) (s *ScheduleConfig) { + var defaults *ScheduleBaseConfig + if config != nil { + defaults = config.mustGetGlobalSection().ScheduleDefaults + } + + s = new(ScheduleConfig) + if len(schedules) > 0 { + s.Schedules = slices.Clone(schedules) + } + s.init(defaults) + s.origin = origin + return s +} + +func newScheduleConfig(config *Config, section *ScheduleBaseSection) (s *ScheduleConfig) { + s = new(ScheduleConfig) + + // decode ScheduleBaseSection.Schedule + switch expression := section.Schedule.(type) { + case string: + s.Schedules = append(s.Schedules, expression) + case []string, []any: + s.Schedules = append(s.Schedules, cast.ToStringSlice(expression)...) + default: + if expression != nil { + decoder, err := config.newUnmarshaller(s) + if err == nil { + err = decoder.Decode(expression) + } + if err != nil { + if bytes, e := json.Marshal(expression); e == nil { + expression = string(bytes) + } + clog.Errorf("failed decoding schedule %v: %s", expression, err.Error()) + s = nil + } + } + } + + // init + if s != nil { + s.init(config.mustGetGlobalSection().ScheduleDefaults) + s.applyOverrides(section) + } + return +} + +func (s *ScheduleConfig) ScheduleOrigin() ScheduleConfigOrigin { + return s.origin +} + +func (s *ScheduleConfig) HasSchedules() bool { + return s != nil && len(s.Schedules) > 0 +} + +// Schedulable may be implemented by sections that can provide command schedules (= groups and profiles) +type Schedulable interface { + // Schedules returns a command to schedule map + Schedules() map[string]*Schedule +} + +// Schedule is the configuration used in profiles and groups for passing the user config to the scheduler system. type Schedule struct { - CommandName string `mapstructure:"run"` - Group string `mapstructure:"group"` // v2+ only - Profiles []string `mapstructure:"profiles"` // multiple profiles in v2+ only - Schedules []string `mapstructure:"schedule"` - Permission string `mapstructure:"permission"` - Log string `mapstructure:"log"` - Priority string `mapstructure:"priority"` - LockMode string `mapstructure:"lock-mode"` - LockWait time.Duration `mapstructure:"lock-wait"` - Environment []string `mapstructure:"environment"` - IgnoreOnBattery bool `mapstructure:"ignore-on-battery"` - IgnoreOnBatteryLessThan int `mapstructure:"ignore-on-battery-less-than"` - AfterNetworkOnline bool `mapstructure:"after-network-online"` - SystemdDropInFiles []string `mapstructure:"systemd-drop-in-files"` - ConfigFile string `show:"noshow"` - Flags map[string]string `show:"noshow"` -} - -func NewEmptySchedule(profileName, command string) *Schedule { - return &Schedule{ - Profiles: []string{profileName}, - CommandName: command, - } -} - -func (s *Schedule) Init(config *Config, profiles ...*Profile) { - // populate profiles from group (v2+ only) + ScheduleConfig + ConfigFile string `show:"noshow"` + Environment []string `show:"noshow"` + SystemdDropInFiles []string `show:"noshow"` + Flags map[string]string `show:"noshow"` +} + +// NewDefaultSchedule creates a new Schedule for the specified ScheduleConfigOrigin that is initialized with defaults +func NewDefaultSchedule(config *Config, origin ScheduleConfigOrigin, schedules ...string) *Schedule { + return NewSchedule(config, NewDefaultScheduleConfig(config, origin, schedules...)) +} + +// NewSchedule creates a new Schedule for the specified Config and ScheduleConfig +func NewSchedule(config *Config, sc *ScheduleConfig) *Schedule { + return newSchedule(config, sc, nil) +} + +// newScheduleForProfile creates a Schedule for the given Profile and ScheduleConfig +func newScheduleForProfile(profile *Profile, sc *ScheduleConfig) *Schedule { + origin := sc.ScheduleOrigin() + if origin.Type == ScheduleOriginProfile && origin.Name == profile.Name { + return newSchedule(profile.config, sc, profile) + } + panic(fmt.Sprintf("invalid use of newScheduleForProfile(%s, %s)", profile.Name, origin)) +} + +func newSchedule(config *Config, sc *ScheduleConfig, profile *Profile) *Schedule { + var env *util.Environment + + // schedule + s := new(Schedule) + if sc != nil { + s.ScheduleConfig = *sc + } + + // config + if config != nil { + s.ConfigFile = config.GetConfigFile() + + // global defaults + global := config.mustGetGlobalSection() + s.SystemdDropInFiles = global.SystemdDropInFiles + } + + // profile + if profile != nil { + if profile.SystemdDropInFiles != nil { + s.SystemdDropInFiles = profile.SystemdDropInFiles + } + + // env - todo: replace with profile.GetEnvironment(withOs=true) when available + env = util.NewDefaultEnvironment(os.Environ()...) + for k, v := range profile.Environment { + env.Put(env.ResolveName(k), v.Value()) + } + } + + // init + s.init(env) + return s +} + +var uriPrefixRegex = regexp.MustCompile("^(?i)[a-z]{2,}:") + +func (s *Schedule) init(env *util.Environment) { + // fix paths + rootPath := filepath.Dir(s.ConfigFile) + s.SystemdDropInFiles = fixPaths(s.SystemdDropInFiles, expandEnv, expandUserHome, absolutePrefix(rootPath)) + if uriPrefixRegex.MatchString(s.Log) { + s.Log = fixPath(s.Log, expandEnv, expandUserHome) + } else { + s.Log = fixPath(s.Log, expandEnv, expandUserHome, absolutePrefix(rootPath)) + } // temporary log file if s.Log != "" { @@ -59,8 +281,52 @@ func (s *Schedule) Init(config *Config, profiles ...*Profile) { s.Log = path.Join(constants.TemporaryDirMarker, s.Log[len(tempDir):]) } } + + // capture schedule environment + if len(s.EnvCapture) > 0 { + if env == nil { + env = util.NewDefaultEnvironment(os.Environ()...) + } + + for index, key := range env.Names() { + matched := slices.ContainsFunc(s.EnvCapture, func(pattern string) bool { + matched, err := filepath.Match(pattern, key) + if err != nil && index == 0 { + clog.Tracef("env not matched with invalid glob expression '%s': %s", pattern, err.Error()) + } + return matched + }) + if !matched { + env.Remove(key) + } + } + + env.Remove("RESTICPROFILE_SCHEDULE_ID") + s.Environment = env.Values() + } + + // add the ID of the schedule so that shell hooks can know in which schedule they're in + s.Environment = append(s.Environment, fmt.Sprintf("RESTICPROFILE_SCHEDULE_ID=%s", s.GetId())) + sort.Strings(s.Environment) +} + +func (s *Schedule) GetId() string { + return fmt.Sprintf("%s:%s", s.ConfigFile, s.origin) } +func (s *Schedule) Compare(other *Schedule) (c int) { + c = int(other.origin.Type) - int(s.origin.Type) + if c == 0 { + c = strings.Compare(s.origin.Name, other.origin.Name) + } + if c == 0 { + c = strings.Compare(s.origin.Command, other.origin.Command) + } + return +} + +func CompareSchedules(a, b *Schedule) int { return a.Compare(b) } + func (s *Schedule) GetLockMode() ScheduleLockMode { switch s.LockMode { case constants.ScheduleLockModeOptionFail: @@ -73,10 +339,10 @@ func (s *Schedule) GetLockMode() ScheduleLockMode { } func (s *Schedule) GetLockWait() time.Duration { - if s.LockWait <= 2*time.Second { + if !s.LockWait.HasValue() || s.LockWait.Value() <= 2*time.Second { return 0 } - return s.LockWait + return s.LockWait.Value() } func (s *Schedule) GetFlag(name string) (string, bool) { diff --git a/config/schedule_test.go b/config/schedule_test.go index 5e637cca5..9fb37328a 100644 --- a/config/schedule_test.go +++ b/config/schedule_test.go @@ -1,58 +1,175 @@ package config import ( + "strings" "testing" "time" "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/util/maybe" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestScheduleProperties(t *testing.T) { - schedule := Schedule{ - Profiles: []string{"profile"}, - CommandName: "command name", - Schedules: []string{"1", "2", "3"}, - Permission: "admin", - Environment: []string{"test=dev"}, - Priority: "", - LockMode: "undefined", - LockWait: 1 * time.Minute, - ConfigFile: "config", - Flags: map[string]string{}, - IgnoreOnBattery: false, - IgnoreOnBatteryLessThan: 0, +func TestNewSchedule(t *testing.T) { + profile := func(t *testing.T, config string) (*Profile, ScheduleConfigOrigin) { + if !strings.Contains(config, "[default") { + config += "\n[default]" + } + profile, err := getResolvedProfile("toml", config, "default") + require.NoError(t, err) + require.NotNil(t, profile) + return profile, ScheduleOrigin(profile.Name, constants.CommandBackup) } - assert.Equal(t, "config", schedule.ConfigFile) - assert.Equal(t, "profile", schedule.Profiles[0]) - assert.Equal(t, "command name", schedule.CommandName) - assert.ElementsMatch(t, []string{"1", "2", "3"}, schedule.Schedules) - assert.Equal(t, "admin", schedule.Permission) - assert.Equal(t, []string{"test=dev"}, schedule.Environment) - assert.Equal(t, ScheduleLockModeDefault, schedule.GetLockMode()) - assert.Equal(t, 60*time.Second, schedule.GetLockWait()) + t.Run("create-united-with-nil", func(t *testing.T) { + schedule := NewSchedule(nil, nil) + assert.NotEqual(t, scheduleBaseConfigDefaults, schedule.ScheduleBaseConfig) + }) + + t.Run("default-can-init-with-nil", func(t *testing.T) { + origin := ScheduleOrigin("p", "c") + schedule := NewDefaultSchedule(nil, origin) + assert.Equal(t, origin, schedule.ScheduleOrigin()) + assert.Equal(t, scheduleBaseConfigDefaults, schedule.ScheduleBaseConfig) + }) + + t.Run("default-without-schedule", func(t *testing.T) { + // Ensure DefaultSchedule can be used as remove-only config + p, origin := profile(t, ``) + schedule := NewDefaultSchedule(p.config, origin) + assert.False(t, schedule.HasSchedules()) + }) + + t.Run("global defaults", func(t *testing.T) { + p, origin := profile(t, ` + [global] + systemd-drop-in-files = "drop-in-file.conf" + + [global.schedule-defaults] + log = "/custom/path" + lock-wait = "30s" + + [default.backup] + schedule = "daily" + `) + t.Run("schedule-defaults apply", func(t *testing.T) { + for i := 0; i < 2; i++ { + var schedule *Schedule + if i == 0 { + schedule = NewDefaultSchedule(p.config, origin) + } else { + schedule = p.Schedules()["backup"] + } + assert.Equal(t, "/custom/path", schedule.Log) + assert.Equal(t, 30*time.Second, schedule.GetLockWait()) + assert.Equal(t, []string{"drop-in-file.conf"}, schedule.SystemdDropInFiles) + } + }) + t.Run("schedule-defaults do not apply", func(t *testing.T) { + schedule := NewSchedule(p.config, NewDefaultScheduleConfig(nil, origin)) + assert.Empty(t, schedule.Log) + assert.Equal(t, 0*time.Second, schedule.GetLockWait()) + // other global defaults are applied + assert.Equal(t, []string{"drop-in-file.conf"}, schedule.SystemdDropInFiles) + }) + }) + + t.Run("profile schedule overrides", func(t *testing.T) { + p, _ := profile(t, ` + [default] + systemd-drop-in-files = "my-systemd-drop-in.conf" + + [default.backup] + schedule-log = "overridden.log" + schedule-lock-wait = "55s" + + [default.backup.schedule] + at = "monthly" + log = "schedule.log" + lock-mode = "ignore" + lock-wait = "30s" + `) + + schedule := p.Schedules()["backup"] + assert.Equal(t, []string{"monthly"}, schedule.Schedules) + assert.Equal(t, []string{"my-systemd-drop-in.conf"}, schedule.SystemdDropInFiles) + assert.Equal(t, "overridden.log", schedule.Log) + assert.Equal(t, 55*time.Second, schedule.GetLockWait()) + assert.Equal(t, "ignore", schedule.LockMode) + }) + + t.Run("profile inline schedule", func(t *testing.T) { + p, _ := profile(t, ` + [default.backup] + schedule = ["10:00", "weekly"] + + [default.check] + schedule = "daily" + `) + + schedule := p.Schedules()["backup"] + assert.Equal(t, []string{"10:00", "weekly"}, schedule.Schedules) + schedule = p.Schedules()["check"] + assert.Equal(t, []string{"daily"}, schedule.Schedules) + }) + + t.Run("profile environment", func(t *testing.T) { + p, _ := profile(t, ` + [default.env] + MY_KEY = "value" + MY_PASSWORD = "plain" + OTHER_KEY = "value" + RESTICPROFILE_SCHEDULE_ID = "cannot-override" + + [default.backup.schedule] + at = "daily" + capture-environment = "MY_*" + `) + + schedule := p.Schedules()["backup"] + assert.Equal(t, []string{ + "MY_KEY=value", + "MY_PASSWORD=plain", + "RESTICPROFILE_SCHEDULE_ID=:backup@default", + }, schedule.Environment) + }) +} + +func TestScheduleBuiltinDefaults(t *testing.T) { + s := NewDefaultSchedule(nil, ScheduleOrigin("", "")) + require.Equal(t, scheduleBaseConfigDefaults, s.ScheduleBaseConfig) + + assert.Equal(t, "auto", s.Permission) + assert.Equal(t, "background", s.Priority) + assert.Equal(t, "default", s.LockMode) + assert.Equal(t, []string{"RESTIC_*"}, s.EnvCapture) + assert.Equal(t, ScheduleLockModeDefault, s.GetLockMode()) } func TestLockModes(t *testing.T) { - tests := map[ScheduleLockMode]Schedule{ + tests := map[ScheduleLockMode]ScheduleBaseConfig{ ScheduleLockModeDefault: {LockMode: ""}, ScheduleLockModeFail: {LockMode: constants.ScheduleLockModeOptionFail}, ScheduleLockModeIgnore: {LockMode: constants.ScheduleLockModeOptionIgnore}, } for mode, config := range tests { - assert.Equal(t, mode, config.GetLockMode()) + s := Schedule{} + s.ScheduleBaseConfig = config + assert.Equal(t, mode, s.GetLockMode()) } } func TestLockWait(t *testing.T) { - tests := map[time.Duration]Schedule{ - 0: {LockWait: 2 * time.Second}, // min lock wait is is >2 seconds - 3 * time.Second: {LockWait: 3 * time.Second}, - 120 * time.Hour: {LockWait: 120 * time.Hour}, + tests := map[time.Duration]ScheduleBaseConfig{ + 0: {LockWait: maybe.SetDuration(2 * time.Second)}, // min lock wait is is >2 seconds + 3 * time.Second: {LockWait: maybe.SetDuration(3 * time.Second)}, + 120 * time.Hour: {LockWait: maybe.SetDuration(120 * time.Hour)}, } for mode, config := range tests { - assert.Equal(t, mode, config.GetLockWait()) + s := Schedule{} + s.ScheduleBaseConfig = config + assert.Equal(t, mode, s.GetLockWait()) } } diff --git a/constants/section.go b/constants/section.go index ed36606d7..9e9bf3a60 100644 --- a/constants/section.go +++ b/constants/section.go @@ -10,9 +10,9 @@ const ( SectionConfigurationIncludes = "includes" SectionConfigurationInherit = "inherit" SectionConfigurationProfiles = "profiles" - SectionConfigurationSchedules = "schedules" SectionConfigurationMixins = "mixins" SectionConfigurationMixinUse = "use" + SectionConfigurationSchedule = "schedule" SectionDefinitionCommon = "common" SectionDefinitionForget = "forget" diff --git a/main.go b/main.go index 10c2ab5fd..c62fde840 100644 --- a/main.go +++ b/main.go @@ -541,14 +541,7 @@ func runProfile(ctx *Context) error { } func loadScheduledProfile(ctx *Context) error { - // get the list of all scheduled commands to find the current command - schedules := ctx.profile.Schedules() - for _, schedule := range schedules { - if schedule.CommandName == ctx.command { - ctx.schedule = schedule - break - } - } + ctx.schedule, _ = ctx.profile.Schedules()[ctx.command] return nil } diff --git a/schedule_jobs.go b/schedule_jobs.go index 56a934fce..43b5139e9 100644 --- a/schedule_jobs.go +++ b/schedule_jobs.go @@ -129,13 +129,14 @@ func statusJobs(handler schedule.Handler, profileName string, configs []*config. } func scheduleToConfig(sched *config.Schedule) *schedule.Config { - if len(sched.Schedules) == 0 { + origin := sched.ScheduleOrigin() + if !sched.HasSchedules() { // there's no schedule defined, so this record is for removal only - return schedule.NewRemoveOnlyConfig(sched.Profiles[0], sched.CommandName) + return schedule.NewRemoveOnlyConfig(origin.Name, origin.Command) } return &schedule.Config{ - ProfileName: sched.Profiles[0], - CommandName: sched.CommandName, + ProfileName: origin.Name, + CommandName: origin.Command, Schedules: sched.Schedules, Permission: sched.Permission, WorkingDirectory: "", @@ -147,7 +148,7 @@ func scheduleToConfig(sched *config.Schedule) *schedule.Config { Priority: sched.Priority, ConfigFile: sched.ConfigFile, Flags: sched.Flags, - IgnoreOnBattery: sched.IgnoreOnBattery, + IgnoreOnBattery: sched.IgnoreOnBattery.IsTrue(), IgnoreOnBatteryLessThan: sched.IgnoreOnBatteryLessThan, } } diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go index 48f61091b..44ab08ec9 100644 --- a/schedule_jobs_test.go +++ b/schedule_jobs_test.go @@ -12,6 +12,11 @@ import ( "github.com/stretchr/testify/mock" ) +func configForJob(command string, at ...string) *config.Schedule { + origin := config.ScheduleOrigin("profile", command) + return config.NewDefaultSchedule(nil, origin, at...) +} + func TestScheduleNilJobs(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().Init().Return(nil) @@ -36,11 +41,7 @@ func TestSimpleScheduleJob(t *testing.T) { return nil }) - scheduleConfig := &config.Schedule{ - Profiles: []string{"profile"}, - CommandName: "backup", - Schedules: []string{"sched"}, - } + scheduleConfig := configForJob("backup", "sched") err := scheduleJobs(handler, "profile", []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } @@ -57,11 +58,7 @@ func TestFailScheduleJob(t *testing.T) { mock.AnythingOfType("string")). Return(errors.New("error creating job")) - scheduleConfig := &config.Schedule{ - Profiles: []string{"profile"}, - CommandName: "backup", - Schedules: []string{"sched"}, - } + scheduleConfig := configForJob("backup", "sched") err := scheduleJobs(handler, "profile", []*config.Schedule{scheduleConfig}) assert.Error(t, err) } @@ -86,11 +83,7 @@ func TestRemoveJob(t *testing.T) { return nil }) - scheduleConfig := &config.Schedule{ - Profiles: []string{"profile"}, - CommandName: "backup", - Schedules: []string{"sched"}, - } + scheduleConfig := configForJob("backup", "sched") err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } @@ -106,10 +99,7 @@ func TestRemoveJobNoConfig(t *testing.T) { return nil }) - scheduleConfig := &config.Schedule{ - Profiles: []string{"profile"}, - CommandName: "backup", - } + scheduleConfig := configForJob("backup") err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } @@ -121,11 +111,7 @@ func TestFailRemoveJob(t *testing.T) { handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). Return(errors.New("error removing job")) - scheduleConfig := &config.Schedule{ - Profiles: []string{"profile"}, - CommandName: "backup", - Schedules: []string{"sched"}, - } + scheduleConfig := configForJob("backup", "sched") err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) assert.Error(t, err) } @@ -137,11 +123,7 @@ func TestNoFailRemoveUnknownJob(t *testing.T) { handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). Return(schedule.ErrorServiceNotFound) - scheduleConfig := &config.Schedule{ - Profiles: []string{"profile"}, - CommandName: "backup", - Schedules: []string{"sched"}, - } + scheduleConfig := configForJob("backup", "sched") err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } @@ -153,10 +135,7 @@ func TestNoFailRemoveUnknownRemoveOnlyJob(t *testing.T) { handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). Return(schedule.ErrorServiceNotFound) - scheduleConfig := &config.Schedule{ - Profiles: []string{"profile"}, - CommandName: "backup", - } + scheduleConfig := configForJob("backup") err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } @@ -180,11 +159,7 @@ func TestStatusJob(t *testing.T) { handler.EXPECT().DisplayJobStatus(mock.AnythingOfType("*schedule.Config")).Return(nil) handler.EXPECT().DisplayStatus("profile").Return(nil) - scheduleConfig := &config.Schedule{ - Profiles: []string{"profile"}, - CommandName: "backup", - Schedules: []string{"sched"}, - } + scheduleConfig := configForJob("backup", "sched") err := statusJobs(handler, "profile", []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } @@ -194,10 +169,7 @@ func TestStatusRemoveOnlyJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() - scheduleConfig := &config.Schedule{ - Profiles: []string{"profile"}, - CommandName: "backup", - } + scheduleConfig := configForJob("backup") err := statusJobs(handler, "profile", []*config.Schedule{scheduleConfig}) assert.Error(t, err) } diff --git a/util/maybe/bool.go b/util/maybe/bool.go index db0381ccb..c744223a7 100644 --- a/util/maybe/bool.go +++ b/util/maybe/bool.go @@ -2,7 +2,6 @@ package maybe import ( "reflect" - "strconv" ) type Bool struct { @@ -17,13 +16,6 @@ func True() Bool { return Bool{Set(true)} } -func (value Bool) String() string { - if !value.HasValue() { - return "" - } - return strconv.FormatBool(value.Value()) -} - func (value Bool) IsTrue() bool { return value.HasValue() && value.Value() } diff --git a/util/maybe/bool_test.go b/util/maybe/bool_test.go index 305aa5580..817697660 100644 --- a/util/maybe/bool_test.go +++ b/util/maybe/bool_test.go @@ -135,3 +135,19 @@ func TestBoolJSON(t *testing.T) { }) } } + +func TestBoolString(t *testing.T) { + fixtures := []struct { + source maybe.Bool + expected string + }{ + {source: maybe.Bool{}, expected: ""}, + {source: maybe.True(), expected: "true"}, + {source: maybe.False(), expected: "false"}, + } + for _, fixture := range fixtures { + t.Run(fixture.source.String(), func(t *testing.T) { + assert.Equal(t, fixture.expected, fixture.source.String()) + }) + } +} diff --git a/util/maybe/duration.go b/util/maybe/duration.go new file mode 100644 index 000000000..61be34c46 --- /dev/null +++ b/util/maybe/duration.go @@ -0,0 +1,44 @@ +package maybe + +import ( + "reflect" + "strings" + "time" + + "github.com/spf13/cast" +) + +type Duration struct { + Optional[time.Duration] +} + +func SetDuration(value time.Duration) Duration { + return Duration{Set(value)} +} + +// DurationDecoder implements config parsing for maybe.Duration +func DurationDecoder() func(from, to reflect.Type, data any) (any, error) { + fromType := reflect.TypeOf(time.Duration(0)) + valueType := reflect.TypeOf(Duration{}) + + return func(from, to reflect.Type, data any) (result any, err error) { + result = data + if to != valueType { + return + } + + if value, e := cast.ToDurationE(data); e == nil { + from = fromType + data = value + } else if strings.HasPrefix(e.Error(), "time:") { + err = e + } + + if err != nil || from != fromType { + return + } + + result = SetDuration(data.(time.Duration)) + return + } +} diff --git a/util/maybe/duration_test.go b/util/maybe/duration_test.go new file mode 100644 index 000000000..a3265b6f8 --- /dev/null +++ b/util/maybe/duration_test.go @@ -0,0 +1,128 @@ +package maybe_test + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "testing" + "time" + + "github.com/creativeprojects/resticprofile/util/maybe" + "github.com/stretchr/testify/assert" +) + +func TestDurationDecoder(t *testing.T) { + fixtures := []struct { + toUnexpected bool + source any + expected any + }{ + // same value returned as the "to" type in unexpected + { + toUnexpected: true, + source: "anything", + expected: "anything", + }, + // already at target type + { + source: maybe.SetDuration(5 * time.Second), + expected: maybe.SetDuration(5 * time.Second), + }, + // convert from duration + { + source: 15 * time.Second, + expected: maybe.SetDuration(15 * time.Second), + }, + // convert from number is unexpected + { + source: int(25 * time.Second), + expected: maybe.SetDuration(25 * time.Second), + }, + // convert from string + { + source: "32m60s", + expected: maybe.SetDuration(33 * time.Minute), + }, + // convert from empty string + { + source: "", + expected: errors.New(`time: invalid duration "ns"`), + }, + // string parse error + { + source: "invalid", + expected: errors.New(`time: invalid duration "invalid"`), + }, + } + for index, fixture := range fixtures { + decoder := maybe.DurationDecoder() + + t.Run(fmt.Sprintf("%d", index), func(t *testing.T) { + to := reflect.TypeOf(maybe.Duration{}) + if fixture.toUnexpected { + to = reflect.TypeOf(false) + } + from := reflect.TypeOf(fixture.source) + + decoded, err := decoder(from, to, fixture.source) + if fe, ok := fixture.expected.(error); ok { + assert.Equal(t, fe, err) + } else { + assert.NoError(t, err) + assert.Equal(t, fixture.expected, decoded) + } + }) + } +} + +func TestDurationJSON(t *testing.T) { + fixtures := []struct { + source maybe.Duration + expected string + }{ + { + source: maybe.Duration{}, + expected: "null", + }, + { + source: maybe.SetDuration(0), + expected: "0", + }, + { + source: maybe.SetDuration(25 * time.Minute), + expected: strconv.Itoa(int(25 * time.Minute)), + }, + } + for _, fixture := range fixtures { + t.Run(fixture.source.String(), func(t *testing.T) { + // encode value into JSON + encoded, err := fixture.source.MarshalJSON() + assert.NoError(t, err) + assert.Equal(t, fixture.expected, string(encoded)) + + // decode value from JSON + decodedValue := maybe.Duration{} + err = decodedValue.UnmarshalJSON(encoded) + assert.NoError(t, err) + assert.Equal(t, fixture.source, decodedValue) + }) + } +} + +func TestDurationString(t *testing.T) { + fixtures := []struct { + source maybe.Duration + expected string + }{ + {source: maybe.Duration{}, expected: ""}, + {source: maybe.SetDuration(0), expected: "0s"}, + {source: maybe.SetDuration(5 * time.Minute), expected: "5m0s"}, + {source: maybe.SetDuration(-10 * time.Minute), expected: "-10m0s"}, + } + for _, fixture := range fixtures { + t.Run(fixture.source.String(), func(t *testing.T) { + assert.Equal(t, fixture.expected, fixture.source.String()) + }) + } +} diff --git a/util/maybe/optional.go b/util/maybe/optional.go index f1ed86d37..83f44cfe9 100644 --- a/util/maybe/optional.go +++ b/util/maybe/optional.go @@ -2,6 +2,7 @@ package maybe import ( "encoding/json" + "fmt" ) type Optional[T any] struct { @@ -24,6 +25,13 @@ func (m Optional[T]) Value() T { return m.value } +func (m Optional[T]) String() string { + if !m.HasValue() { + return "" + } + return fmt.Sprintf("%v", m.Value()) +} + func (m *Optional[T]) UnmarshalJSON(data []byte) error { var t *T if err := json.Unmarshal(data, &t); err != nil { From 51f0d45bc41b37bb7cdb9604d9e63de10cd3dab0 Mon Sep 17 00:00:00 2001 From: Juergen Kellerer Date: Sun, 3 Mar 2024 21:42:28 +0100 Subject: [PATCH 2/5] schedule: fixed tests --- config/schedule_test.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/config/schedule_test.go b/config/schedule_test.go index 9fb37328a..ccfbc9fa0 100644 --- a/config/schedule_test.go +++ b/config/schedule_test.go @@ -47,7 +47,7 @@ func TestNewSchedule(t *testing.T) { systemd-drop-in-files = "drop-in-file.conf" [global.schedule-defaults] - log = "/custom/path" + log = "global-custom.log" lock-wait = "30s" [default.backup] @@ -61,7 +61,7 @@ func TestNewSchedule(t *testing.T) { } else { schedule = p.Schedules()["backup"] } - assert.Equal(t, "/custom/path", schedule.Log) + assert.Equal(t, "global-custom.log", schedule.Log) assert.Equal(t, 30*time.Second, schedule.GetLockWait()) assert.Equal(t, []string{"drop-in-file.conf"}, schedule.SystemdDropInFiles) } @@ -136,6 +136,11 @@ func TestNewSchedule(t *testing.T) { }) } +func TestQueryNilScheduleConfig(t *testing.T) { + var config *ScheduleConfig + assert.False(t, config.HasSchedules()) +} + func TestScheduleBuiltinDefaults(t *testing.T) { s := NewDefaultSchedule(nil, ScheduleOrigin("", "")) require.Equal(t, scheduleBaseConfigDefaults, s.ScheduleBaseConfig) @@ -162,7 +167,7 @@ func TestLockModes(t *testing.T) { func TestLockWait(t *testing.T) { tests := map[time.Duration]ScheduleBaseConfig{ - 0: {LockWait: maybe.SetDuration(2 * time.Second)}, // min lock wait is is >2 seconds + 0: {LockWait: maybe.SetDuration(2 * time.Second)}, // min lock wait is >2 seconds 3 * time.Second: {LockWait: maybe.SetDuration(3 * time.Second)}, 120 * time.Hour: {LockWait: maybe.SetDuration(120 * time.Hour)}, } From f7755a351eb89facc3a1cede5ec3c19f8686e84b Mon Sep 17 00:00:00 2001 From: Juergen Kellerer Date: Sat, 9 Mar 2024 13:55:46 +0100 Subject: [PATCH 3/5] schedule: more tests & cleanup --- config/config.go | 2 +- config/group.go | 22 ++-- config/info_customizer.go | 2 + config/info_customizer_test.go | 70 ++++++++++++ config/jsonschema/model.go | 4 +- config/jsonschema/model_test.go | 4 +- config/jsonschema/schema_test.go | 8 +- config/profile.go | 1 + config/schedule.go | 81 +++++++++----- config/schedule_test.go | 183 +++++++++++++++++++++++++++++++ constants/env.go | 1 + docs/content/usage/_index.md | 5 + examples/schedules.yaml | 35 ++++++ flags.go | 18 ++- flags_test.go | 16 +++ util/bools/bools.go | 31 ------ util/bools/bools_test.go | 61 ----------- util/maybe/bool.go | 19 +++- util/maybe/bool_test.go | 19 +++- util/maybe/duration.go | 12 +- util/maybe/optional.go | 9 ++ 21 files changed, 448 insertions(+), 155 deletions(-) create mode 100644 examples/schedules.yaml delete mode 100644 util/bools/bools.go delete mode 100644 util/bools/bools_test.go diff --git a/config/config.go b/config/config.go index 7c7c0ef6d..dabb4ecb2 100644 --- a/config/config.go +++ b/config/config.go @@ -487,7 +487,7 @@ func (c *Config) GetGlobalSection() (*Global, error) { return global, nil } -// mustGetGlobalSection returns a cached global configuration, panics if it can't be loaded (is for internal use) +// mustGetGlobalSection returns a cached global configuration, panics if it can't be loaded (for internal use only) func (c *Config) mustGetGlobalSection() *Global { if c.cached.global == nil { var err error diff --git a/config/group.go b/config/group.go index 59fe3fb19..60bd86b58 100644 --- a/config/group.go +++ b/config/group.go @@ -5,11 +5,11 @@ import "github.com/creativeprojects/resticprofile/util/maybe" // Group of profiles type Group struct { config *Config - Name string `show:"noshow"` - Description string `mapstructure:"description" description:"Describe the group"` - Profiles []string `mapstructure:"profiles" description:"Names of the profiles belonging to this group"` - ContinueOnError maybe.Bool `mapstructure:"continue-on-error" default:"auto" description:"Continue with the next profile on a failure, overrides \"global.group-continue-on-error\""` - CommandSchedules map[string]ScheduleConfig `mapstructure:"schedules" description:"Allows to run the group on schedule for the specified command name."` + Name string `show:"noshow"` + Description string `mapstructure:"description" description:"Describe the group"` + Profiles []string `mapstructure:"profiles" description:"Names of the profiles belonging to this group"` + ContinueOnError maybe.Bool `mapstructure:"continue-on-error" default:"auto" description:"Continue with the next profile on a failure, overrides \"global.group-continue-on-error\""` + CommandSchedules map[string]*ScheduleConfig `mapstructure:"schedules" description:"Allows to run the group on schedule for the specified command name."` } func NewGroup(c *Config, name string) (g *Group) { @@ -23,15 +23,21 @@ func NewGroup(c *Config, name string) (g *Group) { func (g *Group) ResolveConfiguration() { global := g.config.mustGetGlobalSection() for command, cfg := range g.CommandSchedules { - cfg.init(global.ScheduleDefaults) - cfg.origin = ScheduleOrigin(g.Name, command, ScheduleOriginGroup) + if cfg.HasSchedules() { + cfg.init(global.ScheduleDefaults) + cfg.origin = ScheduleOrigin(g.Name, command, ScheduleOriginGroup) + } else { + delete(g.CommandSchedules, command) + } } } func (g *Group) Schedules() map[string]*Schedule { schedules := make(map[string]*Schedule) for command, cfg := range g.CommandSchedules { - schedules[command] = NewSchedule(g.config, &cfg) + if cfg.HasSchedules() { + schedules[command] = NewSchedule(g.config, cfg) + } } return schedules } diff --git a/config/info_customizer.go b/config/info_customizer.go index 4f67aaa70..fcb0f92f1 100644 --- a/config/info_customizer.go +++ b/config/info_customizer.go @@ -146,6 +146,7 @@ func init() { if util.ElementType(field.Type).AssignableTo(maybeBoolType) { basic := property.basic().resetTypeInfo() basic.mayBool = true + basic.mayNil = true } } }) @@ -160,6 +161,7 @@ func init() { basic.mustInt = true basic.format = "duration" basic.mayString = true + basic.mayNil = true } } }) diff --git a/config/info_customizer_test.go b/config/info_customizer_test.go index cebcc4449..d867335eb 100644 --- a/config/info_customizer_test.go +++ b/config/info_customizer_test.go @@ -196,6 +196,7 @@ func TestMaybeBoolProperty(t *testing.T) { for _, name := range set.Properties() { t.Run(name, func(t *testing.T) { info := set.PropertyInfo(name) + assert.True(t, info.CanBeNil()) assert.True(t, info.CanBeBool()) assert.False(t, info.CanBePropertySet()) assert.False(t, info.IsMultiType()) @@ -203,6 +204,75 @@ func TestMaybeBoolProperty(t *testing.T) { } } +func TestMaybeDurationProperty(t *testing.T) { + var testType = struct { + Simple maybe.Duration `mapstructure:"simple"` + }{} + + set := propertySetFromType(reflect.TypeOf(testType)) + + assert.ElementsMatch(t, []string{"simple"}, set.Properties()) + for _, name := range set.Properties() { + t.Run(name+"/before", func(t *testing.T) { + info := set.PropertyInfo(name) + require.True(t, info.CanBePropertySet()) + assert.Equal(t, "Duration", info.PropertySet().TypeName()) + assert.False(t, info.CanBeNumeric()) + assert.False(t, info.IsMultiType()) + }) + } + + customizeProperties("any", set.properties) + + for _, name := range set.Properties() { + t.Run(name, func(t *testing.T) { + info := set.PropertyInfo(name) + assert.True(t, info.CanBeNil()) + assert.True(t, info.CanBeNumeric()) + assert.True(t, info.CanBeString()) + assert.True(t, info.IsMultiType()) + assert.False(t, info.CanBeBool()) + assert.False(t, info.CanBePropertySet()) + assert.Equal(t, "duration", info.Format()) + }) + } +} + +func TestScheduleProperty(t *testing.T) { + var testType = struct { + Schedule any `mapstructure:"schedule"` + }{} + + set := propertySetFromType(reflect.TypeOf(testType)) + + assert.ElementsMatch(t, []string{"schedule"}, set.Properties()) + for _, name := range set.Properties() { + t.Run(name+"/before", func(t *testing.T) { + info := set.PropertyInfo(name) + require.True(t, info.IsAnyType()) + }) + } + + customizeProperties("any", set.properties) + + for _, name := range set.Properties() { + t.Run(name, func(t *testing.T) { + info := set.PropertyInfo(name) + assert.True(t, info.CanBeNil()) + assert.True(t, info.IsMultiType()) + assert.True(t, info.CanBeString()) + assert.False(t, info.CanBeNumeric()) + assert.False(t, info.CanBeBool()) + + require.True(t, info.CanBePropertySet()) + assert.Equal(t, NewScheduleConfigInfo().Name(), info.PropertySet().Name()) + + assert.False(t, info.IsSingle(), "multiple strings") + assert.True(t, info.IsSinglePropertySet(), "just one nested type") + }) + } +} + func TestDeprecatedSection(t *testing.T) { var testType = struct { ScheduleBaseSection `mapstructure:",squash" deprecated:"true"` diff --git a/config/jsonschema/model.go b/config/jsonschema/model.go index 11bf52ce1..08a397edd 100644 --- a/config/jsonschema/model.go +++ b/config/jsonschema/model.go @@ -10,7 +10,7 @@ import ( "strings" "github.com/creativeprojects/resticprofile/config" - "github.com/creativeprojects/resticprofile/util" + "github.com/creativeprojects/resticprofile/util/maybe" ) const ( @@ -172,7 +172,7 @@ func (s *schemaTypeBase) verify() (err error) { func (s *schemaTypeBase) setDeprecated(value bool) { if value { - s.Deprecated = util.CopyRef(value) + s.Deprecated = maybe.True().Nilable() } else { s.Deprecated = nil } diff --git a/config/jsonschema/model_test.go b/config/jsonschema/model_test.go index 549a1093a..ced63ce1d 100644 --- a/config/jsonschema/model_test.go +++ b/config/jsonschema/model_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/creativeprojects/resticprofile/config" - "github.com/creativeprojects/resticprofile/util/bools" + "github.com/creativeprojects/resticprofile/util/maybe" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -51,7 +51,7 @@ func TestBase(t *testing.T) { assert.Same(t, base, base.base()) base.setDeprecated(true) - assert.Equal(t, bools.True(), base.Deprecated) + assert.Equal(t, maybe.True().Nilable(), base.Deprecated) base.setDeprecated(false) assert.Nil(t, base.Deprecated) diff --git a/config/jsonschema/schema_test.go b/config/jsonschema/schema_test.go index 8f0e374bb..77b5ae9e0 100644 --- a/config/jsonschema/schema_test.go +++ b/config/jsonschema/schema_test.go @@ -19,8 +19,8 @@ import ( "github.com/creativeprojects/resticprofile/config/mocks" "github.com/creativeprojects/resticprofile/restic" "github.com/creativeprojects/resticprofile/util" - "github.com/creativeprojects/resticprofile/util/bools" "github.com/creativeprojects/resticprofile/util/collect" + "github.com/creativeprojects/resticprofile/util/maybe" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -139,7 +139,7 @@ func TestJsonSchemaValidation(t *testing.T) { } extensionMatcher := regexp.MustCompile(`\.(conf|toml|yaml|json)$`) - version2Matcher := regexp.MustCompile(`^version[:=\s]+2`) + version2Matcher := regexp.MustCompile(`"version":\s*"2`) exclusions := regexp.MustCompile(`[\\/](rsyslogd\.conf|utf.*\.conf)$`) testCount := 0 @@ -161,7 +161,7 @@ func TestJsonSchemaValidation(t *testing.T) { content, e = os.ReadFile(filename) assert.NoError(t, e) schema := schema1 - if version2Matcher.Match(content) { + if version2Matcher.Find(content) != nil { schema = schema2 } @@ -662,7 +662,7 @@ func TestConfigureBasicInfo(t *testing.T) { schemaType := newType() configureBasicInfo(schemaType, nil, newMock("IsDeprecated", true)) each(schemaType, func(item SchemaType) { - assert.Equal(t, bools.True(), item.base().Deprecated) + assert.Equal(t, maybe.True().Nilable(), item.base().Deprecated) }) }) diff --git a/config/profile.go b/config/profile.go index 947561f68..21ab4cb27 100644 --- a/config/profile.go +++ b/config/profile.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/Masterminds/semver/v3" + "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/restic" "github.com/creativeprojects/resticprofile/shell" diff --git a/config/schedule.go b/config/schedule.go index fe540402a..97c911909 100644 --- a/config/schedule.go +++ b/config/schedule.go @@ -15,6 +15,7 @@ import ( "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/util" + "github.com/creativeprojects/resticprofile/util/collect" "github.com/creativeprojects/resticprofile/util/maybe" "github.com/spf13/cast" ) @@ -111,6 +112,17 @@ type ScheduleConfigOrigin struct { Name, Command string } +func (o ScheduleConfigOrigin) Compare(other ScheduleConfigOrigin) (c int) { + c = int(other.Type) - int(o.Type) // groups first + if c == 0 { + c = strings.Compare(o.Name, other.Name) + } + if c == 0 { + c = strings.Compare(o.Command, other.Command) + } + return +} + func (o ScheduleConfigOrigin) String() string { kind := "" if o.Type == ScheduleOriginGroup { @@ -131,6 +143,7 @@ func ScheduleOrigin(name, command string, kind ...ScheduleOriginType) (s Schedul // ScheduleConfig is the user configuration of a specific schedule bound to a command in a profile or group. type ScheduleConfig struct { + normalized bool origin ScheduleConfigOrigin `show:"noshow"` Schedules []string `mapstructure:"at" examples:"hourly;daily;weekly;monthly;10:00,14:00,18:00,22:00;Wed,Fri 17:48;*-*-15 02:45;Mon..Fri 00:30" description:"Set the times at which the scheduled command is run (times are specified in systemd timer format)"` ScheduleBaseConfig `mapstructure:",squash"` @@ -145,7 +158,7 @@ func NewDefaultScheduleConfig(config *Config, origin ScheduleConfigOrigin, sched s = new(ScheduleConfig) if len(schedules) > 0 { - s.Schedules = slices.Clone(schedules) + s.setSchedules(schedules) } s.init(defaults) s.origin = origin @@ -153,44 +166,62 @@ func NewDefaultScheduleConfig(config *Config, origin ScheduleConfigOrigin, sched } func newScheduleConfig(config *Config, section *ScheduleBaseSection) (s *ScheduleConfig) { - s = new(ScheduleConfig) + origin := ScheduleConfigOrigin{} // is set later // decode ScheduleBaseSection.Schedule switch expression := section.Schedule.(type) { case string: - s.Schedules = append(s.Schedules, expression) + s = NewDefaultScheduleConfig(config, origin, expression) case []string, []any: - s.Schedules = append(s.Schedules, cast.ToStringSlice(expression)...) + s = NewDefaultScheduleConfig(config, origin, cast.ToStringSlice(expression)...) default: if expression != nil { - decoder, err := config.newUnmarshaller(s) + cfg := NewDefaultScheduleConfig(config, origin) + decoder, err := config.newUnmarshaller(cfg) if err == nil { err = decoder.Decode(expression) } - if err != nil { + if err == nil { + s = cfg + } else { if bytes, e := json.Marshal(expression); e == nil { expression = string(bytes) } clog.Errorf("failed decoding schedule %v: %s", expression, err.Error()) - s = nil } } } // init - if s != nil { - s.init(config.mustGetGlobalSection().ScheduleDefaults) + if s.HasSchedules() { s.applyOverrides(section) + } else { + s = nil } return } -func (s *ScheduleConfig) ScheduleOrigin() ScheduleConfigOrigin { - return s.origin +func (s *ScheduleConfig) setSchedules(schedules []string) { + schedules = collect.From(schedules, strings.TrimSpace) + schedules = collect.All(schedules, func(at string) bool { return len(at) > 0 }) + s.Schedules = schedules + s.normalized = true } +// HasSchedules returns true if the normalized list of schedules is not empty. +// The func is nil tolerant and returns false for config.Schedule(nil).HasSchedules() func (s *ScheduleConfig) HasSchedules() bool { - return s != nil && len(s.Schedules) > 0 + if s == nil { + return false + } + if !s.normalized { + s.setSchedules(s.Schedules) + } + return len(s.Schedules) > 0 +} + +func (s *ScheduleConfig) ScheduleOrigin() ScheduleConfigOrigin { + return s.origin } // Schedulable may be implemented by sections that can provide command schedules (= groups and profiles) @@ -224,7 +255,7 @@ func newScheduleForProfile(profile *Profile, sc *ScheduleConfig) *Schedule { if origin.Type == ScheduleOriginProfile && origin.Name == profile.Name { return newSchedule(profile.config, sc, profile) } - panic(fmt.Sprintf("invalid use of newScheduleForProfile(%s, %s)", profile.Name, origin)) + panic(fmt.Errorf("invalid use of newScheduleForProfile(%s, %s)", profile.Name, origin)) } func newSchedule(config *Config, sc *ScheduleConfig, profile *Profile) *Schedule { @@ -251,11 +282,7 @@ func newSchedule(config *Config, sc *ScheduleConfig, profile *Profile) *Schedule s.SystemdDropInFiles = profile.SystemdDropInFiles } - // env - todo: replace with profile.GetEnvironment(withOs=true) when available - env = util.NewDefaultEnvironment(os.Environ()...) - for k, v := range profile.Environment { - env.Put(env.ResolveName(k), v.Value()) - } + env = profile.GetEnvironment(true) } // init @@ -301,12 +328,12 @@ func (s *Schedule) init(env *util.Environment) { } } - env.Remove("RESTICPROFILE_SCHEDULE_ID") + env.Remove(constants.EnvScheduleId) s.Environment = env.Values() } // add the ID of the schedule so that shell hooks can know in which schedule they're in - s.Environment = append(s.Environment, fmt.Sprintf("RESTICPROFILE_SCHEDULE_ID=%s", s.GetId())) + s.Environment = append(s.Environment, fmt.Sprintf("%s=%s", constants.EnvScheduleId, s.GetId())) sort.Strings(s.Environment) } @@ -315,12 +342,14 @@ func (s *Schedule) GetId() string { } func (s *Schedule) Compare(other *Schedule) (c int) { - c = int(other.origin.Type) - int(s.origin.Type) - if c == 0 { - c = strings.Compare(s.origin.Name, other.origin.Name) - } - if c == 0 { - c = strings.Compare(s.origin.Command, other.origin.Command) + if s == other { + c = 0 + } else if s == nil { + c = -1 + } else if other == nil { + c = 1 + } else { + c = s.origin.Compare(other.origin) } return } diff --git a/config/schedule_test.go b/config/schedule_test.go index ccfbc9fa0..f3e5e19de 100644 --- a/config/schedule_test.go +++ b/config/schedule_test.go @@ -1,10 +1,14 @@ package config import ( + "bytes" + "path/filepath" + "regexp" "strings" "testing" "time" + "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/util/maybe" "github.com/stretchr/testify/assert" @@ -41,6 +45,15 @@ func TestNewSchedule(t *testing.T) { assert.False(t, schedule.HasSchedules()) }) + t.Run("profile origin", func(t *testing.T) { + // Ensure DefaultSchedule can be used as remove-only config + p, origin := profile(t, ` + [default.backup] + schedule = "daily" + `) + assert.Equal(t, origin, p.Schedules()["backup"].ScheduleOrigin()) + }) + t.Run("global defaults", func(t *testing.T) { p, origin := profile(t, ` [global] @@ -99,6 +112,32 @@ func TestNewSchedule(t *testing.T) { assert.Equal(t, "ignore", schedule.LockMode) }) + t.Run("profile schedule parse error", func(t *testing.T) { + defaultLogger := clog.GetDefaultLogger() + defer clog.SetDefaultLogger(defaultLogger) + mem := clog.NewMemoryHandler() + clog.SetDefaultLogger(clog.NewLogger(mem)) + + p, _ := profile(t, ` + [default.backup.schedule] + at = true + non-existing = "xyz" + + [default.check.schedule] + at = "daily" + lock-wait = ["invalid"] + `) + + schedule := p.Schedules()["backup"] + assert.Equal(t, []string{"1"}, schedule.Schedules) + + assert.Nil(t, p.Schedules()["check"]) + msg := `failed decoding schedule {"at":"daily","lock-wait":["invalid"]}: 1 error(s) decoding: + +* 'lock-wait' expected a map, got 'slice'` + assert.Contains(t, mem.Logs(), msg) + }) + t.Run("profile inline schedule", func(t *testing.T) { p, _ := profile(t, ` [default.backup] @@ -114,6 +153,19 @@ func TestNewSchedule(t *testing.T) { assert.Equal(t, []string{"daily"}, schedule.Schedules) }) + t.Run("profile undefined schedule", func(t *testing.T) { + p, _ := profile(t, ` + [default.backup] + schedule = "" + + [default.check.schedule] + at = "" + `) + + assert.Nil(t, p.Schedules()["backup"]) + assert.Nil(t, p.Schedules()["check"]) + }) + t.Run("profile environment", func(t *testing.T) { p, _ := profile(t, ` [default.env] @@ -136,11 +188,142 @@ func TestNewSchedule(t *testing.T) { }) } +func TestNewScheduleFromGroup(t *testing.T) { + group := func(t *testing.T, config string) (*Group, ScheduleConfigOrigin) { + config = "version = \"2\"\n\n" + config + if !strings.Contains(config, "profiles =") { + config += ` + [groups.default] + profiles = "default" + ` + } + c, err := Load(bytes.NewBufferString(config), "toml") + require.NoError(t, err) + group, err := c.GetProfileGroup("default") + require.NoError(t, err) + require.NotNil(t, group) + return group, ScheduleOrigin(group.Name, constants.CommandBackup, ScheduleOriginGroup) + } + + t.Run("group without schedule", func(t *testing.T) { + g, _ := group(t, ``) + assert.Empty(t, g.Schedules()) + }) + + t.Run("group with undefined schedule", func(t *testing.T) { + g, _ := group(t, ` + [groups.default.schedules.backup] + at = "" # empty schedule + `) + assert.Empty(t, g.Schedules()) + }) + + t.Run("group with schedule", func(t *testing.T) { + g, _ := group(t, ` + [groups.default.schedules.backup] + at = "daily" + log = "group-backup.log" + [groups.default.schedules.check] + at = "monthly" + log = "group-check.log" + `) + require.Len(t, g.Schedules(), 2) + backup, check := g.Schedules()["backup"], g.Schedules()["check"] + require.NotNil(t, backup) + require.Equal(t, []string{"daily"}, backup.Schedules) + require.Equal(t, "group-backup.log", backup.Log) + require.Equal(t, []string{"monthly"}, check.Schedules) + require.Equal(t, "group-check.log", check.Log) + }) + + t.Run("group origin", func(t *testing.T) { + g, origin := group(t, ` + [groups.default.schedules.backup] + at = "daily" + `) + assert.Equal(t, origin, g.Schedules()["backup"].ScheduleOrigin()) + }) + + t.Run("global defaults", func(t *testing.T) { + g, origin := group(t, ` + [global] + systemd-drop-in-files = "drop-in-file.conf" + + [global.schedule-defaults] + log = "global-custom.log" + lock-wait = "30s" + + [groups.default.schedules.backup] + at = "daily" + `) + t.Run("schedule-defaults apply", func(t *testing.T) { + for i := 0; i < 2; i++ { + var schedule *Schedule + if i == 0 { + schedule = NewDefaultSchedule(g.config, origin) + } else { + schedule = g.Schedules()["backup"] + assert.Equal(t, []string{"daily"}, schedule.Schedules) + } + assert.Equal(t, "global-custom.log", schedule.Log) + assert.Equal(t, 30*time.Second, schedule.GetLockWait()) + assert.Equal(t, []string{"drop-in-file.conf"}, schedule.SystemdDropInFiles) + } + }) + }) +} + func TestQueryNilScheduleConfig(t *testing.T) { var config *ScheduleConfig assert.False(t, config.HasSchedules()) } +func TestNormalizeLogPath(t *testing.T) { + sep := regexp.MustCompile(`[/\\]`) + baseDir, _ := filepath.Abs(t.TempDir()) + s := NewSchedule(nil, NewDefaultScheduleConfig(nil, ScheduleOrigin("", ""))) + s.ConfigFile = filepath.Join(baseDir, "profiles.yaml") + + expected := filepath.Join(baseDir, "schedule.log") + s.Log = "schedule.log" + s.init(nil) + assert.Equal(t, sep.Split(expected, -1), sep.Split(s.Log, -1)) + + expected = "tcp://localhost" + s.Log = "tcp://localhost" + s.init(nil) + assert.Equal(t, expected, s.Log) +} + +func TestCompareSchedules(t *testing.T) { + cfgA := NewDefaultScheduleConfig(nil, ScheduleOrigin("a-name", "a-command")) + cfgB := NewDefaultScheduleConfig(nil, ScheduleOrigin("b-name", "b-command")) + cfgC := NewDefaultScheduleConfig(nil, ScheduleOrigin("a-name", "b-command")) + a, b, c := NewSchedule(nil, cfgA), NewSchedule(nil, cfgB), NewSchedule(nil, cfgC) + + assert.Equal(t, 0, CompareSchedules(nil, nil)) + assert.Equal(t, 0, CompareSchedules(a, a)) + assert.Equal(t, 0, CompareSchedules(b, b)) + assert.Equal(t, 1, CompareSchedules(a, nil)) + assert.Equal(t, -1, CompareSchedules(nil, a)) + assert.Equal(t, -1, CompareSchedules(a, b)) + assert.Equal(t, 1, CompareSchedules(b, a)) + assert.Equal(t, 1, CompareSchedules(c, a)) + assert.Equal(t, -1, CompareSchedules(a, c)) +} + +func TestScheduleForProfileEnforcesOrigin(t *testing.T) { + profile := NewProfile(nil, "profile") + config := NewDefaultScheduleConfig(nil, ScheduleOrigin(profile.Name, "backup", ScheduleOriginGroup)) + + assert.PanicsWithError(t, "invalid use of newScheduleForProfile(profile, g:backup@profile)", func() { + newScheduleForProfile(profile, config) + }) + + config.origin.Type = ScheduleOriginProfile + assert.NotNil(t, newScheduleForProfile(profile, config)) +} + func TestScheduleBuiltinDefaults(t *testing.T) { s := NewDefaultSchedule(nil, ScheduleOrigin("", "")) require.Equal(t, scheduleBaseConfigDefaults, s.ScheduleBaseConfig) diff --git a/constants/env.go b/constants/env.go index 8120346d5..17e61f482 100644 --- a/constants/env.go +++ b/constants/env.go @@ -8,4 +8,5 @@ const ( EnvErrorCommandLine = "ERROR_COMMANDLINE" EnvErrorExitCode = "ERROR_EXIT_CODE" EnvErrorStderr = "ERROR_STDERR" + EnvScheduleId = "RESTICPROFILE_SCHEDULE_ID" ) diff --git a/docs/content/usage/_index.md b/docs/content/usage/_index.md index 43b187937..005e503fb 100644 --- a/docs/content/usage/_index.md +++ b/docs/content/usage/_index.md @@ -124,3 +124,8 @@ Most flags for resticprofile can be set using environment variables. If both are |---------------------------------|---------|--------------------------------------------------------------------------------------| | `RESTICPROFILE_PWSH_NO_AUTOENV` | _empty_ | Disables powershell script pre-processing that converts unset `$VAR` into `$Env:VAR` | +### Environment variables set by resticprofile + +| Environment Variable | Example | When | +|-----------------------------|--------------------------------|-------------------------------------| +| `RESTICPROFILE_SCHEDULE_ID` | `profiles.yaml:backup@profile` | Set when running scheduled commands | diff --git a/examples/schedules.yaml b/examples/schedules.yaml new file mode 100644 index 000000000..6ebbbf79e --- /dev/null +++ b/examples/schedules.yaml @@ -0,0 +1,35 @@ +version: "2" + +global: + systemd-drop-in-files: + - my-drop-in.conf + schedule-defaults: + log: "scheduling.log" + lock-wait: 120m + after-network-online: true + +profiles: + __base: + repository: "file:/mnt/backup" + + default: + inherit: "__base" + check: + read-data: true + schedule: "monthly" + prune: + schedule: "weekly" + + system: + inherit: "__base" + + backup: + source: "/etc" + +groups: + all: + profiles: "*" + schedules: + backup: + at: "hourly" + lock-wait: "15m" diff --git a/flags.go b/flags.go index 68351102e..0c4a335e0 100644 --- a/flags.go +++ b/flags.go @@ -1,6 +1,7 @@ package main import ( + "github.com/creativeprojects/clog" "os" "slices" "strings" @@ -40,18 +41,25 @@ type commandLineFlags struct { func envValueOverride[T any](defaultValue T, keys ...string) T { for _, key := range keys { if value := strings.TrimSpace(os.Getenv(key)); len(value) > 0 { - var v any = defaultValue + var ( + err error + v any = defaultValue + ) switch v.(type) { case bool: - v = cast.ToBool(value) + v, err = cast.ToBoolE(value) case int: - v = cast.ToInt(value) + v, err = cast.ToIntE(value) case time.Duration: - v = cast.ToDuration(value) + v, err = cast.ToDurationE(value) case string: v = value } - defaultValue = v.(T) + if err == nil { + defaultValue = v.(T) + } else { + clog.Errorf("cannot convert env variable %s=%q: %s", key, value, err.Error()) + } break } } diff --git a/flags_test.go b/flags_test.go index 65768396e..36a75161a 100644 --- a/flags_test.go +++ b/flags_test.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "github.com/creativeprojects/clog" "os" "testing" "time" @@ -97,6 +98,21 @@ func TestEnvOverrides(t *testing.T) { }) } +func TestEnvOverridesError(t *testing.T) { + logger := clog.GetDefaultLogger() + defer clog.SetDefaultLogger(logger) + mem := clog.NewMemoryHandler() + clog.SetDefaultLogger(clog.NewLogger(mem)) + + assert.NoError(t, os.Setenv("RESTICPROFILE_LOCK_WAIT", "no-valid-duration")) + t.Cleanup(func() { _ = os.Unsetenv("RESTICPROFILE_LOCK_WAIT") }) + + _, loaded, err := loadFlags([]string{"--verbose"}) + assert.NoError(t, err) + assert.NotNil(t, loaded) + assert.Contains(t, mem.Logs(), `cannot convert env variable RESTICPROFILE_LOCK_WAIT="no-valid-duration": time: invalid duration "no-valid-duration"`) +} + func TestProfileCommandWithProfileNamePrecedence(t *testing.T) { _, flags, err := loadFlags([]string{"-n", "profile2", "-v", "some.command"}) require.NoError(t, err) diff --git a/util/bools/bools.go b/util/bools/bools.go deleted file mode 100644 index 693f9b44f..000000000 --- a/util/bools/bools.go +++ /dev/null @@ -1,31 +0,0 @@ -package bools - -import "github.com/creativeprojects/resticprofile/util" - -func IsTrue(value *bool) bool { - return util.NotNilAnd(value, true) -} - -func IsStrictlyFalse(value *bool) bool { - return util.NotNilAnd(value, false) -} - -func IsFalseOrUndefined(value *bool) bool { - return util.NilOr(value, false) -} - -func IsUndefined(value *bool) bool { - return value == nil -} - -func IsTrueOrUndefined(value *bool) bool { - return util.NilOr(value, true) -} - -func False() *bool { - return util.CopyRef(false) -} - -func True() *bool { - return util.CopyRef(true) -} diff --git a/util/bools/bools_test.go b/util/bools/bools_test.go deleted file mode 100644 index de5a0e8a8..000000000 --- a/util/bools/bools_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package bools - -import ( - "strconv" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestBools(t *testing.T) { - fixtures := []struct { - source *bool - isTrue bool - isStrictlyFalse bool - isFalseOrUndefined bool - isUndefined bool - isTrueOrUndefined bool - }{ - { - source: nil, - isTrue: false, - isStrictlyFalse: false, - isFalseOrUndefined: true, - isUndefined: true, - isTrueOrUndefined: true, - }, - { - source: True(), - isTrue: true, - isStrictlyFalse: false, - isFalseOrUndefined: false, - isUndefined: false, - isTrueOrUndefined: true, - }, - { - source: False(), - isTrue: false, - isStrictlyFalse: true, - isFalseOrUndefined: true, - isUndefined: false, - isTrueOrUndefined: false, - }, - } - - for _, fixture := range fixtures { - t.Run(toString(fixture.source), func(t *testing.T) { - assert.Equal(t, fixture.isTrue, IsTrue(fixture.source)) - assert.Equal(t, fixture.isStrictlyFalse, IsStrictlyFalse(fixture.source)) - assert.Equal(t, fixture.isFalseOrUndefined, IsFalseOrUndefined(fixture.source)) - assert.Equal(t, fixture.isUndefined, IsUndefined(fixture.source)) - assert.Equal(t, fixture.isTrueOrUndefined, IsTrueOrUndefined(fixture.source)) - }) - } -} - -func toString(value *bool) string { - if value == nil { - return "" - } - return strconv.FormatBool(*value) -} diff --git a/util/maybe/bool.go b/util/maybe/bool.go index c744223a7..11b850d23 100644 --- a/util/maybe/bool.go +++ b/util/maybe/bool.go @@ -8,13 +8,13 @@ type Bool struct { Optional[bool] } -func False() Bool { - return Bool{Set(false)} -} +func SetBool(value bool) Bool { return Bool{Set(value)} } -func True() Bool { - return Bool{Set(true)} -} +func UnsetBool() Bool { return Bool{} } + +func False() Bool { return SetBool(false) } + +func True() Bool { return SetBool(true) } func (value Bool) IsTrue() bool { return value.HasValue() && value.Value() @@ -36,6 +36,13 @@ func (value Bool) IsTrueOrUndefined() bool { return !value.HasValue() || value.Value() == true } +func BoolFromNilable(value *bool) Bool { + if value == nil { + return UnsetBool() + } + return SetBool(*value) +} + // BoolDecoder implements config parsing for maybe.Bool func BoolDecoder() func(from reflect.Type, to reflect.Type, data interface{}) (interface{}, error) { boolValueType := reflect.TypeOf(Bool{}) diff --git a/util/maybe/bool_test.go b/util/maybe/bool_test.go index 817697660..280a2934c 100644 --- a/util/maybe/bool_test.go +++ b/util/maybe/bool_test.go @@ -4,6 +4,7 @@ import ( "reflect" "testing" + "github.com/creativeprojects/resticprofile/util" "github.com/creativeprojects/resticprofile/util/maybe" "github.com/stretchr/testify/assert" ) @@ -54,6 +55,20 @@ func TestMaybeBool(t *testing.T) { } } +func TestBoolCreators(t *testing.T) { + assert.Equal(t, maybe.Bool{}, maybe.UnsetBool()) + assert.Equal(t, maybe.True(), maybe.SetBool(true)) + assert.Equal(t, maybe.False(), maybe.SetBool(false)) + + assert.Equal(t, maybe.UnsetBool(), maybe.BoolFromNilable(nil)) + assert.Equal(t, maybe.True(), maybe.BoolFromNilable(util.CopyRef(true))) + assert.Equal(t, maybe.False(), maybe.BoolFromNilable(util.CopyRef(false))) + + assert.Nil(t, maybe.UnsetBool().Nilable()) + assert.Equal(t, util.CopyRef(true), maybe.True().Nilable()) + assert.Equal(t, util.CopyRef(false), maybe.False().Nilable()) +} + func TestBoolDecoder(t *testing.T) { fixtures := []struct { from reflect.Type @@ -108,7 +123,7 @@ func TestBoolJSON(t *testing.T) { expected string }{ { - source: maybe.Bool{}, + source: maybe.UnsetBool(), expected: "null", }, { @@ -141,7 +156,7 @@ func TestBoolString(t *testing.T) { source maybe.Bool expected string }{ - {source: maybe.Bool{}, expected: ""}, + {source: maybe.UnsetBool(), expected: ""}, {source: maybe.True(), expected: "true"}, {source: maybe.False(), expected: "false"}, } diff --git a/util/maybe/duration.go b/util/maybe/duration.go index 61be34c46..478fd3297 100644 --- a/util/maybe/duration.go +++ b/util/maybe/duration.go @@ -8,13 +8,13 @@ import ( "github.com/spf13/cast" ) +// Duration implements Optional[time.Duration] type Duration struct { Optional[time.Duration] } -func SetDuration(value time.Duration) Duration { - return Duration{Set(value)} -} +// SetDuration returns a maybe.Duration with value +func SetDuration(value time.Duration) Duration { return Duration{Set(value)} } // DurationDecoder implements config parsing for maybe.Duration func DurationDecoder() func(from, to reflect.Type, data any) (any, error) { @@ -34,11 +34,9 @@ func DurationDecoder() func(from, to reflect.Type, data any) (any, error) { err = e } - if err != nil || from != fromType { - return + if err == nil && from == fromType { + result = SetDuration(data.(time.Duration)) } - - result = SetDuration(data.(time.Duration)) return } } diff --git a/util/maybe/optional.go b/util/maybe/optional.go index 83f44cfe9..dded44306 100644 --- a/util/maybe/optional.go +++ b/util/maybe/optional.go @@ -3,6 +3,8 @@ package maybe import ( "encoding/json" "fmt" + + "github.com/creativeprojects/resticprofile/util" ) type Optional[T any] struct { @@ -25,6 +27,13 @@ func (m Optional[T]) Value() T { return m.value } +func (m Optional[T]) Nilable() *T { + if m.HasValue() { + return util.CopyRef(m.value) + } + return nil +} + func (m Optional[T]) String() string { if !m.HasValue() { return "" From 35f7b6ab10728b7af23a9b751e4d46aa8d1dda07 Mon Sep 17 00:00:00 2001 From: Juergen Kellerer Date: Sun, 10 Mar 2024 15:16:11 +0100 Subject: [PATCH 4/5] schedule: updated documentation --- config/info.go | 10 +++++----- config/profile.go | 8 ++++---- config/schedule.go | 18 +++++++++--------- config/template.go | 6 ++++-- config/template_test.go | 6 ++++-- contrib/templates/config-reference.gomd | 7 ++++--- 6 files changed, 30 insertions(+), 25 deletions(-) diff --git a/config/info.go b/config/info.go index fa9775b57..759e396a3 100644 --- a/config/info.go +++ b/config/info.go @@ -610,7 +610,7 @@ func init() { func NewGlobalInfo() NamedPropertySet { set := &namedPropertySet{ name: constants.SectionConfigurationGlobal, - description: "global settings", + description: "Global settings", propertySet: propertySetFromType(infoTypes.global), } customizeProperties(constants.SectionConfigurationGlobal, set.properties) @@ -621,7 +621,7 @@ func NewGlobalInfo() NamedPropertySet { func NewGroupInfo() NamedPropertySet { set := &namedPropertySet{ name: constants.SectionConfigurationGroups, - description: "profile groups", + description: "Profile groups", propertySet: propertySetFromType(infoTypes.group), } customizeProperties(constants.SectionConfigurationGroups, set.properties) @@ -632,7 +632,7 @@ func NewGroupInfo() NamedPropertySet { func NewMixinsInfo() NamedPropertySet { return &namedPropertySet{ name: constants.SectionConfigurationMixins, - description: "global mixins declaration", + description: "Global mixins declaration.", propertySet: propertySetFromType(infoTypes.mixins), } } @@ -641,7 +641,7 @@ func NewMixinsInfo() NamedPropertySet { func NewMixinUseInfo() NamedPropertySet { return &namedPropertySet{ name: constants.SectionConfigurationMixinUse, - description: "named mixin reference to apply to the current location", + description: "Named mixin reference to apply to the current location.", propertySet: propertySetFromType(infoTypes.mixinUse), } } @@ -650,7 +650,7 @@ func NewMixinUseInfo() NamedPropertySet { func NewScheduleConfigInfo() NamedPropertySet { return &namedPropertySet{ name: constants.SectionConfigurationSchedule, - description: "schedule configuration structure", + description: "Schedule configuration structure. Can be used to define schedules in profiles and groups.", propertySet: propertySetFromType(infoTypes.scheduleConfig), } } diff --git a/config/profile.go b/config/profile.go index 21ab4cb27..76d9d50e5 100644 --- a/config/profile.go +++ b/config/profile.go @@ -291,16 +291,16 @@ func (s *SectionWithScheduleAndMonitoring) IsEmpty() bool { return s == nil } // ScheduleBaseSection contains the parameters for scheduling a command (backup, check, forget, etc.) type ScheduleBaseSection struct { scheduleConfig *ScheduleConfig - Schedule any `mapstructure:"schedule" show:"noshow" examples:"hourly;daily;weekly;monthly;10:00,14:00,18:00,22:00;Wed,Fri 17:48;*-*-15 02:45;Mon..Fri 00:30" description:"Configures the schedule can be times in systemd timer format or a config structure"` + Schedule any `mapstructure:"schedule" show:"noshow" examples:"hourly;daily;weekly;monthly;10:00,14:00,18:00,22:00;Wed,Fri 17:48;*-*-15 02:45;Mon..Fri 00:30" description:"Configures the scheduled execution of this profile section. Can be times in systemd timer format or a config structure"` SchedulePermission string `mapstructure:"schedule-permission" show:"noshow" default:"auto" enum:"auto;system;user;user_logged_on" description:"Specify whether the schedule runs with system or user privileges - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` ScheduleLog string `mapstructure:"schedule-log" show:"noshow" examples:"/resticprofile.log;tcp://localhost:514" description:"Redirect the output into a log file or to syslog when running on schedule"` SchedulePriority string `mapstructure:"schedule-priority" show:"noshow" default:"background" enum:"background;standard" description:"Set the priority at which the schedule is run"` ScheduleLockMode string `mapstructure:"schedule-lock-mode" show:"noshow" default:"default" enum:"default;fail;ignore" description:"Specify how locks are used when running on schedule - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` ScheduleLockWait maybe.Duration `mapstructure:"schedule-lock-wait" show:"noshow" examples:"150s;15m;30m;45m;1h;2h30m" description:"Set the maximum time to wait for acquiring locks when running on schedule"` ScheduleEnvCapture []string `mapstructure:"schedule-capture-environment" show:"noshow" default:"RESTIC_*" description:"Set names (or glob expressions) of environment variables to capture during schedule creation. The captured environment is applied prior to \"profile.env\" when running the schedule. Whether capturing is supported depends on the type of scheduler being used (supported in \"systemd\" and \"launchd\")"` - ScheduleIgnoreOnBattery maybe.Bool `mapstructure:"schedule-ignore-on-battery" show:"noshow" default:"false" description:"Don't schedule the start of this profile when running on battery"` - ScheduleIgnoreOnBatteryLessThan int `mapstructure:"schedule-ignore-on-battery-less-than" show:"noshow" default:"" description:"Don't schedule the start of this profile when running on battery, and the battery charge left is less than the value"` - ScheduleAfterNetworkOnline maybe.Bool `mapstructure:"schedule-after-network-online" show:"noshow" description:"Don't schedule the start of this profile when the network is offline (supported in \"systemd\")."` + 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\")"` } func (s *ScheduleBaseSection) setRootPath(_ *Profile, _ string) { diff --git a/config/schedule.go b/config/schedule.go index 97c911909..a7515da69 100644 --- a/config/schedule.go +++ b/config/schedule.go @@ -34,15 +34,15 @@ const ( // ScheduleBaseConfig is the base user configuration that could be shared across all schedules. type ScheduleBaseConfig struct { - Permission string `mapstructure:"permission" show:"noshow" default:"auto" enum:"auto;system;user;user_logged_on" description:"Specify whether the schedule runs with system or user privileges - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` - Log string `mapstructure:"log" show:"noshow" examples:"/resticprofile.log;tcp://localhost:514" description:"Redirect the output into a log file or to syslog when running on schedule"` - Priority string `mapstructure:"priority" show:"noshow" default:"background" enum:"background;standard" description:"Set the priority at which the schedule is run"` - LockMode string `mapstructure:"lock-mode" show:"noshow" default:"default" enum:"default;fail;ignore" description:"Specify how locks are used when running on schedule - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` - LockWait maybe.Duration `mapstructure:"lock-wait" show:"noshow" examples:"150s;15m;30m;45m;1h;2h30m" description:"Set the maximum time to wait for acquiring locks when running on schedule"` - EnvCapture []string `mapstructure:"capture-environment" show:"noshow" default:"RESTIC_*" description:"Set names (or glob expressions) of environment variables to capture during schedule creation. The captured environment is applied prior to \"profile.env\" when running the schedule. Whether capturing is supported depends on the type of scheduler being used (supported in \"systemd\" and \"launchd\")"` - IgnoreOnBattery maybe.Bool `mapstructure:"ignore-on-battery" show:"noshow" default:"false" description:"Don't schedule the start of this profile when running on battery"` - IgnoreOnBatteryLessThan int `mapstructure:"ignore-on-battery-less-than" show:"noshow" default:"" description:"Don't schedule the start of this profile when running on battery, and the battery charge left is less than the value"` - AfterNetworkOnline maybe.Bool `mapstructure:"after-network-online" show:"noshow" description:"Don't schedule the start of this profile when the network is offline (supported in \"systemd\")."` + Permission string `mapstructure:"permission" default:"auto" enum:"auto;system;user;user_logged_on" description:"Specify whether the schedule runs with system or user privileges - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` + Log string `mapstructure:"log" examples:"/resticprofile.log;tcp://localhost:514" description:"Redirect the output into a log file or to syslog when running on schedule"` + Priority string `mapstructure:"priority" default:"background" enum:"background;standard" description:"Set the priority at which the schedule is run"` + LockMode string `mapstructure:"lock-mode" default:"default" enum:"default;fail;ignore" description:"Specify how locks are used when running on schedule - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` + LockWait maybe.Duration `mapstructure:"lock-wait" examples:"150s;15m;30m;45m;1h;2h30m" description:"Set the maximum time to wait for acquiring locks when running on schedule"` + EnvCapture []string `mapstructure:"capture-environment" default:"RESTIC_*" description:"Set names (or glob expressions) of environment variables to capture during schedule creation. The captured environment is applied prior to \"profile.env\" when running the schedule. Whether capturing is supported depends on the type of scheduler being used (supported in \"systemd\" and \"launchd\")"` + 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\")"` } // scheduleBaseConfigDefaults declares built-in scheduling defaults diff --git a/config/template.go b/config/template.go index b99121366..7ac2ca54a 100644 --- a/config/template.go +++ b/config/template.go @@ -78,10 +78,12 @@ func (t *TemplateInfoData) collectPropertiesByType(set PropertySet, byType map[s } } -// NestedProfileSections lists SectionInfo of all nested sections that may be used inside the configuration -func (t *TemplateInfoData) NestedProfileSections() []SectionInfo { +// NestedSections lists SectionInfo of all nested sections that may be used inside the configuration +func (t *TemplateInfoData) NestedSections() []SectionInfo { sectionByType := make(map[string]*namedPropertySet) + t.collectPropertiesByType(t.Global, sectionByType) + t.collectPropertiesByType(t.Group, sectionByType) t.collectPropertiesByType(t.Profile, sectionByType) for _, section := range t.ProfileSections() { t.collectPropertiesByType(section, sectionByType) diff --git a/config/template_test.go b/config/template_test.go index 434347172..faeeb8a04 100644 --- a/config/template_test.go +++ b/config/template_test.go @@ -443,10 +443,12 @@ func TestInfoData(t *testing.T) { assert.NotEmpty(t, data.ProfileSections()) - t.Run("NestedProfileSections", func(t *testing.T) { - sections := data.NestedProfileSections() + t.Run("NestedSections", func(t *testing.T) { + sections := data.NestedSections() assert.NotEmpty(t, sections) assert.Subset(t, collect.From(sections, SectionInfo.Name), []string{ + "ScheduleBaseConfig", + "ScheduleConfig", "SendMonitoringHeader", "SendMonitoringSection", "StreamErrorSection", diff --git a/contrib/templates/config-reference.gomd b/contrib/templates/config-reference.gomd index 3d5643806..1f75a841f 100644 --- a/contrib/templates/config-reference.gomd +++ b/contrib/templates/config-reference.gomd @@ -72,6 +72,7 @@ date: {{ .Now.Format "2006-01-02T15:04:05Z07:00" }} {{- /*gotype: github.com/creativeprojects/resticprofile/config.PropertyInfo*/ -}} {{- $more := 0 -}} {{- $single := .IsSingle -}} + {{- $singlePropertySet := .IsSinglePropertySet -}} {{- if .CanBeBool -}} `true` / `false` {{- $more = 1 -}} @@ -98,7 +99,7 @@ date: {{ .Now.Format "2006-01-02T15:04:05Z07:00" }} {{- if $more }} OR {{ end -}} {{- with .PropertySet -}} {{- if .TypeName -}} - {{- if not $single }} one or more {{ end -}} + {{- if not $singlePropertySet }} one or more {{ end -}} nested *[{{ .TypeName }}](#nested-{{ .TypeName | lower }})* {{- else -}} {{- if .OtherPropertyInfo -}} @@ -187,7 +188,7 @@ The configuration file reference is generated from resticprofile's data model an * [Section profile\.{{ .Name }}](#section-profile{{ .Name | lower }}) {{- end }} * [Nested profile sections](#nested-profile-sections) -{{- range .NestedProfileSections }} +{{- range .NestedSections }} * [Nested {{ .Name }}](#nested-{{ .Name | lower }}) {{- end }} * [Section groups](#section-groups) @@ -277,7 +278,7 @@ can be overridden in this section. Nested sections describe configuration structure that is assigned to flags within the configuration, see [HTTP Hooks]({{- $configWebUrl -}}/http_hooks/) as an example. -{{ range .NestedProfileSections -}}{{ $layoutHeadings -}}### Nested *{{ .Name }}* +{{ range .NestedSections -}}{{ $layoutHeadings -}}### Nested *{{ .Name }}* {{ .Description }} From b850ecc7c4e0d1f8c06fd6ef621062ed92aed9df Mon Sep 17 00:00:00 2001 From: Juergen Kellerer Date: Sun, 10 Mar 2024 15:51:30 +0100 Subject: [PATCH 5/5] schedule: fixed json schema creation --- commands_test.go | 13 ++++++++++--- config/info.go | 6 +++++- config/jsonschema/schema.go | 2 +- config/profile.go | 2 +- config/schedule.go | 2 +- flags.go | 2 +- flags_test.go | 2 +- 7 files changed, 20 insertions(+), 9 deletions(-) diff --git a/commands_test.go b/commands_test.go index f40fea459..e52886dd0 100644 --- a/commands_test.go +++ b/commands_test.go @@ -286,11 +286,18 @@ func TestShowSchedules(t *testing.T) { } expected := strings.TrimSpace(` schedule backup@default: - at: daily + at: daily + permission: auto + priority: background + lock-mode: default + capture-environment: RESTIC_* schedule check@default: - at: weekly - + at: weekly + permission: auto + priority: background + lock-mode: default + capture-environment: RESTIC_* `) showSchedules(buffer, schedules) assert.Equal(t, expected, strings.TrimSpace(buffer.String())) diff --git a/config/info.go b/config/info.go index 759e396a3..6ca77f37e 100644 --- a/config/info.go +++ b/config/info.go @@ -573,7 +573,11 @@ func customizeProperties(sectionName string, properties map[string]PropertyInfo) } if nested := property.PropertySet(); nested != nil { if ps, ok := nested.(*namedPropertySet); ok { - customizeProperties("nested:"+nested.TypeName(), ps.properties) + name := fmt.Sprintf("nested:%s", nested.TypeName()) + customizeProperties(name, ps.properties) + if ps.otherProperty != nil { + customizeProperties(name, map[string]PropertyInfo{"*": ps.otherProperty}) + } } } } diff --git a/config/jsonschema/schema.go b/config/jsonschema/schema.go index d979b66ee..a3f3d99a5 100644 --- a/config/jsonschema/schema.go +++ b/config/jsonschema/schema.go @@ -334,7 +334,7 @@ func schemaForGroups(version config.Version) SchemaType { groups = newSchemaArray(newSchemaString()) describeAll(groups, "profile-name", "profile names in this group") } else { - groups = schemaForPropertySet(config.NewGroupInfo()) + groups = schemaForPropertySet(info) } groups.describe("group", "group declaration") object.PatternProperties[matchAll] = groups diff --git a/config/profile.go b/config/profile.go index 76d9d50e5..f5684820c 100644 --- a/config/profile.go +++ b/config/profile.go @@ -299,7 +299,7 @@ type ScheduleBaseSection struct { ScheduleLockWait maybe.Duration `mapstructure:"schedule-lock-wait" show:"noshow" examples:"150s;15m;30m;45m;1h;2h30m" description:"Set the maximum time to wait for acquiring locks when running on schedule"` ScheduleEnvCapture []string `mapstructure:"schedule-capture-environment" show:"noshow" default:"RESTIC_*" description:"Set names (or glob expressions) of environment variables to capture during schedule creation. The captured environment is applied prior to \"profile.env\" when running the schedule. Whether capturing is supported depends on the type of scheduler being used (supported in \"systemd\" and \"launchd\")"` 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"` + 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\")"` } diff --git a/config/schedule.go b/config/schedule.go index a7515da69..960b136f8 100644 --- a/config/schedule.go +++ b/config/schedule.go @@ -41,7 +41,7 @@ type ScheduleBaseConfig struct { LockWait maybe.Duration `mapstructure:"lock-wait" examples:"150s;15m;30m;45m;1h;2h30m" description:"Set the maximum time to wait for acquiring locks when running on schedule"` EnvCapture []string `mapstructure:"capture-environment" default:"RESTIC_*" description:"Set names (or glob expressions) of environment variables to capture during schedule creation. The captured environment is applied prior to \"profile.env\" when running the schedule. Whether capturing is supported depends on the type of scheduler being used (supported in \"systemd\" and \"launchd\")"` 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"` + 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\")"` } diff --git a/flags.go b/flags.go index 0c4a335e0..0234143c5 100644 --- a/flags.go +++ b/flags.go @@ -1,12 +1,12 @@ package main import ( - "github.com/creativeprojects/clog" "os" "slices" "strings" "time" + "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/platform" "github.com/creativeprojects/resticprofile/term" diff --git a/flags_test.go b/flags_test.go index 36a75161a..a4000d870 100644 --- a/flags_test.go +++ b/flags_test.go @@ -2,11 +2,11 @@ package main import ( "fmt" - "github.com/creativeprojects/clog" "os" "testing" "time" + "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/constants" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require"