diff --git a/commands.go b/commands.go index bea2b70a4..cd305cdca 100644 --- a/commands.go +++ b/commands.go @@ -626,6 +626,9 @@ func prepareScheduledProfile(ctx *Context) { if len(s.Log) > 0 { ctx.logTarget = s.Log } + if len(s.CommandOutput) > 0 { + ctx.commandOutput = s.CommandOutput + } // battery if s.IgnoreOnBatteryLessThan > 0 && !s.IgnoreOnBattery.IsStrictlyFalse() { ctx.stopOnBattery = s.IgnoreOnBatteryLessThan diff --git a/commands_test.go b/commands_test.go index e52886dd0..10491ac17 100644 --- a/commands_test.go +++ b/commands_test.go @@ -288,6 +288,7 @@ func TestShowSchedules(t *testing.T) { schedule backup@default: at: daily permission: auto + command-output: auto priority: background lock-mode: default capture-environment: RESTIC_* @@ -295,6 +296,7 @@ schedule backup@default: schedule check@default: at: weekly permission: auto + command-output: auto priority: background lock-mode: default capture-environment: RESTIC_* diff --git a/complete.go b/complete.go index 5395d985e..58dcbf17b 100644 --- a/complete.go +++ b/complete.go @@ -115,6 +115,8 @@ func (c *Completer) completeFlagSetValue(flag *pflag.Flag, word string) (complet fallthrough case "log": completions = []string{RequestFileCompletion} + case "command-output": + completions = []string{"auto", "log", "console", "all"} } completions = c.appendMatches(completions, word, list...) diff --git a/complete_test.go b/complete_test.go index edd7edc51..7971f1dac 100644 --- a/complete_test.go +++ b/complete_test.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "slices" "sort" "strings" "testing" @@ -62,7 +63,12 @@ func TestCompleter(t *testing.T) { if flag.Hidden { expected = nil } - assert.Equal(t, expected, completer.completeFlagSet(fmt.Sprintf("--%s", flag.Name))) + actual := completer.completeFlagSet(fmt.Sprintf("--%s", flag.Name)) + assert.Subset(t, actual, expected) + for _, flag := range actual { + ok := slices.ContainsFunc(expected, func(prefix string) bool { return strings.HasPrefix(flag, prefix) }) + assert.True(t, ok, "prefixes not matched for %q", flag) + } if len(flag.Shorthand) > 0 && !flag.Hidden { expected = flagCompletion(flag, true)[0:1] diff --git a/config/global.go b/config/global.go index 06d35f2b5..c726de8ec 100644 --- a/config/global.go +++ b/config/global.go @@ -26,7 +26,8 @@ type Global struct { MinMemory uint64 `mapstructure:"min-memory" default:"100" description:"Minimum available memory (in MB) required to run any commands - see https://creativeprojects.github.io/resticprofile/usage/memory/"` Scheduler string `mapstructure:"scheduler" description:"Leave blank for the default scheduler or use \"crond\" to select cron on supported operating systems"` ScheduleDefaults *ScheduleBaseConfig `mapstructure:"schedule-defaults" default:"" description:"Sets defaults for all schedules"` - Log string `mapstructure:"log" default:"" examples:"/resticprofile.log;syslog-tcp://syslog-server:514;syslog:server;syslog:" description:"Sets the default log destination to be used if not specified in '--log' or 'schedule-log' - see https://creativeprojects.github.io/resticprofile/configuration/logs/"` + Log string `mapstructure:"log" default:"" examples:"/resticprofile.log;syslog-tcp://syslog-server:514;syslog:server;syslog:" description:"Sets the default log destination to be used if not specified in \"--log\" or \"schedule-log\" - see https://creativeprojects.github.io/resticprofile/configuration/logs/"` + CommandOutput string `mapstructure:"command-output" default:"auto" enum:"auto;log;console;all" description:"Sets the destination for command output (stderr/stdout). \"log\" sends output to the log file (if specified), \"console\" sends it to the console instead. \"auto\" sends it to \"both\" if console is a terminal otherwise to \"log\" only - see https://creativeprojects.github.io/resticprofile/configuration/logs/"` LegacyArguments bool `mapstructure:"legacy-arguments" default:"false" deprecated:"0.20.0" description:"Legacy, broken arguments mode of resticprofile before version 0.15"` SystemdUnitTemplate string `mapstructure:"systemd-unit-template" default:"" description:"File containing the go template to generate a systemd unit - see https://creativeprojects.github.io/resticprofile/schedules/systemd/"` SystemdTimerTemplate string `mapstructure:"systemd-timer-template" default:"" description:"File containing the go template to generate a systemd timer - see https://creativeprojects.github.io/resticprofile/schedules/systemd/"` @@ -47,6 +48,7 @@ func NewGlobal() *Global { ResticLockRetryAfter: constants.DefaultResticLockRetryAfter, ResticStaleLockAge: constants.DefaultResticStaleLockAge, MinMemory: constants.DefaultMinMemory, + CommandOutput: constants.DefaultCommandOutput, SenderTimeout: constants.DefaultSenderTimeout, } } diff --git a/config/schedule.go b/config/schedule.go index 3fc324a15..305c92e79 100644 --- a/config/schedule.go +++ b/config/schedule.go @@ -35,7 +35,8 @@ const ( // ScheduleBaseConfig is the base user configuration that could be shared across all schedules. type ScheduleBaseConfig struct { Permission string `mapstructure:"permission" default:"auto" enum:"auto;system;user;user_logged_on" description:"Specify whether the schedule runs with system or user privileges - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` - Log string `mapstructure:"log" examples:"/resticprofile.log;syslog-tcp://syslog-server:514;syslog:server;syslog:" description:"Redirect the output into a log file or to syslog when running on schedule"` + Log string `mapstructure:"log" examples:"/resticprofile.log;syslog-tcp://syslog-server:514;syslog:server;syslog:" description:"Redirect the output into a log file or to syslog when running on schedule - see https://creativeprojects.github.io/resticprofile/configuration/logs/"` + CommandOutput string `mapstructure:"command-output" default:"auto" enum:"auto;log;console;all" description:"Sets the destination for command output (stderr/stdout). \"log\" sends output to the log file (if specified), \"console\" sends it to the console instead. \"auto\" sends it to \"both\" if console is a terminal otherwise to \"log\" only - see https://creativeprojects.github.io/resticprofile/configuration/logs/"` Priority string `mapstructure:"priority" default:"background" enum:"background;standard" description:"Set the priority at which the schedule is run"` LockMode string `mapstructure:"lock-mode" default:"default" enum:"default;fail;ignore" description:"Specify how locks are used when running on schedule - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"` LockWait maybe.Duration `mapstructure:"lock-wait" examples:"150s;15m;30m;45m;1h;2h30m" description:"Set the maximum time to wait for acquiring locks when running on schedule"` @@ -48,10 +49,11 @@ type ScheduleBaseConfig struct { // scheduleBaseConfigDefaults declares built-in scheduling defaults var scheduleBaseConfigDefaults = ScheduleBaseConfig{ - Permission: "auto", - Priority: "background", - LockMode: "default", - EnvCapture: []string{"RESTIC_*"}, + Permission: "auto", + CommandOutput: constants.DefaultCommandOutput, + Priority: "background", + LockMode: "default", + EnvCapture: []string{"RESTIC_*"}, } func (s *ScheduleBaseConfig) init(defaults *ScheduleBaseConfig) { @@ -65,6 +67,9 @@ func (s *ScheduleBaseConfig) init(defaults *ScheduleBaseConfig) { if s.Log == "" { s.Log = defaults.Log } + if s.CommandOutput == "" { + s.CommandOutput = defaults.CommandOutput + } if s.Priority == "" { s.Priority = defaults.Priority } diff --git a/constants/default.go b/constants/default.go index a63bf3011..29b526338 100644 --- a/constants/default.go +++ b/constants/default.go @@ -18,6 +18,7 @@ const ( DefaultVerboseFlag = false DefaultQuietFlag = false DefaultMinMemory = 100 + DefaultCommandOutput = "auto" DefaultSenderTimeout = 30 * time.Second DefaultPrometheusPushFormat = "text" BatteryFull = 100 diff --git a/context.go b/context.go index b41bb1f12..4985d3e91 100644 --- a/context.go +++ b/context.go @@ -5,6 +5,7 @@ import ( "time" "github.com/creativeprojects/resticprofile/config" + "github.com/creativeprojects/resticprofile/constants" ) type Request struct { @@ -29,6 +30,7 @@ type Context struct { 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 + commandOutput string // where to send the command output when a lotTarget is set stopOnBattery int // stop if running on battery noLock bool // skip profile lock file lockWait time.Duration // wait up to duration to acquire a lock @@ -51,15 +53,16 @@ func CreateContext(flags commandLineFlags, global *config.Global, cfg *config.Co 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) + flags: flags, + global: global, + config: cfg, + binary: "", + command: "", + profile: nil, + schedule: nil, + sigChan: nil, + logTarget: global.Log, // default to global (which can be empty) + commandOutput: global.CommandOutput, } // own commands can check the context before running if ownCommands.Exists(command, true) { @@ -72,6 +75,9 @@ func CreateContext(flags commandLineFlags, global *config.Global, cfg *config.Co if flags.log != "" { ctx.logTarget = flags.log } + if flags.commandOutput != constants.DefaultCommandOutput { + ctx.commandOutput = flags.commandOutput + } // same for battery configuration if flags.ignoreOnBattery > 0 { ctx.stopOnBattery = flags.ignoreOnBattery diff --git a/context_test.go b/context_test.go index a2b70fc31..a4621433d 100644 --- a/context_test.go +++ b/context_test.go @@ -131,30 +131,32 @@ func TestCreateContext(t *testing.T) { }, { description: "global log target", - flags: commandLineFlags{}, - global: &config.Global{Log: "global"}, + flags: commandLineFlags{commandOutput: "auto"}, + global: &config.Global{Log: "global", CommandOutput: "global"}, cfg: &config.Config{}, ownCommands: &OwnCommands{}, context: &Context{ - flags: commandLineFlags{}, - request: Request{}, - global: &config.Global{Log: "global"}, - config: &config.Config{}, - logTarget: "global", + flags: commandLineFlags{commandOutput: "auto"}, + request: Request{}, + global: &config.Global{Log: "global", CommandOutput: "global"}, + config: &config.Config{}, + logTarget: "global", + commandOutput: "global", }, }, { description: "log target on the command line", - flags: commandLineFlags{log: "cmdline"}, - global: &config.Global{Log: "global"}, + flags: commandLineFlags{log: "cmdline", commandOutput: "cmdline"}, + global: &config.Global{Log: "global", CommandOutput: "global"}, cfg: &config.Config{}, ownCommands: &OwnCommands{}, context: &Context{ - flags: commandLineFlags{log: "cmdline"}, - request: Request{}, - global: &config.Global{Log: "global"}, - config: &config.Config{}, - logTarget: "cmdline", + flags: commandLineFlags{log: "cmdline", commandOutput: "cmdline"}, + request: Request{}, + global: &config.Global{Log: "global", CommandOutput: "global"}, + config: &config.Config{}, + logTarget: "cmdline", + commandOutput: "cmdline", }, }, { diff --git a/dial/url.go b/dial/url.go index dbefc381f..f06509bb0 100644 --- a/dial/url.go +++ b/dial/url.go @@ -20,14 +20,14 @@ var noHostAllowed = []string{ "syslog", } -// GetAddr returns scheme, host&port, isURL +// GetAddr returns scheme, host&port, is(Supported)URL func GetAddr(source string) (scheme, hostPort string, isURL bool) { URL, err := url.Parse(source) if err == nil { scheme = strings.ToLower(URL.Scheme) hostPort = URL.Host schemeOk := slices.Contains(validSchemes, scheme) - hostOk := len(hostPort) >= 3 || slices.Contains(noHostAllowed, scheme) + hostOk := len(hostPort) >= 3 || (slices.Contains(noHostAllowed, scheme) && len(URL.Opaque) == 0) if isURL = schemeOk && hostOk; isURL { return } @@ -37,7 +37,14 @@ func GetAddr(source string) (scheme, hostPort string, isURL bool) { return "", "", false } -func IsURL(source string) bool { +// IsSupportedURL returns true if the provided source is valid for GetAddr +func IsSupportedURL(source string) bool { _, _, isURL := GetAddr(source) return isURL } + +// IsURL is true if the provided source is a parsable URL and no file path +func IsURL(source string) bool { + u, e := url.Parse(source) + return e == nil && len(u.Scheme) > 1 +} diff --git a/dial/url_test.go b/dial/url_test.go index b46d43f06..04c497699 100644 --- a/dial/url_test.go +++ b/dial/url_test.go @@ -30,6 +30,7 @@ func TestGetDialAddr(t *testing.T) { {"syslog://", "syslog", "", true}, {"syslog:", "syslog", "", true}, // too short + {"syslog:opaque", "", "", false}, {"tcp://", "", "", false}, {"tcp:", "", "", false}, {"syslog-tcp:", "", "", false}, @@ -55,6 +56,15 @@ func TestGetDialAddr(t *testing.T) { assert.Equal(t, fixture.isURL, isURL) assert.Equal(t, fixture.scheme, scheme) assert.Equal(t, fixture.hostPort, port) + + assert.Equal(t, fixture.isURL, dial.IsSupportedURL(fixture.addr)) }) } } + +func TestIsUrl(t *testing.T) { + assert.True(t, dial.IsURL("ftp://")) + assert.True(t, dial.IsURL("http://")) + assert.False(t, dial.IsURL("c://")) + assert.False(t, dial.IsURL("")) +} diff --git a/docs/content/usage/_index.md b/docs/content/usage/_index.md index 005e503fb..9512f6635 100644 --- a/docs/content/usage/_index.md +++ b/docs/content/usage/_index.md @@ -87,9 +87,10 @@ There are not many options on the command line, most of the options are in the c light or dark terminal (none to disable colouring) * **[--lock-wait] duration**: Retry to acquire resticprofile and restic locks for up to the specified amount of time before failing on a lock failure. * **[-l | --log] file path or url**: To write the logs to a file or a syslog server instead of displaying on the console. -The format of the server url is `tcp://192.168.0.1:514` or `udp://localhost:514`. +The format of the syslog server url is `syslog-tcp://192.168.0.1:514`, `syslog://udp-server:514` or `syslog:`. For custom log forwarding, the prefix `temp:` can be used (e.g. `temp:/t/msg.log`) to create unique log output that can be fed -into a command or http hook by referencing it with `{{ tempDir }}/...` or `{{ tempFile "msg.log" }}` in the configuration file. +into a command or http hook by referencing it with `"{{ tempFile "msg.log" }}"` in the configuration file. +* **[--command-output]**: Sets how to redirect command output when a log target is specified. Can be `auto`, `log`, `console` or `all`. * **[-w | --wait]**: Wait at the very end of the execution for the user to press enter. This is only useful in Windows when resticprofile is started from explorer and the console window closes automatically at the end. * **[--ignore-on-battery]**: Don't start the profile when the computer is running on battery. You can specify a value to ignore only when the % charge left is less or equal than the value. @@ -109,6 +110,7 @@ Most flags for resticprofile can be set using environment variables. If both are | `--format` | `RESTICPROFILE_FORMAT` | `""` | | `--name` | `RESTICPROFILE_NAME` | `"default"` | | `--log` | `RESTICPROFILE_LOG` | `""` | +| `--command-output` | `RESTICPROFILE_COMMAND_OUTPUT` | `"auto"` | | `--dry-run` | `RESTICPROFILE_DRY_RUN` | `false` | | `--no-lock` | `RESTICPROFILE_NO_LOCK` | `false` | | `--lock-wait` | `RESTICPROFILE_LOCK_WAIT` | `0` | diff --git a/flags.go b/flags.go index 0234143c5..fbabc3335 100644 --- a/flags.go +++ b/flags.go @@ -24,6 +24,7 @@ type commandLineFlags struct { format string name string log string // file path or log url + commandOutput string dryRun bool noLock bool lockWait time.Duration @@ -80,6 +81,7 @@ func loadFlags(args []string) (*pflag.FlagSet, commandLineFlags, error) { format: envValueOverride("", "RESTICPROFILE_FORMAT"), name: envValueOverride(constants.DefaultProfileName, "RESTICPROFILE_NAME"), log: envValueOverride("", "RESTICPROFILE_LOG"), + commandOutput: envValueOverride(constants.DefaultCommandOutput, "RESTICPROFILE_COMMAND_OUTPUT"), dryRun: envValueOverride(false, "RESTICPROFILE_DRY_RUN"), noLock: envValueOverride(false, "RESTICPROFILE_NO_LOCK"), lockWait: envValueOverride(time.Duration(0), "RESTICPROFILE_LOCK_WAIT"), @@ -97,7 +99,8 @@ func loadFlags(args []string) (*pflag.FlagSet, commandLineFlags, error) { flagset.StringVarP(&flags.config, "config", "c", flags.config, "configuration file") flagset.StringVarP(&flags.format, "format", "f", flags.format, "file format of the configuration (default is to use the file extension)") flagset.StringVarP(&flags.name, "name", "n", flags.name, "profile name") - flagset.StringVarP(&flags.log, "log", "l", flags.log, "logs to a target instead of the console") + flagset.StringVarP(&flags.log, "log", "l", flags.log, "logs to a target instead of the console (file, syslog:[//server])") + flagset.StringVar(&flags.commandOutput, "command-output", flags.commandOutput, "redirect command output when a log target is specified (log, console, all)") flagset.BoolVar(&flags.dryRun, "dry-run", flags.dryRun, "display the restic commands instead of running them") flagset.BoolVar(&flags.noLock, "no-lock", flags.noLock, "skip profile lock file") flagset.DurationVar(&flags.lockWait, "lock-wait", flags.lockWait, "wait up to duration to acquire a lock (syntax \"1h5m30s\")") diff --git a/flags_test.go b/flags_test.go index a4000d870..41ca10ded 100644 --- a/flags_test.go +++ b/flags_test.go @@ -65,6 +65,7 @@ func TestEnvOverrides(t *testing.T) { format: setEnv("custom-format", "RESTICPROFILE_FORMAT").(string), name: setEnv("custom-profile", "RESTICPROFILE_NAME").(string), log: setEnv("custom.log", "RESTICPROFILE_LOG").(string), + commandOutput: setEnv("log", "RESTICPROFILE_COMMAND_OUTPUT").(string), dryRun: setEnv(true, "RESTICPROFILE_DRY_RUN").(bool), noLock: setEnv(true, "RESTICPROFILE_NO_LOCK").(bool), lockWait: setEnv(time.Minute*5, "RESTICPROFILE_LOCK_WAIT").(time.Duration), diff --git a/logger.go b/logger.go index 08048e379..02ce7c37b 100644 --- a/logger.go +++ b/logger.go @@ -1,10 +1,12 @@ package main import ( + "fmt" "io" "log" "os" "path/filepath" + "slices" "strings" "time" @@ -15,6 +17,7 @@ import ( "github.com/creativeprojects/resticprofile/remote" "github.com/creativeprojects/resticprofile/term" "github.com/creativeprojects/resticprofile/util" + "github.com/creativeprojects/resticprofile/util/collect" ) type LogCloser interface { @@ -40,15 +43,16 @@ func setupRemoteLogger(flags commandLineFlags, client *remote.Client) { clog.SetDefaultLogger(logger) } -func setupTargetLogger(flags commandLineFlags, logTarget string) (io.Closer, error) { +func setupTargetLogger(flags commandLineFlags, logTarget, commandOutput string) (io.Closer, error) { var ( handler LogCloser file io.Writer err error ) - scheme, hostPort, isURL := dial.GetAddr(logTarget) - if isURL { + if scheme, hostPort, isURL := dial.GetAddr(logTarget); isURL { handler, file, err = getSyslogHandler(scheme, hostPort) + } else if dial.IsURL(logTarget) { + err = fmt.Errorf("unsupported URL: %s", logTarget) } else { handler, file, err = getFileHandler(logTarget) } @@ -62,12 +66,31 @@ func setupTargetLogger(flags commandLineFlags, logTarget string) (io.Closer, err // also redirect all terminal output if file != nil { - term.SetAllOutput(file) + if all, toLog := parseCommandOutput(commandOutput); all { + term.SetOutput(io.MultiWriter(file, term.GetOutput())) + term.SetErrorOutput(io.MultiWriter(file, term.GetErrorOutput())) + } else if toLog { + term.SetAllOutput(file) + } } // and return the handler (so we can close it at the end) return handler, nil } +func parseCommandOutput(commandOutput string) (all, log bool) { + if strings.TrimSpace(commandOutput) == "auto" { + if term.OsStdoutIsTerminal() { + commandOutput = "log,console" + } else { + commandOutput = "log" + } + } + co := collect.From(strings.Split(commandOutput, ","), strings.TrimSpace) + log = slices.Contains(co, "log") + all = slices.Contains(co, "all") || (log && slices.Contains(co, "console")) + return +} + func getFileHandler(logfile string) (*clog.StandardLogHandler, io.Writer, error) { if strings.HasPrefix(logfile, constants.TemporaryDirMarker) { if tempDir, err := util.TempDir(); err == nil { diff --git a/logger_test.go b/logger_test.go index d577910b0..91fb2a364 100644 --- a/logger_test.go +++ b/logger_test.go @@ -96,6 +96,39 @@ func TestFileHandler(t *testing.T) { } } +func TestParseCommandOutput(t *testing.T) { + tests := []struct { + co string + all, log bool + }{ + {co: "", all: false, log: false}, + {co: "auto", all: term.OsStdoutIsTerminal(), log: true}, + {co: "log", all: false, log: true}, + {co: "console", all: false, log: false}, + {co: "all", all: true, log: false}, + {co: "all,log", all: true, log: true}, + {co: "console,log", all: true, log: true}, + {co: "log,console", all: true, log: true}, + {co: "log,a", all: false, log: true}, + {co: "console,a", all: false, log: false}, + + {co: " auto ", all: term.OsStdoutIsTerminal(), log: true}, + {co: " all ", all: true, log: false}, + {co: " log ", all: false, log: true}, + {co: " console ", all: false, log: false}, + {co: " console , log ", all: true, log: true}, + {co: " log , console ", all: true, log: true}, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + a, l := parseCommandOutput(test.co) + assert.Equal(t, test.all, a, "all") + assert.Equal(t, test.log, l, "log") + }) + } +} + // FIXME: writing into a closed handler shouldn't panic // // func TestCloseFileHandler(t *testing.T) { diff --git a/main.go b/main.go index c62fde840..f53aa10d9 100644 --- a/main.go +++ b/main.go @@ -109,12 +109,13 @@ func main() { // also redirect the terminal through the client term.SetAllOutput(term.NewRemoteTerm(client)) } else { - logTarget := "" + logTarget, commandOutput := "", "" if ctx != nil { logTarget = ctx.logTarget + commandOutput = ctx.commandOutput } if logTarget != "" && logTarget != "-" { - if closer, err := setupTargetLogger(flags, logTarget); err == nil { + if closer, err := setupTargetLogger(flags, logTarget, commandOutput); err == nil { logCloser = func() { _ = closer.Close() } } else { // fallback to a console logger diff --git a/syslog.go b/syslog.go index 0538bb54b..2f8391c1c 100644 --- a/syslog.go +++ b/syslog.go @@ -86,7 +86,8 @@ func getSyslogHandler(scheme, hostPort string) (handler *Syslog, writer io.Write scheme = "udp" } default: - err = fmt.Errorf("unsupported scheme %q", scheme) + err = fmt.Errorf("unsupported syslog URL scheme %q", scheme) + return } var logger *syslog.Writer