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..e52886dd0 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,29 +276,28 @@ 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 + permission: auto + priority: background + lock-mode: default + capture-environment: RESTIC_* +schedule check@default: + 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/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..dabb4ecb2 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 (for internal use only) +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..60bd86b58 100644 --- a/config/group.go +++ b/config/group.go @@ -4,7 +4,43 @@ 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 { + 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 { + if cfg.HasSchedules() { + 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..6ca77f37e 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 } @@ -570,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}) + } } } } @@ -583,6 +590,7 @@ var infoTypes struct { mixins, mixinUse, profile, + scheduleConfig, genericSection reflect.Type genericSectionNames []string } @@ -596,6 +604,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) } @@ -605,7 +614,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) @@ -616,7 +625,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) @@ -627,7 +636,7 @@ func NewGroupInfo() NamedPropertySet { func NewMixinsInfo() NamedPropertySet { return &namedPropertySet{ name: constants.SectionConfigurationMixins, - description: "global mixins declaration", + description: "Global mixins declaration.", propertySet: propertySetFromType(infoTypes.mixins), } } @@ -636,11 +645,20 @@ 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), } } +// NewScheduleConfigInfo returns structural information on the "schedule" config structure +func NewScheduleConfigInfo() NamedPropertySet { + return &namedPropertySet{ + name: constants.SectionConfigurationSchedule, + description: "Schedule configuration structure. Can be used to define schedules in profiles and groups.", + 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..fcb0f92f1 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,18 +139,34 @@ 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 { if util.ElementType(field.Type).AssignableTo(maybeBoolType) { basic := property.basic().resetTypeInfo() basic.mayBool = true + basic.mayNil = true + } + } + }) + + // 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 + basic.mayNil = true } } }) - // Profile: deprecated sections (squash with deprecated, e.g. schedule in retention) + // 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/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.go b/config/jsonschema/schema.go index 0bd2b0712..a3f3d99a5 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) @@ -313,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/jsonschema/schema_test.go b/config/jsonschema/schema_test.go index efd2644d3..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 } @@ -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 } @@ -659,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/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..f5684820c 100644 --- a/config/profile.go +++ b/config/profile.go @@ -5,10 +5,8 @@ import ( "os" "path/filepath" "reflect" - "slices" "sort" "strings" - "time" "github.com/Masterminds/semver/v3" "github.com/creativeprojects/clog" @@ -29,11 +27,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 +42,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 +183,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 +228,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 +290,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 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 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) { 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 +342,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 +779,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 +829,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 +860,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 +952,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..960b136f8 100644 --- a/config/schedule.go +++ b/config/schedule.go @@ -1,13 +1,23 @@ 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/collect" + "github.com/creativeprojects/resticprofile/util/maybe" + "github.com/spf13/cast" ) type ScheduleLockMode int8 @@ -22,36 +32,275 @@ 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" 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 +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) 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 { + 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 { + 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"` +} + +// 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.setSchedules(schedules) + } + s.init(defaults) + s.origin = origin + return s +} + +func newScheduleConfig(config *Config, section *ScheduleBaseSection) (s *ScheduleConfig) { + origin := ScheduleConfigOrigin{} // is set later + + // decode ScheduleBaseSection.Schedule + switch expression := section.Schedule.(type) { + case string: + s = NewDefaultScheduleConfig(config, origin, expression) + case []string, []any: + s = NewDefaultScheduleConfig(config, origin, cast.ToStringSlice(expression)...) + default: + if expression != nil { + cfg := NewDefaultScheduleConfig(config, origin) + decoder, err := config.newUnmarshaller(cfg) + if err == nil { + err = decoder.Decode(expression) + } + 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()) + } + } + } + + // init + if s.HasSchedules() { + s.applyOverrides(section) + } else { + s = nil + } + return +} + +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 { + 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) +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.Errorf("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 = profile.GetEnvironment(true) + } + + // 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 +308,54 @@ 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(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("%s=%s", constants.EnvScheduleId, 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) { + 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 +} + +func CompareSchedules(a, b *Schedule) int { return a.Compare(b) } + func (s *Schedule) GetLockMode() ScheduleLockMode { switch s.LockMode { case constants.ScheduleLockModeOptionFail: @@ -73,10 +368,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..f3e5e19de 100644 --- a/config/schedule_test.go +++ b/config/schedule_test.go @@ -1,58 +1,363 @@ 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" + "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("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] + systemd-drop-in-files = "drop-in-file.conf" + + [global.schedule-defaults] + log = "global-custom.log" + 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, "global-custom.log", 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 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] + 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 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] + 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 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) + + 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 >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/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/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/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/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 }} 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..0234143c5 100644 --- a/flags.go +++ b/flags.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/platform" "github.com/creativeprojects/resticprofile/term" @@ -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..a4000d870 100644 --- a/flags_test.go +++ b/flags_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/constants" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -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/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/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 db0381ccb..11b850d23 100644 --- a/util/maybe/bool.go +++ b/util/maybe/bool.go @@ -2,27 +2,19 @@ package maybe import ( "reflect" - "strconv" ) 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 (value Bool) String() string { - if !value.HasValue() { - return "" - } - return strconv.FormatBool(value.Value()) -} +func False() Bool { return SetBool(false) } + +func True() Bool { return SetBool(true) } func (value Bool) IsTrue() bool { return value.HasValue() && value.Value() @@ -44,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 305aa5580..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", }, { @@ -135,3 +150,19 @@ func TestBoolJSON(t *testing.T) { }) } } + +func TestBoolString(t *testing.T) { + fixtures := []struct { + source maybe.Bool + expected string + }{ + {source: maybe.UnsetBool(), 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..478fd3297 --- /dev/null +++ b/util/maybe/duration.go @@ -0,0 +1,42 @@ +package maybe + +import ( + "reflect" + "strings" + "time" + + "github.com/spf13/cast" +) + +// Duration implements Optional[time.Duration] +type Duration struct { + Optional[time.Duration] +} + +// 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) { + 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 { + 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..dded44306 100644 --- a/util/maybe/optional.go +++ b/util/maybe/optional.go @@ -2,6 +2,9 @@ package maybe import ( "encoding/json" + "fmt" + + "github.com/creativeprojects/resticprofile/util" ) type Optional[T any] struct { @@ -24,6 +27,20 @@ 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 "" + } + 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 {