diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb3e4ae..04130b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,25 @@ on: push: branches: [master] +env: + RESTIC_VERSION: "0.17.1" + jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + + - name: Install restic@${{ env.RESTIC_VERSION }} + run: | + mkdir -p tools/restic + curl --fail --location --silent --show-error --output tools/restic/restic.bz2 \ + "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2" + bzip2 -d tools/restic/restic.bz2 + chmod +x tools/restic/restic + echo "$GITHUB_WORKSPACE/tools/restic" >> "$GITHUB_PATH" + - run: restic version + - uses: actions/setup-go@v3 with: go-version: '^1.21' diff --git a/cmd/backup_test.go b/cmd/backup_test.go new file mode 100644 index 0000000..2b1287f --- /dev/null +++ b/cmd/backup_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "os" + "path" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + viper.Reset() + rootCmd.SetArgs(args) + + err := rootCmd.Execute() + return err +} + +func TestBackupCmd(t *testing.T) { + workDir := t.TempDir() + + // Prepare content to be backed up + locationDir := path.Join(workDir, "my-location") + err := os.Mkdir(locationDir, 0750) + assert.Nil(t, err) + err = os.WriteFile(path.Join(locationDir, "back-me-up.txt"), []byte("hello world"), 0640) + assert.Nil(t, err) + + // Write config file + config, err := yaml.Marshal(map[string]interface{}{ + "version": 2, + "locations": map[string]map[string]interface{}{ + "my-location": { + "type": "local", + "from": []string{locationDir}, + "to": []string{"test"}, + }, + }, + "backends": map[string]map[string]interface{}{ + "test": { + "type": "local", + "path": path.Join(workDir, "test-backend"), + "key": "supersecret", + }, + }, + }) + assert.Nil(t, err) + configPath := path.Join(workDir, ".autorestic.yml") + err = os.WriteFile(configPath, config, 0640) + assert.Nil(t, err) + + // Init repo (not initialized by default) + err = runCmd(t, "exec", "--ci", "-a", "-c", configPath, "init") + assert.Nil(t, err) + + // Do the backup + err = runCmd(t, "backup", "--ci", "-a", "-c", configPath) + assert.Nil(t, err) + + // Restore in a separate dir + restoreDir := path.Join(workDir, "restore") + err = runCmd(t, "restore", "--ci", "-c", configPath, "-l", "my-location", "--to", restoreDir) + assert.Nil(t, err) + + // Check restored file + restoredContent, err := os.ReadFile(path.Join(restoreDir, locationDir, "back-me-up.txt")) + assert.Nil(t, err) + assert.Equal(t, "hello world", string(restoredContent)) +} diff --git a/docs/pages/backend/index.md b/docs/pages/backend/index.md index 01a14fa..545abcf 100644 --- a/docs/pages/backend/index.md +++ b/docs/pages/backend/index.md @@ -38,3 +38,19 @@ backends: ``` With this setting, if a key is missing, `autorestic` will crash instead of generating a new key and updating your config file. + +## Automatic Backend Initialization + +`autorestic` is able to automatically initialize backends for you. This is done by setting `init: true` in the config for a given backend. For example: + +```yaml | .autorestic.yml +backend: + foo: + type: ... + path: ... + init: true +``` + +When you set `init: true` on a backend config, `autorestic` will automatically initialize the underlying `restic` repository that powers the backend if it's not already initialized. In practice, this means that the backend will be initialized the first time it is being backed up to. + +This option is helpful in cases where you want to automate the configuration of `autorestic`. This means that instead of running `autorestic exec init -b ...` manually when you create a new backend, you can let `autorestic` initialize it for you. diff --git a/go.mod b/go.mod index 8b1d3e9..577a95d 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/spf13/cobra v1.4.0 github.com/spf13/viper v1.11.0 github.com/stretchr/testify v1.9.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -35,5 +36,4 @@ require ( golang.org/x/text v0.3.8 // indirect gopkg.in/ini.v1 v1.66.4 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/backend.go b/internal/backend.go index 669935a..fd76990 100644 --- a/internal/backend.go +++ b/internal/backend.go @@ -24,6 +24,7 @@ type Backend struct { Path string `mapstructure:"path,omitempty"` Key string `mapstructure:"key,omitempty"` RequireKey bool `mapstructure:"requireKey,omitempty"` + Init bool `mapstructure:"init,omitempty"` Env map[string]string `mapstructure:"env,omitempty"` Rest BackendRest `mapstructure:"rest,omitempty"` Options Options `mapstructure:"options,omitempty"` @@ -130,20 +131,44 @@ func (b Backend) validate() error { return err } options := ExecuteOptions{Envs: env, Silent: true} - // Check if already initialized + + err = b.EnsureInit() + if err != nil { + return err + } + cmd := []string{"check"} cmd = append(cmd, combineBackendOptions("check", b)...) _, _, err = ExecuteResticCommand(options, cmd...) - if err == nil { - return nil - } else { - // If not initialize - colors.Body.Printf("Initializing backend \"%s\"...\n", b.name) - cmd := []string{"init"} - cmd = append(cmd, combineBackendOptions("init", b)...) - _, _, err := ExecuteResticCommand(options, cmd...) + return err +} + +// EnsureInit initializes the backend if it is not already initialized +func (b Backend) EnsureInit() error { + env, err := b.getEnv() + if err != nil { return err } + options := ExecuteOptions{Envs: env, Silent: true} + + checkInitCmd := []string{"cat", "config"} + checkInitCmd = append(checkInitCmd, combineBackendOptions("cat", b)...) + _, _, err = ExecuteResticCommand(options, checkInitCmd...) + + // Note that `restic` has a special exit code (10) to indicate that the + // repository does not exist. This exit code was introduced in `restic@0.17.0` + // on 2024-07-26. We're not using it here because this is a too recent and + // people on older versions of `restic` won't have this feature work correctly. + // See: https://restic.readthedocs.io/en/latest/075_scripting.html#exit-codes + if err != nil { + colors.Body.Printf("Initializing backend \"%s\"...\n", b.name) + initCmd := []string{"init"} + initCmd = append(initCmd, combineBackendOptions("init", b)...) + _, _, err := ExecuteResticCommand(options, initCmd...) + return err + } + + return err } func (b Backend) Exec(args []string) error { diff --git a/internal/backend_test.go b/internal/backend_test.go index e65e4fe..508297f 100644 --- a/internal/backend_test.go +++ b/internal/backend_test.go @@ -3,8 +3,10 @@ package internal import ( "fmt" "os" + "path" "testing" + "github.com/cupcakearmy/autorestic/internal/flags" "github.com/spf13/viper" "github.com/stretchr/testify/assert" ) @@ -263,3 +265,69 @@ func TestValidate(t *testing.T) { assert.EqualError(t, err, "backend foo requires a key but none was provided") }) } + +func TestValidateInitsRepo(t *testing.T) { + // This is normally initialized by the cobra commands but they don't run in + // this test so we do it ourselves. + flags.RESTIC_BIN = "restic" + + workDir := t.TempDir() + + b := Backend{ + name: "test", + Type: "local", + Path: path.Join(workDir, "backend"), + Key: "supersecret", + } + + config = &Config{Backends: map[string]Backend{"test": b}} + defer func() { config = nil }() + + // Check should fail because the repo doesn't exist + err := b.Exec([]string{"check"}) + assert.Error(t, err) + + err = b.validate() + assert.NoError(t, err) + + // Check should pass now + err = b.Exec([]string{"check"}) + assert.NoError(t, err) +} + +func TestEnsureInit(t *testing.T) { + // This is normally initialized by the cobra commands but they don't run in + // this test so we do it ourselves. + flags.RESTIC_BIN = "restic" + + workDir := t.TempDir() + + b := Backend{ + name: "test", + Type: "local", + Path: path.Join(workDir, "backend"), + Key: "supersecret", + } + + config = &Config{Backends: map[string]Backend{"test": b}} + defer func() { config = nil }() + + // Check should fail because the repo doesn't exist + err := b.Exec([]string{"check"}) + assert.Error(t, err) + + err = b.EnsureInit() + assert.NoError(t, err) + + // Check should pass now + err = b.Exec([]string{"check"}) + assert.NoError(t, err) + + // Run again to make sure it's idempotent + err = b.EnsureInit() + assert.NoError(t, err) + + // Check should still pass + err = b.Exec([]string{"check"}) + assert.NoError(t, err) +} diff --git a/internal/location.go b/internal/location.go index a20efa7..e8cc84a 100644 --- a/internal/location.go +++ b/internal/location.go @@ -222,6 +222,14 @@ func (l Location) Backup(cron bool, specificBackend string) []error { continue } + if backend.Init { + err = backend.EnsureInit() + if err != nil { + errors = append(errors, err) + continue + } + } + cmd := []string{"backup"} cmd = append(cmd, combineAllOptions("backup", l, backend)...) if cron {