From 67e24a4b9ab094f68dbf9b53b003507e34dd8c02 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 17 Jul 2020 11:35:34 +0100 Subject: [PATCH] add dry-run flag --- README.md | 27 ++++++++++++++++++-------- commands.go | 16 +++++++-------- examples/linux.yaml | 7 ++++--- flags.go | 2 ++ main.go | 1 + shell_command.go | 12 +++++++++++- term/term_test.go | 16 +++++++++++++++ wrapper.go | 44 +++++++++++++++++++++++++----------------- wrapper_test.go | 47 +++++++++++++++++++++++++++++++++++++-------- 9 files changed, 126 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index ec4f2987..870decb1 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The configuration file accepts various formats: * [TOML](https://github.com/toml-lang/toml) : configuration file with extension _.toml_ and _.conf_ to keep compatibility with versions before 0.6.0 * [JSON](https://en.wikipedia.org/wiki/JSON) : configuration file with extension _.json_ * [YAML](https://en.wikipedia.org/wiki/YAML) : configuration file with extension _.yaml_ -* [HCL](https://github.com/hashicorp/hcl): **experimental support**, configuration file with extension _.hcl_ +* [HCL](https://github.com/hashicorp/hcl): configuration file with extension _.hcl_ For the rest of the documentation, I'll be showing examples using the TOML file configuration format (because it was the only one supported before version 0.6.0) but you can pick your favourite: they all work with resticprofile :-) @@ -277,7 +277,10 @@ exclude-file = [ "root-excludes", "excludes" ] exclude-caches = true one-file-system = false tag = [ "test", "dev" ] -source = [ "." ] +source = [ "/" ] +# if scheduled, will run every dat at midnight +schedule = "daily" +schedule-permission = "system" # retention policy for profile root [root.retention] @@ -316,6 +319,9 @@ check-before = true # will only run these scripts before and after a backup run-before = [ "echo Starting!", "ls -al ./src" ] run-after = "echo All Done!" +# if scheduled, will run every 30 minutes +schedule = "*:0,30" +schedule-permission = "user" # retention policy for profile src [src.retention] @@ -325,6 +331,11 @@ keep-within = "30d" compact = false prune = true +[src.check] +read-data = true +# if scheduled, will check the repository the first day of each month at 3am +schedule = "*-*-01 03:00" + ``` And another simple example for Windows: @@ -487,6 +498,7 @@ Usage of resticprofile: resticprofile flags: -c, --config string configuration file (default "profiles") + --dry-run display the restic commands instead of running them -f, --format string file format of the configuration (default is to use the file extension) -h, --help display this help -l, --log string logs into a file instead of the console @@ -507,9 +519,10 @@ resticprofile own commands: + ``` -A command is a restic command **except** for one command recognized by resticprofile only: `profiles` +A command is either a restic command or a resticprofile own command. ## Command line reference ## @@ -520,6 +533,7 @@ There are not many options on the command line, most of the options are in the c * **[-c | --config] configuration_file**: Specify a configuration file other than the default * **[-f | --format] configuration_format**: Specify the configuration file format: `toml`, `yaml`, `json` or `hcl` * **[-n | --name] profile_name**: Profile section to use from the configuration file +* **[--dry-run]**: Doesn't run the restic command but display the command line instead * **[-q | --quiet]**: Force resticprofile and restic to be quiet (override any configuration from the profile) * **[-v | --verbose]**: Force resticprofile and restic to be verbose (override any configuration from the profile) * **[--no-ansi]**: Disable console colouring (to save output into a log file) @@ -857,7 +871,7 @@ As an example, here's a similar configuration file in YAML: ```yaml global: - default-command: version + default-command: snapshots initialize: false priority: low @@ -869,14 +883,12 @@ groups: default: env: tmp: /tmp - initialize: false password-file: key repository: /backup documents: backup: source: ~/Documents - initialize: false repository: ~/backup snapshots: tag: @@ -890,7 +902,7 @@ root: - excludes one-file-system: false source: - - . + - / tag: - test - dev @@ -918,7 +930,6 @@ root: self: backup: source: ./ - initialize: false repository: ../backup snapshots: tag: diff --git a/commands.go b/commands.go index f958b333..cee3e034 100644 --- a/commands.go +++ b/commands.go @@ -43,14 +43,6 @@ var ( description: "show all the details of the current profile", action: showProfile, needConfiguration: true, - hide: false, - }, - { - name: "panic", - description: "(debug only) simulates a panic", - action: panicCommand, - needConfiguration: false, - hide: true, }, { name: "schedule", @@ -73,6 +65,7 @@ var ( needConfiguration: true, hide: false, }, + // hidden commands { name: "elevation", description: "test windows elevated mode", @@ -80,6 +73,13 @@ var ( needConfiguration: true, hide: true, }, + { + name: "panic", + description: "(debug only) simulates a panic", + action: panicCommand, + needConfiguration: false, + hide: true, + }, } ) diff --git a/examples/linux.yaml b/examples/linux.yaml index e03370da..5cdbf381 100644 --- a/examples/linux.yaml +++ b/examples/linux.yaml @@ -10,11 +10,12 @@ default: documents: inherit: default initialize: true - schedule: - - "*:00,30" # every 15 minutes - - "*:15,45" # both combined together backup: + tag: documents source: ~/Documents + schedule: + - "*:00,30" # every 15 minutes + - "*:15,45" # both combined together snapshots: tag: documents diff --git a/flags.go b/flags.go index 9c3d5b1d..1ce39c41 100644 --- a/flags.go +++ b/flags.go @@ -17,6 +17,7 @@ type commandLineFlags struct { format string name string logFile string + dryRun bool noAnsi bool theme string resticArgs []string @@ -49,6 +50,7 @@ func loadFlags() (*pflag.FlagSet, commandLineFlags) { flagset.StringVarP(&flags.format, "format", "f", "", "file format of the configuration (default is to use the file extension)") flagset.StringVarP(&flags.name, "name", "n", constants.DefaultProfileName, "profile name") flagset.StringVarP(&flags.logFile, "log", "l", "", "logs into a file instead of the console") + flagset.BoolVar(&flags.dryRun, "dry-run", false, "display the restic commands instead of running them") flagset.BoolVar(&flags.noAnsi, "no-ansi", false, "disable ansi control characters (disable console colouring)") flagset.StringVar(&flags.theme, "theme", constants.DefaultTheme, "console colouring theme (dark, light, none)") diff --git a/main.go b/main.go index addcd15b..4597929b 100644 --- a/main.go +++ b/main.go @@ -302,6 +302,7 @@ func runProfile( wrapper := newResticWrapper( resticBinary, global.Initialize || profile.Initialize, + flags.dryRun, profile, resticCommand, resticArguments, diff --git a/shell_command.go b/shell_command.go index 46286a4c..9c8cd5d0 100644 --- a/shell_command.go +++ b/shell_command.go @@ -3,7 +3,9 @@ package main import ( "io" "os" + "strings" + "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/shell" ) @@ -14,11 +16,12 @@ type shellCommandDefinition struct { displayStderr bool useStdin bool stdout io.Writer + dryRun bool sigChan chan os.Signal } // newShellCommand creates a new shell command definition -func newShellCommand(command string, args, env []string) shellCommandDefinition { +func newShellCommand(command string, args, env []string, dryRun bool, sigChan chan os.Signal) shellCommandDefinition { if env == nil { env = make([]string, 0) } @@ -29,6 +32,8 @@ func newShellCommand(command string, args, env []string) shellCommandDefinition displayStderr: true, useStdin: false, stdout: os.Stdout, + dryRun: dryRun, + sigChan: sigChan, } } @@ -36,6 +41,11 @@ func newShellCommand(command string, args, env []string) shellCommandDefinition func runShellCommand(command shellCommandDefinition) error { var err error + if command.dryRun { + clog.Infof("dry-run: %s %s", command.command, strings.Join(command.args, " ")) + return nil + } + shellCmd := shell.NewSignalledCommand(command.command, command.args, command.sigChan) shellCmd.Stdout = command.stdout diff --git a/term/term_test.go b/term/term_test.go index 2173330c..d8f72038 100644 --- a/term/term_test.go +++ b/term/term_test.go @@ -2,6 +2,7 @@ package term import ( "bytes" + "os" "testing" "github.com/stretchr/testify/assert" @@ -67,3 +68,18 @@ func TestAskYesNo(t *testing.T) { assert.Equalf(t, testItem.expected, result, "when input was %q", testItem.input) } } + +func ExamplePrint() { + SetOutput(os.Stdout) + Print("ExamplePrint") + // Output: ExamplePrint +} + +func TestCanRedirectTermOutput(t *testing.T) { + message := "TestCanRedirectTermOutput" + buffer := &bytes.Buffer{} + SetOutput(buffer) + _, err := Print(message) + assert.NoError(t, err) + assert.Equal(t, message, buffer.String()) +} diff --git a/wrapper.go b/wrapper.go index 9c9ad2ac..620b6792 100644 --- a/wrapper.go +++ b/wrapper.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "github.com/creativeprojects/clog" @@ -16,6 +17,7 @@ import ( type resticWrapper struct { resticBinary string initialize bool + dryRun bool profile *config.Profile command string moreArgs []string @@ -25,6 +27,7 @@ type resticWrapper struct { func newResticWrapper( resticBinary string, initialize bool, + dryRun bool, profile *config.Profile, command string, moreArgs []string, @@ -33,6 +36,7 @@ func newResticWrapper( return &resticWrapper{ resticBinary: resticBinary, initialize: initialize, + dryRun: dryRun, profile: profile, command: command, moreArgs: moreArgs, @@ -131,7 +135,7 @@ func (r *resticWrapper) runProfile() error { } func (r *resticWrapper) runInitialize() error { - clog.Infof("profile '%s': Initializing repository (if not existing)", r.profile.Name) + clog.Infof("profile '%s': initializing repository (if not existing)", r.profile.Name) args := convertIntoArgs(r.profile.GetCommandFlags(constants.CommandInit)) rCommand := r.prepareCommand(constants.CommandInit, args) rCommand.displayStderr = false @@ -139,25 +143,25 @@ func (r *resticWrapper) runInitialize() error { } func (r *resticWrapper) runCheck() error { - clog.Infof("profile '%s': Checking repository consistency", r.profile.Name) + clog.Infof("profile '%s': checking repository consistency", r.profile.Name) args := convertIntoArgs(r.profile.GetCommandFlags(constants.CommandCheck)) rCommand := r.prepareCommand(constants.CommandCheck, args) return runShellCommand(rCommand) } func (r *resticWrapper) runRetention() error { - clog.Infof("profile '%s': Cleaning up repository using retention information", r.profile.Name) + clog.Infof("profile '%s': cleaning up repository using retention information", r.profile.Name) args := convertIntoArgs(r.profile.GetRetentionFlags()) rCommand := r.prepareCommand(constants.CommandForget, args) return runShellCommand(rCommand) } func (r *resticWrapper) runCommand(command string) error { - clog.Infof("profile '%s': Starting '%s'", r.profile.Name, command) + clog.Infof("profile '%s': starting '%s'", r.profile.Name, command) args := convertIntoArgs(r.profile.GetCommandFlags(command)) rCommand := r.prepareCommand(command, args) err := runShellCommand(rCommand) - clog.Infof("profile '%s': Finished '%s'", r.profile.Name, command) + clog.Infof("profile '%s': finished '%s'", r.profile.Name, command) return err } @@ -177,8 +181,7 @@ func (r *resticWrapper) prepareCommand(command string, args []string) shellComma env := append(os.Environ(), r.getEnvironment()...) clog.Debugf("starting command: %s %s", r.resticBinary, strings.Join(arguments, " ")) - rCommand := newShellCommand(r.resticBinary, arguments, env) - rCommand.sigChan = r.sigChan + rCommand := newShellCommand(r.resticBinary, arguments, env, r.dryRun, r.sigChan) // stdout is coming from the default terminal rCommand.stdout = term.GetOutput() @@ -200,8 +203,7 @@ func (r *resticWrapper) runPreCommand(command string) error { for i, preCommand := range r.profile.Backup.RunBefore { clog.Debugf("starting pre-backup command %d/%d", i+1, len(r.profile.Backup.RunBefore)) env := append(os.Environ(), r.getEnvironment()...) - rCommand := newShellCommand(preCommand, nil, env) - rCommand.sigChan = r.sigChan + rCommand := newShellCommand(preCommand, nil, env, r.dryRun, r.sigChan) err := runShellCommand(rCommand) if err != nil { return err @@ -221,8 +223,7 @@ func (r *resticWrapper) runPostCommand(command string) error { for i, postCommand := range r.profile.Backup.RunAfter { clog.Debugf("starting post-backup command %d/%d", i+1, len(r.profile.Backup.RunAfter)) env := append(os.Environ(), r.getEnvironment()...) - rCommand := newShellCommand(postCommand, nil, env) - rCommand.sigChan = r.sigChan + rCommand := newShellCommand(postCommand, nil, env, r.dryRun, r.sigChan) err := runShellCommand(rCommand) if err != nil { return err @@ -238,8 +239,7 @@ func (r *resticWrapper) runProfilePreCommand() error { for i, preCommand := range r.profile.RunBefore { clog.Debugf("starting 'run-before' profile command %d/%d", i+1, len(r.profile.RunBefore)) env := append(os.Environ(), r.getEnvironment()...) - rCommand := newShellCommand(preCommand, nil, env) - rCommand.sigChan = r.sigChan + rCommand := newShellCommand(preCommand, nil, env, r.dryRun, r.sigChan) err := runShellCommand(rCommand) if err != nil { return err @@ -255,8 +255,7 @@ func (r *resticWrapper) runProfilePostCommand() error { for i, postCommand := range r.profile.RunAfter { clog.Debugf("starting 'run-after' profile command %d/%d", i+1, len(r.profile.RunAfter)) env := append(os.Environ(), r.getEnvironment()...) - rCommand := newShellCommand(postCommand, nil, env) - rCommand.sigChan = r.sigChan + rCommand := newShellCommand(postCommand, nil, env, r.dryRun, r.sigChan) err := runShellCommand(rCommand) if err != nil { return err @@ -272,8 +271,7 @@ func (r *resticWrapper) runProfilePostFailCommand() error { for i, postCommand := range r.profile.RunAfterFail { clog.Debugf("starting 'run-after-fail' profile command %d/%d", i+1, len(r.profile.RunAfterFail)) env := append(os.Environ(), r.getEnvironment()...) - rCommand := newShellCommand(postCommand, nil, env) - rCommand.sigChan = r.sigChan + rCommand := newShellCommand(postCommand, nil, env, r.dryRun, r.sigChan) err := runShellCommand(rCommand) if err != nil { return err @@ -305,7 +303,17 @@ func convertIntoArgs(flags map[string][]string) []string { return args } - for key, values := range flags { + // we make a list of keys first, so we can loop on the map from an ordered list of keys + keys := make([]string, 0, len(flags)) + for key := range flags { + keys = append(keys, key) + } + // sort the keys in order + sort.Strings(keys) + + // now we loop from the ordered list of keys + for _, key := range keys { + values := flags[key] if values == nil { continue } diff --git a/wrapper_test.go b/wrapper_test.go index 1730b448..d57f4ac7 100644 --- a/wrapper_test.go +++ b/wrapper_test.go @@ -1,16 +1,19 @@ package main import ( + "bytes" "os" + "strings" "testing" "github.com/creativeprojects/resticprofile/config" + "github.com/creativeprojects/resticprofile/term" "github.com/stretchr/testify/assert" ) func TestGetEmptyEnvironment(t *testing.T) { profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper("restic", false, profile, "test", nil, nil) + wrapper := newResticWrapper("restic", false, false, profile, "test", nil, nil) env := wrapper.getEnvironment() assert.Empty(t, env) } @@ -20,7 +23,7 @@ func TestGetSingleEnvironment(t *testing.T) { profile.Environment = map[string]string{ "User": "me", } - wrapper := newResticWrapper("restic", false, profile, "test", nil, nil) + wrapper := newResticWrapper("restic", false, false, profile, "test", nil, nil) env := wrapper.getEnvironment() assert.Equal(t, []string{"USER=me"}, env) } @@ -31,7 +34,7 @@ func TestGetMultipleEnvironment(t *testing.T) { "User": "me", "Password": "secret", } - wrapper := newResticWrapper("restic", false, profile, "test", nil, nil) + wrapper := newResticWrapper("restic", false, false, profile, "test", nil, nil) env := wrapper.getEnvironment() assert.Len(t, env, 2) assert.Contains(t, env, "USER=me") @@ -70,7 +73,7 @@ func TestConversionToArgs(t *testing.T) { func TestPreProfileScriptFail(t *testing.T) { profile := config.NewProfile(nil, "name") profile.RunBefore = []string{"exit 1"} // this should both work on unix shell and windows batch - wrapper := newResticWrapper("echo", false, profile, "test", nil, nil) + wrapper := newResticWrapper("echo", false, false, profile, "test", nil, nil) err := wrapper.runProfile() assert.EqualError(t, err, "exit status 1") } @@ -78,14 +81,14 @@ func TestPreProfileScriptFail(t *testing.T) { func TestPostProfileScriptFail(t *testing.T) { profile := config.NewProfile(nil, "name") profile.RunAfter = []string{"exit 1"} // this should both work on unix shell and windows batch - wrapper := newResticWrapper("echo", false, profile, "test", nil, nil) + wrapper := newResticWrapper("echo", false, false, profile, "test", nil, nil) err := wrapper.runProfile() assert.EqualError(t, err, "exit status 1") } func TestRunEchoProfile(t *testing.T) { profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper("echo", false, profile, "test", nil, nil) + wrapper := newResticWrapper("echo", false, false, profile, "test", nil, nil) err := wrapper.runProfile() assert.NoError(t, err) } @@ -95,7 +98,7 @@ func TestPostProfileAfterFail(t *testing.T) { _ = os.Remove(testFile) profile := config.NewProfile(nil, "name") profile.RunAfter = []string{"echo failed > " + testFile} - wrapper := newResticWrapper("exit", false, profile, "1", nil, nil) + wrapper := newResticWrapper("exit", false, false, profile, "1", nil, nil) err := wrapper.runProfile() assert.EqualError(t, err, "exit status 1") assert.NoFileExistsf(t, testFile, "the run-after script should not have been running") @@ -107,9 +110,37 @@ func TestPostFailProfile(t *testing.T) { _ = os.Remove(testFile) profile := config.NewProfile(nil, "name") profile.RunAfterFail = []string{"echo failed > " + testFile} - wrapper := newResticWrapper("exit", false, profile, "1", nil, nil) + wrapper := newResticWrapper("exit", false, false, profile, "1", nil, nil) err := wrapper.runProfile() assert.EqualError(t, err, "exit status 1") assert.FileExistsf(t, testFile, "the run-after-fail script has not been running") _ = os.Remove(testFile) } + +func Example_runProfile() { + term.SetOutput(os.Stdout) + profile := config.NewProfile(nil, "name") + wrapper := newResticWrapper("echo", false, false, profile, "test", nil, nil) + wrapper.runProfile() + // Output: test +} + +func TestRunRedirectOutputOfEchoProfile(t *testing.T) { + buffer := &bytes.Buffer{} + term.SetOutput(buffer) + profile := config.NewProfile(nil, "name") + wrapper := newResticWrapper("echo", false, false, profile, "test", nil, nil) + err := wrapper.runProfile() + assert.NoError(t, err) + assert.Equal(t, "test", strings.TrimSpace(buffer.String())) +} + +func TestDryRun(t *testing.T) { + buffer := &bytes.Buffer{} + term.SetOutput(buffer) + profile := config.NewProfile(nil, "name") + wrapper := newResticWrapper("echo", false, true, profile, "test", nil, nil) + err := wrapper.runProfile() + assert.NoError(t, err) + assert.Equal(t, "", buffer.String()) +}