Skip to content

Commit

Permalink
Add option to set working directory for restic backup (#354)
Browse files Browse the repository at this point in the history
* feat: allow to set a working directory for the restic backup command

* fix: `TestRunShellWorkingDir` cross-platform compatible

* refactor: rename `ChangeWorkingDir` option to `SourceRelative`

* refactor: check if `p.Backup` is `nil`

* fix: `TestRunShellWorkingDir` for GitHub Actions on Windows

* fix: only change the backup command's working directory if `source-relative` is set

* fix: add path change report when `source-base` is not set
  • Loading branch information
seiuneko authored Apr 3, 2024
1 parent 1bbac2a commit f9b5dac
Show file tree
Hide file tree
Showing 8 changed files with 70 additions and 19 deletions.
2 changes: 1 addition & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ func (c *Config) DisplayConfigurationIssues() {
msg = append([]string{
"the configuration contains relative \"path\" items which may lead to unstable results in restic " +
"commands that select snapshots. Consider using absolute paths in \"path\" (and \"source\"), " +
"set \"base-dir\" in the profile or use \"tag\" instead of \"path\" (path = false) to select " +
"set \"base-dir\" or \"source-base\" in the profile or use \"tag\" instead of \"path\" (path = false) to select " +
"snapshots for restic commands.",
"Affected paths are:",
}, msg...)
Expand Down
33 changes: 20 additions & 13 deletions config/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ type BackupSection struct {
CheckAfter bool `mapstructure:"check-after" description:"Check the repository after the backup command succeeded"`
UseStdin bool `mapstructure:"stdin" argument:"stdin"`
StdinCommand []string `mapstructure:"stdin-command" description:"Shell command(s) that generate content to redirect into the stdin of restic. When set, the flag \"stdin\" is always set to \"true\"."`
SourceRelative bool `mapstructure:"source-relative" description:"Enable backup with relative source paths. This will change the working directory of the \"restic backup\" command to \"source-base\", and will not expand \"source\" to an absolute path."`
SourceBase string `mapstructure:"source-base" examples:"/;$PWD;C:\\;%cd%" description:"The base path to resolve relative backup paths against. Defaults to current directory if unset or empty (see also \"base-dir\" in profile)"`
Source []string `mapstructure:"source" examples:"/opt/;/home/user/;C:\\Users\\User\\Documents" description:"The paths to backup"`
Exclude []string `mapstructure:"exclude" argument:"exclude" argument-type:"no-glob"`
Expand All @@ -198,7 +199,7 @@ func (b *BackupSection) resolve(profile *Profile) {
if b.unresolvedSource == nil {
b.unresolvedSource = b.Source
}
b.Source = profile.resolveSourcePath(b.SourceBase, b.unresolvedSource...)
b.Source = profile.resolveSourcePath(b.SourceBase, b.SourceRelative, b.unresolvedSource...)

// Extras, only enabled for Version >= 2 (to remain backward compatible in version 1)
if profile.config != nil && profile.config.version >= Version02 {
Expand Down Expand Up @@ -648,18 +649,24 @@ func (p *Profile) SetRootPath(rootPath string) {
}
}

func (p *Profile) resolveSourcePath(sourceBase string, sourcePaths ...string) []string {
func (p *Profile) resolveSourcePath(sourceBase string, relativePaths bool, sourcePaths ...string) []string {
var applySourceBase, applyBaseDir pathFix

// Backup source is NOT relative to the configuration, but to PWD or sourceBase (if not empty)
// Applying "sourceBase" if set
if sourceBase = strings.TrimSpace(sourceBase); sourceBase != "" {
sourceBase = fixPath(sourceBase, expandEnv, expandUserHome)
applySourceBase = absolutePrefix(sourceBase)
}
// Applying a custom PWD eagerly so that own commands (e.g. "show") display correct paths
if p.BaseDir != "" {
applyBaseDir = absolutePrefix(p.BaseDir)
sourceBase = fixPath(strings.TrimSpace(sourceBase), expandEnv, expandUserHome)
// When "source-relative" is set, the source paths are relative to the "source-base"
if !relativePaths {
// Backup source is NOT relative to the configuration, but to PWD or sourceBase (if not empty)
// Applying "sourceBase" if set
if sourceBase != "" {
applySourceBase = absolutePrefix(sourceBase)
}
// Applying a custom PWD eagerly so that own commands (e.g. "show") display correct paths
if p.BaseDir != "" {
applyBaseDir = absolutePrefix(p.BaseDir)
}

} else if p.BaseDir == "" && sourceBase == "" && p.config != nil {
p.config.reportChangedPath(".", "<none>", "source-base (for relative source)")
}

// prefix paths starting with "-" with a "./" to distinguish a source path from a flag
Expand Down Expand Up @@ -693,7 +700,7 @@ func (p *Profile) SetTag(tags ...string) {
// SetPath will replace any path value from a boolean to sourcePaths and change paths to absolute
func (p *Profile) SetPath(basePath string, sourcePaths ...string) {
resolvePath := func(origin string, paths []string, revolver func(string) []string) (resolved []string) {
hasAbsoluteBase := len(p.BaseDir) > 0 && filepath.IsAbs(p.BaseDir)
hasAbsoluteBase := len(p.BaseDir) > 0 && filepath.IsAbs(p.BaseDir) || basePath != "" && filepath.IsAbs(basePath)
for _, path := range paths {
if len(path) > 0 {
for _, rp := range revolver(path) {
Expand All @@ -720,7 +727,7 @@ func (p *Profile) SetPath(basePath string, sourcePaths ...string) {
// Replace bool-true with absolute sourcePaths
if !sourcePathsResolved {
sourcePaths = resolvePath("path (from source)", sourcePaths, func(path string) []string {
return fixPaths(p.resolveSourcePath(basePath, path), absolutePath)
return fixPaths(p.resolveSourcePath(basePath, false, path), absolutePath)
})
sourcePathsResolved = true
}
Expand Down
20 changes: 15 additions & 5 deletions config/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -636,9 +636,10 @@ func TestResolveSourcesWithFlagPrefixInBackup(t *testing.T) {
}

func TestResolveSourcesAgainstBase(t *testing.T) {
backupSource := func(base, source string) []string {
backupSource := func(base, source string, changeWorkingDir bool) []string {
config := `
[profile.backup]
source-relative = ` + strconv.FormatBool(changeWorkingDir) + `
source-base = "` + filepath.ToSlash(base) + `"
source = "` + filepath.ToSlash(source) + `"
`
Expand All @@ -653,18 +654,27 @@ func TestResolveSourcesAgainstBase(t *testing.T) {
assert.NoError(t, err)

t.Run("no-base", func(t *testing.T) {
assert.Equal(t, []string{"src"}, backupSource("", "src"))
assert.Equal(t, []string{"src"}, backupSource("", "src", false))
})
t.Run("relative-base", func(t *testing.T) {
assert.Equal(t, []string{filepath.Join("rel", "src")}, backupSource("rel", "src"))
assert.Equal(t, []string{filepath.Join("rel", "src")}, backupSource("rel", "src", false))
})
t.Run("absolute-base", func(t *testing.T) {
assert.Equal(t, []string{filepath.Join(cwd, "src")}, backupSource(cwd, "src"))
assert.Equal(t, []string{filepath.Join(cwd, "src")}, backupSource(cwd, "src", false))
})
t.Run("env-var-base", func(t *testing.T) {
assert.NoError(t, os.Setenv("RP_TEST_CWD", cwd))
defer os.Unsetenv("RP_TEST_CWD")
assert.Equal(t, []string{filepath.Join(cwd, "path", "src")}, backupSource("${RP_TEST_CWD}/path", "src"))
assert.Equal(t, []string{filepath.Join(cwd, "path", "src")}, backupSource("${RP_TEST_CWD}/path", "src", false))
})
t.Run("change-relative-working-dir", func(t *testing.T) {
assert.Equal(t, []string{"."}, backupSource("path", ".", true))
assert.Equal(t, []string{filepath.Join("path", ".")}, backupSource("path", ".", false))
})
t.Run("change-env-var-working-dir", func(t *testing.T) {
assert.NoError(t, os.Setenv("RP_TEST_ENV", "."))
defer os.Unsetenv("RP_TEST_ENV")
assert.Equal(t, []string{"."}, backupSource("path", "${RP_TEST_ENV}", true))
})
}

Expand Down
1 change: 1 addition & 0 deletions docs/content/configuration/path.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ default {
{{% notice hint %}}
Set `base-dir` to an absolute path to resolve `files` and `local:backup` relative to it.
Set `source-base` if you need a separate base path for backup sources.
When you want to use relative source paths for backup, set the `source-relative` option. This will change the working directory of the `restic backup` command to `source-base` and will not expand `source` to an absolute path.
{{% /notice %}}

## How the configuration file is resolved
Expand Down
2 changes: 2 additions & 0 deletions shell/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ func (c *Command) Run() (monitor.Summary, string, error) {
cmd.Env = append(cmd.Env, c.Environ...)
}

cmd.Dir = c.Dir

start := time.Now()

// spawn the child process
Expand Down
21 changes: 21 additions & 0 deletions shell/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package shell
import (
"bytes"
"fmt"
"github.com/creativeprojects/resticprofile/platform"
"io/ioutil"
"os"
"os/exec"
Expand Down Expand Up @@ -285,6 +286,26 @@ func TestSelectCustomShell(t *testing.T) {
assert.Empty(t, shell)
}

func TestRunShellWorkingDir(t *testing.T) {
command := func() string {
if platform.IsWindows() {
return "@echo %CD%"
}
return "pwd"
}()
temp := t.TempDir()
buffer := new(strings.Builder)
cmd := NewCommand(command, nil)
cmd.Stdout = buffer
cmd.Dir = temp
_, _, err := cmd.Run()
if err != nil {
t.Fatal(err)
}

assert.Contains(t, strings.TrimSpace(buffer.String()), temp)
}

func TestRunShellEcho(t *testing.T) {
buffer := &bytes.Buffer{}
cmd := NewCommand("echo", []string{"TestRunShellEcho"})
Expand Down
5 changes: 5 additions & 0 deletions shell_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type shellCommandDefinition struct {
publicArgs []string
env []string
shell []string
dir string
stdin io.ReadCloser
stdout io.Writer
stderr io.Writer
Expand Down Expand Up @@ -85,6 +86,10 @@ func runShellCommand(command shellCommandDefinition) (summary monitor.Summary, s
shellCmd.Environ = append(shellCmd.Environ, command.env...)
}

// If Dir is the empty string, Run runs the command in the
// calling process's current directory.
shellCmd.Dir = command.dir

// scan output
if command.scanOutput != nil {
shellCmd.ScanStdout = command.scanOutput
Expand Down
5 changes: 5 additions & 0 deletions wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,12 @@ func (r *resticWrapper) prepareCommand(command string, args *shell.Args, allowEx
args.AddArgs(moreArgs, shell.ArgCommandLineEscape)

// Special case for backup command
var dir string
if command == constants.CommandBackup {
args.AddArgs(r.profile.GetBackupSource(), shell.ArgConfigBackupSource)
if r.profile.Backup != nil && r.profile.Backup.SourceRelative {
dir = r.profile.Backup.SourceBase
}
}
// Special case for copy command
if command == constants.CommandCopy {
Expand Down Expand Up @@ -409,6 +413,7 @@ func (r *resticWrapper) prepareCommand(command string, args *shell.Args, allowEx
rCommand.stdout = term.GetOutput()
rCommand.stderr = term.GetErrorOutput()
rCommand.streamError = r.profile.StreamError
rCommand.dir = dir

return rCommand
}
Expand Down

0 comments on commit f9b5dac

Please sign in to comment.