diff --git a/commands.go b/commands.go index 2a47b270e..81bff81f3 100644 --- a/commands.go +++ b/commands.go @@ -147,12 +147,12 @@ func getOwnCommands() []ownCommand { } } -func panicCommand(_ io.Writer, _ commandRequest) error { +func panicCommand(_ io.Writer, _ commandContext) error { panic("you asked for it") } -func completeCommand(output io.Writer, request commandRequest) error { - args := request.args +func completeCommand(output io.Writer, ctx commandContext) error { + args := ctx.request.arguments requester := "unknown" requesterVersion := 0 @@ -178,7 +178,7 @@ func completeCommand(output io.Writer, request commandRequest) error { return nil } - completions := NewCompleter(request.ownCommands.All(), DefaultFlagsLoader).Complete(args) + completions := NewCompleter(ctx.ownCommands.All(), DefaultFlagsLoader).Complete(args) if len(completions) > 0 { for _, completion := range completions { fmt.Fprintln(output, completion) @@ -193,8 +193,8 @@ var bashCompletionScript string //go:embed contrib/completion/zsh-completion.sh var zshCompletionScript string -func generateCommand(output io.Writer, request commandRequest) (err error) { - args := request.args +func generateCommand(output io.Writer, ctx commandContext) (err error) { + args := ctx.request.arguments // enforce no-log logger := clog.GetDefaultLogger() handler := logger.GetHandler() @@ -207,8 +207,8 @@ func generateCommand(output io.Writer, request commandRequest) (err error) { } else if slices.Contains(args, "--json-schema") { err = generateJsonSchema(output, args[slices.Index(args, "--json-schema")+1:]) } else if slices.Contains(args, "--random-key") { - request.flags.resticArgs = args[slices.Index(args, "--random-key"):] - err = randomKey(output, request) + ctx.flags.resticArgs = args[slices.Index(args, "--random-key"):] + err = randomKey(output, ctx) } else if slices.Contains(args, "--zsh-completion") { _, err = fmt.Fprintln(output, zshCompletionScript) } else { @@ -278,9 +278,9 @@ func sortedProfileKeys(data map[string]*config.Profile) []string { return keys } -func showProfile(output io.Writer, request commandRequest) error { - c := request.config - flags := request.flags +func showProfile(output io.Writer, ctx commandContext) error { + c := ctx.config + flags := ctx.flags // Load global section global, err := c.GetGlobalSection() @@ -340,9 +340,9 @@ func showSchedules(output io.Writer, schedulesConfig []*config.ScheduleConfig) { } // randomKey simply display a base64'd random key to the console -func randomKey(output io.Writer, request commandRequest) error { +func randomKey(output io.Writer, ctx commandContext) error { var err error - flags := request.flags + flags := ctx.flags size := uint64(1024) // flags.resticArgs contain the command and the rest of the command line if len(flags.resticArgs) > 1 { @@ -398,10 +398,10 @@ func flagsForProfile(flags commandLineFlags, profileName string) commandLineFlag } // createSchedule accepts one argument from the commandline: --no-start -func createSchedule(_ io.Writer, request commandRequest) error { - c := request.config - flags := request.flags - args := request.args +func createSchedule(_ io.Writer, ctx commandContext) error { + c := ctx.config + flags := ctx.flags + args := ctx.request.arguments defer c.DisplayConfigurationIssues() @@ -453,10 +453,10 @@ func createSchedule(_ io.Writer, request commandRequest) error { return nil } -func removeSchedule(_ io.Writer, request commandRequest) error { - c := request.config - flags := request.flags - args := request.args +func removeSchedule(_ io.Writer, ctx commandContext) error { + c := ctx.config + flags := ctx.flags + args := ctx.request.arguments // Unschedule all jobs of all selected profiles for _, profileName := range selectProfiles(c, flags, args) { @@ -476,10 +476,10 @@ func removeSchedule(_ io.Writer, request commandRequest) error { return nil } -func statusSchedule(w io.Writer, request commandRequest) error { - c := request.config - flags := request.flags - args := request.args +func statusSchedule(w io.Writer, ctx commandContext) error { + c := ctx.config + flags := ctx.flags + args := ctx.request.arguments defer c.DisplayConfigurationIssues() @@ -572,9 +572,9 @@ func getRemovableScheduleJobs(c *config.Config, flags commandLineFlags) (schedul return scheduler, profile, schedules, nil } -func testElevationCommand(_ io.Writer, request commandRequest) error { - if request.flags.isChild { - client := remote.NewClient(request.flags.parentPort) +func testElevationCommand(_ io.Writer, ctx commandContext) error { + if ctx.flags.isChild { + client := remote.NewClient(ctx.flags.parentPort) term.Print("first line", "\n") term.Println("second", "one") term.Printf("value = %d\n", 11) @@ -585,7 +585,7 @@ func testElevationCommand(_ io.Writer, request commandRequest) error { return nil } - return elevated(request.flags) + return elevated(ctx.flags) } func retryElevated(err error, flags commandLineFlags) error { diff --git a/commands_display.go b/commands_display.go index 9215f56bd..9de1e1549 100644 --- a/commands_display.go +++ b/commands_display.go @@ -73,11 +73,11 @@ func getCommonUsageHelpLine(commandName string, withProfile bool) string { ) } -func displayOwnCommands(output io.Writer, request commandRequest) { - out, closer := displayWriter(output, request.flags) +func displayOwnCommands(output io.Writer, ctx commandContext) { + out, closer := displayWriter(output, ctx.flags) defer closer() - for _, command := range request.ownCommands.commands { + for _, command := range ctx.ownCommands.commands { if command.hide { continue } @@ -86,12 +86,12 @@ func displayOwnCommands(output io.Writer, request commandRequest) { } } -func displayOwnCommandHelp(output io.Writer, commandName string, request commandRequest) { - out, closer := displayWriter(output, request.flags) +func displayOwnCommandHelp(output io.Writer, commandName string, ctx commandContext) { + out, closer := displayWriter(output, ctx.flags) defer closer() var command *ownCommand - for _, c := range request.ownCommands.commands { + for _, c := range ctx.ownCommands.commands { if c.name == commandName { command = &c break @@ -130,8 +130,8 @@ func displayOwnCommandHelp(output io.Writer, commandName string, request command } } -func displayCommonUsageHelp(output io.Writer, request commandRequest) { - out, closer := displayWriter(output, request.flags) +func displayCommonUsageHelp(output io.Writer, ctx commandContext) { + out, closer := displayWriter(output, ctx.flags) defer closer() out("resticprofile is a configuration profiles manager for backup profiles and ") @@ -142,10 +142,10 @@ func displayCommonUsageHelp(output io.Writer, request commandRequest) { out("\t%s [command specific flags]\n", getCommonUsageHelpLine("resticprofile-command", true)) out("\n") out(ansiBold("resticprofile flags:\n")) - out(request.flags.usagesHelp) + out(ctx.flags.usagesHelp) out("\n\n") out(ansiBold("resticprofile own commands:\n")) - displayOwnCommands(out(), request) + displayOwnCommands(out(), ctx) out("\n") out("%s at %s\n", @@ -218,10 +218,10 @@ func displayResticHelp(output io.Writer, configuration *config.Config, flags com } } -func displayHelpCommand(output io.Writer, request commandRequest) error { - flags := request.flags +func displayHelpCommand(output io.Writer, ctx commandContext) error { + flags := ctx.flags - out, closer := displayWriter(output, request.flags) + out, closer := displayWriter(output, ctx.flags) defer closer() if flags.log == "" { @@ -237,26 +237,27 @@ func displayHelpCommand(output io.Writer, request commandRequest) error { } if helpForCommand == nil { - displayCommonUsageHelp(out("\n"), request) + displayCommonUsageHelp(out("\n"), ctx) - } else if request.ownCommands.Exists(*helpForCommand, true) || request.ownCommands.Exists(*helpForCommand, false) { - displayOwnCommandHelp(out("\n"), *helpForCommand, request) + } else if ctx.ownCommands.Exists(*helpForCommand, true) || ctx.ownCommands.Exists(*helpForCommand, false) { + displayOwnCommandHelp(out("\n"), *helpForCommand, ctx) } else { - displayResticHelp(out(), request.config, flags, *helpForCommand) + displayResticHelp(out(), ctx.config, flags, *helpForCommand) } return nil } -func displayVersion(output io.Writer, request commandRequest) error { - out, closer := displayWriter(output, request.flags) +func displayVersion(output io.Writer, ctx commandContext) error { + out, closer := displayWriter(output, ctx.flags) defer closer() out("resticprofile version %s commit %s\n", ansiBold(version), ansiYellow(commit)) // allow for the general verbose flag, or specified after the command - if request.flags.verbose || (len(request.args) > 0 && (request.args[0] == "-v" || request.args[0] == "--verbose")) { + arguments := ctx.request.arguments + if ctx.flags.verbose || (len(arguments) > 0 && (arguments[0] == "-v" || arguments[0] == "--verbose")) { out("\n") out("\t%s:\t%s\n", "home", "https://github.com/creativeprojects/resticprofile") out("\t%s:\t%s\n", "os", runtime.GOOS) @@ -280,9 +281,9 @@ func displayVersion(output io.Writer, request commandRequest) error { return nil } -func displayProfilesCommand(output io.Writer, request commandRequest) error { - displayProfiles(output, request.config, request.flags) - displayGroups(output, request.config, request.flags) +func displayProfilesCommand(output io.Writer, ctx commandContext) error { + displayProfiles(output, ctx.config, ctx.flags) + displayGroups(output, ctx.config, ctx.flags) return nil } diff --git a/commands_display_test.go b/commands_display_test.go index d6c2efaeb..fd1e3d6f1 100644 --- a/commands_display_test.go +++ b/commands_display_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "fmt" + "runtime" "strings" "testing" @@ -295,3 +296,15 @@ https://creativeprojects.github.io/resticprofile/ } } } + +func TestDisplayVersionVerbose1(t *testing.T) { + buffer := &bytes.Buffer{} + displayVersion(buffer, commandContext{Context: Context{flags: commandLineFlags{verbose: true}}}) + assert.True(t, strings.Contains(buffer.String(), runtime.GOOS)) +} + +func TestDisplayVersionVerbose2(t *testing.T) { + buffer := &bytes.Buffer{} + displayVersion(buffer, commandContext{Context: Context{request: Request{arguments: []string{"-v"}}}}) + assert.True(t, strings.Contains(buffer.String(), runtime.GOOS)) +} diff --git a/commands_test.go b/commands_test.go index 31c3307c0..bb56da208 100644 --- a/commands_test.go +++ b/commands_test.go @@ -2,9 +2,7 @@ package main import ( "bytes" - "errors" "fmt" - "io" "os" "sort" "strings" @@ -16,100 +14,31 @@ import ( "github.com/stretchr/testify/assert" ) -func fakeCommands() *OwnCommands { - ownCommands := NewOwnCommands() - ownCommands.Register([]ownCommand{ - { - name: "first", - description: "first first", - action: firstCommand, - needConfiguration: false, - }, - { - name: "second", - description: "second second", - action: secondCommand, - needConfiguration: true, - flags: map[string]string{ - "-f, --first": "first flag", - "-s, --seccond": "second flag", - }, - }, - { - name: "third", - description: "third third", - action: thirdCommand, - needConfiguration: false, - hide: true, - }, - }) - return ownCommands -} - -func firstCommand(_ io.Writer, _ commandRequest) error { - return errors.New("first") -} - -func secondCommand(_ io.Writer, _ commandRequest) error { - return errors.New("second") -} - -func thirdCommand(_ io.Writer, _ commandRequest) error { - return errors.New("third") -} - -func TestDisplayOwnCommands(t *testing.T) { - buffer := &strings.Builder{} - displayOwnCommands(buffer, commandRequest{ownCommands: fakeCommands()}) - assert.Equal(t, " first first first\n second second second\n", buffer.String()) -} - -func TestDisplayOwnCommand(t *testing.T) { - buffer := &strings.Builder{} - displayOwnCommandHelp(buffer, "second", commandRequest{ownCommands: fakeCommands()}) - assert.Equal(t, `Purpose: second second - -Usage: - resticprofile [resticprofile flags] [profile name.]second [command specific flags] - -Flags: - -f, --first first flag - -s, --seccond second flag - -`, buffer.String()) -} - -func TestIsOwnCommand(t *testing.T) { - assert.True(t, fakeCommands().Exists("first", false)) - assert.True(t, fakeCommands().Exists("second", true)) - assert.True(t, fakeCommands().Exists("third", false)) - assert.False(t, fakeCommands().Exists("another one", true)) -} - -func TestRunOwnCommand(t *testing.T) { - assert.EqualError(t, fakeCommands().Run(nil, "first", commandLineFlags{}, nil), "first") - assert.EqualError(t, fakeCommands().Run(nil, "second", commandLineFlags{}, nil), "second") - assert.EqualError(t, fakeCommands().Run(nil, "third", commandLineFlags{}, nil), "third") - assert.EqualError(t, fakeCommands().Run(nil, "another one", commandLineFlags{}, nil), "command not found: another one") -} - func TestPanicCommand(t *testing.T) { assert.Panics(t, func() { - _ = panicCommand(nil, commandRequest{}) + _ = panicCommand(nil, commandContext{}) }) } func TestRandomKeyOfInvalidSize(t *testing.T) { - assert.Error(t, randomKey(os.Stdout, commandRequest{flags: commandLineFlags{resticArgs: []string{"restic", "size"}}})) + assert.Error(t, randomKey(os.Stdout, commandContext{ + Context: Context{ + flags: commandLineFlags{resticArgs: []string{"restic", "size"}}, + }, + })) } func TestRandomKeyOfZeroSize(t *testing.T) { - assert.Error(t, randomKey(os.Stdout, commandRequest{flags: commandLineFlags{resticArgs: []string{"restic", "0"}}})) + assert.Error(t, randomKey(os.Stdout, commandContext{ + Context: Context{ + flags: commandLineFlags{resticArgs: []string{"restic", "0"}}, + }, + })) } func TestRandomKey(t *testing.T) { // doesn't look like much, but it's testing the random generator is not throwing an error - assert.NoError(t, randomKey(os.Stdout, commandRequest{})) + assert.NoError(t, randomKey(os.Stdout, commandContext{})) } func TestRemovableSchedules(t *testing.T) { @@ -263,7 +192,10 @@ func TestCompleteCall(t *testing.T) { for _, test := range testTable { t.Run(strings.Join(test.args, " "), func(t *testing.T) { buffer := &strings.Builder{} - assert.Nil(t, completeCommand(buffer, commandRequest{ownCommands: ownCommands, args: test.args})) + assert.Nil(t, completeCommand(buffer, commandContext{ + ownCommands: ownCommands, + Context: Context{request: Request{arguments: test.args}}, + })) assert.Equal(t, test.expected, buffer.String()) }) } @@ -272,23 +204,28 @@ func TestCompleteCall(t *testing.T) { func TestGenerateCommand(t *testing.T) { buffer := &strings.Builder{} + contextWithArguments := func(args []string) commandContext { + t.Helper() + return commandContext{Context: Context{request: Request{arguments: args}}} //nolint:exhaustivestruct + } + t.Run("--bash-completion", func(t *testing.T) { buffer.Reset() - assert.Nil(t, generateCommand(buffer, commandRequest{args: []string{"--bash-completion"}})) + assert.Nil(t, generateCommand(buffer, contextWithArguments([]string{"--bash-completion"}))) assert.Equal(t, strings.TrimSpace(bashCompletionScript), strings.TrimSpace(buffer.String())) assert.Contains(t, bashCompletionScript, "#!/usr/bin/env bash") }) t.Run("--zsh-completion", func(t *testing.T) { buffer.Reset() - assert.Nil(t, generateCommand(buffer, commandRequest{args: []string{"--zsh-completion"}})) + assert.Nil(t, generateCommand(buffer, contextWithArguments([]string{"--zsh-completion"}))) assert.Equal(t, strings.TrimSpace(zshCompletionScript), strings.TrimSpace(buffer.String())) assert.Contains(t, zshCompletionScript, "#!/usr/bin/env zsh") }) t.Run("--config-reference", func(t *testing.T) { buffer.Reset() - assert.NoError(t, generateCommand(buffer, commandRequest{args: []string{"--config-reference"}})) + assert.NoError(t, generateCommand(buffer, contextWithArguments([]string{"--config-reference"}))) ref := buffer.String() assert.Contains(t, ref, "| **ionice-class** |") assert.Contains(t, ref, "| **check-after** |") @@ -297,7 +234,7 @@ func TestGenerateCommand(t *testing.T) { t.Run("--json-schema", func(t *testing.T) { buffer.Reset() - assert.NoError(t, generateCommand(buffer, commandRequest{args: []string{"--json-schema"}})) + assert.NoError(t, generateCommand(buffer, contextWithArguments([]string{"--json-schema"}))) ref := buffer.String() assert.Contains(t, ref, "\"profiles\":") assert.Contains(t, ref, "/jsonschema/config-2.json") @@ -305,21 +242,21 @@ func TestGenerateCommand(t *testing.T) { t.Run("--json-schema v1", func(t *testing.T) { buffer.Reset() - assert.NoError(t, generateCommand(buffer, commandRequest{args: []string{"--json-schema", "v1"}})) + assert.NoError(t, generateCommand(buffer, contextWithArguments([]string{"--json-schema", "v1"}))) ref := buffer.String() assert.Contains(t, ref, "/jsonschema/config-1.json") }) t.Run("--json-schema --version 0.13 v1", func(t *testing.T) { buffer.Reset() - assert.NoError(t, generateCommand(buffer, commandRequest{args: []string{"--json-schema", "--version", "0.13", "v1"}})) + assert.NoError(t, generateCommand(buffer, contextWithArguments([]string{"--json-schema", "--version", "0.13", "v1"}))) ref := buffer.String() assert.Contains(t, ref, "/jsonschema/config-1-restic-0-13.json") }) t.Run("--random-key", func(t *testing.T) { buffer.Reset() - assert.Nil(t, generateCommand(buffer, commandRequest{args: []string{"--random-key", "512"}})) + assert.Nil(t, generateCommand(buffer, contextWithArguments([]string{"--random-key", "512"}))) assert.Equal(t, 684, len(strings.TrimSpace(buffer.String()))) }) @@ -328,7 +265,7 @@ func TestGenerateCommand(t *testing.T) { opts := []string{"", "invalid", "--unknown"} for _, option := range opts { buffer.Reset() - err := generateCommand(buffer, commandRequest{args: []string{option}}) + err := generateCommand(buffer, contextWithArguments([]string{option})) assert.EqualError(t, err, fmt.Sprintf("nothing to generate for: %s", option)) assert.Equal(t, 0, buffer.Len()) } diff --git a/context.go b/context.go new file mode 100644 index 000000000..664bcd4a5 --- /dev/null +++ b/context.go @@ -0,0 +1,77 @@ +package main + +import ( + "os" + + "github.com/creativeprojects/resticprofile/config" +) + +type Request struct { + command string // from the command line + arguments []string // added arguments after the restic command; all arguments for own commands + profile string // profile name (if any) + group string // when running as part of a group of profiles + schedule string // when started with command: run-schedule +} + +// Context for running a profile command. +// 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 +} + +// WithConfig sets the configuration and global values. A new copy of the context is returned. +func (c *Context) WithConfig(cfg *config.Config, global *config.Global) *Context { + newContext := c.clone() + newContext.config = cfg + newContext.global = global + return newContext +} + +// WithBinary sets the restic binary to use. A new copy of the context is returned. +func (c *Context) WithBinary(resticBinary string) *Context { + newContext := c.clone() + newContext.binary = resticBinary + return newContext +} + +// WithCommand sets the restic command. A new copy of the context is returned. +func (c *Context) WithCommand(resticCommand string) *Context { + newContext := c.clone() + newContext.command = resticCommand + return newContext +} + +// WithGroup sets the configuration group. A new copy of the context is returned. +func (c *Context) WithGroup(group string) *Context { + newContext := c.clone() + newContext.request.group = group + return newContext +} + +// WithProfile sets the profile name. A new copy of the context is returned. +// Profile and schedule information are not copied over. +func (c *Context) WithProfile(profileName string) *Context { + newContext := c.clone() + newContext.request.profile = profileName + newContext.request.group = "" + newContext.request.schedule = "" + newContext.profile = nil + newContext.schedule = nil + return newContext +} + +func (c *Context) clone() *Context { + clone := *c + return &clone +} diff --git a/context_test.go b/context_test.go new file mode 100644 index 000000000..c876ee0b5 --- /dev/null +++ b/context_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "testing" + + "github.com/creativeprojects/resticprofile/config" + "github.com/stretchr/testify/assert" +) + +func TestContextClone(t *testing.T) { + ctx := &Context{ + config: &config.Config{}, + global: &config.Global{}, + binary: "test", + } + clone := ctx.clone() + assert.False(t, ctx == clone) // different pointers + assert.Equal(t, ctx, clone) // same values +} + +func TestContextWithConfig(t *testing.T) { + ctx := &Context{ + config: nil, + global: nil, + } + ctx = ctx.WithConfig(&config.Config{}, &config.Global{}) + assert.NotNil(t, ctx.config) + assert.NotNil(t, ctx.global) +} + +func TestContextWithBinary(t *testing.T) { + ctx := &Context{ + binary: "test", + } + ctx = ctx.WithBinary("test2") + assert.Equal(t, "test2", ctx.binary) +} + +func TestContextWithCommand(t *testing.T) { + ctx := &Context{ + command: "test", + } + ctx = ctx.WithCommand("test2") + assert.Equal(t, "test2", ctx.command) +} + +func TestContextWithGroup(t *testing.T) { + ctx := &Context{ + request: Request{ + command: "test", + group: "test", + }, + } + ctx = ctx.WithGroup("test2") + assert.Equal(t, "test2", ctx.request.group) + assert.NotEmpty(t, ctx.request.command) +} + +func TestContextWithProfile(t *testing.T) { + ctx := &Context{ + request: Request{ + command: "test", + profile: "test", + group: "test", + schedule: "test", + }, + profile: &config.Profile{}, + schedule: &config.Schedule{}, + } + ctx = ctx.WithProfile("test2") + assert.Equal(t, "test2", ctx.request.profile) + assert.NotEmpty(t, ctx.request.command) + + assert.Empty(t, ctx.request.group) + assert.Empty(t, ctx.request.schedule) + + assert.Nil(t, ctx.profile) + assert.Nil(t, ctx.schedule) +} diff --git a/errors.go b/errors.go new file mode 100644 index 000000000..0899973ec --- /dev/null +++ b/errors.go @@ -0,0 +1,7 @@ +package main + +import "errors" + +var ( + ErrProfileNotFound = errors.New("profile or group not found") +) diff --git a/integration_test.go b/integration_test.go index ccfedd385..36ea17324 100644 --- a/integration_test.go +++ b/integration_test.go @@ -137,15 +137,16 @@ func TestFromConfigFileToCommandLine(t *testing.T) { require.NoError(t, err) require.NotNil(t, profile) - wrapper := newResticWrapper( - nil, - echoBinary, - false, - profile, - fixture.commandName, - fixture.cmdlineArgs, - nil, - ) + ctx := &Context{ + request: Request{ + profile: fixture.profileName, + arguments: fixture.cmdlineArgs, + }, + binary: echoBinary, + profile: profile, + command: fixture.commandName, + } + wrapper := newResticWrapper(ctx) buffer := &bytes.Buffer{} // setting the output via the package global setter could lead to some issues // when some tests are running in parallel. I should fix that at some point :-/ @@ -178,15 +179,16 @@ func TestFromConfigFileToCommandLine(t *testing.T) { profile.SetLegacyArg(true) - wrapper := newResticWrapper( - nil, - echoBinary, - false, - profile, - fixture.commandName, - fixture.cmdlineArgs, - nil, - ) + ctx := &Context{ + request: Request{ + profile: fixture.profileName, + arguments: fixture.cmdlineArgs, + }, + binary: echoBinary, + profile: profile, + command: fixture.commandName, + } + wrapper := newResticWrapper(ctx) buffer := &bytes.Buffer{} // setting the output via the package global setter could lead to some issues // when some tests are running in parallel. I should fix that at some point :-/ diff --git a/logger.go b/logger.go index 19196a71a..8226e6beb 100644 --- a/logger.go +++ b/logger.go @@ -40,17 +40,17 @@ func setupRemoteLogger(flags commandLineFlags, client *remote.Client) { clog.SetDefaultLogger(logger) } -func setupTargetLogger(flags commandLineFlags) (io.Closer, error) { +func setupTargetLogger(flags commandLineFlags, logTarget string) (io.Closer, error) { var ( handler LogCloser file io.Writer err error ) - scheme, hostPort, isURL := dial.GetAddr(flags.log) + scheme, hostPort, isURL := dial.GetAddr(logTarget) if isURL { - handler, err = getSyslogHandler(flags, scheme, hostPort) + handler, err = getSyslogHandler(scheme, hostPort) } else { - handler, file, err = getFileHandler(flags) + handler, file, err = getFileHandler(logTarget) } if err != nil { return nil, err @@ -68,15 +68,15 @@ func setupTargetLogger(flags commandLineFlags) (io.Closer, error) { return handler, nil } -func getFileHandler(flags commandLineFlags) (*clog.StandardLogHandler, io.Writer, error) { - if strings.HasPrefix(flags.log, constants.TemporaryDirMarker) { +func getFileHandler(logfile string) (*clog.StandardLogHandler, io.Writer, error) { + if strings.HasPrefix(logfile, constants.TemporaryDirMarker) { if tempDir, err := util.TempDir(); err == nil { - flags.log = flags.log[len(constants.TemporaryDirMarker):] - if len(flags.log) > 0 && os.IsPathSeparator(flags.log[0]) { - flags.log = flags.log[1:] + logfile = logfile[len(constants.TemporaryDirMarker):] + if len(logfile) > 0 && os.IsPathSeparator(logfile[0]) { + logfile = logfile[1:] } - flags.log = filepath.Join(tempDir, flags.log) - _ = os.MkdirAll(filepath.Dir(flags.log), 0755) + logfile = filepath.Join(tempDir, logfile) + _ = os.MkdirAll(filepath.Dir(logfile), 0755) } } @@ -95,7 +95,7 @@ func getFileHandler(flags commandLineFlags) (*clog.StandardLogHandler, io.Writer } } - writer, err := newDeferredFileWriter(flags.log, keepOpen, appender) + writer, err := newDeferredFileWriter(logfile, keepOpen, appender) if err != nil { return nil, nil, err } diff --git a/logger_test.go b/logger_test.go index 5e8971777..d577910b0 100644 --- a/logger_test.go +++ b/logger_test.go @@ -36,9 +36,7 @@ func TestFileHandlerWithTemporaryDirMarker(t *testing.T) { logFile := filepath.Join(util.MustGetTempDir(), "sub", "file.log") assert.NoFileExists(t, logFile) - handler, _, err := getFileHandler(commandLineFlags{ - log: filepath.Join(constants.TemporaryDirMarker, "sub", "file.log"), - }) + handler, _, err := getFileHandler(filepath.Join(constants.TemporaryDirMarker, "sub", "file.log")) require.NoError(t, err) assert.FileExists(t, logFile) @@ -49,7 +47,7 @@ func TestFileHandlerWithTemporaryDirMarker(t *testing.T) { func TestFileHandler(t *testing.T) { logFile := filepath.Join(t.TempDir(), "file.log") - handler, writer, err := getFileHandler(commandLineFlags{log: logFile}) + handler, writer, err := getFileHandler(logFile) require.NoError(t, err) defer handler.Close() @@ -97,3 +95,22 @@ func TestFileHandler(t *testing.T) { } } } + +// FIXME: writing into a closed handler shouldn't panic +// +// func TestCloseFileHandler(t *testing.T) { +// logFile := filepath.Join(t.TempDir(), "file.log") +// handler, writer, err := getFileHandler(logFile) +// require.NoError(t, err) +// assert.NotNil(t, handler) +// assert.NotNil(t, writer) +// defer handler.Close() + +// log := func(line string) { +// assert.NoError(t, handler.LogEntry(clog.LogEntry{Level: clog.LevelInfo, Format: line})) +// } + +// log("log-line-1") +// handler.Close() +// log("log-line-2") +// } diff --git a/main.go b/main.go index e14979a51..8a467a8ca 100644 --- a/main.go +++ b/main.go @@ -54,7 +54,15 @@ func main() { _, flags, flagErr := loadFlags(args) if flagErr != nil && flagErr != pflag.ErrHelp { fmt.Println(flagErr) - _ = displayHelpCommand(os.Stdout, commandRequest{ownCommands: ownCommands, flags: flags, args: args}) + _ = displayHelpCommand(os.Stdout, commandContext{ + ownCommands: ownCommands, + Context: Context{ + flags: flags, + request: Request{ + arguments: args, + }, + }, + }) exitCode = 2 return } @@ -77,12 +85,20 @@ func main() { // help if flags.help || errors.Is(flagErr, pflag.ErrHelp) { - _ = displayHelpCommand(os.Stdout, commandRequest{ownCommands: ownCommands, flags: flags, args: args}) + _ = displayHelpCommand(os.Stdout, commandContext{ + ownCommands: ownCommands, + Context: Context{ + flags: flags, + request: Request{ + arguments: args, + }, + }, + }) return } // logger setup logic - is delayed until config was loaded (or attempted) - setupLogging := func(global *config.Global) (logCloser func()) { + setupLogging := func(ctx *Context) (logCloser func()) { logCloser = func() {} if flags.isChild { @@ -94,11 +110,12 @@ func main() { // also redirect the terminal through the client term.SetAllOutput(term.NewRemoteTerm(client)) } else { - if flags.log == "" && global != nil { - flags.log = global.Log + logTarget := "" + if ctx != nil { + logTarget = ctx.logTarget } - if flags.log != "" && flags.log != "-" { - if closer, err := setupTargetLogger(flags); err == nil { + if logTarget != "" && logTarget != "-" { + if closer, err := setupTargetLogger(flags, logTarget); err == nil { logCloser = func() { _ = closer.Close() } } else { // fallback to a console logger @@ -118,25 +135,41 @@ func main() { banner() - // resticprofile own commands (configuration file may NOT be loaded) + // resticprofile own commands (configuration file may not be loaded) if len(flags.resticArgs) > 0 { if ownCommands.Exists(flags.resticArgs[0], false) { + ctx := &Context{ + flags: flags, + request: Request{ + command: flags.resticArgs[0], + arguments: flags.resticArgs[1:], + }, + } // try to load the config and setup logging for own command - configuration, global, _ := loadConfig(flags, true) - defer setupLogging(global)() - err = ownCommands.Run(configuration, flags.resticArgs[0], flags, flags.resticArgs[1:]) + cfg, global, err := loadConfig(flags, true) + if err == nil { + ctx = ctx.WithConfig(cfg, global) + } + closeLogger := setupLogging(ctx) + defer closeLogger() + err = ownCommands.Run(ctx) if err != nil { clog.Error(err) exitCode = 1 + var ownCommandError *ownCommandError + if errors.As(err, &ownCommandError) { + exitCode = ownCommandError.ExitCode() + } return } return } } - // Load the mandatory configuration and setup logging (before returning on error) - c, global, err := loadConfig(flags, false) - defer setupLogging(global)() + // Load the now mandatory configuration and setup logging (before returning an error) + ctx, err := loadContext(flags, false) + closeLogger := setupLogging(ctx) + defer closeLogger() if err != nil { clog.Error(err) exitCode = 1 @@ -144,29 +177,14 @@ func main() { } // check if we're running on battery - if flags.ignoreOnBattery > 0 && flags.ignoreOnBattery <= constants.BatteryFull { - battery, charge, err := IsRunningOnBattery() - if err != nil { - clog.Errorf("cannot check if the computer is running on battery: %s", err) - } - if battery { - if flags.ignoreOnBattery == constants.BatteryFull { - clog.Warning("running on battery, leaving now") - exitCode = 3 - return - } - if charge < flags.ignoreOnBattery { - clog.Warningf("running on battery (%d%%), leaving now", charge) - exitCode = 3 - return - } - clog.Infof("running on battery with enough charge (%d%%)", charge) - } + if shouldStopOnBattery(flags.ignoreOnBattery) { + exitCode = 3 + return } // prevent computer from sleeping var caffeinate *preventsleep.Caffeinate - if global.PreventSleep { + if ctx.global.PreventSleep { clog.Debug("preventing the system from sleeping") caffeinate = preventsleep.New() err = caffeinate.Start() @@ -185,109 +203,64 @@ func main() { }() // Check memory pressure - if global.MinMemory > 0 { + if ctx.global.MinMemory > 0 { avail := free() - if avail > 0 && avail < global.MinMemory { - clog.Errorf("available memory is < %v MB (option 'min-memory' in the 'global' section)", global.MinMemory) + if avail > 0 && avail < ctx.global.MinMemory { + clog.Errorf("available memory is < %v MB (option 'min-memory' in the 'global' section)", ctx.global.MinMemory) exitCode = 1 return } } if !flags.noPriority { - err = setPriority(global.Nice, global.Priority) + err = setPriority(ctx.global.Nice, ctx.global.Priority) if err != nil { clog.Warning(err) } - if global.IONice { - err = priority.SetIONice(global.IONiceClass, global.IONiceLevel) + if ctx.global.IONice { + err = priority.SetIONice(ctx.global.IONiceClass, ctx.global.IONiceLevel) if err != nil { clog.Warning(err) } } } - resticBinary, err := filesearch.FindResticBinary(global.ResticBinary) + resticBinary, err := detectResticBinary(ctx.global) if err != nil { - clog.Error("cannot find restic: ", err) + clog.Error(err) clog.Warning("you can specify the path of the restic binary in the global section of the configuration file (restic-binary)") exitCode = 1 return } - - // The remaining arguments are going to be sent to the restic command line - resticArguments := flags.resticArgs - resticCommand := global.DefaultCommand - if len(resticArguments) > 0 { - resticCommand = resticArguments[0] - resticArguments = resticArguments[1:] - } + ctx = ctx.WithBinary(resticBinary) // resticprofile own commands (with configuration file) - if ownCommands.Exists(resticCommand, true) { - err = ownCommands.Run(c, resticCommand, flags, resticArguments) + if ownCommands.Exists(ctx.request.command, true) { + err = ownCommands.Run(ctx) if err != nil { clog.Error(err) exitCode = 1 + var ownCommandError *ownCommandError + if errors.As(err, &ownCommandError) { + exitCode = ownCommandError.ExitCode() + } return } return } - // detect restic version - if len(global.ResticVersion) == 0 { - if global.ResticVersion, err = restic.GetVersion(resticBinary); err != nil { - clog.Warningf("assuming restic is at latest known version ; %s", err.Error()) - global.ResticVersion = restic.AnyVersion - } - } - clog.Debugf("restic %s", global.ResticVersion) - - if c.HasProfile(flags.name) { - // if running as a systemd timer - notifyStart() - defer notifyStop() - - // Single profile run - err = runProfile(c, global, flags, flags.name, resticBinary, resticArguments, resticCommand, "") - if err != nil { - clog.Error(err) - exitCode = 1 - return - } - - } else if c.HasProfileGroup(flags.name) { - // Group run - group, err := c.GetProfileGroup(flags.name) - if err != nil { - clog.Errorf("cannot load group '%s': %v", flags.name, err) - } - if group != nil && len(group.Profiles) > 0 { - // if running as a systemd timer - notifyStart() - defer notifyStop() + // since it's not a resticprofile command, it's a restic command + ctx = ctx.WithCommand(ctx.request.command) - for i, profileName := range group.Profiles { - clog.Debugf("[%d/%d] starting profile '%s' from group '%s'", i+1, len(group.Profiles), profileName, flags.name) - err = runProfile(c, global, flags, profileName, resticBinary, resticArguments, resticCommand, flags.name) - if err != nil { - clog.Error(err) - if global.GroupContinueOnError && bools.IsTrueOrUndefined(group.ContinueOnError) || - bools.IsTrue(group.ContinueOnError) { - // keep going to the next profile - continue - } - exitCode = 1 - return - } - } + // it wasn't an internal command so we run a profile + err = startProfileOrGroup(ctx) + if err != nil { + clog.Error(err) + if errors.Is(err, ErrProfileNotFound) { + displayProfiles(os.Stdout, ctx.config, flags) + displayGroups(os.Stdout, ctx.config, flags) } - - } else { - clog.Errorf("profile or group not found '%s'", flags.name) - displayProfiles(os.Stdout, c, flags) - displayGroups(os.Stdout, c, flags) exitCode = 1 return } @@ -316,6 +289,52 @@ func loadConfig(flags commandLineFlags, silent bool) (cfg *config.Config, global return } +// loadContext loads the configuration and creates a context. +func loadContext(flags commandLineFlags, silent bool) (*Context, error) { + cfg, global, err := loadConfig(flags, silent) + 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 +} + func setPriority(nice int, class string) error { var err error @@ -339,6 +358,94 @@ func setPriority(nice int, class string) error { return nil } +func shouldStopOnBattery(batteryLimit int) bool { + if batteryLimit > 0 && batteryLimit <= constants.BatteryFull { + battery, charge, err := IsRunningOnBattery() + if err != nil { + clog.Errorf("cannot check if the computer is running on battery: %s", err) + } + if battery { + if batteryLimit == constants.BatteryFull { + clog.Warning("running on battery, leaving now") + return true + } + if charge < batteryLimit { + clog.Warningf("running on battery (%d%%), leaving now", charge) + return true + } + clog.Infof("running on battery with enough charge (%d%%)", charge) + } + } + return false +} + +func detectResticBinary(global *config.Global) (string, error) { + resticBinary, err := filesearch.FindResticBinary(global.ResticBinary) + if err != nil { + return "", fmt.Errorf("cannot find restic: %w", err) + } + // detect restic version + if len(global.ResticVersion) == 0 { + if global.ResticVersion, err = restic.GetVersion(resticBinary); err != nil { + clog.Warningf("assuming restic is at latest known version ; %s", err.Error()) + global.ResticVersion = restic.AnyVersion + } + } + if len(global.ResticVersion) > 0 { + clog.Debugf("using restic %s", global.ResticVersion) + } + return resticBinary, nil +} + +func startProfileOrGroup(ctx *Context) error { + if ctx.config.HasProfile(ctx.request.profile) { + // if running as a systemd timer + notifyStart() + defer notifyStop() + + // Single profile run + err := runProfile(ctx) + if err != nil { + return err + } + + } else if ctx.config.HasProfileGroup(ctx.request.profile) { + // Group run + group, err := ctx.config.GetProfileGroup(ctx.request.profile) + if err != nil { + clog.Errorf("cannot load group '%s': %v", ctx.request.profile, err) + } + if group != nil && len(group.Profiles) > 0 { + // if running as a systemd timer + notifyStart() + defer notifyStop() + + // profile name is the group name + groupName := ctx.request.profile + + for i, profileName := range group.Profiles { + clog.Debugf("[%d/%d] starting profile '%s' from group '%s'", i+1, len(group.Profiles), profileName, groupName) + ctx = ctx.WithProfile(profileName).WithGroup(groupName) + err = runProfile(ctx) + if err != nil { + if ctx.global.GroupContinueOnError && bools.IsTrueOrUndefined(group.ContinueOnError) || + bools.IsTrue(group.ContinueOnError) { + // keep going to the next profile + clog.Error(err) + continue + } + // fail otherwise + return err + } + } + } + + } else { + return fmt.Errorf("%w: %q", ErrProfileNotFound, ctx.request.profile) + } + return nil +} + func openProfile(c *config.Config, profileName string) (profile *config.Profile, cleanup func(), err error) { done := false for attempts := 3; attempts > 0 && !done; attempts-- { @@ -387,35 +494,27 @@ func openProfile(c *config.Config, profileName string) (profile *config.Profile, return } -func runProfile( - c *config.Config, - global *config.Global, - flags commandLineFlags, - profileName string, - resticBinary string, - resticArguments []string, - resticCommand string, - group string, -) error { - profile, cleanup, err := openProfile(c, profileName) +func runProfile(ctx *Context) error { + profile, cleanup, err := openProfile(ctx.config, ctx.request.profile) defer cleanup() if err != nil { return err } + ctx.profile = profile displayProfileDeprecationNotices(profile) - c.DisplayConfigurationIssues() + ctx.config.DisplayConfigurationIssues() // Send the quiet/verbose down to restic as well (override profile configuration) - if flags.quiet { + if ctx.flags.quiet { profile.Quiet = true profile.Verbose = constants.VerbosityNone } - if flags.verbose { + if ctx.flags.verbose { profile.Verbose = constants.VerbosityLevel1 profile.Quiet = false } - if flags.veryVerbose { + if ctx.flags.veryVerbose { profile.Verbose = constants.VerbosityLevel3 profile.Quiet = false } @@ -423,18 +522,18 @@ func runProfile( // change log filter according to profile settings if profile.Quiet { changeLevelFilter(clog.LevelWarning) - } else if profile.Verbose > constants.VerbosityNone && !flags.veryVerbose { + } else if profile.Verbose > constants.VerbosityNone && !ctx.flags.veryVerbose { changeLevelFilter(clog.LevelDebug) } // use the broken arguments escaping (before v0.15.0) - if global.LegacyArguments { + if ctx.global.LegacyArguments { profile.SetLegacyArg(true) } // tell the profile what version of restic is in use - if e := profile.SetResticVersion(global.ResticVersion); e != nil { - clog.Warningf("restic version %q is no valid semver: %s", global.ResticVersion, e.Error()) + if e := profile.SetResticVersion(ctx.global.ResticVersion); e != nil { + clog.Warningf("restic version %q is no valid semver: %s", ctx.global.ResticVersion, e.Error()) } // Specific case for the "host" flag where an empty value should be replaced by the hostname @@ -451,20 +550,13 @@ func runProfile( // remove signal catch before leaving defer signal.Stop(sigChan) - wrapper := newResticWrapper( - global, - resticBinary, - flags.dryRun, - profile, - resticCommand, - resticArguments, - sigChan, - ) - - if flags.noLock { + ctx.sigChan = sigChan + wrapper := newResticWrapper(ctx) + + if ctx.flags.noLock { wrapper.ignoreLock() - } else if flags.lockWait > 0 { - wrapper.maxWaitOnLock(flags.lockWait) + } else if ctx.flags.lockWait > 0 { + wrapper.maxWaitOnLock(ctx.flags.lockWait) } // add progress receivers if necessary @@ -472,7 +564,7 @@ func runProfile( wrapper.addProgress(status.NewProgress(profile, status.NewStatus(profile.StatusFile))) } if profile.PrometheusPush != "" || profile.PrometheusSaveToFile != "" { - wrapper.addProgress(prom.NewProgress(profile, prom.NewMetrics(group, version, profile.PrometheusLabels))) + wrapper.addProgress(prom.NewProgress(profile, prom.NewMetrics(ctx.request.group, version, profile.PrometheusLabels))) } err = wrapper.runProfile() @@ -488,12 +580,13 @@ func randomBool() bool { } func free() uint64 { + const oneMB = 1048576 mem, err := memory.Get() if err != nil { clog.Info("OS memory information not available") return 0 } - avail := (mem.Total - mem.Used) / 1048576 + avail := (mem.Total - mem.Used) / oneMB clog.Debugf("memory available: %vMB", avail) return avail } diff --git a/own_command_error.go b/own_command_error.go new file mode 100644 index 000000000..3e938ccf8 --- /dev/null +++ b/own_command_error.go @@ -0,0 +1,28 @@ +package main + +// ownCommandError is an error that can be returned by an own command. +// It contains an exit code that should be used to exit the program. +// There's no need to return this error if the exit code is 1. +type ownCommandError struct { + err error + exitCode int +} + +func (e *ownCommandError) Error() string { + return e.err.Error() +} + +func (e *ownCommandError) Unwrap() error { + return e.err +} + +func (e *ownCommandError) ExitCode() int { + return e.exitCode +} + +func newOwnCommandError(err error, exitCode int) *ownCommandError { + return &ownCommandError{ + err: err, + exitCode: exitCode, + } +} diff --git a/own_command_error_test.go b/own_command_error_test.go new file mode 100644 index 000000000..445c6a896 --- /dev/null +++ b/own_command_error_test.go @@ -0,0 +1,20 @@ +package main + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOwnCommandError(t *testing.T) { + var wrap error = errors.New("wrap") + var err error = newOwnCommandError(wrap, 10) + + assert.Equal(t, "wrap", err.Error()) + assert.ErrorIs(t, err, wrap) + + var unwrap *ownCommandError + assert.True(t, errors.As(err, &unwrap)) + assert.Equal(t, 10, unwrap.ExitCode()) +} diff --git a/own_commands.go b/own_commands.go index 9c302fd6d..ff23beae8 100644 --- a/own_commands.go +++ b/own_commands.go @@ -4,28 +4,29 @@ import ( "fmt" "io" "os" - - "github.com/creativeprojects/resticprofile/config" + "strings" ) -type commandRequest struct { +// commandContext is the context for running a command. +type commandContext struct { + Context ownCommands *OwnCommands - config *config.Config - flags commandLineFlags - args []string } type ownCommand struct { name string description string longDescription string - action func(io.Writer, commandRequest) error - needConfiguration bool // true if the action needs a configuration file loaded - hide bool // don't display the command in help and completion - hideInCompletion bool // don't display the command in completion - flags map[string]string // own command flags should be simple enough to be handled manually for now + pre func(*Context) error // pre-command action (for checking the context) + action func(io.Writer, commandContext) error // run command action + needConfiguration bool // true if the action needs a configuration file loaded + hide bool // don't display the command in help and completion + hideInCompletion bool // don't display the command in completion + noProfile bool // true if the command doesn't need a profile name + flags map[string]string // own command flags should be simple enough to be handled manually for now } +// OwnCommands is a list of resticprofile commands type OwnCommands struct { commands []ownCommand } @@ -55,16 +56,34 @@ func (o *OwnCommands) All() []ownCommand { return ownCommands } -func (o *OwnCommands) Run(configuration *config.Config, commandName string, flags commandLineFlags, args []string) error { - for _, command := range o.commands { - if command.name == commandName { - return command.action(os.Stdout, commandRequest{ - ownCommands: o, - config: configuration, - flags: flags, - args: args, - }) +func (o *OwnCommands) Run(ctx *Context) error { + command := o.find(ctx.request.command) + if command == nil { + return fmt.Errorf("command not found: %v", ctx.request.command) + } + return command.action(os.Stdout, commandContext{ + ownCommands: o, + Context: *ctx, + }) +} + +func (o *OwnCommands) Pre(ctx *Context) error { + command := o.find(ctx.request.command) + if command == nil { + return fmt.Errorf("command not found: %v", ctx.request.command) + } + if command.pre == nil { + return nil + } + return command.pre(ctx) +} + +func (o *OwnCommands) find(commandName string) *ownCommand { + commandName = strings.ToLower(commandName) + for _, commandDef := range o.commands { + if commandDef.name == commandName { + return &commandDef } } - return fmt.Errorf("command not found: %v", commandName) + return nil } diff --git a/own_commands_test.go b/own_commands_test.go new file mode 100644 index 000000000..093346232 --- /dev/null +++ b/own_commands_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func fakeCommands() *OwnCommands { + ownCommands := NewOwnCommands() + ownCommands.Register([]ownCommand{ + { + name: "first", + description: "first first", + pre: pre, + action: firstCommand, + needConfiguration: false, + }, + { + name: "second", + description: "second second", + action: secondCommand, + needConfiguration: true, + flags: map[string]string{ + "-f, --first": "first flag", + "-s, --seccond": "second flag", + }, + }, + { + name: "third", + description: "third third", + action: thirdCommand, + needConfiguration: false, + hide: true, + }, + }) + return ownCommands +} + +func firstCommand(_ io.Writer, _ commandContext) error { + return errors.New("first") +} + +func secondCommand(_ io.Writer, _ commandContext) error { + return errors.New("second") +} + +func thirdCommand(_ io.Writer, _ commandContext) error { + return errors.New("third") +} + +func pre(_ *Context) error { + return errors.New("pre") +} + +func TestDisplayOwnCommands(t *testing.T) { + buffer := &strings.Builder{} + displayOwnCommands(buffer, commandContext{ownCommands: fakeCommands()}) + assert.Equal(t, " first first first\n second second second\n", buffer.String()) +} + +func TestDisplayOwnCommand(t *testing.T) { + buffer := &strings.Builder{} + displayOwnCommandHelp(buffer, "second", commandContext{ownCommands: fakeCommands()}) + assert.Equal(t, `Purpose: second second + +Usage: + resticprofile [resticprofile flags] [profile name.]second [command specific flags] + +Flags: + -f, --first first flag + -s, --seccond second flag + +`, buffer.String()) +} + +func TestIsOwnCommand(t *testing.T) { + assert.True(t, fakeCommands().Exists("first", false)) + assert.True(t, fakeCommands().Exists("second", true)) + assert.True(t, fakeCommands().Exists("third", false)) + assert.False(t, fakeCommands().Exists("another one", true)) +} + +func TestRunOwnCommand(t *testing.T) { + assert.EqualError(t, fakeCommands().Run(&Context{request: Request{command: "first"}}), "first") + assert.EqualError(t, fakeCommands().Run(&Context{request: Request{command: "second"}}), "second") + assert.EqualError(t, fakeCommands().Run(&Context{request: Request{command: "third"}}), "third") + assert.EqualError(t, fakeCommands().Run(&Context{request: Request{command: "another one"}}), "command not found: another one") +} + +func TestPreOwnCommand(t *testing.T) { + assert.EqualError(t, fakeCommands().Pre(&Context{request: Request{command: "first"}}), "pre") + assert.NoError(t, fakeCommands().Pre(&Context{request: Request{command: "second"}})) + assert.NoError(t, fakeCommands().Pre(&Context{request: Request{command: "third"}})) + assert.EqualError(t, fakeCommands().Pre(&Context{request: Request{command: "another one"}}), "command not found: another one") +} diff --git a/syslog.go b/syslog.go index 95fbbc7db..2115d89c8 100644 --- a/syslog.go +++ b/syslog.go @@ -48,7 +48,7 @@ func (l *Syslog) Close() error { var _ LogCloser = &Syslog{} -func getSyslogHandler(flags commandLineFlags, scheme, hostPort string) (*Syslog, error) { +func getSyslogHandler(scheme, hostPort string) (*Syslog, error) { writer, err := syslog.Dial(scheme, hostPort, syslog.LOG_USER|syslog.LOG_NOTICE, constants.ApplicationName) if err != nil { return nil, fmt.Errorf("cannot open syslog logger: %w", err) diff --git a/syslog_windows.go b/syslog_windows.go index e1457cf82..8efd921bb 100644 --- a/syslog_windows.go +++ b/syslog_windows.go @@ -6,6 +6,6 @@ import ( "errors" ) -func getSyslogHandler(flags commandLineFlags, scheme, hostPort string) (LogCloser, error) { +func getSyslogHandler(scheme, hostPort string) (LogCloser, error) { return nil, errors.New("syslog is not supported on Windows") } diff --git a/update.go b/update.go index 3b4e2feff..96efb9b7a 100644 --- a/update.go +++ b/update.go @@ -32,12 +32,12 @@ func init() { config.ExcludeProfileSection(def.name) } -func selfUpdate(_ io.Writer, request commandRequest) error { - quiet := request.flags.quiet - if !quiet && len(request.args) > 0 && (request.args[0] == "-q" || request.args[0] == "--quiet") { +func selfUpdate(_ io.Writer, ctx commandContext) error { + quiet := ctx.flags.quiet + if !quiet && len(ctx.request.arguments) > 0 && (ctx.request.arguments[0] == "-q" || ctx.request.arguments[0] == "--quiet") { quiet = true } - err := confirmAndSelfUpdate(quiet, request.flags.verbose, version, true) + err := confirmAndSelfUpdate(quiet, ctx.flags.verbose, version, true) if err != nil { return err } diff --git a/wrapper.go b/wrapper.go index 740e86c0f..7e3555fcd 100644 --- a/wrapper.go +++ b/wrapper.go @@ -27,19 +27,19 @@ import ( ) type resticWrapper struct { - resticBinary string - dryRun bool - noLock bool - lockWait *time.Duration - profile *config.Profile - global *config.Global - command string - moreArgs []string - sigChan chan os.Signal - setPID func(pid int) - stdin io.ReadCloser - progress []monitor.Receiver - sender *hook.Sender + ctx *Context + dryRun bool // resticprofile dry-run (not restic dry-run via flags added on the command line) + noLock bool + lockWait *time.Duration + profile *config.Profile + global *config.Global + command string + moreArgs []string + sigChan chan os.Signal + setPID func(pid int) + stdin io.ReadCloser + progress []monitor.Receiver + sender *hook.Sender // States startTime time.Time @@ -47,34 +47,31 @@ type resticWrapper struct { doneTryUnlock bool } -func newResticWrapper( - global *config.Global, - resticBinary string, - dryRun bool, - profile *config.Profile, - command string, - moreArgs []string, - c chan os.Signal, -) *resticWrapper { - if global == nil { - global = config.NewGlobal() +func newResticWrapper(ctx *Context) *resticWrapper { + if ctx.global == nil { + ctx.global = config.NewGlobal() } - senderDryRun := dryRun || slices.ContainsFunc(moreArgs, collect.In("--dry-run", "-n")) + resticDryRun := slices.ContainsFunc(ctx.request.arguments, collect.In("--dry-run", "-n")) return &resticWrapper{ - resticBinary: resticBinary, - dryRun: dryRun, - noLock: false, - lockWait: nil, - profile: profile, - global: global, - command: command, - moreArgs: moreArgs, - sigChan: c, - stdin: os.Stdin, - progress: make([]monitor.Receiver, 0), - sender: hook.NewSender(global.CACertificates, "resticprofile/"+version, global.SenderTimeout, senderDryRun), + ctx: ctx, + dryRun: ctx.flags.dryRun, + noLock: false, + lockWait: nil, + profile: ctx.profile, + global: ctx.global, + command: ctx.command, + moreArgs: ctx.request.arguments, + sigChan: ctx.sigChan, + stdin: os.Stdin, + progress: make([]monitor.Receiver, 0), + sender: hook.NewSender( + ctx.global.CACertificates, + "resticprofile/"+version, + ctx.global.SenderTimeout, + ctx.flags.dryRun || resticDryRun, + ), startTime: time.Unix(0, 0), executionTime: 0, doneTryUnlock: false, @@ -333,7 +330,7 @@ func (r *resticWrapper) getShell() (shell []string) { func (r *resticWrapper) getCommandArgumentsFilter(command string) argumentsFilter { binaryIsRestic := strings.EqualFold( "restic", - strings.TrimSuffix(filepath.Base(r.resticBinary), filepath.Ext(r.resticBinary)), + strings.TrimSuffix(filepath.Base(r.ctx.binary), filepath.Ext(r.ctx.binary)), ) if binaryIsRestic && (r.global == nil || r.global.FilterResticFlags) { if validArgs := r.validResticArgumentsList(command); len(validArgs) > 0 { @@ -399,8 +396,8 @@ func (r *resticWrapper) prepareCommand(command string, args *shell.Args, allowEx env := append(os.Environ(), r.getEnvironment()...) env = append(env, r.getProfileEnvironment()...) - clog.Debugf("starting command: %s %s", r.resticBinary, strings.Join(publicArguments, " ")) - rCommand := newShellCommand(r.resticBinary, arguments, env, r.getShell(), r.dryRun, r.sigChan, r.setPID) + clog.Debugf("starting command: %s %s", r.ctx.binary, strings.Join(publicArguments, " ")) + rCommand := newShellCommand(r.ctx.binary, arguments, env, r.getShell(), r.dryRun, r.sigChan, r.setPID) rCommand.publicArgs = publicArguments // stdout are stderr are coming from the default terminal (in case they're redirected) rCommand.stdout = term.GetOutput() diff --git a/wrapper_test.go b/wrapper_test.go index 0271614c2..abe2f0bac 100644 --- a/wrapper_test.go +++ b/wrapper_test.go @@ -189,7 +189,12 @@ func TestFilteredArgumentsRegression(t *testing.T) { require.NoError(t, err) profile, err := cfg.GetProfile("default") require.NoError(t, err) - wrapper := newResticWrapper(nil, "restic", true, profile, "test", nil, nil) + wrapper := newResticWrapper(&Context{ + flags: commandLineFlags{dryRun: true}, + binary: "restic", + profile: profile, + command: "test", + }) for command, commandline := range test.expected { args := profile.GetCommandFlags(command) @@ -203,7 +208,12 @@ func TestFilteredArgumentsRegression(t *testing.T) { func TestGetEmptyEnvironment(t *testing.T) { profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper(nil, "restic", false, profile, "test", nil, nil) + ctx := &Context{ + binary: "restic", + profile: profile, + command: "test", + } + wrapper := newResticWrapper(ctx) env := wrapper.getEnvironment() assert.Empty(t, env) } @@ -214,7 +224,12 @@ func TestGetSingleEnvironment(t *testing.T) { "User": config.NewConfidentialValue("me"), } profile.ResolveConfiguration() - wrapper := newResticWrapper(nil, "restic", false, profile, "test", nil, nil) + ctx := &Context{ + binary: "restic", + profile: profile, + command: "test", + } + wrapper := newResticWrapper(ctx) env := wrapper.getEnvironment() assert.Equal(t, []string{"USER=me"}, env) } @@ -226,7 +241,12 @@ func TestGetMultipleEnvironment(t *testing.T) { "Password": config.NewConfidentialValue("secret"), } profile.ResolveConfiguration() - wrapper := newResticWrapper(nil, "restic", false, profile, "test", nil, nil) + ctx := &Context{ + binary: "restic", + profile: profile, + command: "test", + } + wrapper := newResticWrapper(ctx) env := wrapper.getEnvironment() assert.Len(t, env, 2) assert.Contains(t, env, "USER=me") @@ -236,7 +256,12 @@ func TestGetMultipleEnvironment(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(nil, "echo", false, profile, "test", nil, nil) + ctx := &Context{ + binary: "echo", + profile: profile, + command: "test", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.EqualError(t, err, "run-before on profile 'name': exit status 1") } @@ -244,14 +269,24 @@ 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(nil, "echo", false, profile, "test", nil, nil) + ctx := &Context{ + binary: "echo", + profile: profile, + command: "test", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.EqualError(t, err, "run-after on profile 'name': exit status 1") } func TestRunEchoProfile(t *testing.T) { profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper(nil, "echo", false, profile, "test", nil, nil) + ctx := &Context{ + binary: "echo", + profile: profile, + command: "test", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.NoError(t, err) } @@ -261,7 +296,12 @@ func TestPostProfileAfterFail(t *testing.T) { _ = os.Remove(testFile) profile := config.NewProfile(nil, "name") profile.RunAfter = []string{"echo failed > " + testFile} - wrapper := newResticWrapper(nil, "exit", false, profile, "1", nil, nil) + ctx := &Context{ + binary: "exit", + profile: profile, + command: "1", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.EqualError(t, err, "1 on profile 'name': exit status 1") assert.NoFileExistsf(t, testFile, "the run-after script should not have been running") @@ -273,7 +313,12 @@ func TestPostFailProfile(t *testing.T) { _ = os.Remove(testFile) profile := config.NewProfile(nil, "name") profile.RunAfterFail = []string{"echo failed > " + testFile} - wrapper := newResticWrapper(nil, "exit", false, profile, "1", nil, nil) + ctx := &Context{ + binary: "exit", + profile: profile, + command: "1", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.EqualError(t, err, "1 on profile 'name': exit status 1") assert.FileExistsf(t, testFile, "the run-after-fail script has not been running") @@ -301,7 +346,12 @@ func TestFinallyProfile(t *testing.T) { t.Run("backup-before-profile", func(t *testing.T) { newProfile() - wrapper := newResticWrapper(nil, "echo", false, profile, "backup", nil, nil) + ctx := &Context{ + binary: "echo", + profile: profile, + command: "backup", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.NoError(t, err) assertFileEquals(t, "finally") @@ -310,7 +360,12 @@ func TestFinallyProfile(t *testing.T) { t.Run("on-backup-only", func(t *testing.T) { newProfile() profile.RunFinally = nil - wrapper := newResticWrapper(nil, "echo", false, profile, "backup", nil, nil) + ctx := &Context{ + binary: "echo", + profile: profile, + command: "backup", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.NoError(t, err) assertFileEquals(t, "finally-backup") @@ -318,7 +373,12 @@ func TestFinallyProfile(t *testing.T) { t.Run("on-error", func(t *testing.T) { newProfile() - wrapper := newResticWrapper(nil, "exit", false, profile, "1", nil, nil) + ctx := &Context{ + binary: "exit", + profile: profile, + command: "1", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.EqualError(t, err, "1 on profile 'name': exit status 1") assertFileEquals(t, "finally") @@ -328,7 +388,12 @@ func TestFinallyProfile(t *testing.T) { func Example_runProfile() { term.SetOutput(os.Stdout) profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper(nil, "echo", false, profile, "test", nil, nil) + ctx := &Context{ + binary: "echo", + profile: profile, + command: "test", + } + wrapper := newResticWrapper(ctx) wrapper.runProfile() // Output: test } @@ -337,7 +402,12 @@ func TestRunRedirectOutputOfEchoProfile(t *testing.T) { buffer := &bytes.Buffer{} term.SetOutput(buffer) profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper(nil, "echo", false, profile, "test", nil, nil) + ctx := &Context{ + binary: "echo", + profile: profile, + command: "test", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.NoError(t, err) assert.Equal(t, "test", strings.TrimSpace(buffer.String())) @@ -347,7 +417,12 @@ func TestDryRun(t *testing.T) { buffer := &bytes.Buffer{} term.SetOutput(buffer) profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper(nil, "echo", true, profile, "test", nil, nil) + wrapper := newResticWrapper(&Context{ + flags: commandLineFlags{dryRun: true}, + binary: "echo", + profile: profile, + command: "test", + }) err := wrapper.runProfile() assert.NoError(t, err) assert.Equal(t, "", buffer.String()) @@ -359,7 +434,12 @@ func TestEnvProfileName(t *testing.T) { profile := config.NewProfile(nil, "TestEnvProfileName") profile.RunBefore = []string{"echo profile name = $PROFILE_NAME"} - wrapper := newResticWrapper(nil, "echo", false, profile, "test", nil, nil) + ctx := &Context{ + binary: "echo", + profile: profile, + command: "test", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.NoError(t, err) assert.Equal(t, "profile name = TestEnvProfileName\ntest\n", strings.ReplaceAll(buffer.String(), "\r\n", "\n")) @@ -371,7 +451,12 @@ func TestEnvProfileCommand(t *testing.T) { profile := config.NewProfile(nil, "name") profile.RunBefore = []string{"echo profile command = $PROFILE_COMMAND"} - wrapper := newResticWrapper(nil, "echo", false, profile, "test-command", nil, nil) + ctx := &Context{ + binary: "echo", + profile: profile, + command: "test-command", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.NoError(t, err) assert.Equal(t, "profile command = test-command\ntest-command\n", strings.ReplaceAll(buffer.String(), "\r\n", "\n")) @@ -383,7 +468,12 @@ func TestEnvError(t *testing.T) { profile := config.NewProfile(nil, "name") profile.RunAfterFail = []string{"echo error: $ERROR_MESSAGE"} - wrapper := newResticWrapper(nil, "exit", false, profile, "1", nil, nil) + ctx := &Context{ + binary: "exit", + profile: profile, + command: "1", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.Error(t, err) assert.Equal(t, "error: 1 on profile 'name': exit status 1\n", strings.ReplaceAll(buffer.String(), "\r\n", "\n")) @@ -395,7 +485,12 @@ func TestEnvErrorCommandLine(t *testing.T) { profile := config.NewProfile(nil, "name") profile.RunAfterFail = []string{"echo cmd: $ERROR_COMMANDLINE"} - wrapper := newResticWrapper(nil, "exit", false, profile, "1", nil, nil) + ctx := &Context{ + binary: "exit", + profile: profile, + command: "1", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.Error(t, err) assert.Equal(t, "cmd: \"exit\" \"1\"\n", strings.ReplaceAll(buffer.String(), "\r\n", "\n")) @@ -407,7 +502,12 @@ func TestEnvErrorExitCode(t *testing.T) { profile := config.NewProfile(nil, "name") profile.RunAfterFail = []string{"echo exit-code: $ERROR_EXIT_CODE"} - wrapper := newResticWrapper(nil, "exit", false, profile, "5", nil, nil) + ctx := &Context{ + binary: "exit", + profile: profile, + command: "5", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.Error(t, err) assert.Equal(t, "exit-code: 5\n", strings.ReplaceAll(buffer.String(), "\r\n", "\n")) @@ -419,7 +519,13 @@ func TestEnvStderr(t *testing.T) { profile := config.NewProfile(nil, "name") profile.RunAfterFail = []string{"echo stderr: $ERROR_STDERR"} - wrapper := newResticWrapper(nil, mockBinary, false, profile, "command", []string{"--stderr", "error_message", "--exit", "1"}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "command", + request: Request{arguments: []string{"--stderr", "error_message", "--exit", "1"}}, + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.Error(t, err) assert.Equal(t, "stderr: error_message", strings.TrimSpace(strings.ReplaceAll(buffer.String(), "\r\n", "\n"))) @@ -429,21 +535,37 @@ func TestRunProfileWithSetPIDCallback(t *testing.T) { profile := config.NewProfile(nil, "name") profile.Lock = filepath.Join(os.TempDir(), fmt.Sprintf("%s%d%d.tmp", "TestRunProfileWithSetPIDCallback", time.Now().UnixNano(), os.Getpid())) t.Logf("lockfile = %s", profile.Lock) - wrapper := newResticWrapper(nil, "echo", false, profile, "test", nil, nil) + ctx := &Context{ + binary: "echo", + profile: profile, + command: "test", + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.NoError(t, err) } func TestInitializeNoError(t *testing.T) { profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", nil, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + } + wrapper := newResticWrapper(ctx) err := wrapper.runInitialize() require.NoError(t, err) } func TestInitializeWithError(t *testing.T) { profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", []string{"--exit", "10"}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + request: Request{arguments: []string{"--exit", "10"}}, + } + wrapper := newResticWrapper(ctx) err := wrapper.runInitialize() require.Error(t, err) } @@ -451,7 +573,12 @@ func TestInitializeWithError(t *testing.T) { func TestInitializeCopyNoError(t *testing.T) { profile := config.NewProfile(nil, "name") profile.Copy = &config.CopySection{InitializeCopyChunkerParams: bools.False()} - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", nil, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + } + wrapper := newResticWrapper(ctx) err := wrapper.runInitializeCopy() require.NoError(t, err) } @@ -459,35 +586,63 @@ func TestInitializeCopyNoError(t *testing.T) { func TestInitializeCopyWithError(t *testing.T) { profile := config.NewProfile(nil, "name") profile.Copy = &config.CopySection{InitializeCopyChunkerParams: bools.False()} - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", []string{"--exit", "10"}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + request: Request{arguments: []string{"--exit", "10"}}, + } + wrapper := newResticWrapper(ctx) err := wrapper.runInitializeCopy() require.Error(t, err) } func TestCheckNoError(t *testing.T) { profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", nil, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + } + wrapper := newResticWrapper(ctx) err := wrapper.runCheck() require.NoError(t, err) } func TestCheckWithError(t *testing.T) { profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", []string{"--exit", "10"}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + request: Request{arguments: []string{"--exit", "10"}}, + } + wrapper := newResticWrapper(ctx) err := wrapper.runCheck() require.Error(t, err) } func TestRetentionNoError(t *testing.T) { profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", nil, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + } + wrapper := newResticWrapper(ctx) err := wrapper.runRetention() require.NoError(t, err) } func TestRetentionWithError(t *testing.T) { profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", []string{"--exit", "10"}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + request: Request{arguments: []string{"--exit", "10"}}, + } + wrapper := newResticWrapper(ctx) err := wrapper.runRetention() require.Error(t, err) } @@ -511,7 +666,13 @@ func TestBackupWithStreamSource(t *testing.T) { profile = config.NewProfile(nil, "name") profile.Backup = &config.BackupSection{} signals := make(chan os.Signal, 1) - wrapper = newResticWrapper(nil, mockBinary, false, profile, "stdin-test", nil, signals) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "stdin-test", + sigChan: signals, + } + wrapper = newResticWrapper(ctx) return } @@ -641,7 +802,12 @@ func TestBackupWithStreamSource(t *testing.T) { func TestBackupWithSuccess(t *testing.T) { profile := config.NewProfile(nil, "name") profile.Backup = &config.BackupSection{} - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", nil, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + } + wrapper := newResticWrapper(ctx) err := wrapper.runCommand("backup") require.NoError(t, err) } @@ -649,7 +815,13 @@ func TestBackupWithSuccess(t *testing.T) { func TestBackupWithError(t *testing.T) { profile := config.NewProfile(nil, "name") profile.Backup = &config.BackupSection{} - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", []string{"--exit", "1"}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + request: Request{arguments: []string{"--exit", "1"}}, + } + wrapper := newResticWrapper(ctx) err := wrapper.runCommand("backup") require.Error(t, err) } @@ -671,7 +843,15 @@ func TestBackupWithResticLockFailureRetried(t *testing.T) { } profile := config.NewProfile(nil, "name") profile.Backup = &config.BackupSection{} - wrapper := newResticWrapper(global, mockBinary, false, profile, "", []string{"--stderr", "@" + tempfile, "--exit", "1"}, sigChan) + ctx := &Context{ + global: global, + binary: mockBinary, + profile: profile, + command: "", + request: Request{arguments: []string{"--stderr", "@" + tempfile, "--exit", "1"}}, + sigChan: sigChan, + } + wrapper := newResticWrapper(ctx) wrapper.lockWait = &lockWait wrapper.startTime = time.Now() @@ -697,7 +877,15 @@ func TestBackupWithResticLockFailureCancelled(t *testing.T) { } profile := config.NewProfile(nil, "name") profile.Backup = &config.BackupSection{} - wrapper := newResticWrapper(global, mockBinary, false, profile, "", []string{"--stderr", "@" + tempfile, "--exit", "1"}, sigChan) + ctx := &Context{ + global: global, + binary: mockBinary, + profile: profile, + command: "", + request: Request{arguments: []string{"--stderr", "@" + tempfile, "--exit", "1"}}, + sigChan: sigChan, + } + wrapper := newResticWrapper(ctx) wrapper.lockWait = &lockWait wrapper.startTime = time.Now() @@ -712,7 +900,13 @@ func TestBackupWithResticLockFailureCancelled(t *testing.T) { func TestBackupWithNoConfiguration(t *testing.T) { profile := config.NewProfile(nil, "name") - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", []string{"--exit", "1"}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + request: Request{arguments: []string{"--exit", "1"}}, + } + wrapper := newResticWrapper(ctx) err := wrapper.runCommand("backup") require.Error(t, err) } @@ -720,7 +914,13 @@ func TestBackupWithNoConfiguration(t *testing.T) { func TestBackupWithNoConfigurationButStatusFile(t *testing.T) { profile := config.NewProfile(nil, "name") profile.StatusFile = "status.json" - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", []string{"--exit", "1"}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + request: Request{arguments: []string{"--exit", "1"}}, + } + wrapper := newResticWrapper(ctx) wrapper.addProgress(status.NewProgress(profile, status.NewStatus("status.json"))) err := wrapper.runCommand("backup") require.Error(t, err) @@ -729,7 +929,13 @@ func TestBackupWithNoConfigurationButStatusFile(t *testing.T) { func TestBackupWithWarningAsError(t *testing.T) { profile := config.NewProfile(nil, "name") profile.Backup = &config.BackupSection{} - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", []string{"--exit", "3"}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + request: Request{arguments: []string{"--exit", "3"}}, + } + wrapper := newResticWrapper(ctx) err := wrapper.runCommand("backup") require.Error(t, err) } @@ -737,7 +943,13 @@ func TestBackupWithWarningAsError(t *testing.T) { func TestBackupWithSupressedWarnings(t *testing.T) { profile := config.NewProfile(&config.Config{}, "name") profile.Backup = &config.BackupSection{NoErrorOnWarning: true} - wrapper := newResticWrapper(nil, mockBinary, false, profile, "", []string{"--exit", "3"}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "", + request: Request{arguments: []string{"--exit", "3"}}, + } + wrapper := newResticWrapper(ctx) err := wrapper.runCommand("backup") require.NoError(t, err) } @@ -763,26 +975,36 @@ func TestRunShellCommands(t *testing.T) { for command, section := range sections { t.Run(fmt.Sprintf("run-before '%s'", command), func(t *testing.T) { section.RunBefore = []string{"exit 2"} - wrapper := newResticWrapper(nil, mockBinary, false, profile, command, nil, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: command, + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() require.Error(t, err) assert.Contains(t, err.Error(), "exit status 2") section.RunBefore = []string{""} - wrapper = newResticWrapper(nil, mockBinary, false, profile, command, nil, nil) + wrapper = newResticWrapper(ctx) err = wrapper.runProfile() require.NoError(t, err) }) t.Run(fmt.Sprintf("run-after '%s'", command), func(t *testing.T) { section.RunAfter = []string{"exit 2"} - wrapper := newResticWrapper(nil, mockBinary, false, profile, command, nil, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: command, + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() require.Error(t, err) assert.Contains(t, err.Error(), "exit status 2") section.RunAfter = []string{""} - wrapper = newResticWrapper(nil, mockBinary, false, profile, command, nil, nil) + wrapper = newResticWrapper(ctx) err = wrapper.runProfile() require.NoError(t, err) }) @@ -798,7 +1020,13 @@ func TestRunStreamErrorHandler(t *testing.T) { profile := config.NewProfile(&config.Config{}, "name") profile.Backup = &config.BackupSection{} profile.StreamError = []config.StreamErrorSection{{Pattern: ".+error-line.+", Run: errorCommand}} - wrapper := newResticWrapper(nil, mockBinary, false, profile, "backup", []string{"--stderr", "--error-line--"}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "backup", + request: Request{arguments: []string{"--stderr", "--error-line--"}}, + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() require.NoError(t, err) @@ -809,7 +1037,13 @@ func TestRunStreamErrorHandlerDoesNotBreakCommand(t *testing.T) { profile := config.NewProfile(&config.Config{}, "name") profile.Backup = &config.BackupSection{} profile.StreamError = []config.StreamErrorSection{{Pattern: ".+error-line.+", Run: "exit 1"}} - wrapper := newResticWrapper(nil, mockBinary, false, profile, "backup", []string{"--stderr", "--error-line--"}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "backup", + request: Request{arguments: []string{"--stderr", "--error-line--"}}, + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() require.NoError(t, err) @@ -819,7 +1053,13 @@ func TestStreamErrorHandlerWithInvalidRegex(t *testing.T) { profile := config.NewProfile(&config.Config{}, "name") profile.Backup = &config.BackupSection{} profile.StreamError = []config.StreamErrorSection{{Pattern: "(", Run: "echo pass"}} - wrapper := newResticWrapper(nil, mockBinary, false, profile, "backup", []string{}, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "backup", + request: Request{arguments: []string{}}, + } + wrapper := newResticWrapper(ctx) err := wrapper.runProfile() assert.EqualError(t, err, "backup on profile 'name': stream error callback: echo pass failed to register (: error parsing regexp: missing closing ): `(`") @@ -827,7 +1067,12 @@ func TestStreamErrorHandlerWithInvalidRegex(t *testing.T) { func TestCanRetryAfterErrorDontFailWhenNoOutputAnalysis(t *testing.T) { profile := config.NewProfile(&config.Config{}, "name") - wrapper := newResticWrapper(nil, mockBinary, false, profile, "backup", nil, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "backup", + } + wrapper := newResticWrapper(ctx) summary := monitor.Summary{} retry, err := wrapper.canRetryAfterError("backup", summary) assert.False(t, retry) @@ -843,7 +1088,12 @@ func TestCanRetryAfterRemoteStaleLockFailure(t *testing.T) { profile := config.NewProfile(&config.Config{}, "name") profile.Repository = config.NewConfidentialValue("my-repo") profile.ForceLock = true - wrapper := newResticWrapper(nil, mockBinary, false, profile, "backup", nil, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "backup", + } + wrapper := newResticWrapper(ctx) wrapper.startTime = time.Now() wrapper.global.ResticStaleLockAge = 0 // disable stale lock handling @@ -895,7 +1145,12 @@ func TestCanRetryAfterRemoteLockFailure(t *testing.T) { profile := config.NewProfile(&config.Config{}, "name") profile.Repository = config.NewConfidentialValue("my-repo") - wrapper := newResticWrapper(nil, mockBinary, false, profile, "backup", nil, nil) + ctx := &Context{ + binary: mockBinary, + profile: profile, + command: "backup", + } + wrapper := newResticWrapper(ctx) wrapper.startTime = time.Now() wrapper.global.ResticLockRetryAfter = 0 // disable remote lock retry @@ -941,7 +1196,12 @@ func TestCanUseResticLockRetry(t *testing.T) { argMatcher := regexp.MustCompile(".*retry-lock.*").MatchString getWrapper := func() *resticWrapper { - wrapper := newResticWrapper(nil, "restic", true, profile, constants.CommandBackup, nil, nil) + wrapper := newResticWrapper(&Context{ + flags: commandLineFlags{dryRun: true}, + binary: "restic", + profile: profile, + command: constants.CommandBackup, + }) wrapper.startTime = time.Now() wrapper.global.ResticLockRetryAfter = 1 * time.Minute wrapper.global.ResticVersion = "0.16" @@ -1025,9 +1285,25 @@ func TestLocksAndLockWait(t *testing.T) { term.SetOutput(os.Stdout) - w1 := newResticWrapper(nil, mockBinary, false, profile, "backup", []string{"--sleep", "1500"}, nil) - w2 := newResticWrapper(nil, mockBinary, false, profile, "backup", nil, nil) - w3 := newResticWrapper(nil, mockBinary, false, profile, "backup", nil, nil) + ctx1 := &Context{ + binary: mockBinary, + profile: profile, + command: constants.CommandBackup, + request: Request{arguments: []string{"--sleep", "1500"}}, + } + ctx2 := &Context{ + binary: mockBinary, + profile: profile, + command: constants.CommandBackup, + } + ctx3 := &Context{ + binary: mockBinary, + profile: profile, + command: constants.CommandBackup, + } + w1 := newResticWrapper(ctx1) + w2 := newResticWrapper(ctx2) + w3 := newResticWrapper(ctx3) assertIsLockError := func(err error) bool { return err != nil && strings.HasPrefix(err.Error(), "another process is already running this profile") @@ -1078,55 +1354,80 @@ func TestLocksAndLockWait(t *testing.T) { func TestGetContext(t *testing.T) { profile := config.NewProfile(&config.Config{}, "TestProfile") - wrapper := newResticWrapper(nil, "", false, profile, "TestCommand", nil, nil) + ctx := &Context{ + binary: "", + profile: profile, + command: "TestCommand", + } + wrapper := newResticWrapper(ctx) require.NotNil(t, wrapper) - ctx := wrapper.getContext() - assert.Equal(t, "TestProfile", ctx.ProfileName) - assert.Equal(t, "TestCommand", ctx.ProfileCommand) - assert.Equal(t, "", ctx.Error.Message) - assert.Equal(t, "", ctx.Error.ExitCode) - assert.Equal(t, "", ctx.Error.CommandLine) - assert.Equal(t, "", ctx.Error.Stderr) + hookCtx := wrapper.getContext() + assert.Equal(t, "TestProfile", hookCtx.ProfileName) + assert.Equal(t, "TestCommand", hookCtx.ProfileCommand) + assert.Equal(t, "", hookCtx.Error.Message) + assert.Equal(t, "", hookCtx.Error.ExitCode) + assert.Equal(t, "", hookCtx.Error.CommandLine) + assert.Equal(t, "", hookCtx.Error.Stderr) } func TestGetContextWithError(t *testing.T) { profile := config.NewProfile(&config.Config{}, "TestProfile") - wrapper := newResticWrapper(nil, "", false, profile, "TestCommand", nil, nil) + ctx := &Context{ + binary: "", + profile: profile, + command: "TestCommand", + } + wrapper := newResticWrapper(ctx) require.NotNil(t, wrapper) - ctx := wrapper.getContextWithError(nil) - assert.Equal(t, "TestProfile", ctx.ProfileName) - assert.Equal(t, "TestCommand", ctx.ProfileCommand) - assert.Equal(t, "", ctx.Error.Message) - assert.Equal(t, "", ctx.Error.ExitCode) - assert.Equal(t, "", ctx.Error.CommandLine) - assert.Equal(t, "", ctx.Error.Stderr) + hookCtx := wrapper.getContextWithError(nil) + assert.Equal(t, "TestProfile", hookCtx.ProfileName) + assert.Equal(t, "TestCommand", hookCtx.ProfileCommand) + assert.Equal(t, "", hookCtx.Error.Message) + assert.Equal(t, "", hookCtx.Error.ExitCode) + assert.Equal(t, "", hookCtx.Error.CommandLine) + assert.Equal(t, "", hookCtx.Error.Stderr) } func TestGetErrorContext(t *testing.T) { profile := config.NewProfile(&config.Config{}, "") - wrapper := newResticWrapper(nil, "", false, profile, "", nil, nil) + ctx := &Context{ + binary: "", + profile: profile, + command: "", + } + wrapper := newResticWrapper(ctx) require.NotNil(t, wrapper) - ctx := wrapper.getErrorContext(nil) - assert.Equal(t, "", ctx.Message) - assert.Equal(t, "", ctx.ExitCode) - assert.Equal(t, "", ctx.CommandLine) - assert.Equal(t, "", ctx.Stderr) + hookCtx := wrapper.getErrorContext(nil) + assert.Equal(t, "", hookCtx.Message) + assert.Equal(t, "", hookCtx.ExitCode) + assert.Equal(t, "", hookCtx.CommandLine) + assert.Equal(t, "", hookCtx.Stderr) } func TestGetErrorContextWithStandardError(t *testing.T) { profile := config.NewProfile(&config.Config{}, "") - wrapper := newResticWrapper(nil, "", false, profile, "", nil, nil) + ctx := &Context{ + binary: "", + profile: profile, + command: "", + } + wrapper := newResticWrapper(ctx) require.NotNil(t, wrapper) - ctx := wrapper.getErrorContext(errors.New("test error message 1")) - assert.Equal(t, "test error message 1", ctx.Message) - assert.Equal(t, "", ctx.ExitCode) - assert.Equal(t, "", ctx.CommandLine) - assert.Equal(t, "", ctx.Stderr) + hookCtx := wrapper.getErrorContext(errors.New("test error message 1")) + assert.Equal(t, "test error message 1", hookCtx.Message) + assert.Equal(t, "", hookCtx.ExitCode) + assert.Equal(t, "", hookCtx.CommandLine) + assert.Equal(t, "", hookCtx.Stderr) } func TestGetErrorContextWithCommandError(t *testing.T) { profile := config.NewProfile(&config.Config{}, "") - wrapper := newResticWrapper(nil, "", false, profile, "", nil, nil) + ctx := &Context{ + binary: "", + profile: profile, + command: "", + } + wrapper := newResticWrapper(ctx) require.NotNil(t, wrapper) def := shellCommandDefinition{ @@ -1134,16 +1435,21 @@ func TestGetErrorContextWithCommandError(t *testing.T) { args: []string{"arg1"}, publicArgs: []string{"publicArg1"}, } - ctx := wrapper.getErrorContext(newCommandError(def, "stderr", errors.New("test error message 2"))) - assert.Equal(t, "test error message 2", ctx.Message) - assert.Equal(t, "-1", ctx.ExitCode) - assert.Equal(t, "\"command\" \"publicArg1\"", ctx.CommandLine) - assert.Equal(t, "stderr", ctx.Stderr) + hookCtx := wrapper.getErrorContext(newCommandError(def, "stderr", errors.New("test error message 2"))) + assert.Equal(t, "test error message 2", hookCtx.Message) + assert.Equal(t, "-1", hookCtx.ExitCode) + assert.Equal(t, "\"command\" \"publicArg1\"", hookCtx.CommandLine) + assert.Equal(t, "stderr", hookCtx.Stderr) } func TestGetProfileEnvironment(t *testing.T) { profile := config.NewProfile(&config.Config{}, "TestProfile") - wrapper := newResticWrapper(nil, "", false, profile, "TestCommand", nil, nil) + ctx := &Context{ + binary: "", + profile: profile, + command: "TestCommand", + } + wrapper := newResticWrapper(ctx) require.NotNil(t, wrapper) env := wrapper.getProfileEnvironment() @@ -1152,7 +1458,12 @@ func TestGetProfileEnvironment(t *testing.T) { func TestGetFailEnvironmentNoError(t *testing.T) { profile := config.NewProfile(&config.Config{}, "") - wrapper := newResticWrapper(nil, "", false, profile, "", nil, nil) + ctx := &Context{ + binary: "", + profile: profile, + command: "", + } + wrapper := newResticWrapper(ctx) require.NotNil(t, wrapper) env := wrapper.getFailEnvironment(nil) @@ -1161,7 +1472,12 @@ func TestGetFailEnvironmentNoError(t *testing.T) { func TestGetFailEnvironmentWithStandardError(t *testing.T) { profile := config.NewProfile(&config.Config{}, "") - wrapper := newResticWrapper(nil, "", false, profile, "", nil, nil) + ctx := &Context{ + binary: "", + profile: profile, + command: "", + } + wrapper := newResticWrapper(ctx) require.NotNil(t, wrapper) env := wrapper.getFailEnvironment(errors.New("test error message 3")) @@ -1170,7 +1486,12 @@ func TestGetFailEnvironmentWithStandardError(t *testing.T) { func TestGetFailEnvironmentWithCommandError(t *testing.T) { profile := config.NewProfile(&config.Config{}, "") - wrapper := newResticWrapper(nil, "", false, profile, "", nil, nil) + ctx := &Context{ + binary: "", + profile: profile, + command: "", + } + wrapper := newResticWrapper(ctx) require.NotNil(t, wrapper) def := shellCommandDefinition{ @@ -1248,7 +1569,13 @@ func TestRunInitCopyCommand(t *testing.T) { clog.SetDefaultLogger(clog.NewLogger(mem)) defer clog.SetDefaultLogger(defaultLogger) - wrapper := newResticWrapper(config.NewGlobal(), "test", true, testCase.profile, "copy", nil, nil) + wrapper := newResticWrapper(&Context{ + flags: commandLineFlags{dryRun: true}, + global: config.NewGlobal(), + binary: "test", + profile: testCase.profile, + command: "copy", + }) // 1. run init command with copy profile err := wrapper.runInitializeCopy() require.NoError(t, err)