diff --git a/commands.go b/commands.go index 8cf5adce..18d54285 100644 --- a/commands.go +++ b/commands.go @@ -552,7 +552,13 @@ 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 + schedules := profile.Schedules() + // schedulesConfig := make([]*config.ScheduleConfig, len(schedules)) + // for index, schedule := range schedules { + // schedulesConfig[index] = schedule.GetScheduleConfig() + // } + + return schedule.NewSchedulerConfig(global), profile, schedules, nil } func requireScheduleJobs(schedules []*config.Schedule, flags commandLineFlags) error { @@ -585,13 +591,11 @@ func getRemovableScheduleJobs(c *config.Config, flags commandLineFlags) (schedul } func preRunSchedule(ctx *Context) error { - if len(ctx.request.arguments) < 1 { - return errors.New("run-schedule command expects one argument: schedule name") + if len(ctx.request.arguments) != 1 { + return errors.New("run-schedule command expects one argument (only): schedule name") } scheduleName := ctx.request.arguments[0] - // temporarily allow v2 configuration to run v1 schedules - // if ctx.config.GetVersion() < config.Version02 - { + if ctx.config.GetVersion() < config.Version02 { // schedule name is in the form "command@profile" commandName, profileName, ok := strings.Cut(scheduleName, "@") if !ok { diff --git a/config/config.go b/config/config.go index 677d373c..cc721223 100644 --- a/config/config.go +++ b/config/config.go @@ -657,18 +657,30 @@ func (c *Config) GetSchedules() ([]*Schedule, error) { if c.GetVersion() <= Version01 { return c.getSchedulesV1() } - return nil, nil + + schedules := make([]*Schedule, 0) + + if section := c.viper.Sub(constants.SectionConfigurationSchedules); section != nil { + for sectionKey := range section.AllSettings() { + schedule, err := c.getSchedule(sectionKey) + if err != nil { + continue + } + schedules = append(schedules, schedule.GetSchedule()) + } + } + return schedules, nil } // GetScheduleSections returns a list of schedules -func (c *Config) GetScheduleSections() (schedules map[string]Schedule, err error) { +func (c *Config) GetScheduleSections() (schedules map[string]*ScheduleSection, err error) { c.requireMinVersion(Version02) - schedules = map[string]Schedule{} + schedules = map[string]*ScheduleSection{} if section := c.viper.Sub(constants.SectionConfigurationSchedules); section != nil { for sectionKey := range section.AllSettings() { - var schedule Schedule + var schedule *ScheduleSection schedule, err = c.getSchedule(sectionKey) if err != nil { break @@ -680,9 +692,9 @@ func (c *Config) GetScheduleSections() (schedules map[string]Schedule, err error return } -func (c *Config) getSchedule(key string) (Schedule, error) { - schedule := Schedule{} - err := c.unmarshalKey(c.flatKey(constants.SectionConfigurationSchedules, key), &schedule) +func (c *Config) getSchedule(key string) (*ScheduleSection, error) { + schedule := NewScheduleSection(c, key) + err := c.unmarshalKey(c.flatKey(constants.SectionConfigurationSchedules, key), schedule) if err != nil { return schedule, err } diff --git a/config/config_schedule_test.go b/config/config_schedule_test.go index a3a66a82..39ffeee6 100644 --- a/config/config_schedule_test.go +++ b/config/config_schedule_test.go @@ -56,3 +56,92 @@ func TestGetScheduleSectionsOnV1(t *testing.T) { c := newConfig("toml") assert.Panics(t, func() { c.GetScheduleSections() }) } + +func TestGetEmptySchedules(t *testing.T) { + fixtures := []testTemplate{ + {FormatTOML, `version = "1"`}, + {FormatJSON, `{"version": "1"}`}, + {FormatYAML, `version: "1"`}, + {FormatTOML, `version = "2"`}, + {FormatJSON, `{"version": "2"}`}, + {FormatYAML, `version: "2"`}, + } + + for _, testItem := range fixtures { + 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.GetSchedules() + require.NoError(t, err) + assert.Empty(t, schedules) + }) + } +} + +func TestGetSchedules(t *testing.T) { + fixtures := []testTemplate{ + {FormatTOML, `version = "1" +[profile1] +[profile1.backup] +schedule = "daily" +[profile2] +[profile2.backup] +schedule = "weekly" +`}, + {FormatJSON, `{"version": "1", "profile1": {"backup": {"schedule": "daily"}}, "profile2": {"backup": {"schedule": "weekly"}}}`}, + {FormatYAML, `version: "1" +profile1: + backup: + schedule: "daily" +profile2: + backup: + schedule: "weekly" +`}, + {FormatTOML, `version = "2" +[schedules] +[schedules.schedule1] +profiles="profile1" +schedule="daily" +[schedules.schedule2] +profiles="profile2" +schedule="weekly" +`}, + {FormatJSON, `{"version": "2", "schedules": {"schedule1": {"profiles": "profile1", "schedule": "daily"}, "schedule2": {"profiles": "profile2", "schedule": "weekly"}}}`}, + {FormatYAML, `version: "2" +schedules: + schedule1: + profiles: profile1 + schedule: daily + schedule2: + profiles: profile2 + schedule: weekly +`}, + } + + for _, testItem := range fixtures { + 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.GetSchedules() + require.NoError(t, err) + require.Len(t, schedules, 2) + for _, schedule := range schedules { + assertSchedule(t, schedule) + } + }) + } +} + +func assertSchedule(t *testing.T, schedule *Schedule) { + t.Helper() + + assert.Len(t, schedule.Profiles, 1) + assert.Len(t, schedule.Schedules, 1) + assert.NotEmpty(t, schedule.ProfileName) +} diff --git a/config/config_test.go b/config/config_test.go index 0eafc5d7..deb815a8 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -599,7 +599,7 @@ two: } } -func TestGetSchedules(t *testing.T) { +func TestGetSchedulesV1(t *testing.T) { content := `--- profile: backup: diff --git a/config/info.go b/config/info.go index e1cdd9e6..8e70b645 100644 --- a/config/info.go +++ b/config/info.go @@ -583,6 +583,7 @@ var infoTypes struct { mixins, mixinUse, profile, + schedule, genericSection reflect.Type genericSectionNames []string } @@ -596,6 +597,7 @@ func init() { infoTypes.mixins = reflect.TypeOf(mixin{}) infoTypes.mixinUse = reflect.TypeOf(mixinUse{}) infoTypes.profile = reflect.TypeOf(profile) + infoTypes.schedule = reflect.TypeOf(ScheduleSection{}) infoTypes.genericSection = reflect.TypeOf(GenericSection{}) infoTypes.genericSectionNames = maps.Keys(profile.OtherSections) } @@ -623,6 +625,15 @@ func NewGroupInfo() NamedPropertySet { return set } +// NewScheduleInfo returns structural information on the "schedules" config v2 section +func NewScheduleInfo() NamedPropertySet { + return &namedPropertySet{ + name: constants.SectionConfigurationSchedules, + description: "profile and group schedules", + propertySet: propertySetFromType(infoTypes.schedule), + } +} + // NewMixinsInfo returns structural information on the "mixins" config v2 section func NewMixinsInfo() NamedPropertySet { return &namedPropertySet{ diff --git a/config/jsonschema/schema.go b/config/jsonschema/schema.go index 0bd2b071..51dba1ee 100644 --- a/config/jsonschema/schema.go +++ b/config/jsonschema/schema.go @@ -320,6 +320,16 @@ func schemaForGroups(version config.Version) SchemaType { return object } +func schemaForSchedules() SchemaType { + info := config.NewScheduleInfo() + object := newSchemaObject() + object.Description = info.Description() + schedules := schemaForPropertySet(info) + schedules.describe("schedule", "schedule declaration") + object.PatternProperties[matchAll] = schedules + return object +} + func schemaForGlobal() SchemaType { return schemaForPropertySet(config.NewGlobalInfo()) } @@ -433,12 +443,13 @@ func schemaForConfigV2(profileInfo config.ProfileInfo) (object *schemaObject) { object = newSchemaObject() object.Description = "resticprofile configuration v2" object.Properties = map[string]SchemaType{ - constants.SectionConfigurationGlobal: schemaForGlobal(), - constants.SectionConfigurationGroups: schemaForGroups(config.Version02), - constants.SectionConfigurationIncludes: schemaForIncludes(), - constants.SectionConfigurationMixins: schemaForMixins(), - constants.SectionConfigurationProfiles: schemaForProfile(profileInfo), - constants.ParameterVersion: schemaForConfigVersion(config.Version02), + constants.SectionConfigurationGlobal: schemaForGlobal(), + constants.SectionConfigurationGroups: schemaForGroups(config.Version02), + constants.SectionConfigurationIncludes: schemaForIncludes(), + constants.SectionConfigurationMixins: schemaForMixins(), + constants.SectionConfigurationProfiles: schemaForProfile(profileInfo), + constants.SectionConfigurationSchedules: schemaForSchedules(), + constants.ParameterVersion: schemaForConfigVersion(config.Version02), } object.Required = append(object.Required, constants.ParameterVersion) { diff --git a/config/jsonschema/schema_test.go b/config/jsonschema/schema_test.go index efd2644d..79456064 100644 --- a/config/jsonschema/schema_test.go +++ b/config/jsonschema/schema_test.go @@ -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 diff --git a/config/profile.go b/config/profile.go index e9d4c7c4..487ef609 100644 --- a/config/profile.go +++ b/config/profile.go @@ -820,12 +820,14 @@ func (p *Profile) SchedulableCommands() (commands []string) { // Schedules returns a slice of Schedule for all the commands that have a schedule configuration // Only v1 configuration have schedules inside the profile func (p *Profile) Schedules() []*Schedule { + p.config.requireVersion(Version01) + // All SectionWithSchedule (backup, check, prune, etc) sections := GetSectionsWith[Scheduling](p) configs := make([]*Schedule, 0, len(sections)) - for name, section := range sections { - if s := section.GetSchedule(); len(s.Schedule) > 0 { + for sectionName, section := range sections { + if s := section.GetSchedule(); s != nil && len(s.Schedule) > 0 { env := util.NewDefaultEnvironment() if len(s.ScheduleEnvCapture) > 0 { @@ -852,7 +854,7 @@ func (p *Profile) Schedules() []*Schedule { } config := &Schedule{ - CommandName: name, + CommandName: sectionName, Group: "", Profiles: []string{p.Name}, Schedules: s.Schedule, diff --git a/config/profile_test.go b/config/profile_test.go index d5dc528c..6cb727cf 100644 --- a/config/profile_test.go +++ b/config/profile_test.go @@ -883,7 +883,7 @@ profile: } } -func TestSchedules(t *testing.T) { +func TestSchedulesV1(t *testing.T) { util.ClearTempDir() defer util.ClearTempDir() logFile := path.Join(filepath.ToSlash(util.MustGetTempDir()), "rp.log") diff --git a/config/schedule_section.go b/config/schedule_section.go new file mode 100644 index 00000000..3ec9b379 --- /dev/null +++ b/config/schedule_section.go @@ -0,0 +1,44 @@ +package config + +import ( + "time" +) + +// ScheduleSection contains the information from the schedule profile in the configuration file (v2+). +type ScheduleSection struct { + config *Config + name string + Group string `mapstructure:"group" description:"Group name to schedule (from groups section)"` + Profiles []string `mapstructure:"profiles" description:"List of profile name to schedule one after another"` + Command string `mapstructure:"run" default:"backup" examples:"backup;copy;check;forget;prune" description:"Command to schedule. Default is 'backup' if not specified"` + Schedule []string `mapstructure:"schedule" 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)"` + 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 time.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" show:"noshow" default:"RESTIC_*" description:"Set names (or glob expressions) of environment variables to capture during schedule creation. The captured environment is applied prior to \"profile.env\" when running the schedule. Whether capturing is supported depends on the type of scheduler being used (supported in \"systemd\" and \"launchd\")"` + IgnoreOnBattery bool `mapstructure:"ignore-on-battery" default:"false" description:"Don't schedule the start of this profile when running on battery"` + IgnoreOnBatteryLessThan int `mapstructure:"ignore-on-battery-less-than" default:"" description:"Don't schedule the start of this profile when running on battery, and the battery charge left is less than the value"` +} + +// NewScheduleSection instantiates a new blank schedule +func NewScheduleSection(c *Config, name string) *ScheduleSection { + return &ScheduleSection{ + name: name, + config: c, + } +} + +func (s *ScheduleSection) GetSchedule() *Schedule { + // TODO: implement + return nil +} + +func (s *ScheduleSection) Name() string { + if len(s.name) == 0 && len(s.Profiles) == 1 { + // configuration v1 + return s.Profiles[0] + "-" + s.Command + } + return s.name +} diff --git a/flags.go b/flags.go index 021913a3..eab2d3d2 100644 --- a/flags.go +++ b/flags.go @@ -47,7 +47,7 @@ func loadFlags(args []string) (*pflag.FlagSet, commandLineFlags, error) { flagset.BoolVar(&flags.veryVerbose, "trace", constants.DefaultVerboseFlag, "display even more debugging information") flagset.StringVarP(&flags.config, "config", "c", constants.DefaultConfigurationFile, "configuration file") flagset.StringVarP(&flags.format, "format", "f", "", "file format of the configuration (default is to use the file extension)") - flagset.StringVarP(&flags.name, "name", "n", constants.DefaultProfileName, "profile name") + flagset.StringVarP(&flags.name, "name", "n", constants.DefaultProfileName, "profile (or schedule) name") flagset.StringVarP(&flags.log, "log", "l", "", "logs to a target instead of the console") flagset.BoolVar(&flags.dryRun, "dry-run", false, "display the restic commands instead of running them") flagset.BoolVar(&flags.noLock, "no-lock", false, "skip profile lock file") diff --git a/schedule/removeonly_test.go b/schedule/removeonly_test.go index 355ac60f..2cdd6a41 100644 --- a/schedule/removeonly_test.go +++ b/schedule/removeonly_test.go @@ -18,7 +18,6 @@ func TestNewRemoveOnlyConfig(t *testing.T) { assert.Equal(t, "", cfg.WorkingDirectory) assert.Equal(t, "", cfg.Command) assert.Empty(t, cfg.Arguments) - assert.Empty(t, cfg.Environment) assert.Equal(t, "", cfg.Priority) assert.Equal(t, "", cfg.ConfigFile) {