From e84045e22a136982933670e3e394a01b7fedd478 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 21 Feb 2024 18:40:26 +0000 Subject: [PATCH] Squashed commit of the following: commit 62fc47ece8cc31bc25ca1600576445578c007c37 Author: Fred Date: Wed Feb 21 10:10:53 2024 +0000 avoid the file to be checked by the json schema validator commit 911b6d2315f41e162817e1ef5044f4c5fe4d7dbd Author: Fred Date: Wed Feb 21 09:57:13 2024 +0000 commit updated mock commit 6062a7cf369e6f217dde6a26516f68d34337aef2 Author: Fred Date: Wed Feb 21 09:46:58 2024 +0000 Squashed commit of the following: commit f461ef9f19a6528e9f30f8490f53fdceecbd8ca0 Author: Fred Date: Tue Feb 13 21:22:30 2024 +0000 WIP commit 12ede06f7a20de080f16c0bda5c97b3f796b5959 Author: Fred Date: Sat Feb 10 11:00:07 2024 +0000 create Init method commit d72db8a7ae99a0f04a9731bf6f128b3d0e4a2a2a Author: Fred Date: Sat Feb 10 10:10:27 2024 +0000 Squashed commit of the following: commit 31729b536cd49469fb73924ff35be17219a29526 Author: Fred Date: Sat Feb 10 10:07:30 2024 +0000 allow run-schedule command on v2 format commit bffa06db440e2a74bdb386c0c3cd7a4f66131b96 Author: Fred Date: Wed Feb 7 21:50:03 2024 +0000 merge ProfileName and Profiles into one Profiles field commit ec09efd3673ddd52e2c00c5ab5ac719f8d91d3a5 Author: Fred Date: Wed Feb 7 21:05:33 2024 +0000 Squashed commit of the following: commit 24a6d6d1f768e4dc02f77fb3b175d6ac2407dbc2 Author: Fred Date: Tue Nov 14 16:51:50 2023 +0000 fix: remove schedule name from arguments commit ffee9841c7df5e64e1bdfea4cc7fb4b4975c3792 Author: Fred Date: Tue Nov 14 15:37:48 2023 +0000 fix: add noshow tag to hide schedule options from main section commit e84095673be7815643e63c7de6a53f28268b5c01 Author: Fred Date: Sun Nov 12 18:06:50 2023 +0000 docs: add information on run-schedule command commit 4e4bebb63ad9734054fea8c4ab85195b436d93e2 Author: Fred Date: Sun Nov 12 17:15:33 2023 +0000 extract context creation for unit tests commit c7775a429093562d9fa76de70b92f38c292fef7c Author: Fred Date: Sun Nov 12 12:56:42 2023 +0000 wire up ignore-on-battery and locks from schedule configuration to context commit 329696ef728d43b18ce8f4964c4c8ea3354a7a6d Author: Fred Date: Sat Nov 11 20:14:29 2023 +0000 update crond to support new run-schedule command line commit 78a9c7dbbe66ef2e154e5470c5f80b39d1c94606 Author: Fred Date: Sat Nov 11 19:34:49 2023 +0000 Squashed commit of the following: commit 97086b82abe6a1fa9f380bc92969b517768eea98 Author: Fred Date: Sat Nov 11 18:00:29 2023 +0000 add a default log file on darwin commit ef2469a14284c04c86961a921d2b102cbeee86dc Author: Fred Date: Sat Nov 11 15:57:13 2023 +0000 Merge from pass-context-struct commit f650dd4ba706d3a35b7c89ec2d4c304ae5274511 Author: Fred Date: Sat Nov 11 15:43:17 2023 +0000 return a new context on each `With` method commit 1157e074e263070738d9bea41829dea8ae8392e4 Author: Fred Date: Sun Nov 5 19:07:33 2023 +0000 add tests commit a3f49195e8e5a2ac660c57b0172925259b6874f3 Author: Fred Date: Sun Nov 5 18:42:50 2023 +0000 move logTarget to context commit c8b442123e0f2f214bf76a5e4ed39574c4b0ab93 Author: Fred Date: Sun Nov 5 17:26:24 2023 +0000 refactoring Context struct commit 41446421332d354ed1fba87670157a4894e66d7a Author: Fred Date: Wed Nov 1 17:15:46 2023 +0000 pass context to own commands and profile runnner --- .vscode/settings.json | 1 + codecov.yml | 2 +- commands.go | 142 ++++++++++++++---- commands_display.go | 4 +- commands_test.go | 167 ++++++++++++++++++++- config/config.go | 3 +- config/config_schedule_test.go | 2 +- config/config_v1.go | 4 +- config/profile.go | 34 ++--- config/profile_test.go | 2 +- config/schedule.go | 101 +++++++++++-- config/schedule_config.go | 117 --------------- config/schedule_config_test.go | 100 ------------- config/schedule_test.go | 70 +++++++++ context.go | 76 ++++++++-- context_test.go | 219 ++++++++++++++++++++++++++++ crond/crontab.go | 19 ++- crond/crontab_test.go | 11 +- docs/content/configuration/logs.md | 8 + docs/content/schedules/commands.md | 25 +++- docs/content/schedules/examples.md | 2 +- examples/dev.yaml | 4 + examples/v2._yaml | 225 +++++++++++++++++++++++++++++ examples/v2.yaml | 223 ---------------------------- main.go | 70 ++++----- schedule/config.go | 70 +++++++++ schedule/config_test.go | 75 ++++++++++ schedule/handler.go | 7 +- schedule/handler_crond.go | 15 +- schedule/handler_darwin.go | 37 ++--- schedule/handler_darwin_test.go | 40 +---- schedule/handler_fake_test.go | 13 +- schedule/handler_systemd.go | 25 ++-- schedule/handler_windows.go | 21 ++- schedule/job.go | 29 +--- schedule/job_test.go | 17 +-- schedule/mocks/Handler.go | 40 ++--- schedule/removeonly_test.go | 19 +-- schedule/scheduler.go | 3 +- schedule/scheduler_config.go | 5 +- schedule_jobs.go | 92 ++++++------ schedule_jobs_test.go | 206 ++++++++++++++++++++------ schtasks/config.go | 10 ++ schtasks/taskscheduler.go | 27 ++-- schtasks/taskscheduler_test.go | 11 +- 45 files changed, 1527 insertions(+), 866 deletions(-) delete mode 100644 config/schedule_config.go delete mode 100644 config/schedule_config_test.go create mode 100644 config/schedule_test.go create mode 100644 examples/v2._yaml delete mode 100644 examples/v2.yaml create mode 100644 schedule/config.go create mode 100644 schedule/config_test.go create mode 100644 schtasks/config.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 3d048b07..3af104a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cSpell.words": [ "afero", "caffeinate", + "creativeprojects", "crond", "ionice", "journalctl", diff --git a/codecov.yml b/codecov.yml index b3639c8b..53831104 100644 --- a/codecov.yml +++ b/codecov.yml @@ -27,5 +27,5 @@ coverage: threshold: "2%" patch: default: - target: "80%" + target: "70%" threshold: "2%" diff --git a/commands.go b/commands.go index 81bff81f..8cf5adce 100644 --- a/commands.go +++ b/commands.go @@ -41,6 +41,7 @@ func init() { func getOwnCommands() []ownCommand { return []ownCommand{ + // commands that don't need loading the configuration { name: "help", description: "display help (use resticprofile help [command])", @@ -56,6 +57,29 @@ func getOwnCommands() []ownCommand { needConfiguration: false, flags: map[string]string{"-v, --verbose": "display detailed version information"}, }, + { + name: "random-key", + description: "generate a cryptographically secure random key to use as a restic keyfile", + action: randomKey, + needConfiguration: false, + hide: true, // replaced by the generate command + }, + { + name: "generate", + description: "generate resources such as random key, bash/zsh completion scripts, etc.", + longDescription: "The \"generate\" command is used to create various resources and print them to stdout", + action: generateCommand, + needConfiguration: false, + hide: false, + flags: map[string]string{ + "--random-key [size]": "generate a cryptographically secure random key to use as a restic keyfile (size defaults to 1024 when omitted)", + "--config-reference [--version 0.15] [template]": "generate a config file reference from a go template (defaults to the built-in markdown template when omitted)", + "--json-schema [--version 0.15] [v1|v2]": "generate a JSON schema that validates resticprofile configuration files in YAML or JSON format", + "--bash-completion": "generate a shell completion script for bash", + "--zsh-completion": "generate a shell completion script for zsh", + }, + }, + // commands that need the configuration { name: "profiles", description: "display profile names from the configuration file", @@ -70,13 +94,6 @@ func getOwnCommands() []ownCommand { action: showProfile, needConfiguration: true, }, - { - name: "random-key", - description: "generate a cryptographically secure random key to use as a restic keyfile", - action: randomKey, - needConfiguration: false, - hide: true, - }, { name: "schedule", description: "schedule jobs from a profile (or of all profiles)", @@ -108,19 +125,15 @@ func getOwnCommands() []ownCommand { flags: map[string]string{"--all": "display the status of all scheduled jobs of all profiles"}, }, { - name: "generate", - description: "generate resources such as random key, bash/zsh completion scripts, etc.", - longDescription: "The \"generate\" command is used to create various resources and print them to stdout", - action: generateCommand, - needConfiguration: false, + name: "run-schedule", + description: "runs a scheduled job. This command should only be called by the scheduling service", + longDescription: "The \"run-schedule\" command loads the scheduled job configuration from the name in parameter and runs the restic command with the arguments defined in the profile. The name in parameter is @ for the configuration file v1, and the schedule name for the configuration file v2+.", + pre: preRunSchedule, + action: runSchedule, + needConfiguration: true, hide: false, - flags: map[string]string{ - "--random-key [size]": "generate a cryptographically secure random key to use as a restic keyfile (size defaults to 1024 when omitted)", - "--config-reference [--version 0.15] [template]": "generate a config file reference from a go template (defaults to the built-in markdown template when omitted)", - "--json-schema [--version 0.15] [v1|v2]": "generate a JSON schema that validates resticprofile configuration files in YAML or JSON format", - "--bash-completion": "generate a shell completion script for bash", - "--zsh-completion": "generate a shell completion script for zsh", - }, + hideInCompletion: true, + noProfile: true, }, // hidden commands { @@ -328,10 +341,9 @@ func showProfile(output io.Writer, ctx commandContext) error { return nil } -func showSchedules(output io.Writer, schedulesConfig []*config.ScheduleConfig) { - for _, schedule := range schedulesConfig { - export := schedule.Export() - err := config.ShowStruct(output, export, "schedule "+export.Profiles[0]+"-"+export.Command) +func showSchedules(output io.Writer, schedules []*config.Schedule) { + for _, schedule := range schedules { + err := config.ShowStruct(output, schedule, "schedule "+schedule.CommandName+"@"+schedule.Profiles[0]) if err != nil { fmt.Fprintln(output, err) } @@ -408,7 +420,7 @@ func createSchedule(_ io.Writer, ctx commandContext) error { type profileJobs struct { scheduler schedule.SchedulerConfig profile string - jobs []*config.ScheduleConfig + jobs []*config.Schedule } allJobs := make([]profileJobs, 0, 1) @@ -516,7 +528,7 @@ func statusSchedule(w io.Writer, ctx commandContext) error { return nil } -func statusScheduleProfile(scheduler schedule.SchedulerConfig, profile *config.Profile, schedules []*config.ScheduleConfig, flags commandLineFlags) error { +func statusScheduleProfile(scheduler schedule.SchedulerConfig, profile *config.Profile, schedules []*config.Schedule, flags commandLineFlags) error { displayProfileDeprecationNotices(profile) err := statusJobs(schedule.NewHandler(scheduler), flags.name, schedules) @@ -526,7 +538,7 @@ func statusScheduleProfile(scheduler schedule.SchedulerConfig, profile *config.P return nil } -func getScheduleJobs(c *config.Config, flags commandLineFlags) (schedule.SchedulerConfig, *config.Profile, []*config.ScheduleConfig, error) { +func getScheduleJobs(c *config.Config, flags commandLineFlags) (schedule.SchedulerConfig, *config.Profile, []*config.Schedule, error) { global, err := c.GetGlobalSection() if err != nil { return nil, nil, nil, fmt.Errorf("cannot load global section: %w", err) @@ -543,14 +555,14 @@ func getScheduleJobs(c *config.Config, flags commandLineFlags) (schedule.Schedul return schedule.NewSchedulerConfig(global), profile, profile.Schedules(), nil } -func requireScheduleJobs(schedules []*config.ScheduleConfig, flags commandLineFlags) error { +func requireScheduleJobs(schedules []*config.Schedule, flags commandLineFlags) error { if len(schedules) == 0 { return fmt.Errorf("no schedule found for profile '%s'", flags.name) } return nil } -func getRemovableScheduleJobs(c *config.Config, flags commandLineFlags) (schedule.SchedulerConfig, *config.Profile, []*config.ScheduleConfig, error) { +func getRemovableScheduleJobs(c *config.Config, flags commandLineFlags) (schedule.SchedulerConfig, *config.Profile, []*config.Schedule, error) { scheduler, profile, schedules, err := getScheduleJobs(c, flags) if err != nil { return nil, nil, nil, err @@ -560,18 +572,88 @@ func getRemovableScheduleJobs(c *config.Config, flags commandLineFlags) (schedul for _, command := range profile.SchedulableCommands() { declared := false for _, s := range schedules { - if declared = s.SubTitle == command; declared { + if declared = s.CommandName == command; declared { break } } if !declared { - schedules = append(schedules, config.NewRemoveOnlyConfig(profile.Name, command)) + schedules = append(schedules, config.NewEmptySchedule(profile.Name, command)) } } return scheduler, profile, schedules, nil } +func preRunSchedule(ctx *Context) error { + if len(ctx.request.arguments) < 1 { + return errors.New("run-schedule command expects one argument: schedule name") + } + scheduleName := ctx.request.arguments[0] + // temporarily allow v2 configuration to run v1 schedules + // if ctx.config.GetVersion() < config.Version02 + { + // schedule name is in the form "command@profile" + commandName, profileName, ok := strings.Cut(scheduleName, "@") + if !ok { + return errors.New("the expected format of the schedule name is @") + } + ctx.request.profile = profileName + ctx.request.schedule = scheduleName + ctx.command = commandName + // remove the parameter from the arguments + ctx.request.arguments = ctx.request.arguments[1:] + + // don't save the profile in the context now, it's only loaded but not prepared + profile, err := ctx.config.GetProfile(profileName) + if err != nil || profile == nil { + 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 + } + } + } + return nil +} + +func prepareScheduledProfile(ctx *Context) { + clog.Debugf("preparing scheduled profile %q", ctx.request.schedule) + // log file + if len(ctx.schedule.Log) > 0 { + ctx.logTarget = ctx.schedule.Log + } + // battery + if ctx.schedule.IgnoreOnBatteryLessThan > 0 { + ctx.stopOnBattery = ctx.schedule.IgnoreOnBatteryLessThan + } else if ctx.schedule.IgnoreOnBattery { + 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() + } + } else if ctx.schedule.GetLockMode() == config.ScheduleLockModeIgnore { + ctx.noLock = true + } +} + +func runSchedule(_ io.Writer, cmdCtx commandContext) error { + err := startProfileOrGroup(&cmdCtx.Context) + if err != nil { + return err + } + return nil +} + func testElevationCommand(_ io.Writer, ctx commandContext) error { if ctx.flags.isChild { client := remote.NewClient(ctx.flags.parentPort) diff --git a/commands_display.go b/commands_display.go index 9de1e154..19be14a9 100644 --- a/commands_display.go +++ b/commands_display.go @@ -114,9 +114,9 @@ func displayOwnCommandHelp(output io.Writer, commandName string, ctx commandCont commandFlags = "[command specific flags]" } out("Usage:\n") - out("\t%s %s\n\n", getCommonUsageHelpLine(command.name, command.needConfiguration), commandFlags) + out("\t%s %s\n\n", getCommonUsageHelpLine(command.name, command.needConfiguration && !command.noProfile), commandFlags) - var flags []string + var flags = make([]string, 0, len(command.flags)) for f, _ := range command.flags { flags = append(flags, f) } diff --git a/commands_test.go b/commands_test.go index bb56da20..59136611 100644 --- a/commands_test.go +++ b/commands_test.go @@ -64,14 +64,14 @@ schedule = "daily" declaredCount := 0 for _, jobConfig := range schedules { - scheduler := schedule.NewScheduler(schedule.NewHandler(schedule.SchedulerDefaultOS{}), jobConfig.Title) + scheduler := schedule.NewScheduler(schedule.NewHandler(schedule.SchedulerDefaultOS{}), jobConfig.Profiles[0]) defer func(s *schedule.Scheduler) { s.Close() }(scheduler) // Capture current ref to scheduler to be able to close it when function returns. - if jobConfig.SubTitle == "check" { - assert.False(t, scheduler.NewJob(jobConfig).RemoveOnly()) + if jobConfig.CommandName == "check" { + assert.False(t, scheduler.NewJob(scheduleToConfig(jobConfig)).RemoveOnly()) declaredCount++ } else { - assert.True(t, scheduler.NewJob(jobConfig).RemoveOnly()) + assert.True(t, scheduler.NewJob(scheduleToConfig(jobConfig)).RemoveOnly()) } } @@ -114,7 +114,7 @@ schedule = "daily" assert.NotNil(t, profile) assert.NotEmpty(t, schedules) assert.Len(t, schedules, 1) - assert.Equal(t, "check", schedules[0].SubTitle) + assert.Equal(t, "check", schedules[0].CommandName) } } @@ -271,3 +271,160 @@ func TestGenerateCommand(t *testing.T) { } }) } + +func TestShowSchedules(t *testing.T) { + buffer := &bytes.Buffer{} + schedules := []*config.Schedule{ + { + Profiles: []string{"default"}, + CommandName: "check", + Schedules: []string{"weekly"}, + }, + { + Profiles: []string{"default"}, + CommandName: "backup", + Schedules: []string{"daily"}, + }, + } + expected := strings.TrimSpace(` +schedule check@default: + run: check + profiles: default + schedule: weekly + +schedule backup@default: + run: backup + profiles: default + schedule: daily + +`) + showSchedules(buffer, schedules) + assert.Equal(t, expected, strings.TrimSpace(buffer.String())) +} + +func TestCreateScheduleWhenNoneAvailable(t *testing.T) { + // loads an (almost) empty config + cfg, err := config.Load(bytes.NewBufferString("[default]"), "toml") + assert.NoError(t, err) + + err = createSchedule(nil, commandContext{ + Context: Context{ + config: cfg, + flags: commandLineFlags{ + name: "default", + }, + }, + }) + assert.Error(t, err) +} + +func TestCreateScheduleAll(t *testing.T) { + // loads an (almost) empty config + // note that a default (or specific) profile is needed to load all schedules: + // TODO: we should be able to load them all without a default profile + cfg, err := config.Load(bytes.NewBufferString("[default]"), "toml") + assert.NoError(t, err) + + err = createSchedule(nil, commandContext{ + Context: Context{ + config: cfg, + flags: commandLineFlags{ + name: "default", + }, + request: Request{arguments: []string{"--all"}}, + }, + }) + assert.NoError(t, err) +} + +func TestPreRunScheduleNoScheduleName(t *testing.T) { + // loads an (almost) empty config + cfg, err := config.Load(bytes.NewBufferString("[default]"), "toml") + assert.NoError(t, err) + + err = preRunSchedule(&Context{ + config: cfg, + flags: commandLineFlags{ + name: "default", + }, + }) + assert.Error(t, err) + t.Log(err) +} + +func TestPreRunScheduleWrongScheduleName(t *testing.T) { + // loads an (almost) empty config + cfg, err := config.Load(bytes.NewBufferString("[default]"), "toml") + assert.NoError(t, err) + + err = preRunSchedule(&Context{ + request: Request{arguments: []string{"wrong"}}, + config: cfg, + flags: commandLineFlags{ + name: "default", + }, + }) + assert.Error(t, err) + t.Log(err) +} + +func TestPreRunScheduleProfileUnknown(t *testing.T) { + // loads an (almost) empty config + cfg, err := config.Load(bytes.NewBufferString("[default]"), "toml") + assert.NoError(t, err) + + err = preRunSchedule(&Context{ + request: Request{arguments: []string{"backup@profile"}}, + config: cfg, + }) + assert.ErrorIs(t, err, config.ErrNotFound) +} + +func TestRunScheduleNoScheduleName(t *testing.T) { + // loads an (almost) empty config + cfg, err := config.Load(bytes.NewBufferString("[default]"), "toml") + assert.NoError(t, err) + + err = runSchedule(nil, commandContext{ + Context: Context{ + config: cfg, + flags: commandLineFlags{ + name: "default", + }, + }, + }) + assert.Error(t, err) + t.Log(err) +} + +func TestRunScheduleWrongScheduleName(t *testing.T) { + // loads an (almost) empty config + cfg, err := config.Load(bytes.NewBufferString("[default]"), "toml") + assert.NoError(t, err) + + err = runSchedule(nil, commandContext{ + Context: Context{ + request: Request{arguments: []string{"wrong"}}, + config: cfg, + flags: commandLineFlags{ + name: "default", + }, + }, + }) + assert.Error(t, err) + t.Log(err) +} + +func TestRunScheduleProfileUnknown(t *testing.T) { + // loads an (almost) empty config + cfg, err := config.Load(bytes.NewBufferString("[default]"), "toml") + assert.NoError(t, err) + + err = runSchedule(nil, commandContext{ + Context: Context{ + request: Request{arguments: []string{"backup@profile"}}, + config: cfg, + }, + }) + assert.ErrorIs(t, err, ErrProfileNotFound) +} diff --git a/config/config.go b/config/config.go index eb0b2e9f..677d373c 100644 --- a/config/config.go +++ b/config/config.go @@ -653,8 +653,7 @@ func (c *Config) getProfilePath(key string) string { } // GetSchedules loads all schedules from the configuration. -// !!! Nothing is using this method yet !!! -func (c *Config) GetSchedules() ([]*ScheduleConfig, error) { +func (c *Config) GetSchedules() ([]*Schedule, error) { if c.GetVersion() <= Version01 { return c.getSchedulesV1() } diff --git a/config/config_schedule_test.go b/config/config_schedule_test.go index 0ae9d446..a3a66a82 100644 --- a/config/config_schedule_test.go +++ b/config/config_schedule_test.go @@ -47,7 +47,7 @@ schedules: require.NoError(t, err) assert.NotEmpty(t, schedules) assert.Equal(t, []string{"value"}, schedules["sname"].Profiles) - assert.Equal(t, []string{"daily"}, schedules["sname"].Schedule) + assert.Equal(t, []string{"daily"}, schedules["sname"].Schedules) }) } } diff --git a/config/config_v1.go b/config/config_v1.go index 8448e926..81864a2f 100644 --- a/config/config_v1.go +++ b/config/config_v1.go @@ -122,12 +122,12 @@ func (c *Config) getProfileV1(profileKey string) (profile *Profile, err error) { } // getSchedulesV1 loads schedules from profiles -func (c *Config) getSchedulesV1() ([]*ScheduleConfig, error) { +func (c *Config) getSchedulesV1() ([]*Schedule, error) { profiles := c.GetProfileNames() if len(profiles) == 0 { return nil, nil } - schedules := []*ScheduleConfig{} + schedules := []*Schedule{} for _, profileName := range profiles { profile, err := c.GetProfile(profileName) if err != nil { diff --git a/config/profile.go b/config/profile.go index e47c70b9..e9d4c7c4 100644 --- a/config/profile.go +++ b/config/profile.go @@ -3,7 +3,6 @@ package config import ( "fmt" "os" - "path" "path/filepath" "reflect" "slices" @@ -295,9 +294,9 @@ type ScheduleBaseSection struct { 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" default:"false" description:"Don't schedule the start of this profile when running on battery"` - ScheduleIgnoreOnBatteryLessThan int `mapstructure:"schedule-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"` - ScheduleAfterNetworkOnline bool `mapstructure:"schedule-after-network-online" description:"Don't schedule the start of this profile when the network is offline (supported in \"systemd\")."` + 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\")."` } func (s *ScheduleBaseSection) setRootPath(_ *Profile, _ string) { @@ -818,11 +817,12 @@ func (p *Profile) SchedulableCommands() (commands []string) { return } -// Schedules returns a slice of ScheduleConfig that satisfy the schedule.Config interface -func (p *Profile) Schedules() []*ScheduleConfig { +// 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 { // All SectionWithSchedule (backup, check, prune, etc) sections := GetSectionsWith[Scheduling](p) - configs := make([]*ScheduleConfig, 0, len(sections)) + configs := make([]*Schedule, 0, len(sections)) for name, section := range sections { if s := section.GetSchedule(); len(s.Schedule) > 0 { @@ -851,28 +851,26 @@ func (p *Profile) Schedules() []*ScheduleConfig { } } - config := &ScheduleConfig{ - Title: p.Name, - SubTitle: name, + config := &Schedule{ + CommandName: name, + Group: "", + Profiles: []string{p.Name}, Schedules: s.Schedule, Permission: s.SchedulePermission, - Environment: env.Values(), Log: s.ScheduleLog, + Priority: s.SchedulePriority, LockMode: s.ScheduleLockMode, LockWait: s.ScheduleLockWait, - Priority: s.SchedulePriority, - ConfigFile: p.config.configFile, + Environment: env.Values(), IgnoreOnBattery: s.ScheduleIgnoreOnBattery, IgnoreOnBatteryLessThan: s.ScheduleIgnoreOnBatteryLessThan, AfterNetworkOnline: s.ScheduleAfterNetworkOnline, SystemdDropInFiles: p.SystemdDropInFiles, + ConfigFile: p.config.configFile, + Flags: map[string]string{}, } - if len(config.Log) > 0 { - if tempDir, err := util.TempDir(); err == nil && strings.HasPrefix(config.Log, filepath.ToSlash(tempDir)) { - config.Log = path.Join(constants.TemporaryDirMarker, config.Log[len(tempDir):]) - } - } + config.Init(p.config) configs = append(configs, config) } diff --git a/config/profile_test.go b/config/profile_test.go index b44807db..d5dc528c 100644 --- a/config/profile_test.go +++ b/config/profile_test.go @@ -928,7 +928,7 @@ func TestSchedules(t *testing.T) { require.Len(t, config, 1) schedule := config[0] - assert.Equal(t, command, schedule.SubTitle) + assert.Equal(t, command, schedule.CommandName) assert.Equal(t, []string{"@hourly"}, schedule.Schedules) assert.Equal(t, path.Join(constants.TemporaryDirMarker, "rp.log"), schedule.Log) assert.Equal(t, map[string]string{ diff --git a/config/schedule.go b/config/schedule.go index 359436e5..1b05be1d 100644 --- a/config/schedule.go +++ b/config/schedule.go @@ -1,15 +1,96 @@ package config -import "time" +import ( + "path" + "path/filepath" + "strings" + "time" + "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/util" +) + +type ScheduleLockMode int8 + +const ( + // ScheduleLockModeDefault waits on acquiring a lock (local and repository) for up to ScheduleConfig lockWait (duration), before failing a schedule. + // With lockWait set to 0, ScheduleLockModeDefault and ScheduleLockModeFail behave the same. + ScheduleLockModeDefault = ScheduleLockMode(0) + // ScheduleLockModeFail fails immediately on a lock failure without waiting. + ScheduleLockModeFail = ScheduleLockMode(1) + // ScheduleLockModeIgnore does not create or fail on resticprofile locks. Repository locks cause an immediate failure. + 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 type Schedule struct { - Group string `mapstructure:"group"` - Profiles []string `mapstructure:"profiles"` - Command string `mapstructure:"run"` - Schedule []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"` + 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) + + // temporary log file + if s.Log != "" { + if tempDir, err := util.TempDir(); err == nil && strings.HasPrefix(s.Log, filepath.ToSlash(tempDir)) { + s.Log = path.Join(constants.TemporaryDirMarker, s.Log[len(tempDir):]) + } + } +} + +func (s *Schedule) GetLockMode() ScheduleLockMode { + switch s.LockMode { + case constants.ScheduleLockModeOptionFail: + return ScheduleLockModeFail + case constants.ScheduleLockModeOptionIgnore: + return ScheduleLockModeIgnore + default: + return ScheduleLockModeDefault + } +} + +func (s *Schedule) GetLockWait() time.Duration { + if s.LockWait <= 2*time.Second { + return 0 + } + return s.LockWait +} + +func (s *Schedule) GetFlag(name string) (string, bool) { + if len(s.Flags) == 0 { + return "", false + } + // we can't do a direct return, technically the map returns only one value + value, found := s.Flags[name] + return value, found +} + +func (s *Schedule) SetFlag(name, value string) { + if s.Flags == nil { + s.Flags = make(map[string]string) + } + s.Flags[name] = value } diff --git a/config/schedule_config.go b/config/schedule_config.go deleted file mode 100644 index 00ebb85c..00000000 --- a/config/schedule_config.go +++ /dev/null @@ -1,117 +0,0 @@ -package config - -import ( - "strings" - "time" - - "github.com/creativeprojects/resticprofile/constants" -) - -type ScheduleLockMode int8 - -const ( - // ScheduleLockModeDefault waits on acquiring a lock (local and repository) for up to ScheduleConfig lockWait (duration), before failing a schedule. - // With lockWait set to 0, ScheduleLockModeDefault and ScheduleLockModeFail behave the same. - ScheduleLockModeDefault = ScheduleLockMode(0) - // ScheduleLockModeFail fails immediately on a lock failure without waiting. - ScheduleLockModeFail = ScheduleLockMode(1) - // ScheduleLockModeIgnore does not create or fail on resticprofile locks. Repository locks cause an immediate failure. - ScheduleLockModeIgnore = ScheduleLockMode(2) -) - -// ScheduleConfig contains all information to schedule a profile command -type ScheduleConfig struct { - Title string - SubTitle string - Schedules []string - Permission string - WorkingDirectory string - Command string - Arguments []string - Environment []string - JobDescription string - TimerDescription string - Priority string - Log string - LockMode string - LockWait time.Duration - ConfigFile string - Flags map[string]string - RemoveOnly bool - IgnoreOnBattery bool - IgnoreOnBatteryLessThan int - AfterNetworkOnline bool - SystemdDropInFiles []string -} - -// NewRemoveOnlyConfig creates a job config that may be used to call Job.Remove() on a scheduled job -func NewRemoveOnlyConfig(profileName, commandName string) *ScheduleConfig { - return &ScheduleConfig{ - Title: profileName, - SubTitle: commandName, - RemoveOnly: true, - } -} - -func (s *ScheduleConfig) SetCommand(wd, command string, args []string) { - s.WorkingDirectory = wd - s.Command = command - s.Arguments = args -} - -// Priority is either "background" or "standard" -func (s *ScheduleConfig) GetPriority() string { - s.Priority = strings.ToLower(s.Priority) - // default value for priority is "background" - if s.Priority != constants.SchedulePriorityBackground && s.Priority != constants.SchedulePriorityStandard { - s.Priority = constants.SchedulePriorityBackground - } - return s.Priority -} - -func (s *ScheduleConfig) GetLockMode() ScheduleLockMode { - switch s.LockMode { - case constants.ScheduleLockModeOptionFail: - return ScheduleLockModeFail - case constants.ScheduleLockModeOptionIgnore: - return ScheduleLockModeIgnore - default: - return ScheduleLockModeDefault - } -} - -func (s *ScheduleConfig) GetLockWait() time.Duration { - if s.LockWait <= 2*time.Second { - return 0 - } - return s.LockWait -} - -func (s *ScheduleConfig) GetFlag(name string) (string, bool) { - if len(s.Flags) == 0 { - return "", false - } - // we can't do a direct return, technically the map returns only one value - value, found := s.Flags[name] - return value, found -} - -func (s *ScheduleConfig) SetFlag(name, value string) { - if s.Flags == nil { - s.Flags = make(map[string]string) - } - s.Flags[name] = value -} - -func (s *ScheduleConfig) Export() Schedule { - return Schedule{ - Profiles: []string{s.Title}, - Command: s.SubTitle, - Permission: s.Permission, - Log: s.Log, - Priority: s.Priority, - LockMode: s.LockMode, - LockWait: s.LockWait, - Schedule: s.Schedules, - } -} diff --git a/config/schedule_config_test.go b/config/schedule_config_test.go deleted file mode 100644 index ea77525a..00000000 --- a/config/schedule_config_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package config - -import ( - "testing" - "time" - - "github.com/creativeprojects/resticprofile/constants" - "github.com/stretchr/testify/assert" -) - -func TestScheduleProperties(t *testing.T) { - schedule := ScheduleConfig{ - ConfigFile: "config", - Title: "profile", - SubTitle: "command name", - Schedules: []string{"1", "2", "3"}, - Permission: "admin", - WorkingDirectory: "home", - Command: "command", - Arguments: []string{"1", "2"}, - Environment: []string{"test=dev"}, - JobDescription: "job", - TimerDescription: "timer", - Log: "log.txt", - LockMode: "undefined", - LockWait: 1 * time.Minute, - } - - assert.Equal(t, "config", schedule.ConfigFile) - assert.Equal(t, "profile", schedule.Title) - assert.Equal(t, "command name", schedule.SubTitle) - assert.Equal(t, "job", schedule.JobDescription) - assert.Equal(t, "timer", schedule.TimerDescription) - assert.ElementsMatch(t, []string{"1", "2", "3"}, schedule.Schedules) - assert.Equal(t, "admin", schedule.Permission) - assert.Equal(t, "command", schedule.Command) - assert.Equal(t, "home", schedule.WorkingDirectory) - assert.ElementsMatch(t, []string{"1", "2"}, schedule.Arguments) - assert.Equal(t, []string{"test=dev"}, schedule.Environment) - assert.Equal(t, "background", schedule.GetPriority()) // default value - assert.Equal(t, "log.txt", schedule.Log) - assert.Equal(t, ScheduleLockModeDefault, schedule.GetLockMode()) - assert.Equal(t, 60*time.Second, schedule.GetLockWait()) -} - -func TestLockModes(t *testing.T) { - tests := map[ScheduleLockMode]ScheduleConfig{ - ScheduleLockModeDefault: {LockMode: ""}, - ScheduleLockModeFail: {LockMode: constants.ScheduleLockModeOptionFail}, - ScheduleLockModeIgnore: {LockMode: constants.ScheduleLockModeOptionIgnore}, - } - for mode, config := range tests { - assert.Equal(t, mode, config.GetLockMode()) - } -} - -func TestLockWait(t *testing.T) { - tests := map[time.Duration]ScheduleConfig{ - 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}, - } - for mode, config := range tests { - assert.Equal(t, mode, config.GetLockWait()) - } -} - -func TestStandardPriority(t *testing.T) { - schedule := ScheduleConfig{ - Priority: "standard", - } - assert.Equal(t, "standard", schedule.GetPriority()) -} - -func TestCaseInsensitivePriority(t *testing.T) { - schedule := ScheduleConfig{ - Priority: "stANDard", - } - assert.Equal(t, "standard", schedule.GetPriority()) -} - -func TestOtherPriority(t *testing.T) { - schedule := ScheduleConfig{ - Priority: "other", - } - assert.Equal(t, "background", schedule.GetPriority()) // default value -} - -func TestScheduleFlags(t *testing.T) { - schedule := &ScheduleConfig{} - - flag, found := schedule.GetFlag("unit") - assert.Empty(t, flag) - assert.False(t, found) - - schedule.SetFlag("unit", "test") - flag, found = schedule.GetFlag("unit") - assert.Equal(t, "test", flag) - assert.True(t, found) -} diff --git a/config/schedule_test.go b/config/schedule_test.go new file mode 100644 index 00000000..5e637cca --- /dev/null +++ b/config/schedule_test.go @@ -0,0 +1,70 @@ +package config + +import ( + "testing" + "time" + + "github.com/creativeprojects/resticprofile/constants" + "github.com/stretchr/testify/assert" +) + +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, + } + + 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()) +} + +func TestLockModes(t *testing.T) { + tests := map[ScheduleLockMode]Schedule{ + ScheduleLockModeDefault: {LockMode: ""}, + ScheduleLockModeFail: {LockMode: constants.ScheduleLockModeOptionFail}, + ScheduleLockModeIgnore: {LockMode: constants.ScheduleLockModeOptionIgnore}, + } + for mode, config := range tests { + assert.Equal(t, mode, config.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}, + } + for mode, config := range tests { + assert.Equal(t, mode, config.GetLockWait()) + } +} + +func TestScheduleFlags(t *testing.T) { + schedule := &Schedule{} + + flag, found := schedule.GetFlag("unit") + assert.Empty(t, flag) + assert.False(t, found) + + schedule.SetFlag("unit", "test") + flag, found = schedule.GetFlag("unit") + assert.Equal(t, "test", flag) + assert.True(t, found) +} diff --git a/context.go b/context.go index 664bcd4a..b41bb1f1 100644 --- a/context.go +++ b/context.go @@ -2,6 +2,7 @@ package main import ( "os" + "time" "github.com/creativeprojects/resticprofile/config" ) @@ -18,16 +19,71 @@ type Request struct { // Not everything is always available, // but any information should be added to the context as soon as known. type Context struct { - request Request - flags commandLineFlags - global *config.Global - config *config.Config - binary string // where to find the restic binary - command string // which restic command to use - profile *config.Profile - schedule *config.Schedule // when profile is running with run-schedule command - sigChan chan os.Signal // termination request - logTarget string // where to send the log output + request Request + flags commandLineFlags + global *config.Global + config *config.Config + binary string // where to find the restic binary + command string // which restic command to use + profile *config.Profile + schedule *config.Schedule // when profile is running with run-schedule command + sigChan chan os.Signal // termination request + logTarget string // where to send the log output + stopOnBattery int // stop if running on battery + noLock bool // skip profile lock file + lockWait time.Duration // wait up to duration to acquire a lock +} + +func CreateContext(flags commandLineFlags, global *config.Global, cfg *config.Config, ownCommands *OwnCommands) (*Context, error) { + // The remaining arguments are going to be sent to the restic command line + command := global.DefaultCommand + resticArguments := flags.resticArgs + if len(resticArguments) > 0 { + command = resticArguments[0] + resticArguments = resticArguments[1:] + } + + ctx := &Context{ + request: Request{ + command: command, + arguments: resticArguments, + profile: flags.name, + group: "", + schedule: "", + }, + flags: flags, + global: global, + config: cfg, + binary: "", + command: "", + profile: nil, + schedule: nil, + sigChan: nil, + logTarget: global.Log, // default to global (which can be empty) + } + // own commands can check the context before running + if ownCommands.Exists(command, true) { + err := ownCommands.Pre(ctx) + if err != nil { + return ctx, err + } + } + // command line flag supersedes any configuration + if flags.log != "" { + ctx.logTarget = flags.log + } + // same for battery configuration + if flags.ignoreOnBattery > 0 { + ctx.stopOnBattery = flags.ignoreOnBattery + } + // also lock configuration + if flags.noLock { + ctx.noLock = true + } + if flags.lockWait > 0 { + ctx.lockWait = flags.lockWait + } + return ctx, nil } // WithConfig sets the configuration and global values. A new copy of the context is returned. diff --git a/context_test.go b/context_test.go index c876ee0b..a2b70fc3 100644 --- a/context_test.go +++ b/context_test.go @@ -77,3 +77,222 @@ func TestContextWithProfile(t *testing.T) { assert.Nil(t, ctx.profile) assert.Nil(t, ctx.schedule) } + +func TestCreateContext(t *testing.T) { + fixtures := []struct { + description string + flags commandLineFlags + global *config.Global + cfg *config.Config + ownCommands *OwnCommands + context *Context + }{ + { + description: "empty config with default command only", + flags: commandLineFlags{}, + global: &config.Global{ + DefaultCommand: "test", + }, + cfg: &config.Config{}, + ownCommands: &OwnCommands{}, + context: &Context{ + request: Request{ + command: "test", + }, + global: &config.Global{ + DefaultCommand: "test", + }, + config: &config.Config{}, + }, + }, + { + description: "command and arguments", + flags: commandLineFlags{ + resticArgs: []string{"arg1", "arg2"}, + }, + global: &config.Global{ + DefaultCommand: "test", + }, + cfg: &config.Config{}, + ownCommands: &OwnCommands{}, + context: &Context{ + flags: commandLineFlags{ + resticArgs: []string{"arg1", "arg2"}, + }, + request: Request{ + command: "arg1", + arguments: []string{"arg2"}, + }, + global: &config.Global{ + DefaultCommand: "test", + }, + config: &config.Config{}, + }, + }, + { + description: "global log target", + flags: commandLineFlags{}, + global: &config.Global{Log: "global"}, + cfg: &config.Config{}, + ownCommands: &OwnCommands{}, + context: &Context{ + flags: commandLineFlags{}, + request: Request{}, + global: &config.Global{Log: "global"}, + config: &config.Config{}, + logTarget: "global", + }, + }, + { + description: "log target on the command line", + flags: commandLineFlags{log: "cmdline"}, + global: &config.Global{Log: "global"}, + cfg: &config.Config{}, + ownCommands: &OwnCommands{}, + context: &Context{ + flags: commandLineFlags{log: "cmdline"}, + request: Request{}, + global: &config.Global{Log: "global"}, + config: &config.Config{}, + logTarget: "cmdline", + }, + }, + { + description: "log target from command", + flags: commandLineFlags{}, + global: &config.Global{Log: "global", DefaultCommand: "test"}, + cfg: &config.Config{}, + ownCommands: &OwnCommands{ + commands: []ownCommand{ + { + name: "test", + needConfiguration: true, + pre: func(ctx *Context) error { + ctx.logTarget = "command" + return nil + }, + }, + }, + }, + context: &Context{ + flags: commandLineFlags{}, + request: Request{command: "test"}, + global: &config.Global{Log: "global", DefaultCommand: "test"}, + config: &config.Config{}, + logTarget: "command", + }, + }, + { + description: "log target from command line", + flags: commandLineFlags{ + log: "cmdline", + resticArgs: []string{"test"}, + }, + global: &config.Global{}, + cfg: &config.Config{}, + ownCommands: &OwnCommands{ + commands: []ownCommand{ + { + name: "test", + needConfiguration: true, + pre: func(ctx *Context) error { + ctx.logTarget = "command" + return nil + }, + }, + }, + }, + context: &Context{ + flags: commandLineFlags{ + log: "cmdline", + resticArgs: []string{"test"}, + }, + request: Request{ + command: "test", + arguments: []string{}, + }, + global: &config.Global{}, + config: &config.Config{}, + logTarget: "cmdline", + }, + }, + { + description: "battery and lock from command", + flags: commandLineFlags{}, + global: &config.Global{DefaultCommand: "test"}, + cfg: &config.Config{}, + ownCommands: &OwnCommands{ + commands: []ownCommand{ + { + name: "test", + needConfiguration: true, + pre: func(ctx *Context) error { + ctx.stopOnBattery = 10 + ctx.lockWait = 20 + ctx.noLock = true + return nil + }, + }, + }, + }, + context: &Context{ + flags: commandLineFlags{}, + request: Request{command: "test"}, + global: &config.Global{DefaultCommand: "test"}, + config: &config.Config{}, + stopOnBattery: 10, + lockWait: 20, + noLock: true, + }, + }, + { + description: "battery and lock from command line", + flags: commandLineFlags{ + resticArgs: []string{"test"}, + ignoreOnBattery: 80, + lockWait: 30, + noLock: true, + }, + global: &config.Global{}, + cfg: &config.Config{}, + ownCommands: &OwnCommands{ + commands: []ownCommand{ + { + name: "test", + needConfiguration: true, + pre: func(ctx *Context) error { + ctx.stopOnBattery = 10 + ctx.lockWait = 20 + return nil + }, + }, + }, + }, + context: &Context{ + flags: commandLineFlags{ + resticArgs: []string{"test"}, + ignoreOnBattery: 80, + lockWait: 30, + noLock: true, + }, + request: Request{ + command: "test", + arguments: []string{}, + }, + global: &config.Global{}, + config: &config.Config{}, + stopOnBattery: 80, + lockWait: 30, + noLock: true, + }, + }, + } + + for _, fixture := range fixtures { + t.Run(fixture.description, func(t *testing.T) { + ctx, err := CreateContext(fixture.flags, fixture.global, fixture.cfg, fixture.ownCommands) + assert.NoError(t, err) + assert.Equal(t, fixture.context, ctx) + }) + } +} diff --git a/crond/crontab.go b/crond/crontab.go index ba734f9b..efcc4bb1 100644 --- a/crond/crontab.go +++ b/crond/crontab.go @@ -1,4 +1,5 @@ -//+build !darwin,!windows +//go:build !darwin && !windows +// +build !darwin,!windows package crond @@ -33,9 +34,9 @@ func NewCrontab(entries []Entry) *Crontab { // Update crontab entries: // -// If addEntries is set to true, it will delete and add all new entries +// # If addEntries is set to true, it will delete and add all new entries // -// If addEntries is set to false, it will only delete the matching entries +// # If addEntries is set to false, it will only delete the matching entries // // Return values are the number of entries deleted, and an error if any func (c *Crontab) Update(source string, addEntries bool, w io.StringWriter) (int, error) { @@ -197,11 +198,19 @@ func extractOwnSection(crontab string) (string, string, string, bool) { func deleteLine(crontab string, entry Entry) (string, bool, error) { // should match a line like: // 00,15,30,45 * * * * /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup - search := fmt.Sprintf(`(?m)^[^#][^\n]+resticprofile[^\n]+--config %s --name %s[^\n]* %s\n`, - regexp.QuoteMeta(entry.configFile), + // or a line like: + // 00,15,30,45 * * * * /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile + legacy := fmt.Sprintf(`--name %s[^\n]* %s`, regexp.QuoteMeta(entry.profileName), regexp.QuoteMeta(entry.commandName), ) + runSchedule := fmt.Sprintf(`run-schedule %s@%s`, + regexp.QuoteMeta(entry.commandName), + regexp.QuoteMeta(entry.profileName), + ) + search := fmt.Sprintf(`(?m)^[^#][^\n]+resticprofile[^\n]+--config %s (%s|%s)\n`, + regexp.QuoteMeta(entry.configFile), legacy, runSchedule) + pattern, err := regexp.Compile(search) if err != nil { return crontab, false, err diff --git a/crond/crontab_test.go b/crond/crontab_test.go index 2b2fb953..1f0f50e9 100644 --- a/crond/crontab_test.go +++ b/crond/crontab_test.go @@ -1,4 +1,5 @@ -//+build !darwin,!windows +//go:build !darwin && !windows +// +build !darwin,!windows package crond @@ -63,18 +64,20 @@ func TestCleanCrontab(t *testing.T) { func TestDeleteLine(t *testing.T) { testData := []struct { - source string - expected bool + source string + expectFound bool }{ {"#\n#\n#\n# 00,30 * * * * /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup\n", false}, {"#\n#\n#\n00,30 * * * * /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup\n", true}, + {"#\n#\n#\n# 00,30 * * * * /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n", false}, + {"#\n#\n#\n00,30 * * * * /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n", true}, } for _, testRun := range testData { t.Run("", func(t *testing.T) { _, found, err := deleteLine(testRun.source, Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup"}) require.NoError(t, err) - assert.Equal(t, testRun.expected, found) + assert.Equal(t, testRun.expectFound, found) }) } } diff --git a/docs/content/configuration/logs.md b/docs/content/configuration/logs.md index 8999ad6b..d4c2dc37 100644 --- a/docs/content/configuration/logs.md +++ b/docs/content/configuration/logs.md @@ -137,6 +137,14 @@ profile: {{% /tab %}} {{< /tabs >}} +## Priority on the log targets + +If specified in different places, here's the priority order for the log destination: +1. `--log` flag on the command line +2. `schedule-log` in the `profile` section +3. `log` in the `global` section +4. default to the console + ## Send logs to a temporary file This can be done by using the [template]({{% relref "/configuration/templates" %}}) function `tempFile`. diff --git a/docs/content/schedules/commands.md b/docs/content/schedules/commands.md index 48429527..7a24a28e 100644 --- a/docs/content/schedules/commands.md +++ b/docs/content/schedules/commands.md @@ -1,15 +1,16 @@ --- title: "Schedule Commands" weight: 20 +tags: ["v0.25.0"] --- resticprofile accepts these internal commands: -- `schedule` -- `unschedule` -- `status` +- **schedule** +- **unschedule** +- **status** -All internal commands either operate on the profile selected by `--name`, on the profiles selected by a group, or on all profiles when the flag `--all` is passed. +These resticprofile commands either operate on the profile selected by `--name`, on the profiles selected by a group, or on all profiles when the flag `--all` is passed on the command line. Examples: ```shell @@ -39,3 +40,19 @@ Remove all the schedules defined on the selected profile or profiles. Print the status on all the installed schedules of the selected profile or profiles. The display of the `status` command will be OS dependant. Please refer to the [examples]({{% relref "/schedules/examples" %}}) on which output you can expect from it. + +### run-schedule command + +This is the command that is used internally by the scheduler to tell resticprofile to execute in the context of a schedule. It means it will set the proper log output (`schedule-log`) and all other flags specific to the schedule. + +If you're scheduling resticprofile manually you can use this command. It will execute the profile using all the `schedule-*` parameters defined in the profile. + +This command is only taking one argument: name of the command to execute, followed by the profile name, both separated by a `@` sign. + +```shell +resticprofile run-schedule backup@profile +``` + +{{% notice info %}} +You cannot specify the profile name using the `--name` flag. +{{% /notice %}} diff --git a/docs/content/schedules/examples.md b/docs/content/schedules/examples.md index c4347255..5ad1eeb9 100644 --- a/docs/content/schedules/examples.md +++ b/docs/content/schedules/examples.md @@ -356,5 +356,5 @@ If you backup your files to an external repository on a network, you should get If you prefer not being asked, you can add the `--no-start` flag like so: ```shell -% resticprofile -v -c examples/private/azure.yaml -n self schedule --no-start +resticprofile -v -c examples/private/azure.yaml -n self schedule --no-start ``` diff --git a/examples/dev.yaml b/examples/dev.yaml index 9107ab6b..647dbf8e 100644 --- a/examples/dev.yaml +++ b/examples/dev.yaml @@ -11,6 +11,7 @@ global: # legacy-arguments: true group-continue-on-error: true restic-lock-retry-after: "1m" + # log: "_global.txt" groups: full-backup: @@ -113,6 +114,9 @@ self: schedule: - "*:00,30" schedule-permission: user + schedule-log: "_schedule-log.txt" + schedule-ignore-on-battery: true + schedule-after-network-online: true run-after-fail: - "echo restic returned an error, command line = ${ERROR_COMMANDLINE}" diff --git a/examples/v2._yaml b/examples/v2._yaml new file mode 100644 index 00000000..72709038 --- /dev/null +++ b/examples/v2._yaml @@ -0,0 +1,225 @@ +# yaml-language-server: $schema=https://creativeprojects.github.io/resticprofile/jsonschema/config-2.json + +version: "2" +global: + default-command: snapshots + initialize: false + priority: low + # restic-binary: ~/fake_restic + # legacy-arguments: true +groups: + full-backup: + description: Full Backup + profiles: + - root + - src +profiles: + default: + description: Contains default parameters like repository and password file + env: + tmp: /tmp + initialize: false + cleanup-cache: true + password-file: key + repository: "/Volumes/RAMDisk/{{ .Profile.Name }}" + lock: "/Volumes/RAMDisk/resticprofile-{{ .Profile.Name }}.lock" + copy: + password-file: key + repository: "/Volumes/RAMDisk/{{ .Profile.Name }}-copy" + space: + description: Repository contains space + initialize: false + password-file: key + repository: "/Volumes/RAMDisk/with space" + documents: + inherit: default + backup: + source: ~/Documents + initialize: false + repository: ~/backup/{{ .Hostname }} + snapshots: + tag: + - dev + - "{{ .Profile.Name }}" + root: + backup: + schedule: "*:0,15,30,45" + exclude-caches: true + exclude-file: + - root-excludes + - excludes + one-file-system: false + source: + - . + tag: + - dev + - "{{ .Profile.Name }}" + inherit: default + initialize: true + retention: + after-backup: true + before-backup: false + compact: false + host: true + keep-daily: 1 + keep-hourly: 1 + keep-last: 3 + keep-monthly: 1 + keep-tag: + - forever + keep-weekly: 1 + keep-within: 3h + keep-yearly: 1 + prune: false + tag: + - dev + - "{{ .Profile.Name }}" + forget: + host: true + keep-daily: 1 + keep-hourly: 1 + keep-last: 3 + keep-monthly: 1 + keep-tag: + - forever + keep-weekly: 1 + keep-within: 3h + keep-yearly: 1 + prune: false + tag: + - dev + - "{{ .Profile.Name }}" + self: + force-inactive-lock: true + initialize: true + inherit: default + # status-file: /Volumes/RAMDisk/status.json + backup: + extended-status: false + check-before: false + no-error-on-warning: true + source: "{{ .CurrentDir }}" + exclude: + - "/**/.git/" + schedule: + - "*:00,30" + schedule-permission: user + tag: + - dev + - "{{ .Profile.Name }}" + run-after-fail: + - "echo restic returned an error, command line = ${ERROR_COMMANDLINE}" + - "echo restic stderr = ${RESTIC_STDERR}" + check: + schedule: + - "*:15" + retention: + after-backup: true + forget: + keep-daily: 1 + schedule: "weekly" + schedule-priority: standard + copy: + initialize: true + schedule: + - "*:45" + prom: + force-inactive-lock: true + initialize: true + inherit: default + prometheus-save-to-file: "self.prom" + prometheus-push: "http://localhost:9091/" + prometheus-labels: + host: "{{ .Hostname }}" + status-file: /Volumes/RAMDisk/status.json + backup: + extended-status: true + no-error-on-warning: true + source: + - "{{ .CurrentDir }}" + tag: + - dev + - "{{ .Profile.Name }}" + # exclude: + # - examples/private + system: + initialize: true + no-cache: true + inherit: default + backup: + source: ./ + schedule: + - "*:5,10,20,25,35,40,50,55" + schedule-permission: system + forget: + schedule: "weekly" + src: + backup: + check-before: true + exclude: + - /**/.git + exclude-caches: true + one-file-system: false + run-after: echo All Done! + run-before: + - "echo Hello {{ .Env.LOGNAME }}" + - "echo current dir: {{ .CurrentDir }}" + - "echo config dir: {{ .ConfigDir }}" + - 'echo profile started at {{ .Now.Format "02 Jan 06 15:04 MST" }}' + source: + - "{{ .Env.HOME }}/go/src" + tag: + - dev + - "{{ .Profile.Name }}" + inherit: default + initialize: true + retention: + after-backup: true + before-backup: false + compact: false + keep-within: 30d + prune: true + snapshots: + tag: + - dev + - "{{ .Profile.Name }}" + home: + inherit: default + # cache-dir: "${TMPDIR}.restic/" + backup: + source: "${HOME}/Projects" + stdin: + backup: + stdin: true + stdin-filename: stdin-test + tag: + - dev + - "{{ .Profile.Name }}" + inherit: default + snapshots: + tag: + - dev + - "{{ .Profile.Name }}" + dropbox: + initialize: false + inherit: default + backup: + extended-status: false + check-before: false + no-error-on-warning: true + source: "../../../../../Dropbox" + escape: + initialize: true + inherit: default + backup: + source: + - "{{ .CurrentDir }}/examples/private/file with space" + - '{{ .CurrentDir }}/examples/private/quoted"file' + - "{{ .CurrentDir }}/examples/private/Côte d'Ivoire" + exclude: + #- file with space + - "**/.git" + - quoted"file + - Côte d'Ivoire + retention: + after-backup: true diff --git a/examples/v2.yaml b/examples/v2.yaml deleted file mode 100644 index 6d836ba5..00000000 --- a/examples/v2.yaml +++ /dev/null @@ -1,223 +0,0 @@ -{{ define "tags" }} - tag: - - dev - - {{ .Profile.Name }} -{{ end -}} - -version: 2 - -global: - default-command: snapshots - initialize: false - priority: low - # restic-binary: ~/fake_restic - # legacy-arguments: true - -groups: - full-backup: - description: Full Backup - profiles: - - root - - src - -profiles: - default: - description: Contains default parameters like repository and password file - env: - tmp: /tmp - initialize: false - cleanup-cache: true - password-file: key - repository: "/Volumes/RAMDisk/{{ .Profile.Name }}" - lock: "/Volumes/RAMDisk/resticprofile-{{ .Profile.Name }}.lock" - copy: - password-file: key - repository: "/Volumes/RAMDisk/{{ .Profile.Name }}-copy" - - space: - description: Repository contains space - initialize: false - password-file: key - repository: "/Volumes/RAMDisk/with space" - - documents: - inherit: default - backup: - source: ~/Documents - initialize: false - repository: ~/backup/{{ .Hostname }} - snapshots: - {{ template "tags" . }} - - root: - backup: - schedule: "*:0,15,30,45" - exclude-caches: true - exclude-file: - - root-excludes - - excludes - one-file-system: false - source: - - . - {{ template "tags" . }} - inherit: default - initialize: true - retention: - after-backup: true - before-backup: false - compact: false - host: true - keep-daily: 1 - keep-hourly: 1 - keep-last: 3 - keep-monthly: 1 - keep-tag: - - forever - keep-weekly: 1 - keep-within: 3h - keep-yearly: 1 - prune: false - {{ template "tags" . }} - forget: - host: true - keep-daily: 1 - keep-hourly: 1 - keep-last: 3 - keep-monthly: 1 - keep-tag: - - forever - keep-weekly: 1 - keep-within: 3h - keep-yearly: 1 - prune: false - {{ template "tags" . }} - - self: - force-inactive-lock: true - initialize: true - inherit: default - # status-file: /Volumes/RAMDisk/status.json - backup: - extended-status: false - check-before: false - no-error-on-warning: true - source: {{ .CurrentDir }} - exclude: - - "/**/.git/" - schedule: - - "*:00,30" - schedule-permission: user - {{ template "tags" . }} - run-after-fail: - - "echo restic returned an error, command line = ${ERROR_COMMANDLINE}" - - "echo restic stderr = ${RESTIC_STDERR}" - check: - schedule: - - "*:15" - retention: - after-backup: true - forget: - schedule: "weekly" - schedule-priority: standard - copy: - initialize: true - schedule: - - "*:45" - - prom: - force-inactive-lock: true - initialize: true - inherit: default - prometheus-save-to-file: "self.prom" - prometheus-push: "http://localhost:9091/" - prometheus-labels: - - host: {{ .Hostname }} - status-file: /Volumes/RAMDisk/status.json - backup: - extended-status: true - no-error-on-warning: true - source: - - {{ .CurrentDir }} - {{ template "tags" . }} - # exclude: - # - examples/private - - system: - initialize: true - no-cache: true - inherit: default - backup: - source: ./ - schedule: - - "*:5,10,20,25,35,40,50,55" - schedule-permission: system - forget: - schedule: "weekly" - - src: - backup: - check-before: true - exclude: - - /**/.git - exclude-caches: true - one-file-system: false - run-after: echo All Done! - run-before: - - "echo Hello {{ .Env.LOGNAME }}" - - "echo current dir: {{ .CurrentDir }}" - - "echo config dir: {{ .ConfigDir }}" - - "echo profile started at {{ .Now.Format "02 Jan 06 15:04 MST" }}" - source: - - "{{ .Env.HOME }}/go/src" - {{ template "tags" . }} - inherit: default - initialize: true - retention: - after-backup: true - before-backup: false - compact: false - keep-within: 30d - prune: true - snapshots: - {{ template "tags" . }} - - home: - inherit: default - # cache-dir: "${TMPDIR}.restic/" - backup: - source: "${HOME}/Projects" - - stdin: - backup: - stdin: true - stdin-filename: stdin-test - {{ template "tags" . }} - inherit: default - snapshots: - {{ template "tags" . }} - - dropbox: - initialize: false - inherit: default - backup: - extended-status: false - check-before: false - no-error-on-warning: true - source: "../../../../../Dropbox" - - escape: - initialize: true - inherit: default - backup: - source: - - {{ .CurrentDir }}/examples/private/file with space - - {{ .CurrentDir }}/examples/private/quoted"file - - {{ .CurrentDir }}/examples/private/Côte d'Ivoire - exclude: - #- file with space - - "**/.git" - - quoted"file - - Côte d'Ivoire - retention: - after-backup: true - diff --git a/main.go b/main.go index 375ec2b5..10c2ab5f 100644 --- a/main.go +++ b/main.go @@ -166,7 +166,7 @@ func main() { } // Load the now mandatory configuration and setup logging (before returning an error) - ctx, err := loadContext(flags, false) + ctx, err := loadContext(flags) closeLogger := setupLogging(ctx) defer closeLogger() if err != nil { @@ -176,7 +176,7 @@ func main() { } // check if we're running on battery - if shouldStopOnBattery(flags.ignoreOnBattery) { + if shouldStopOnBattery(ctx.stopOnBattery) { exitCode = 3 return } @@ -289,49 +289,12 @@ func loadConfig(flags commandLineFlags, silent bool) (cfg *config.Config, global } // loadContext loads the configuration and creates a context. -func loadContext(flags commandLineFlags, silent bool) (*Context, error) { - cfg, global, err := loadConfig(flags, silent) +func loadContext(flags commandLineFlags) (*Context, error) { + cfg, global, err := loadConfig(flags, false) if err != nil { return nil, err } - // The remaining arguments are going to be sent to the restic command line - command := global.DefaultCommand - resticArguments := flags.resticArgs - if len(resticArguments) > 0 { - command = resticArguments[0] - resticArguments = resticArguments[1:] - } - - ctx := &Context{ - request: Request{ - command: command, - arguments: resticArguments, - profile: flags.name, - group: "", - schedule: "", - }, - flags: flags, - global: global, - config: cfg, - binary: "", - command: "", - profile: nil, - schedule: nil, - sigChan: nil, - logTarget: global.Log, // default to global (which can be empty) - } - // own commands can check the context before running - if ownCommands.Exists(command, true) { - err = ownCommands.Pre(ctx) - if err != nil { - return ctx, err - } - } - // command line flag supersedes any configuration - if flags.log != "" { - ctx.logTarget = flags.log - } - return ctx, nil + return CreateContext(flags, global, cfg, ownCommands) } func setPriority(nice int, class string) error { @@ -542,6 +505,11 @@ func runProfile(ctx *Context) error { } profile.SetHost(hostname) + if ctx.request.schedule != "" { + // this is a scheduled profile + loadScheduledProfile(ctx) + } + // Catch CTR-C keypress, or other signal sent by a service manager (systemd) sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGABRT) @@ -551,10 +519,10 @@ func runProfile(ctx *Context) error { ctx.sigChan = sigChan wrapper := newResticWrapper(ctx) - if ctx.flags.noLock { + if ctx.noLock { wrapper.ignoreLock() - } else if ctx.flags.lockWait > 0 { - wrapper.maxWaitOnLock(ctx.flags.lockWait) + } else if ctx.lockWait > 0 { + wrapper.maxWaitOnLock(ctx.lockWait) } // add progress receivers if necessary @@ -572,6 +540,18 @@ func runProfile(ctx *Context) error { return nil } +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 + } + } + return nil +} + // randomBool returns true for Heads and false for Tails func randomBool() bool { return rand.Int31n(10000) < 5000 diff --git a/schedule/config.go b/schedule/config.go new file mode 100644 index 00000000..d1526506 --- /dev/null +++ b/schedule/config.go @@ -0,0 +1,70 @@ +package schedule + +import ( + "strings" + + "github.com/creativeprojects/resticprofile/constants" +) + +// Config contains all information to schedule a profile command +type Config struct { + ProfileName string + CommandName string // restic command + Schedules []string + Permission string + WorkingDirectory string + Command string // path to resticprofile executable + Arguments []string + Environment []string + JobDescription string + TimerDescription string + Priority string // Priority is either "background" or "standard" + ConfigFile string + Flags map[string]string // flags added to the command line + IgnoreOnBattery bool + IgnoreOnBatteryLessThan int + AfterNetworkOnline bool + SystemdDropInFiles []string + removeOnly bool +} + +// NewRemoveOnlyConfig creates a job config that may be used to call Job.Remove() on a scheduled job +func NewRemoveOnlyConfig(profileName, commandName string) *Config { + return &Config{ + ProfileName: profileName, + CommandName: commandName, + removeOnly: true, + } +} + +func (s *Config) SetCommand(wd, command string, args []string) { + s.WorkingDirectory = wd + s.Command = command + s.Arguments = args +} + +// Priority is either "background" or "standard" +func (s *Config) GetPriority() string { + s.Priority = strings.ToLower(s.Priority) + // default value for priority is "background" + if s.Priority != constants.SchedulePriorityBackground && s.Priority != constants.SchedulePriorityStandard { + s.Priority = constants.SchedulePriorityBackground + } + return s.Priority +} + +func (s *Config) GetFlag(name string) (string, bool) { + if len(s.Flags) == 0 { + return "", false + } + // we can't do a direct return, technically the map returns only one value + value, found := s.Flags[name] + return value, found +} + +func (s *Config) SetFlag(name, value string) { + if s.Flags == nil { + s.Flags = make(map[string]string) + } + s.Flags[name] = value +} diff --git a/schedule/config_test.go b/schedule/config_test.go new file mode 100644 index 00000000..7bf78661 --- /dev/null +++ b/schedule/config_test.go @@ -0,0 +1,75 @@ +package schedule + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestScheduleProperties(t *testing.T) { + schedule := Config{ + ProfileName: "profile", + CommandName: "command name", + Schedules: []string{"1", "2", "3"}, + Permission: "admin", + WorkingDirectory: "home", + Command: "command", + Arguments: []string{"1", "2"}, + Environment: []string{"test=dev"}, + JobDescription: "job", + TimerDescription: "timer", + Priority: "", + ConfigFile: "config", + Flags: map[string]string{}, + removeOnly: false, + IgnoreOnBattery: false, + IgnoreOnBatteryLessThan: 0, + } + + assert.Equal(t, "config", schedule.ConfigFile) + assert.Equal(t, "profile", schedule.ProfileName) + assert.Equal(t, "command name", schedule.CommandName) + assert.Equal(t, "job", schedule.JobDescription) + assert.Equal(t, "timer", schedule.TimerDescription) + assert.ElementsMatch(t, []string{"1", "2", "3"}, schedule.Schedules) + assert.Equal(t, "admin", schedule.Permission) + assert.Equal(t, "command", schedule.Command) + assert.Equal(t, "home", schedule.WorkingDirectory) + assert.ElementsMatch(t, []string{"1", "2"}, schedule.Arguments) + assert.Equal(t, []string{"test=dev"}, schedule.Environment) + assert.Equal(t, "background", schedule.GetPriority()) // default value +} + +func TestStandardPriority(t *testing.T) { + schedule := Config{ + Priority: "standard", + } + assert.Equal(t, "standard", schedule.GetPriority()) +} + +func TestCaseInsensitivePriority(t *testing.T) { + schedule := Config{ + Priority: "stANDard", + } + assert.Equal(t, "standard", schedule.GetPriority()) +} + +func TestOtherPriority(t *testing.T) { + schedule := Config{ + Priority: "other", + } + assert.Equal(t, "background", schedule.GetPriority()) // default value +} + +func TestScheduleFlags(t *testing.T) { + schedule := &Config{} + + flag, found := schedule.GetFlag("unit") + assert.Empty(t, flag) + assert.False(t, found) + + schedule.SetFlag("unit", "test") + flag, found = schedule.GetFlag("unit") + assert.Equal(t, "test", flag) + assert.True(t, found) +} diff --git a/schedule/handler.go b/schedule/handler.go index 4de17910..bf10c8c9 100644 --- a/schedule/handler.go +++ b/schedule/handler.go @@ -5,7 +5,6 @@ import ( "os/exec" "github.com/creativeprojects/resticprofile/calendar" - "github.com/creativeprojects/resticprofile/config" ) // Handler interface for the scheduling software available on the system @@ -16,9 +15,9 @@ type Handler interface { DisplayParsedSchedules(command string, events []*calendar.Event) DisplaySchedules(command string, schedules []string) error DisplayStatus(profileName string) error - CreateJob(job *config.ScheduleConfig, schedules []*calendar.Event, permission string) error - RemoveJob(job *config.ScheduleConfig, permission string) error - DisplayJobStatus(job *config.ScheduleConfig) error + CreateJob(job *Config, schedules []*calendar.Event, permission string) error + RemoveJob(job *Config, permission string) error + DisplayJobStatus(job *Config) error } func lookupBinary(name, binary string) error { diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index 07bdff70..bf7dd25e 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/creativeprojects/resticprofile/calendar" - "github.com/creativeprojects/resticprofile/config" "github.com/creativeprojects/resticprofile/crond" ) @@ -55,14 +54,14 @@ func (h *HandlerCrond) DisplayStatus(profileName string) error { } // CreateJob is creating the crontab -func (h *HandlerCrond) CreateJob(job *config.ScheduleConfig, schedules []*calendar.Event, permission string) error { +func (h *HandlerCrond) CreateJob(job *Config, schedules []*calendar.Event, permission string) error { entries := make([]crond.Entry, len(schedules)) for i, event := range schedules { entries[i] = crond.NewEntry( event, job.ConfigFile, - job.Title, - job.SubTitle, + job.ProfileName, + job.CommandName, job.Command+" "+strings.Join(job.Arguments, " "), job.WorkingDirectory, ) @@ -75,13 +74,13 @@ func (h *HandlerCrond) CreateJob(job *config.ScheduleConfig, schedules []*calend return nil } -func (h *HandlerCrond) RemoveJob(job *config.ScheduleConfig, permission string) error { +func (h *HandlerCrond) RemoveJob(job *Config, permission string) error { entries := []crond.Entry{ crond.NewEntry( calendar.NewEvent(), job.ConfigFile, - job.Title, - job.SubTitle, + job.ProfileName, + job.CommandName, job.Command+" "+strings.Join(job.Arguments, " "), job.WorkingDirectory, ), @@ -98,7 +97,7 @@ func (h *HandlerCrond) RemoveJob(job *config.ScheduleConfig, permission string) } // DisplayJobStatus has nothing to display (crond doesn't provide running information) -func (h *HandlerCrond) DisplayJobStatus(job *config.ScheduleConfig) error { +func (h *HandlerCrond) DisplayJobStatus(job *Config) error { return nil } diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 7b3d4fa1..2bd3c485 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -8,15 +8,12 @@ import ( "os/exec" "path" "regexp" - "slices" "sort" "strings" "text/tabwriter" "github.com/creativeprojects/resticprofile/calendar" - "github.com/creativeprojects/resticprofile/config" "github.com/creativeprojects/resticprofile/constants" - "github.com/creativeprojects/resticprofile/dial" "github.com/creativeprojects/resticprofile/term" "github.com/creativeprojects/resticprofile/util" "github.com/spf13/afero" @@ -109,7 +106,7 @@ func (h *HandlerLaunchd) DisplayStatus(profileName string) error { } // CreateJob creates a plist file and registers it with launchd -func (h *HandlerLaunchd) CreateJob(job *config.ScheduleConfig, schedules []*calendar.Event, permission string) error { +func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, permission string) error { filename, err := h.createPlistFile(h.getLaunchdJob(job, schedules), permission) if err != nil { if filename != "" { @@ -129,7 +126,7 @@ func (h *HandlerLaunchd) CreateJob(job *config.ScheduleConfig, schedules []*cale if _, noStart := job.GetFlag("no-start"); !noStart { // ask the user if he wants to start the service now - name := getJobName(job.Title, job.SubTitle) + name := getJobName(job.ProfileName, job.CommandName) message := ` By default, a macOS agent access is restricted. If you leave it to start in the background it's likely to fail. You have to start it manually the first time to accept the requests for access: @@ -152,24 +149,12 @@ Do you want to start it now?` return nil } -func (h *HandlerLaunchd) getLaunchdJob(job *config.ScheduleConfig, schedules []*calendar.Event) *LaunchdJob { - name := getJobName(job.Title, job.SubTitle) +func (h *HandlerLaunchd) getLaunchdJob(job *Config, schedules []*calendar.Event) *LaunchdJob { + name := getJobName(job.ProfileName, job.CommandName) args := job.Arguments - logfile := job.Log - - // if logfile is an url or in the volatile temp folder, we can't use it as a target for LaunchJob - if dial.IsURL(logfile) || strings.HasPrefix(logfile, constants.TemporaryDirMarker) { - logfile = "" - } else { - // removing the "--log" flag if we can use LaunchdJob's logging facility - logIndex := slices.Index(args, "--log") - if logIndex > -1 && len(args) >= logIndex+2 && args[logIndex+1] == logfile { - args = slices.Delete(args, logIndex, logIndex+2) - } - } - if logfile == "" { - logfile = name + ".log" - } + // we always set the log file in the job settings as a default + // if changed in the configuration via schedule-log the standard output will be empty anyway + logfile := name + ".log" // Format schedule env, adding PATH if not yet provided by the schedule config env := util.NewDefaultEnvironment(job.Environment...) @@ -225,8 +210,8 @@ func (h *HandlerLaunchd) createPlistFile(launchdJob *LaunchdJob, permission stri } // RemoveJob stops and unloads the agent from launchd, then removes the configuration file -func (h *HandlerLaunchd) RemoveJob(job *config.ScheduleConfig, permission string) error { - name := getJobName(job.Title, job.SubTitle) +func (h *HandlerLaunchd) RemoveJob(job *Config, permission string) error { + name := getJobName(job.ProfileName, job.CommandName) filename, err := getFilename(name, permission) if err != nil { return err @@ -258,13 +243,13 @@ func (h *HandlerLaunchd) RemoveJob(job *config.ScheduleConfig, permission string return nil } -func (h *HandlerLaunchd) DisplayJobStatus(job *config.ScheduleConfig) error { +func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error { permission := getSchedulePermission(job.Permission) ok := checkPermission(permission) if !ok { return permissionError("view") } - cmd := exec.Command(launchctlBin, launchdList, getJobName(job.Title, job.SubTitle)) + cmd := exec.Command(launchctlBin, launchdList, getJobName(job.ProfileName, job.CommandName)) output, err := cmd.Output() if cmd.ProcessState.ExitCode() == codeServiceNotFound { return ErrorServiceNotFound diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 327045d6..16d1767e 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -6,12 +6,9 @@ import ( "bytes" "fmt" "os" - "path" "testing" "github.com/creativeprojects/resticprofile/calendar" - "github.com/creativeprojects/resticprofile/config" - "github.com/creativeprojects/resticprofile/constants" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -130,41 +127,6 @@ func TestHandlerInstanceLaunchd(t *testing.T) { assert.NotNil(t, handler) } -func TestLaunchdJobLog(t *testing.T) { - fixtures := []struct { - log string - expected string - noLogArg bool - }{ - {log: path.Join(constants.TemporaryDirMarker, "file"), expected: "local.resticprofile.profile.backup.log"}, - {log: "", expected: "local.resticprofile.profile.backup.log", noLogArg: true}, - {log: "udp://localhost:123", expected: "local.resticprofile.profile.backup.log"}, - {log: "tcp://127.0.0.1:123", expected: "local.resticprofile.profile.backup.log"}, - {log: "other file", expected: "other file", noLogArg: true}, - } - - for _, fixture := range fixtures { - t.Run(fixture.log, func(t *testing.T) { - handler := NewHandler(SchedulerLaunchd{}) - args := []string{"--log", fixture.log} - cfg := &config.ScheduleConfig{ - Title: "profile", - SubTitle: "backup", - Log: fixture.log, - Command: "resticprofile", - Arguments: args, - } - launchdJob := handler.getLaunchdJob(cfg, []*calendar.Event{}) - assert.Equal(t, fixture.expected, launchdJob.StandardOutPath) - if fixture.noLogArg { - assert.NotSubset(t, launchdJob.ProgramArguments, args) - } else { - assert.Subset(t, launchdJob.ProgramArguments, args) - } - }) - } -} - func TestLaunchdJobPreservesEnv(t *testing.T) { pathEnv := os.Getenv("PATH") fixtures := []struct { @@ -179,7 +141,7 @@ func TestLaunchdJobPreservesEnv(t *testing.T) { for i, fixture := range fixtures { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { handler := NewHandler(SchedulerLaunchd{}) - cfg := &config.ScheduleConfig{Title: "t", SubTitle: "s", Environment: fixture.environment} + cfg := &Config{ProfileName: "t", CommandName: "s", Environment: fixture.environment} launchdJob := handler.getLaunchdJob(cfg, []*calendar.Event{}) assert.Equal(t, fixture.expected, launchdJob.EnvironmentVariables) }) diff --git a/schedule/handler_fake_test.go b/schedule/handler_fake_test.go index 30eceba9..cebb64d6 100644 --- a/schedule/handler_fake_test.go +++ b/schedule/handler_fake_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/creativeprojects/resticprofile/calendar" - "github.com/creativeprojects/resticprofile/config" ) const ( @@ -19,9 +18,9 @@ type mockHandler struct { displayParsedSchedules func(command string, events []*calendar.Event) displaySchedules func(command string, schedules []string) error displayStatus func(profileName string) error - createJob func(job *config.ScheduleConfig, schedules []*calendar.Event, permission string) error - removeJob func(job *config.ScheduleConfig, permission string) error - displayJobStatus func(job *config.ScheduleConfig) error + createJob func(job *Config, schedules []*calendar.Event, permission string) error + removeJob func(job *Config, permission string) error + displayJobStatus func(job *Config) error } func (h mockHandler) Init() error { @@ -66,21 +65,21 @@ func (h mockHandler) DisplayStatus(profileName string) error { return h.displayStatus(profileName) } -func (h mockHandler) CreateJob(job *config.ScheduleConfig, schedules []*calendar.Event, permission string) error { +func (h mockHandler) CreateJob(job *Config, schedules []*calendar.Event, permission string) error { if h.createJob == nil { h.t.Fatal(errorMethodNotRegistered) } return h.createJob(job, schedules, permission) } -func (h mockHandler) RemoveJob(job *config.ScheduleConfig, permission string) error { +func (h mockHandler) RemoveJob(job *Config, permission string) error { if h.removeJob == nil { h.t.Fatal(errorMethodNotRegistered) } return h.removeJob(job, permission) } -func (h mockHandler) DisplayJobStatus(job *config.ScheduleConfig) error { +func (h mockHandler) DisplayJobStatus(job *Config) error { if h.displayJobStatus == nil { h.t.Fatal(errorMethodNotRegistered) } diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index b49ba1bd..669db7b0 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -11,7 +11,6 @@ import ( "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/calendar" - "github.com/creativeprojects/resticprofile/config" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/systemd" "github.com/creativeprojects/resticprofile/term" @@ -100,7 +99,7 @@ func (h *HandlerSystemd) DisplayStatus(profileName string) error { } // CreateJob is creating the systemd unit and activating it -func (h *HandlerSystemd) CreateJob(job *config.ScheduleConfig, schedules []*calendar.Event, permission string) error { +func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, permission string) error { unitType := systemd.UserUnit if os.Geteuid() == 0 { // user has sudoed already @@ -115,8 +114,8 @@ func (h *HandlerSystemd) CreateJob(job *config.ScheduleConfig, schedules []*cale CommandLine: job.Command + " --no-prio " + strings.Join(job.Arguments, " "), Environment: job.Environment, WorkingDirectory: job.WorkingDirectory, - Title: job.Title, - SubTitle: job.SubTitle, + Title: job.ProfileName, + SubTitle: job.CommandName, JobDescription: job.JobDescription, TimerDescription: job.TimerDescription, Schedules: job.Schedules, @@ -142,7 +141,7 @@ func (h *HandlerSystemd) CreateJob(job *config.ScheduleConfig, schedules []*cale } } - timerName := systemd.GetTimerFile(job.Title, job.SubTitle) + timerName := systemd.GetTimerFile(job.ProfileName, job.CommandName) // enable the job err = runSystemctlCommand(timerName, systemctlEnable, unitType, false) @@ -165,23 +164,23 @@ func (h *HandlerSystemd) CreateJob(job *config.ScheduleConfig, schedules []*cale } // RemoveJob is disabling the systemd unit and deleting the timer and service files -func (h *HandlerSystemd) RemoveJob(job *config.ScheduleConfig, permission string) error { +func (h *HandlerSystemd) RemoveJob(job *Config, permission string) error { unitType := systemd.UserUnit if os.Geteuid() == 0 { // user has sudoed already unitType = systemd.SystemUnit } var err error - timerFile := systemd.GetTimerFile(job.Title, job.SubTitle) + timerFile := systemd.GetTimerFile(job.ProfileName, job.CommandName) // stop the job - err = runSystemctlCommand(timerFile, systemctlStop, unitType, job.RemoveOnly) + err = runSystemctlCommand(timerFile, systemctlStop, unitType, job.removeOnly) if err != nil { return err } // disable the job - err = runSystemctlCommand(timerFile, systemctlDisable, unitType, job.RemoveOnly) + err = runSystemctlCommand(timerFile, systemctlDisable, unitType, job.removeOnly) if err != nil { return err } @@ -199,13 +198,13 @@ func (h *HandlerSystemd) RemoveJob(job *config.ScheduleConfig, permission string return nil } - serviceFile := systemd.GetServiceFile(job.Title, job.SubTitle) + serviceFile := systemd.GetServiceFile(job.ProfileName, job.CommandName) err = os.Remove(path.Join(systemdPath, serviceFile)) if err != nil { return nil } - dropInDir := systemd.GetServiceFileDropInDir(job.Title, job.SubTitle) + dropInDir := systemd.GetServiceFileDropInDir(job.ProfileName, job.CommandName) err = os.RemoveAll(path.Join(systemdPath, dropInDir)) if err != nil { return nil @@ -215,8 +214,8 @@ func (h *HandlerSystemd) RemoveJob(job *config.ScheduleConfig, permission string } // DisplayJobStatus displays information of a systemd service/timer -func (h *HandlerSystemd) DisplayJobStatus(job *config.ScheduleConfig) error { - timerName := systemd.GetTimerFile(job.Title, job.SubTitle) +func (h *HandlerSystemd) DisplayJobStatus(job *Config) error { + timerName := systemd.GetTimerFile(job.ProfileName, job.CommandName) permission := getSchedulePermission(job.Permission) if permission == constants.SchedulePermissionSystem { err := runJournalCtlCommand(timerName, systemd.SystemUnit) diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index f074a211..09d71790 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -6,7 +6,6 @@ import ( "errors" "github.com/creativeprojects/resticprofile/calendar" - "github.com/creativeprojects/resticprofile/config" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/schtasks" ) @@ -54,7 +53,7 @@ func (h *HandlerWindows) DisplayStatus(profileName string) error { } // CreateJob is creating the task scheduler job. -func (h *HandlerWindows) CreateJob(job *config.ScheduleConfig, schedules []*calendar.Event, permission string) error { +func (h *HandlerWindows) CreateJob(job *Config, schedules []*calendar.Event, permission string) error { // default permission will be system perm := schtasks.SystemAccount if permission == constants.SchedulePermissionUser { @@ -62,7 +61,15 @@ func (h *HandlerWindows) CreateJob(job *config.ScheduleConfig, schedules []*cale } else if permission == constants.SchedulePermissionUserLoggedOn || permission == constants.SchedulePermissionUserLoggedIn { perm = schtasks.UserLoggedOnAccount } - err := schtasks.Create(job, schedules, perm) + jobConfig := &schtasks.Config{ + ProfileName: job.ProfileName, + CommandName: job.CommandName, + Command: job.Command, + Arguments: job.Arguments, + WorkingDirectory: job.WorkingDirectory, + JobDescription: job.JobDescription, + } + err := schtasks.Create(jobConfig, schedules, perm) if err != nil { return err } @@ -70,8 +77,8 @@ func (h *HandlerWindows) CreateJob(job *config.ScheduleConfig, schedules []*cale } // RemoveJob is deleting the task scheduler job -func (h *HandlerWindows) RemoveJob(job *config.ScheduleConfig, permission string) error { - err := schtasks.Delete(job.Title, job.SubTitle) +func (h *HandlerWindows) RemoveJob(job *Config, permission string) error { + err := schtasks.Delete(job.ProfileName, job.CommandName) if err != nil { if errors.Is(err, schtasks.ErrorNotRegistered) { return ErrorServiceNotFound @@ -82,8 +89,8 @@ func (h *HandlerWindows) RemoveJob(job *config.ScheduleConfig, permission string } // DisplayStatus display some information about the task scheduler job -func (h *HandlerWindows) DisplayJobStatus(job *config.ScheduleConfig) error { - err := schtasks.Status(job.Title, job.SubTitle) +func (h *HandlerWindows) DisplayJobStatus(job *Config) error { + err := schtasks.Status(job.ProfileName, job.CommandName) if err != nil { if errors.Is(err, schtasks.ErrorNotRegistered) { return ErrorServiceNotFound diff --git a/schedule/job.go b/schedule/job.go index 5d6fe5a1..97dbe599 100644 --- a/schedule/job.go +++ b/schedule/job.go @@ -2,26 +2,15 @@ package schedule import ( "errors" - - "github.com/creativeprojects/resticprofile/config" ) // -// Job: common code for all systems +// Job: common code for all scheduling systems // -// SchedulerJob interface -type SchedulerJob interface { - Accessible() bool - Create() error - Remove() error - RemoveOnly() bool - Status() error -} - // Job scheduler type Job struct { - config *config.ScheduleConfig + config *Config handler Handler } @@ -51,9 +40,9 @@ func (j *Job) Create() error { } if len(schedules) > 0 { - j.handler.DisplayParsedSchedules(j.config.SubTitle, schedules) + j.handler.DisplayParsedSchedules(j.config.CommandName, schedules) } else { - err := j.handler.DisplaySchedules(j.config.SubTitle, j.config.Schedules) + err := j.handler.DisplaySchedules(j.config.CommandName, j.config.Schedules) if err != nil { return err } @@ -85,7 +74,7 @@ func (j *Job) Remove() error { // RemoveOnly returns true if this job can be removed only func (j *Job) RemoveOnly() bool { - return j.config.RemoveOnly + return j.config.removeOnly } // Status of a job @@ -100,10 +89,9 @@ func (j *Job) Status() error { } if len(schedules) > 0 { - j.handler.DisplayParsedSchedules(j.config.SubTitle, schedules) + j.handler.DisplayParsedSchedules(j.config.CommandName, schedules) } else { - err := j.handler.DisplaySchedules(j.config.SubTitle, j.config.Schedules) - if err != nil { + if err := j.handler.DisplaySchedules(j.config.CommandName, j.config.Schedules); err != nil { return err } } @@ -114,6 +102,3 @@ func (j *Job) Status() error { } return nil } - -// Verify interface -var _ SchedulerJob = &Job{} diff --git a/schedule/job_test.go b/schedule/job_test.go index c2a53fcf..87d3e182 100644 --- a/schedule/job_test.go +++ b/schedule/job_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/creativeprojects/resticprofile/calendar" - "github.com/creativeprojects/resticprofile/config" "github.com/stretchr/testify/assert" ) @@ -21,13 +20,13 @@ func TestCreateJobHappyPathSystemd(t *testing.T) { counter |= 2 return nil }, - createJob: func(job *config.ScheduleConfig, schedules []*calendar.Event, permission string) error { + createJob: func(job *Config, schedules []*calendar.Event, permission string) error { counter |= 4 return nil }, } job := Job{ - config: &config.ScheduleConfig{}, + config: &Config{}, handler: handler, } err := job.Create() @@ -47,13 +46,13 @@ func TestCreateJobHappyPathOther(t *testing.T) { displayParsedSchedules: func(command string, events []*calendar.Event) { counter |= 2 }, - createJob: func(job *config.ScheduleConfig, schedules []*calendar.Event, permission string) error { + createJob: func(job *Config, schedules []*calendar.Event, permission string) error { counter |= 4 return nil }, } job := Job{ - config: &config.ScheduleConfig{}, + config: &Config{}, handler: handler, } err := job.Create() @@ -72,7 +71,7 @@ func TestCreateJobSadPath1(t *testing.T) { }, } job := Job{ - config: &config.ScheduleConfig{}, + config: &Config{}, handler: handler, } err := job.Create() @@ -95,7 +94,7 @@ func TestCreateJobSadPath2(t *testing.T) { }, } job := Job{ - config: &config.ScheduleConfig{}, + config: &Config{}, handler: handler, } err := job.Create() @@ -116,13 +115,13 @@ func TestCreateJobSadPath3(t *testing.T) { counter |= 2 return nil }, - createJob: func(job *config.ScheduleConfig, schedules []*calendar.Event, permission string) error { + createJob: func(job *Config, schedules []*calendar.Event, permission string) error { counter |= 4 return errors.New("test!") }, } job := Job{ - config: &config.ScheduleConfig{}, + config: &Config{}, handler: handler, } err := job.Create() diff --git a/schedule/mocks/Handler.go b/schedule/mocks/Handler.go index c85d79c2..7f7567d5 100644 --- a/schedule/mocks/Handler.go +++ b/schedule/mocks/Handler.go @@ -4,9 +4,9 @@ package mocks import ( calendar "github.com/creativeprojects/resticprofile/calendar" - config "github.com/creativeprojects/resticprofile/config" - mock "github.com/stretchr/testify/mock" + + schedule "github.com/creativeprojects/resticprofile/schedule" ) // Handler is an autogenerated mock type for the Handler type @@ -55,7 +55,7 @@ func (_c *Handler_Close_Call) RunAndReturn(run func()) *Handler_Close_Call { } // CreateJob provides a mock function with given fields: job, schedules, permission -func (_m *Handler) CreateJob(job *config.ScheduleConfig, schedules []*calendar.Event, permission string) error { +func (_m *Handler) CreateJob(job *schedule.Config, schedules []*calendar.Event, permission string) error { ret := _m.Called(job, schedules, permission) if len(ret) == 0 { @@ -63,7 +63,7 @@ func (_m *Handler) CreateJob(job *config.ScheduleConfig, schedules []*calendar.E } var r0 error - if rf, ok := ret.Get(0).(func(*config.ScheduleConfig, []*calendar.Event, string) error); ok { + if rf, ok := ret.Get(0).(func(*schedule.Config, []*calendar.Event, string) error); ok { r0 = rf(job, schedules, permission) } else { r0 = ret.Error(0) @@ -78,16 +78,16 @@ type Handler_CreateJob_Call struct { } // CreateJob is a helper method to define mock.On call -// - job *config.ScheduleConfig +// - job *schedule.Config // - schedules []*calendar.Event // - permission string func (_e *Handler_Expecter) CreateJob(job interface{}, schedules interface{}, permission interface{}) *Handler_CreateJob_Call { return &Handler_CreateJob_Call{Call: _e.mock.On("CreateJob", job, schedules, permission)} } -func (_c *Handler_CreateJob_Call) Run(run func(job *config.ScheduleConfig, schedules []*calendar.Event, permission string)) *Handler_CreateJob_Call { +func (_c *Handler_CreateJob_Call) Run(run func(job *schedule.Config, schedules []*calendar.Event, permission string)) *Handler_CreateJob_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*config.ScheduleConfig), args[1].([]*calendar.Event), args[2].(string)) + run(args[0].(*schedule.Config), args[1].([]*calendar.Event), args[2].(string)) }) return _c } @@ -97,13 +97,13 @@ func (_c *Handler_CreateJob_Call) Return(_a0 error) *Handler_CreateJob_Call { return _c } -func (_c *Handler_CreateJob_Call) RunAndReturn(run func(*config.ScheduleConfig, []*calendar.Event, string) error) *Handler_CreateJob_Call { +func (_c *Handler_CreateJob_Call) RunAndReturn(run func(*schedule.Config, []*calendar.Event, string) error) *Handler_CreateJob_Call { _c.Call.Return(run) return _c } // DisplayJobStatus provides a mock function with given fields: job -func (_m *Handler) DisplayJobStatus(job *config.ScheduleConfig) error { +func (_m *Handler) DisplayJobStatus(job *schedule.Config) error { ret := _m.Called(job) if len(ret) == 0 { @@ -111,7 +111,7 @@ func (_m *Handler) DisplayJobStatus(job *config.ScheduleConfig) error { } var r0 error - if rf, ok := ret.Get(0).(func(*config.ScheduleConfig) error); ok { + if rf, ok := ret.Get(0).(func(*schedule.Config) error); ok { r0 = rf(job) } else { r0 = ret.Error(0) @@ -126,14 +126,14 @@ type Handler_DisplayJobStatus_Call struct { } // DisplayJobStatus is a helper method to define mock.On call -// - job *config.ScheduleConfig +// - job *schedule.Config func (_e *Handler_Expecter) DisplayJobStatus(job interface{}) *Handler_DisplayJobStatus_Call { return &Handler_DisplayJobStatus_Call{Call: _e.mock.On("DisplayJobStatus", job)} } -func (_c *Handler_DisplayJobStatus_Call) Run(run func(job *config.ScheduleConfig)) *Handler_DisplayJobStatus_Call { +func (_c *Handler_DisplayJobStatus_Call) Run(run func(job *schedule.Config)) *Handler_DisplayJobStatus_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*config.ScheduleConfig)) + run(args[0].(*schedule.Config)) }) return _c } @@ -143,7 +143,7 @@ func (_c *Handler_DisplayJobStatus_Call) Return(_a0 error) *Handler_DisplayJobSt return _c } -func (_c *Handler_DisplayJobStatus_Call) RunAndReturn(run func(*config.ScheduleConfig) error) *Handler_DisplayJobStatus_Call { +func (_c *Handler_DisplayJobStatus_Call) RunAndReturn(run func(*schedule.Config) error) *Handler_DisplayJobStatus_Call { _c.Call.Return(run) return _c } @@ -379,7 +379,7 @@ func (_c *Handler_ParseSchedules_Call) RunAndReturn(run func([]string) ([]*calen } // RemoveJob provides a mock function with given fields: job, permission -func (_m *Handler) RemoveJob(job *config.ScheduleConfig, permission string) error { +func (_m *Handler) RemoveJob(job *schedule.Config, permission string) error { ret := _m.Called(job, permission) if len(ret) == 0 { @@ -387,7 +387,7 @@ func (_m *Handler) RemoveJob(job *config.ScheduleConfig, permission string) erro } var r0 error - if rf, ok := ret.Get(0).(func(*config.ScheduleConfig, string) error); ok { + if rf, ok := ret.Get(0).(func(*schedule.Config, string) error); ok { r0 = rf(job, permission) } else { r0 = ret.Error(0) @@ -402,15 +402,15 @@ type Handler_RemoveJob_Call struct { } // RemoveJob is a helper method to define mock.On call -// - job *config.ScheduleConfig +// - job *schedule.Config // - permission string func (_e *Handler_Expecter) RemoveJob(job interface{}, permission interface{}) *Handler_RemoveJob_Call { return &Handler_RemoveJob_Call{Call: _e.mock.On("RemoveJob", job, permission)} } -func (_c *Handler_RemoveJob_Call) Run(run func(job *config.ScheduleConfig, permission string)) *Handler_RemoveJob_Call { +func (_c *Handler_RemoveJob_Call) Run(run func(job *schedule.Config, permission string)) *Handler_RemoveJob_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*config.ScheduleConfig), args[1].(string)) + run(args[0].(*schedule.Config), args[1].(string)) }) return _c } @@ -420,7 +420,7 @@ func (_c *Handler_RemoveJob_Call) Return(_a0 error) *Handler_RemoveJob_Call { return _c } -func (_c *Handler_RemoveJob_Call) RunAndReturn(run func(*config.ScheduleConfig, string) error) *Handler_RemoveJob_Call { +func (_c *Handler_RemoveJob_Call) RunAndReturn(run func(*schedule.Config, string) error) *Handler_RemoveJob_Call { _c.Call.Return(run) return _c } diff --git a/schedule/removeonly_test.go b/schedule/removeonly_test.go index c2bf5a06..355ac60f 100644 --- a/schedule/removeonly_test.go +++ b/schedule/removeonly_test.go @@ -3,16 +3,14 @@ package schedule import ( "testing" - "github.com/creativeprojects/resticprofile/config" - "github.com/stretchr/testify/assert" ) func TestNewRemoveOnlyConfig(t *testing.T) { - cfg := config.NewRemoveOnlyConfig("profile", "command") + cfg := NewRemoveOnlyConfig("profile", "command") - assert.Equal(t, "profile", cfg.Title) - assert.Equal(t, "command", cfg.SubTitle) + assert.Equal(t, "profile", cfg.ProfileName) + assert.Equal(t, "command", cfg.CommandName) assert.Equal(t, "", cfg.JobDescription) assert.Equal(t, "", cfg.TimerDescription) assert.Empty(t, cfg.Schedules) @@ -22,7 +20,6 @@ func TestNewRemoveOnlyConfig(t *testing.T) { assert.Empty(t, cfg.Arguments) assert.Empty(t, cfg.Environment) assert.Equal(t, "", cfg.Priority) - assert.Equal(t, "", cfg.Log) assert.Equal(t, "", cfg.ConfigFile) { flag, found := cfg.GetFlag("") @@ -32,12 +29,12 @@ func TestNewRemoveOnlyConfig(t *testing.T) { } func TestDetectRemoveOnlyConfig(t *testing.T) { - assertRemoveOnly := func(expected bool, config *config.ScheduleConfig) { - assert.Equal(t, expected, config.RemoveOnly) + assertRemoveOnly := func(expected bool, config *Config) { + assert.Equal(t, expected, config.removeOnly) } - assertRemoveOnly(true, config.NewRemoveOnlyConfig("", "")) - assertRemoveOnly(false, &config.ScheduleConfig{}) + assertRemoveOnly(true, NewRemoveOnlyConfig("", "")) + assertRemoveOnly(false, &Config{}) } func TestRemoveOnlyJob(t *testing.T) { @@ -45,7 +42,7 @@ func TestRemoveOnlyJob(t *testing.T) { scheduler := NewScheduler(NewHandler(&SchedulerDefaultOS{}), profile) defer scheduler.Close() - job := scheduler.NewJob(config.NewRemoveOnlyConfig(profile, "check")) + job := scheduler.NewJob(NewRemoveOnlyConfig(profile, "check")) assert.Equal(t, ErrorJobCanBeRemovedOnly, job.Create()) assert.Equal(t, ErrorJobCanBeRemovedOnly, job.Status()) diff --git a/schedule/scheduler.go b/schedule/scheduler.go index bdf1818c..9bdaa7b3 100644 --- a/schedule/scheduler.go +++ b/schedule/scheduler.go @@ -2,7 +2,6 @@ package schedule import ( "github.com/creativeprojects/clog" - "github.com/creativeprojects/resticprofile/config" ) // Scheduler @@ -30,7 +29,7 @@ func (s *Scheduler) Close() { } // NewJob instantiates a Job object (of SchedulerJob interface) to schedule jobs -func (s *Scheduler) NewJob(config *config.ScheduleConfig) SchedulerJob { +func (s *Scheduler) NewJob(config *Config) *Job { return &Job{ config: config, handler: s.handler, diff --git a/schedule/scheduler_config.go b/schedule/scheduler_config.go index 34ea4b7a..b7fca483 100644 --- a/schedule/scheduler_config.go +++ b/schedule/scheduler_config.go @@ -1,10 +1,9 @@ package schedule import ( - "runtime" - "github.com/creativeprojects/resticprofile/config" "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/platform" "github.com/spf13/afero" ) @@ -65,7 +64,7 @@ func NewSchedulerConfig(global *config.Global) SchedulerConfig { case constants.SchedulerWindows: return SchedulerWindows{} default: - if runtime.GOOS != "darwin" && runtime.GOOS != "windows" { + if !platform.IsDarwin() && !platform.IsWindows() { return SchedulerSystemd{ UnitTemplate: global.SystemdUnitTemplate, TimerTemplate: global.SystemdTimerTemplate, diff --git a/schedule_jobs.go b/schedule_jobs.go index 58100b8b..56a934fc 100644 --- a/schedule_jobs.go +++ b/schedule_jobs.go @@ -7,11 +7,10 @@ import ( "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/config" - "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/schedule" ) -func scheduleJobs(handler schedule.Handler, profileName string, configs []*config.ScheduleConfig) error { +func scheduleJobs(handler schedule.Handler, profileName string, configs []*config.Schedule) error { wd, err := os.Getwd() if err != nil { return err @@ -28,55 +27,37 @@ func scheduleJobs(handler schedule.Handler, profileName string, configs []*confi } defer scheduler.Close() - for _, scheduleConfig := range configs { + for _, cfg := range configs { + scheduleConfig := scheduleToConfig(cfg) + scheduleName := scheduleConfig.CommandName + "@" + scheduleConfig.ProfileName args := []string{ "--no-ansi", "--config", scheduleConfig.ConfigFile, - "--name", - scheduleConfig.Title, + "run-schedule", + scheduleName, } - if scheduleConfig.Log != "" { - args = append(args, "--log", scheduleConfig.Log) - } - - if scheduleConfig.GetLockMode() == config.ScheduleLockModeDefault { - if scheduleConfig.GetLockWait() > 0 { - args = append(args, "--lock-wait", scheduleConfig.GetLockWait().String()) - } - } else if scheduleConfig.GetLockMode() == config.ScheduleLockModeIgnore { - args = append(args, "--no-lock") - } - - if scheduleConfig.IgnoreOnBatteryLessThan > 0 && scheduleConfig.IgnoreOnBatteryLessThan <= 100 { - args = append(args, fmt.Sprintf("--ignore-on-battery=%d", scheduleConfig.IgnoreOnBatteryLessThan)) - } else if scheduleConfig.IgnoreOnBattery { - args = append(args, "--ignore-on-battery") - } - - args = append(args, getResticCommand(scheduleConfig.SubTitle)) - scheduleConfig.SetCommand(wd, binary, args) scheduleConfig.JobDescription = - fmt.Sprintf("resticprofile %s for profile %s in %s", scheduleConfig.SubTitle, scheduleConfig.Title, scheduleConfig.ConfigFile) + fmt.Sprintf("resticprofile %s for profile %s in %s", scheduleConfig.CommandName, scheduleConfig.ProfileName, scheduleConfig.ConfigFile) scheduleConfig.TimerDescription = - fmt.Sprintf("%s timer for profile %s in %s", scheduleConfig.SubTitle, scheduleConfig.Title, scheduleConfig.ConfigFile) + fmt.Sprintf("%s timer for profile %s in %s", scheduleConfig.CommandName, scheduleConfig.ProfileName, scheduleConfig.ConfigFile) job := scheduler.NewJob(scheduleConfig) err = job.Create() if err != nil { return fmt.Errorf("error creating job %s/%s: %w", - scheduleConfig.Title, - scheduleConfig.SubTitle, + scheduleConfig.ProfileName, + scheduleConfig.CommandName, err) } - clog.Infof("scheduled job %s/%s created", scheduleConfig.Title, scheduleConfig.SubTitle) + clog.Infof("scheduled job %s/%s created", scheduleConfig.ProfileName, scheduleConfig.CommandName) } return nil } -func removeJobs(handler schedule.Handler, profileName string, configs []*config.ScheduleConfig) error { +func removeJobs(handler schedule.Handler, profileName string, configs []*config.Schedule) error { scheduler := schedule.NewScheduler(handler, profileName) err := scheduler.Init() if err != nil { @@ -84,7 +65,8 @@ func removeJobs(handler schedule.Handler, profileName string, configs []*config. } defer scheduler.Close() - for _, scheduleConfig := range configs { + for _, cfg := range configs { + scheduleConfig := scheduleToConfig(cfg) job := scheduler.NewJob(scheduleConfig) // Skip over non-accessible, RemoveOnly jobs since they may not exist and must not causes errors @@ -98,22 +80,22 @@ func removeJobs(handler schedule.Handler, profileName string, configs []*config. if errors.Is(err, schedule.ErrorServiceNotFound) { // Display a warning and keep going. Skip message for RemoveOnly jobs since they may not exist if !job.RemoveOnly() { - clog.Warningf("service %s/%s not found", scheduleConfig.Title, scheduleConfig.SubTitle) + clog.Warningf("service %s/%s not found", scheduleConfig.ProfileName, scheduleConfig.CommandName) } continue } return fmt.Errorf("error removing job %s/%s: %w", - scheduleConfig.Title, - scheduleConfig.SubTitle, + scheduleConfig.ProfileName, + scheduleConfig.CommandName, err) } - clog.Infof("scheduled job %s/%s removed", scheduleConfig.Title, scheduleConfig.SubTitle) + clog.Infof("scheduled job %s/%s removed", scheduleConfig.ProfileName, scheduleConfig.CommandName) } return nil } -func statusJobs(handler schedule.Handler, profileName string, configs []*config.ScheduleConfig) error { +func statusJobs(handler schedule.Handler, profileName string, configs []*config.Schedule) error { scheduler := schedule.NewScheduler(handler, profileName) err := scheduler.Init() if err != nil { @@ -121,23 +103,24 @@ func statusJobs(handler schedule.Handler, profileName string, configs []*config. } defer scheduler.Close() - for _, scheduleConfig := range configs { + for _, cfg := range configs { + scheduleConfig := scheduleToConfig(cfg) job := scheduler.NewJob(scheduleConfig) err := job.Status() if err != nil { if errors.Is(err, schedule.ErrorServiceNotFound) { // Display a warning and keep going - clog.Warningf("service %s/%s not found", scheduleConfig.Title, scheduleConfig.SubTitle) + clog.Warningf("service %s/%s not found", scheduleConfig.ProfileName, scheduleConfig.CommandName) continue } if errors.Is(err, schedule.ErrorServiceNotRunning) { // Display a warning and keep going - clog.Warningf("service %s/%s is not running", scheduleConfig.Title, scheduleConfig.SubTitle) + clog.Warningf("service %s/%s is not running", scheduleConfig.ProfileName, scheduleConfig.CommandName) continue } return fmt.Errorf("error querying status of job %s/%s: %w", - scheduleConfig.Title, - scheduleConfig.SubTitle, + scheduleConfig.ProfileName, + scheduleConfig.CommandName, err) } } @@ -145,9 +128,26 @@ func statusJobs(handler schedule.Handler, profileName string, configs []*config. return nil } -func getResticCommand(profileCommand string) string { - if profileCommand == constants.SectionConfigurationRetention { - return constants.CommandForget +func scheduleToConfig(sched *config.Schedule) *schedule.Config { + if len(sched.Schedules) == 0 { + // there's no schedule defined, so this record is for removal only + return schedule.NewRemoveOnlyConfig(sched.Profiles[0], sched.CommandName) + } + return &schedule.Config{ + ProfileName: sched.Profiles[0], + CommandName: sched.CommandName, + Schedules: sched.Schedules, + Permission: sched.Permission, + WorkingDirectory: "", + Command: "", + Arguments: []string{}, + Environment: sched.Environment, + JobDescription: "", + TimerDescription: "", + Priority: sched.Priority, + ConfigFile: sched.ConfigFile, + Flags: sched.Flags, + IgnoreOnBattery: sched.IgnoreOnBattery, + IgnoreOnBatteryLessThan: sched.IgnoreOnBatteryLessThan, } - return profileCommand } diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go index 050b835b..48f61091 100644 --- a/schedule_jobs_test.go +++ b/schedule_jobs_test.go @@ -1,11 +1,12 @@ package main import ( + "errors" "testing" "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/config" - "github.com/creativeprojects/resticprofile/platform" + "github.com/creativeprojects/resticprofile/schedule" "github.com/creativeprojects/resticprofile/schedule/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -13,81 +14,190 @@ import ( func TestScheduleNilJobs(t *testing.T) { handler := mocks.NewHandler(t) - handler.On("Init").Return(nil) - handler.On("Close") + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() err := scheduleJobs(handler, "profile", nil) assert.NoError(t, err) } -func TestArgumentsOnScheduleJobNoLog(t *testing.T) { - handler := getMockHandler(t) - handler.On("CreateJob", - mock.AnythingOfType("*config.ScheduleConfig"), +func TestSimpleScheduleJob(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() + handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil) + handler.EXPECT().DisplayParsedSchedules("backup", []*calendar.Event{{}}) + handler.EXPECT().CreateJob( + mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("[]*calendar.Event"), mock.AnythingOfType("string")). - Return(func(scheduleConfig *config.ScheduleConfig, events []*calendar.Event, permission string) error { - assert.Equal(t, []string{"--no-ansi", "--config", "", "--name", "profile", "backup"}, scheduleConfig.Arguments) + RunAndReturn(func(scheduleConfig *schedule.Config, events []*calendar.Event, permission string) error { + assert.Equal(t, []string{"--no-ansi", "--config", "", "run-schedule", "backup@profile"}, scheduleConfig.Arguments) return nil }) - scheduleConfig := &config.ScheduleConfig{ - Title: "profile", - SubTitle: "backup", + scheduleConfig := &config.Schedule{ + Profiles: []string{"profile"}, + CommandName: "backup", + Schedules: []string{"sched"}, } - err := scheduleJobs(handler, "profile", []*config.ScheduleConfig{scheduleConfig}) + err := scheduleJobs(handler, "profile", []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } -func TestArgumentsOnScheduleJobLogFile(t *testing.T) { - handler := getMockHandler(t) +func TestFailScheduleJob(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() + handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil) + handler.EXPECT().DisplayParsedSchedules("backup", []*calendar.Event{{}}) handler.EXPECT().CreateJob( - mock.AnythingOfType("*config.ScheduleConfig"), + mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("[]*calendar.Event"), mock.AnythingOfType("string")). - Run(func(scheduleConfig *config.ScheduleConfig, events []*calendar.Event, permission string) { - assert.Equal(t, []string{"--no-ansi", "--config", "", "--name", "profile", "--log", "/path/to/file", "backup"}, scheduleConfig.Arguments) - }). - Return(nil) - - scheduleConfig := &config.ScheduleConfig{ - Title: "profile", - SubTitle: "backup", - Log: "/path/to/file", + Return(errors.New("error creating job")) + + scheduleConfig := &config.Schedule{ + Profiles: []string{"profile"}, + CommandName: "backup", + Schedules: []string{"sched"}, } - err := scheduleJobs(handler, "profile", []*config.ScheduleConfig{scheduleConfig}) + err := scheduleJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + assert.Error(t, err) +} + +func TestRemoveNilJobs(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() + + err := removeJobs(handler, "profile", nil) assert.NoError(t, err) } -func TestArgumentsOnScheduleJobLogSyslog(t *testing.T) { - if !platform.SupportsSyslog() { - t.Skip("syslog is not supported") +func TestRemoveJob(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() + handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). + RunAndReturn(func(scheduleConfig *schedule.Config, user string) error { + assert.Equal(t, "profile", scheduleConfig.ProfileName) + assert.Equal(t, "backup", scheduleConfig.CommandName) + return nil + }) + + scheduleConfig := &config.Schedule{ + Profiles: []string{"profile"}, + CommandName: "backup", + Schedules: []string{"sched"}, } - handler := getMockHandler(t) - handler.On("CreateJob", - mock.AnythingOfType("*config.ScheduleConfig"), - mock.AnythingOfType("[]*calendar.Event"), - mock.AnythingOfType("string")). - Return(func(scheduleConfig *config.ScheduleConfig, events []*calendar.Event, permission string) error { - assert.Equal(t, []string{"--no-ansi", "--config", "", "--name", "profile", "--log", "tcp://localhost:123", "backup"}, scheduleConfig.Arguments) + err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + assert.NoError(t, err) +} + +func TestRemoveJobNoConfig(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() + handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). + RunAndReturn(func(scheduleConfig *schedule.Config, user string) error { + assert.Equal(t, "profile", scheduleConfig.ProfileName) + assert.Equal(t, "backup", scheduleConfig.CommandName) return nil }) - scheduleConfig := &config.ScheduleConfig{ - Title: "profile", - SubTitle: "backup", - Log: "tcp://localhost:123", + scheduleConfig := &config.Schedule{ + Profiles: []string{"profile"}, + CommandName: "backup", + } + err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + assert.NoError(t, err) +} + +func TestFailRemoveJob(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() + 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"}, } - err := scheduleJobs(handler, "profile", []*config.ScheduleConfig{scheduleConfig}) + err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + assert.Error(t, err) +} + +func TestNoFailRemoveUnknownJob(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() + handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). + Return(schedule.ErrorServiceNotFound) + + scheduleConfig := &config.Schedule{ + Profiles: []string{"profile"}, + CommandName: "backup", + Schedules: []string{"sched"}, + } + err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + assert.NoError(t, err) +} + +func TestNoFailRemoveUnknownRemoveOnlyJob(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() + handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). + Return(schedule.ErrorServiceNotFound) + + scheduleConfig := &config.Schedule{ + Profiles: []string{"profile"}, + CommandName: "backup", + } + err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + assert.NoError(t, err) +} + +func TestStatusNilJobs(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() + handler.EXPECT().DisplayStatus("profile").Return(nil) + + err := statusJobs(handler, "profile", nil) + assert.NoError(t, err) +} + +func TestStatusJob(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() + handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil) + handler.EXPECT().DisplayParsedSchedules("backup", []*calendar.Event{{}}) + 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"}, + } + err := statusJobs(handler, "profile", []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } -func getMockHandler(t *testing.T) *mocks.Handler { - t.Helper() +func TestStatusRemoveOnlyJob(t *testing.T) { handler := mocks.NewHandler(t) - handler.On("Init").Return(nil) - handler.On("Close") - handler.On("ParseSchedules", []string(nil)).Return(nil, nil) - handler.On("DisplaySchedules", "backup", []string(nil)).Return(nil) - return handler + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() + + scheduleConfig := &config.Schedule{ + Profiles: []string{"profile"}, + CommandName: "backup", + } + err := statusJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + assert.Error(t, err) } diff --git a/schtasks/config.go b/schtasks/config.go new file mode 100644 index 00000000..eebb417c --- /dev/null +++ b/schtasks/config.go @@ -0,0 +1,10 @@ +package schtasks + +type Config struct { + ProfileName string + CommandName string + Command string + Arguments []string + WorkingDirectory string + JobDescription string +} diff --git a/schtasks/taskscheduler.go b/schtasks/taskscheduler.go index 327e04dd..c20805e1 100644 --- a/schtasks/taskscheduler.go +++ b/schtasks/taskscheduler.go @@ -14,7 +14,6 @@ import ( "github.com/capnspacehook/taskmaster" "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/calendar" - "github.com/creativeprojects/resticprofile/config" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/term" "github.com/rickb777/date/period" @@ -85,7 +84,7 @@ func Close() { } // Create or update a task (if the name already exists in the Task Scheduler) -func Create(config *config.ScheduleConfig, schedules []*calendar.Event, permission Permission) error { +func Create(config *Config, schedules []*calendar.Event, permission Permission) error { if !IsConnected() { return ErrorNotConnected } @@ -100,8 +99,8 @@ func Create(config *config.ScheduleConfig, schedules []*calendar.Event, permissi } // createUserTask creates a new user task. Will update an existing task instead of overwritting -func createUserTask(config *config.ScheduleConfig, schedules []*calendar.Event) error { - taskName := getTaskPath(config.Title, config.SubTitle) +func createUserTask(config *Config, schedules []*calendar.Event) error { + taskName := getTaskPath(config.ProfileName, config.CommandName) registeredTask, err := taskService.GetRegisteredTask(taskName) if err == nil { // the task already exists @@ -144,8 +143,8 @@ func createUserTask(config *config.ScheduleConfig, schedules []*calendar.Event) } // updateUserTask updates an existing task -func updateUserTask(task taskmaster.RegisteredTask, config *config.ScheduleConfig, schedules []*calendar.Event) error { - taskName := getTaskPath(config.Title, config.SubTitle) +func updateUserTask(task taskmaster.RegisteredTask, config *Config, schedules []*calendar.Event) error { + taskName := getTaskPath(config.ProfileName, config.CommandName) username, password, err := userCredentials() if err != nil { @@ -201,8 +200,8 @@ func userCredentials() (string, string, error) { } // createUserLoggedOnTask creates a new user task. Will update an existing task instead of overwritting -func createUserLoggedOnTask(config *config.ScheduleConfig, schedules []*calendar.Event) error { - taskName := getTaskPath(config.Title, config.SubTitle) +func createUserLoggedOnTask(config *Config, schedules []*calendar.Event) error { + taskName := getTaskPath(config.ProfileName, config.CommandName) registeredTask, err := taskService.GetRegisteredTask(taskName) if err == nil { // the task already exists @@ -239,8 +238,8 @@ func createUserLoggedOnTask(config *config.ScheduleConfig, schedules []*calendar } // updateUserLoggedOnTask updates an existing task -func updateUserLoggedOnTask(task taskmaster.RegisteredTask, config *config.ScheduleConfig, schedules []*calendar.Event) error { - taskName := getTaskPath(config.Title, config.SubTitle) +func updateUserLoggedOnTask(task taskmaster.RegisteredTask, config *Config, schedules []*calendar.Event) error { + taskName := getTaskPath(config.ProfileName, config.CommandName) // clear up all actions and put ours back task.Definition.Actions = make([]taskmaster.Action, 0, 1) @@ -269,8 +268,8 @@ func updateUserLoggedOnTask(task taskmaster.RegisteredTask, config *config.Sched } // createSystemTask creates a new system task. Will update an existing task instead of overwritting -func createSystemTask(config *config.ScheduleConfig, schedules []*calendar.Event) error { - taskName := getTaskPath(config.Title, config.SubTitle) +func createSystemTask(config *Config, schedules []*calendar.Event) error { + taskName := getTaskPath(config.ProfileName, config.CommandName) registeredTask, err := taskService.GetRegisteredTask(taskName) if err == nil { // the task already exists @@ -302,8 +301,8 @@ func createSystemTask(config *config.ScheduleConfig, schedules []*calendar.Event } // updateSystemTask updates an existing task -func updateSystemTask(task taskmaster.RegisteredTask, config *config.ScheduleConfig, schedules []*calendar.Event) error { - taskName := getTaskPath(config.Title, config.SubTitle) +func updateSystemTask(task taskmaster.RegisteredTask, config *Config, schedules []*calendar.Event) error { + taskName := getTaskPath(config.ProfileName, config.CommandName) // clear up all actions and put ours back task.Definition.Actions = make([]taskmaster.Action, 0, 1) diff --git a/schtasks/taskscheduler_test.go b/schtasks/taskscheduler_test.go index e6b4eaa4..487863cc 100644 --- a/schtasks/taskscheduler_test.go +++ b/schtasks/taskscheduler_test.go @@ -14,7 +14,6 @@ import ( "github.com/capnspacehook/taskmaster" "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/calendar" - "github.com/creativeprojects/resticprofile/config" "github.com/rickb777/date/period" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -303,9 +302,9 @@ func TestCreationOfTasks(t *testing.T) { defer Close() assert.NoError(t, err) - scheduleConfig := &config.ScheduleConfig{ - Title: "test", - SubTitle: strconv.Itoa(count), + scheduleConfig := &Config{ + ProfileName: "test", + CommandName: strconv.Itoa(count), Command: "echo", Arguments: []string{"hello"}, WorkingDirectory: "C:\\", @@ -322,9 +321,9 @@ func TestCreationOfTasks(t *testing.T) { // user logged in doesn't need a password err = createUserLoggedOnTask(scheduleConfig, schedules) assert.NoError(t, err) - defer Delete(scheduleConfig.Title, scheduleConfig.SubTitle) + defer Delete(scheduleConfig.ProfileName, scheduleConfig.CommandName) - taskName := getTaskPath(scheduleConfig.Title, scheduleConfig.SubTitle) + taskName := getTaskPath(scheduleConfig.ProfileName, scheduleConfig.CommandName) buffer, err := exportTask(taskName) assert.NoError(t, err)