Skip to content

Commit 48c822c

Browse files
authored
Support "run-finally" in backup-section & profile (#70)
1 parent 85fcc20 commit 48c822c

File tree

4 files changed

+128
-21
lines changed

4 files changed

+128
-21
lines changed

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -665,24 +665,29 @@ documents:
665665
run-before: "echo == run-before profile $PROFILE_NAME command $PROFILE_COMMAND"
666666
run-after: "echo == run-after profile $PROFILE_NAME command $PROFILE_COMMAND"
667667
run-after-fail: "echo == Error in profile $PROFILE_NAME command $PROFILE_COMMAND: $ERROR"
668+
run-finally: "echo == run-finally $PROFILE_NAME command $PROFILE_COMMAND"
668669
backup:
669670
run-before: "echo === run-before backup profile $PROFILE_NAME command $PROFILE_COMMAND"
670671
run-after: "echo === run-after backup profile $PROFILE_NAME command $PROFILE_COMMAND"
672+
run-finally: "echo == run-finally $PROFILE_NAME command $PROFILE_COMMAND"
671673
source: ~/Documents
672674
```
673675
674-
`run-before`, `run-after` and `run-after-fail` can be a string, or an array of strings if you need to run more than one command
676+
`run-before`, `run-after`, `run-after-fail` and `run-finally` can be a string, or an array of strings if you need to run more than one command
675677

676678
A few environment variables will be set before running these commands:
677679
- `PROFILE_NAME`
678680
- `PROFILE_COMMAND`: backup, check, forget, etc.
679681

680-
Additionally for the `run-after-fail` commands, these environment variables will also be available:
682+
Additionally, for the `run-after-fail` commands, these environment variables will also be available:
681683
- `ERROR` containing the latest error message
682684
- `ERROR_COMMANDLINE` containing the command line that failed
683685
- `ERROR_EXIT_CODE` containing the exit code of the command line that failed
684686
- `ERROR_STDERR` containing any message that the failed command sent to the standard error (stderr)
685687

688+
The commands of `run-finally` get the environment of `run-after-fail` when `run-before`, `run-after` or `restic` failed.
689+
Failures in `run-finally` are logged but do not influence environment or return code.
690+
686691
## run before and after order during a backup
687692

688693
The commands will be running in this order **during a backup**:
@@ -691,6 +696,9 @@ The commands will be running in this order **during a backup**:
691696
- run the restic backup (with check and retention if configured) - if error, go to `run-after-fail`
692697
- `run-after` from the backup section - if error, go to `run-after-fail`
693698
- `run-after` from the profile - if error, go to `run-after-fail`
699+
- If error: `run-after-fail` from the profile - if error, go to `run-finally`
700+
- `run-finally` from the backup section - if error, log and continue with next
701+
- `run-finally` from the profile - if error, log and continue with next
694702

695703
# Warnings from restic
696704

config/profile.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type Profile struct {
2929
RunBefore []string `mapstructure:"run-before"`
3030
RunAfter []string `mapstructure:"run-after"`
3131
RunAfterFail []string `mapstructure:"run-after-fail"`
32+
RunFinally []string `mapstructure:"run-finally"`
3233
StatusFile string `mapstructure:"status-file"`
3334
PrometheusSaveToFile string `mapstructure:"prometheus-save-to-file"`
3435
PrometheusPush string `mapstructure:"prometheus-push"`
@@ -51,6 +52,7 @@ type BackupSection struct {
5152
CheckAfter bool `mapstructure:"check-after"`
5253
RunBefore []string `mapstructure:"run-before"`
5354
RunAfter []string `mapstructure:"run-after"`
55+
RunFinally []string `mapstructure:"run-finally"`
5456
UseStdin bool `mapstructure:"stdin" argument:"stdin"`
5557
Source []string `mapstructure:"source"`
5658
Exclude []string `mapstructure:"exclude" argument:"exclude" argument-type:"no-glob"`

wrapper.go

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ func (r *resticWrapper) runProfile() error {
189189
func(err error) {
190190
_ = r.runProfilePostFailCommand(err)
191191
},
192+
func(err error) {
193+
r.runFinalCommand(r.command, err)
194+
},
192195
)
193196
})
194197
if err != nil {
@@ -420,22 +423,7 @@ func (r *resticWrapper) runProfilePostFailCommand(fail error) error {
420423
}
421424
env := append(os.Environ(), r.getEnvironment()...)
422425
env = append(env, r.getProfileEnvironment()...)
423-
env = append(env, fmt.Sprintf("ERROR=%s", fail.Error()))
424-
425-
if fail, ok := fail.(*commandError); ok {
426-
exitCode := -1
427-
if code, err := fail.ExitCode(); err == nil {
428-
exitCode = code
429-
}
430-
431-
env = append(env,
432-
fmt.Sprintf("ERROR_COMMANDLINE=%s", fail.Commandline()),
433-
fmt.Sprintf("ERROR_EXIT_CODE=%d", exitCode),
434-
fmt.Sprintf("ERROR_STDERR=%s", fail.Stderr()),
435-
// Deprecated: STDERR can originate from (pre/post)-command which doesn't need to be restic
436-
fmt.Sprintf("RESTIC_STDERR=%s", fail.Stderr()),
437-
)
438-
}
426+
env = append(env, r.getFailEnvironment(fail)...)
439427

440428
for i, postCommand := range r.profile.RunAfterFail {
441429
clog.Debugf("starting 'run-after-fail' profile command %d/%d", i+1, len(r.profile.RunAfterFail))
@@ -451,6 +439,37 @@ func (r *resticWrapper) runProfilePostFailCommand(fail error) error {
451439
return nil
452440
}
453441

442+
func (r *resticWrapper) runFinalCommand(command string, fail error) {
443+
var commands []string
444+
445+
if command == constants.CommandBackup && r.profile.Backup != nil && r.profile.Backup.RunFinally != nil {
446+
commands = append(commands, r.profile.Backup.RunFinally...)
447+
}
448+
if r.profile.RunFinally != nil {
449+
commands = append(commands, r.profile.RunFinally...)
450+
}
451+
452+
env := append(os.Environ(), r.getEnvironment()...)
453+
env = append(env, r.getProfileEnvironment()...)
454+
env = append(env, r.getFailEnvironment(fail)...)
455+
456+
for i := len(commands) - 1; i >= 0; i-- {
457+
// Using defer stack for "finally" to ensure every command is run even on panic
458+
defer func(index int, cmd string) {
459+
clog.Debugf("starting final command %d/%d", index+1, len(commands))
460+
rCommand := newShellCommand(cmd, nil, env, r.dryRun, r.sigChan, r.setPID)
461+
// stdout are stderr are coming from the default terminal (in case they're redirected)
462+
rCommand.stdout = term.GetOutput()
463+
rCommand.stderr = term.GetErrorOutput()
464+
_, _, err := runShellCommand(rCommand)
465+
if err != nil {
466+
clog.Errorf("run-finally command %d/%d failed ('%s' on profile '%s'): %w",
467+
index+1, len(commands), command, r.profile.Name, err)
468+
}
469+
}(i, commands[i])
470+
}
471+
}
472+
454473
// getEnvironment returns the environment variables defined in the profile configuration
455474
func (r *resticWrapper) getEnvironment() []string {
456475
if r.profile.Environment == nil || len(r.profile.Environment) == 0 {
@@ -477,6 +496,31 @@ func (r *resticWrapper) getProfileEnvironment() []string {
477496
}
478497
}
479498

499+
// getFailEnvironment returns additional environment variables describing the fail reason
500+
func (r *resticWrapper) getFailEnvironment(err error) (env []string) {
501+
if err == nil {
502+
return
503+
}
504+
505+
env = []string{fmt.Sprintf("ERROR=%s", err.Error())}
506+
507+
if fail, ok := err.(*commandError); ok {
508+
exitCode := -1
509+
if code, err := fail.ExitCode(); err == nil {
510+
exitCode = code
511+
}
512+
513+
env = append(env,
514+
fmt.Sprintf("ERROR_COMMANDLINE=%s", fail.Commandline()),
515+
fmt.Sprintf("ERROR_EXIT_CODE=%d", exitCode),
516+
fmt.Sprintf("ERROR_STDERR=%s", fail.Stderr()),
517+
// Deprecated: STDERR can originate from (pre/post)-command which doesn't need to be restic
518+
fmt.Sprintf("RESTIC_STDERR=%s", fail.Stderr()),
519+
)
520+
}
521+
return
522+
}
523+
480524
// canSucceedAfterError returns true if an error reported by running restic in runCommand can be counted as success
481525
func (r *resticWrapper) canSucceedAfterError(command string, summary progress.Summary, err error) bool {
482526
if err == nil {
@@ -689,12 +733,20 @@ func logLockWait(lockName string, started, lastLogged time.Time, maxLockWait tim
689733
}
690734

691735
// runOnFailure will run the onFailure function if an error occurred in the run function
692-
func runOnFailure(run func() error, onFailure func(error)) error {
693-
err := run()
736+
func runOnFailure(run func() error, onFailure func(error), finally func(error)) (err error) {
737+
// Using "defer" for finally to ensure it runs even on panic
738+
if finally != nil {
739+
defer func() {
740+
finally(err)
741+
}()
742+
}
743+
744+
err = run()
694745
if err != nil {
695746
onFailure(err)
696747
}
697-
return err
748+
749+
return
698750
}
699751

700752
func asExitError(err error) (*exec.ExitError, bool) {

wrapper_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,51 @@ func TestPostFailProfile(t *testing.T) {
111111
_ = os.Remove(testFile)
112112
}
113113

114+
func TestFinallyProfile(t *testing.T) {
115+
testFile := "TestFinallyProfile.txt"
116+
defer os.Remove(testFile)
117+
118+
var profile *config.Profile
119+
newProfile := func() {
120+
_ = os.Remove(testFile)
121+
profile = config.NewProfile(nil, "name")
122+
profile.RunFinally = []string{"echo finally > " + testFile}
123+
profile.Backup = &config.BackupSection{}
124+
profile.Backup.RunFinally = []string{"echo finally-backup > " + testFile}
125+
}
126+
127+
assertFileEquals := func(t *testing.T, expected string) {
128+
content, err := os.ReadFile(testFile)
129+
require.NoError(t, err)
130+
assert.Equal(t, strings.TrimSpace(string(content)), expected)
131+
}
132+
133+
t.Run("backup-before-profile", func(t *testing.T) {
134+
newProfile()
135+
wrapper := newResticWrapper("echo", false, profile, "backup", nil, nil)
136+
err := wrapper.runProfile()
137+
assert.NoError(t, err)
138+
assertFileEquals(t, "finally")
139+
})
140+
141+
t.Run("on-backup-only", func(t *testing.T) {
142+
newProfile()
143+
profile.RunFinally = nil
144+
wrapper := newResticWrapper("echo", false, profile, "backup", nil, nil)
145+
err := wrapper.runProfile()
146+
assert.NoError(t, err)
147+
assertFileEquals(t, "finally-backup")
148+
})
149+
150+
t.Run("on-error", func(t *testing.T) {
151+
newProfile()
152+
wrapper := newResticWrapper("exit", false, profile, "1", nil, nil)
153+
err := wrapper.runProfile()
154+
assert.EqualError(t, err, "1 on profile 'name': exit status 1")
155+
assertFileEquals(t, "finally")
156+
})
157+
}
158+
114159
func Example_runProfile() {
115160
term.SetOutput(os.Stdout)
116161
profile := config.NewProfile(nil, "name")

0 commit comments

Comments
 (0)