From 50c2f3ebfb0fe1f09f3cb6b7bab314531c1250b3 Mon Sep 17 00:00:00 2001 From: Juergen Kellerer Date: Sat, 16 Mar 2024 22:18:41 +0100 Subject: [PATCH 1/6] log: allow controlling command output redirection --- commands.go | 3 +++ complete.go | 2 ++ complete_test.go | 8 +++++++- config/global.go | 4 +++- config/schedule.go | 12 +++++++----- constants/default.go | 1 + context.go | 23 ++++++++++++++--------- flags.go | 3 +++ flags_test.go | 1 + logger.go | 16 ++++++++++++++-- main.go | 5 +++-- 11 files changed, 58 insertions(+), 20 deletions(-) diff --git a/commands.go b/commands.go index bea2b70a4..f7f760ef1 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.LogCommands) > 0 { + ctx.logCommands = s.LogCommands + } // battery if s.IgnoreOnBatteryLessThan > 0 && !s.IgnoreOnBattery.IsStrictlyFalse() { ctx.stopOnBattery = s.IgnoreOnBatteryLessThan diff --git a/complete.go b/complete.go index 5395d985e..65a42900d 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 "log-commands": + completions = []string{"auto", "log", "console", "both"} } 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..97498357a 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/"` + LogCommands string `mapstructure:"log-commands" default:"auto" enum:"auto;log;console;both" 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, + LogCommands: constants.DefaultLogCommands, SenderTimeout: constants.DefaultSenderTimeout, } } diff --git a/config/schedule.go b/config/schedule.go index 3fc324a15..4d14805e4 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/"` + LogCommands string `mapstructure:"log-commands" default:"auto" enum:"auto;log;console;both" 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", + LogCommands: constants.DefaultLogCommands, + Priority: "background", + LockMode: "default", + EnvCapture: []string{"RESTIC_*"}, } func (s *ScheduleBaseConfig) init(defaults *ScheduleBaseConfig) { diff --git a/constants/default.go b/constants/default.go index a63bf3011..7cef05f87 100644 --- a/constants/default.go +++ b/constants/default.go @@ -18,6 +18,7 @@ const ( DefaultVerboseFlag = false DefaultQuietFlag = false DefaultMinMemory = 100 + DefaultLogCommands = "auto" DefaultSenderTimeout = 30 * time.Second DefaultPrometheusPushFormat = "text" BatteryFull = 100 diff --git a/context.go b/context.go index b41bb1f12..f5c92e3ca 100644 --- a/context.go +++ b/context.go @@ -29,6 +29,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 + logCommands 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 +52,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) + logCommands: global.LogCommands, } // own commands can check the context before running if ownCommands.Exists(command, true) { @@ -72,6 +74,9 @@ func CreateContext(flags commandLineFlags, global *config.Global, cfg *config.Co if flags.log != "" { ctx.logTarget = flags.log } + if flags.logCommands != "" { + ctx.logCommands = flags.logCommands + } // same for battery configuration if flags.ignoreOnBattery > 0 { ctx.stopOnBattery = flags.ignoreOnBattery diff --git a/flags.go b/flags.go index 0234143c5..350be5e04 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 + logCommands 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"), + logCommands: envValueOverride(constants.DefaultLogCommands, "RESTICPROFILE_LOG_COMMANDS"), dryRun: envValueOverride(false, "RESTICPROFILE_DRY_RUN"), noLock: envValueOverride(false, "RESTICPROFILE_NO_LOCK"), lockWait: envValueOverride(time.Duration(0), "RESTICPROFILE_LOCK_WAIT"), @@ -98,6 +100,7 @@ func loadFlags(args []string) (*pflag.FlagSet, commandLineFlags, error) { 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.StringVar(&flags.logCommands, "log-commands", flags.logCommands, "sets how to log command output when a log target is specified (auto, log, console, both)") 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..39a709cd8 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), + logCommands: setEnv("log", "RESTICPROFILE_LOG_COMMANDS").(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..f78f9f656 100644 --- a/logger.go +++ b/logger.go @@ -40,7 +40,7 @@ func setupRemoteLogger(flags commandLineFlags, client *remote.Client) { clog.SetDefaultLogger(logger) } -func setupTargetLogger(flags commandLineFlags, logTarget string) (io.Closer, error) { +func setupTargetLogger(flags commandLineFlags, logTarget, logCommands string) (io.Closer, error) { var ( handler LogCloser file io.Writer @@ -62,7 +62,19 @@ func setupTargetLogger(flags commandLineFlags, logTarget string) (io.Closer, err // also redirect all terminal output if file != nil { - term.SetAllOutput(file) + if logCommands == "auto" { + if term.OsStdoutIsTerminal() { + logCommands = "both" + } else { + logCommands = "log" + } + } + if logCommands == "both" { + term.SetOutput(io.MultiWriter(file, term.GetOutput())) + term.SetErrorOutput(io.MultiWriter(file, term.GetErrorOutput())) + } else if logCommands == "log" { + term.SetAllOutput(file) + } } // and return the handler (so we can close it at the end) return handler, nil diff --git a/main.go b/main.go index c62fde840..4f515c3c2 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, logCommands := "", "" if ctx != nil { logTarget = ctx.logTarget + logCommands = ctx.logCommands } if logTarget != "" && logTarget != "-" { - if closer, err := setupTargetLogger(flags, logTarget); err == nil { + if closer, err := setupTargetLogger(flags, logTarget, logCommands); err == nil { logCloser = func() { _ = closer.Close() } } else { // fallback to a console logger From 69154f8c621dc650f9c68a3b7062cedc92a64477 Mon Sep 17 00:00:00 2001 From: Juergen Kellerer Date: Sun, 17 Mar 2024 16:06:49 +0100 Subject: [PATCH 2/6] added missing default value handling --- commands_test.go | 2 ++ config/schedule.go | 3 +++ 2 files changed, 5 insertions(+) diff --git a/commands_test.go b/commands_test.go index e52886dd0..c771f2371 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 + log-commands: auto priority: background lock-mode: default capture-environment: RESTIC_* @@ -295,6 +296,7 @@ schedule backup@default: schedule check@default: at: weekly permission: auto + log-commands: auto priority: background lock-mode: default capture-environment: RESTIC_* diff --git a/config/schedule.go b/config/schedule.go index 4d14805e4..1ef1bde98 100644 --- a/config/schedule.go +++ b/config/schedule.go @@ -67,6 +67,9 @@ func (s *ScheduleBaseConfig) init(defaults *ScheduleBaseConfig) { if s.Log == "" { s.Log = defaults.Log } + if s.LogCommands == "" { + s.LogCommands = defaults.LogCommands + } if s.Priority == "" { s.Priority = defaults.Priority } From 519d4b3d2052240539b83802f9c98ea5726248f0 Mon Sep 17 00:00:00 2001 From: Juergen Kellerer Date: Tue, 19 Mar 2024 19:32:53 +0100 Subject: [PATCH 3/6] renamed "log-commands" to "command-output" --- commands.go | 4 ++-- commands_test.go | 4 ++-- complete.go | 4 ++-- config/global.go | 4 ++-- config/schedule.go | 16 ++++++++-------- constants/default.go | 2 +- context.go | 26 +++++++++++++------------- flags.go | 6 +++--- flags_test.go | 2 +- logger.go | 16 ++++++++++------ main.go | 6 +++--- syslog.go | 3 ++- 12 files changed, 49 insertions(+), 44 deletions(-) diff --git a/commands.go b/commands.go index f7f760ef1..cd305cdca 100644 --- a/commands.go +++ b/commands.go @@ -626,8 +626,8 @@ func prepareScheduledProfile(ctx *Context) { if len(s.Log) > 0 { ctx.logTarget = s.Log } - if len(s.LogCommands) > 0 { - ctx.logCommands = s.LogCommands + if len(s.CommandOutput) > 0 { + ctx.commandOutput = s.CommandOutput } // battery if s.IgnoreOnBatteryLessThan > 0 && !s.IgnoreOnBattery.IsStrictlyFalse() { diff --git a/commands_test.go b/commands_test.go index c771f2371..10491ac17 100644 --- a/commands_test.go +++ b/commands_test.go @@ -288,7 +288,7 @@ func TestShowSchedules(t *testing.T) { schedule backup@default: at: daily permission: auto - log-commands: auto + command-output: auto priority: background lock-mode: default capture-environment: RESTIC_* @@ -296,7 +296,7 @@ schedule backup@default: schedule check@default: at: weekly permission: auto - log-commands: auto + command-output: auto priority: background lock-mode: default capture-environment: RESTIC_* diff --git a/complete.go b/complete.go index 65a42900d..58dcbf17b 100644 --- a/complete.go +++ b/complete.go @@ -115,8 +115,8 @@ func (c *Completer) completeFlagSetValue(flag *pflag.Flag, word string) (complet fallthrough case "log": completions = []string{RequestFileCompletion} - case "log-commands": - completions = []string{"auto", "log", "console", "both"} + case "command-output": + completions = []string{"auto", "log", "console", "all"} } completions = c.appendMatches(completions, word, list...) diff --git a/config/global.go b/config/global.go index 97498357a..c726de8ec 100644 --- a/config/global.go +++ b/config/global.go @@ -27,7 +27,7 @@ type Global struct { 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/"` - LogCommands string `mapstructure:"log-commands" default:"auto" enum:"auto;log;console;both" 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/"` + 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/"` @@ -48,7 +48,7 @@ func NewGlobal() *Global { ResticLockRetryAfter: constants.DefaultResticLockRetryAfter, ResticStaleLockAge: constants.DefaultResticStaleLockAge, MinMemory: constants.DefaultMinMemory, - LogCommands: constants.DefaultLogCommands, + CommandOutput: constants.DefaultCommandOutput, SenderTimeout: constants.DefaultSenderTimeout, } } diff --git a/config/schedule.go b/config/schedule.go index 1ef1bde98..305c92e79 100644 --- a/config/schedule.go +++ b/config/schedule.go @@ -36,7 +36,7 @@ const ( 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 - see https://creativeprojects.github.io/resticprofile/configuration/logs/"` - LogCommands string `mapstructure:"log-commands" default:"auto" enum:"auto;log;console;both" 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/"` + 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"` @@ -49,11 +49,11 @@ type ScheduleBaseConfig struct { // scheduleBaseConfigDefaults declares built-in scheduling defaults var scheduleBaseConfigDefaults = ScheduleBaseConfig{ - Permission: "auto", - LogCommands: constants.DefaultLogCommands, - 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) { @@ -67,8 +67,8 @@ func (s *ScheduleBaseConfig) init(defaults *ScheduleBaseConfig) { if s.Log == "" { s.Log = defaults.Log } - if s.LogCommands == "" { - s.LogCommands = defaults.LogCommands + 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 7cef05f87..29b526338 100644 --- a/constants/default.go +++ b/constants/default.go @@ -18,7 +18,7 @@ const ( DefaultVerboseFlag = false DefaultQuietFlag = false DefaultMinMemory = 100 - DefaultLogCommands = "auto" + DefaultCommandOutput = "auto" DefaultSenderTimeout = 30 * time.Second DefaultPrometheusPushFormat = "text" BatteryFull = 100 diff --git a/context.go b/context.go index f5c92e3ca..e06b40e27 100644 --- a/context.go +++ b/context.go @@ -29,7 +29,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 - logCommands string // where to send the command output when a lotTarget is set + 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 @@ -52,16 +52,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) - logCommands: global.LogCommands, + 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) { @@ -74,8 +74,8 @@ func CreateContext(flags commandLineFlags, global *config.Global, cfg *config.Co if flags.log != "" { ctx.logTarget = flags.log } - if flags.logCommands != "" { - ctx.logCommands = flags.logCommands + if flags.commandOutput != "" { + ctx.commandOutput = flags.commandOutput } // same for battery configuration if flags.ignoreOnBattery > 0 { diff --git a/flags.go b/flags.go index 350be5e04..b8c615cf8 100644 --- a/flags.go +++ b/flags.go @@ -24,7 +24,7 @@ type commandLineFlags struct { format string name string log string // file path or log url - logCommands string + commandOutput string dryRun bool noLock bool lockWait time.Duration @@ -81,7 +81,7 @@ func loadFlags(args []string) (*pflag.FlagSet, commandLineFlags, error) { format: envValueOverride("", "RESTICPROFILE_FORMAT"), name: envValueOverride(constants.DefaultProfileName, "RESTICPROFILE_NAME"), log: envValueOverride("", "RESTICPROFILE_LOG"), - logCommands: envValueOverride(constants.DefaultLogCommands, "RESTICPROFILE_LOG_COMMANDS"), + 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"), @@ -100,7 +100,7 @@ func loadFlags(args []string) (*pflag.FlagSet, commandLineFlags, error) { 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.StringVar(&flags.logCommands, "log-commands", flags.logCommands, "sets how to log command output when a log target is specified (auto, log, console, both)") + flagset.StringVar(&flags.commandOutput, "command-output", flags.commandOutput, "sets how to redirect command output when a log target is specified (auto, 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 39a709cd8..41ca10ded 100644 --- a/flags_test.go +++ b/flags_test.go @@ -65,7 +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), - logCommands: setEnv("log", "RESTICPROFILE_LOG_COMMANDS").(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 f78f9f656..0b9d30589 100644 --- a/logger.go +++ b/logger.go @@ -5,6 +5,7 @@ import ( "log" "os" "path/filepath" + "slices" "strings" "time" @@ -15,6 +16,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,7 +42,7 @@ func setupRemoteLogger(flags commandLineFlags, client *remote.Client) { clog.SetDefaultLogger(logger) } -func setupTargetLogger(flags commandLineFlags, logTarget, logCommands string) (io.Closer, error) { +func setupTargetLogger(flags commandLineFlags, logTarget, commandOutput string) (io.Closer, error) { var ( handler LogCloser file io.Writer @@ -62,17 +64,19 @@ func setupTargetLogger(flags commandLineFlags, logTarget, logCommands string) (i // also redirect all terminal output if file != nil { - if logCommands == "auto" { + if commandOutput == "auto" { if term.OsStdoutIsTerminal() { - logCommands = "both" + commandOutput = "log,console" } else { - logCommands = "log" + commandOutput = "log" } } - if logCommands == "both" { + co := collect.From(strings.Split(commandOutput, ","), strings.TrimSpace) + all := slices.Contains(co, "all") || (slices.Contains(co, "log") && slices.Contains(co, "console")) + if all { term.SetOutput(io.MultiWriter(file, term.GetOutput())) term.SetErrorOutput(io.MultiWriter(file, term.GetErrorOutput())) - } else if logCommands == "log" { + } else if slices.Contains(co, "log") { term.SetAllOutput(file) } } diff --git a/main.go b/main.go index 4f515c3c2..f53aa10d9 100644 --- a/main.go +++ b/main.go @@ -109,13 +109,13 @@ func main() { // also redirect the terminal through the client term.SetAllOutput(term.NewRemoteTerm(client)) } else { - logTarget, logCommands := "", "" + logTarget, commandOutput := "", "" if ctx != nil { logTarget = ctx.logTarget - logCommands = ctx.logCommands + commandOutput = ctx.commandOutput } if logTarget != "" && logTarget != "-" { - if closer, err := setupTargetLogger(flags, logTarget, logCommands); 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 From cabb2f6a22077e6e8e8ab710918ec16263dfebd6 Mon Sep 17 00:00:00 2001 From: Juergen Kellerer Date: Tue, 19 Mar 2024 19:51:30 +0100 Subject: [PATCH 4/6] added documentation --- docs/content/usage/_index.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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` | From db6e8c8a00609ce8d9d86e5fc93433f6609bc7dd Mon Sep 17 00:00:00 2001 From: Juergen Kellerer Date: Thu, 21 Mar 2024 18:35:09 +0100 Subject: [PATCH 5/6] updated cli-help, added fail on unsupported URL --- dial/url.go | 13 ++++++++++--- dial/url_test.go | 10 ++++++++++ flags.go | 4 ++-- logger.go | 33 ++++++++++++++++++++------------- logger_test.go | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 18 deletions(-) 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/flags.go b/flags.go index b8c615cf8..fbabc3335 100644 --- a/flags.go +++ b/flags.go @@ -99,8 +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.StringVar(&flags.commandOutput, "command-output", flags.commandOutput, "sets how to redirect command output when a log target is specified (auto, log, console, all)") + 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/logger.go b/logger.go index 0b9d30589..02ce7c37b 100644 --- a/logger.go +++ b/logger.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "io" "log" "os" @@ -48,9 +49,10 @@ func setupTargetLogger(flags commandLineFlags, logTarget, commandOutput string) 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) } @@ -64,19 +66,10 @@ func setupTargetLogger(flags commandLineFlags, logTarget, commandOutput string) // also redirect all terminal output if file != nil { - if commandOutput == "auto" { - if term.OsStdoutIsTerminal() { - commandOutput = "log,console" - } else { - commandOutput = "log" - } - } - co := collect.From(strings.Split(commandOutput, ","), strings.TrimSpace) - all := slices.Contains(co, "all") || (slices.Contains(co, "log") && slices.Contains(co, "console")) - if all { + if all, toLog := parseCommandOutput(commandOutput); all { term.SetOutput(io.MultiWriter(file, term.GetOutput())) term.SetErrorOutput(io.MultiWriter(file, term.GetErrorOutput())) - } else if slices.Contains(co, "log") { + } else if toLog { term.SetAllOutput(file) } } @@ -84,6 +77,20 @@ func setupTargetLogger(flags commandLineFlags, logTarget, commandOutput string) 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) { From 5bf33d7587148bf1f0b5bb333765762a3d772d07 Mon Sep 17 00:00:00 2001 From: Juergen Kellerer Date: Thu, 21 Mar 2024 18:52:57 +0100 Subject: [PATCH 6/6] fixed default value not allowing global config --- context.go | 3 ++- context_test.go | 30 ++++++++++++++++-------------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/context.go b/context.go index e06b40e27..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 { @@ -74,7 +75,7 @@ func CreateContext(flags commandLineFlags, global *config.Global, cfg *config.Co if flags.log != "" { ctx.logTarget = flags.log } - if flags.commandOutput != "" { + if flags.commandOutput != constants.DefaultCommandOutput { ctx.commandOutput = flags.commandOutput } // same for battery configuration 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", }, }, {