Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add init backend config to automatically initialize restic repository #401

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
73 changes: 73 additions & 0 deletions cmd/backup_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
16 changes: 16 additions & 0 deletions docs/pages/backend/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
)
41 changes: 33 additions & 8 deletions internal/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Backend struct {
Path string `mapstructure:"path,omitempty" yaml:"path,omitempty"`
Key string `mapstructure:"key,omitempty" yaml:"key,omitempty"`
RequireKey bool `mapstructure:"requireKey,omitempty" yaml:"requireKey,omitempty"`
Init bool `mapstructure:"init,omitempty" yaml:"init,omitempty"`
Env map[string]string `mapstructure:"env,omitempty" yaml:"env,omitempty"`
Rest BackendRest `mapstructure:"rest,omitempty" yaml:"rest,omitempty"`
Options Options `mapstructure:"options,omitempty" yaml:"options,omitempty"`
Expand Down Expand Up @@ -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
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 `[email protected]`
// 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)
cmd := []string{"init"}
cmd = append(cmd, combineBackendOptions("init", b)...)
_, _, err := ExecuteResticCommand(options, cmd...)
initCmd := []string{"init"}
initCmd = append(initCmd, combineBackendOptions("init", b)...)
_, _, err := ExecuteResticCommand(options, initCmd...)
return err
}

return err
}

func (b Backend) Exec(args []string) error {
Expand Down
68 changes: 68 additions & 0 deletions internal/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
8 changes: 8 additions & 0 deletions internal/location.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,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 {
Expand Down
Loading