Compare commits

...

7 Commits

Author SHA1 Message Date
Boris Bera
c47a7f5b9d
Merge d15945f4b3 into f7d28b486c 2024-11-10 18:20:12 +00:00
Boris Bera
d15945f4b3
Merge branch 'master' into lazy-init 2024-11-10 13:20:03 -05:00
dependabot[bot]
f7d28b486c
Bump restic/restic from 0.17.1 to 0.17.2 (#407)
Bumps restic/restic from 0.17.1 to 0.17.2.

---
updated-dependencies:
- dependency-name: restic/restic
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-04 15:21:16 +01:00
Boris Bera
6895df1c83
fix(config): fix config marshaling producing unreadable config file (#402)
There are two practical changes when the config gets updated:
- The `forgetoption` and `configoption` bug is now gone
- Superfluous config keys no longer get written out
2024-11-04 15:20:42 +01:00
Boris Bera
5ed1af31e0
feat(backend): add init option to backend config
When this option is set to `true`, the backend will automatically get
initialized during the backup process. This applies to invoking
`autorestic backup` or when `autorestic cron` actualy performs a backup.
2024-10-14 14:23:46 -04:00
Boris Bera
8108c52f50
chore(ci): install restic in test env 2024-10-14 14:23:46 -04:00
Boris Bera
eb4013b51d
chore(tests): add integration tests for backup cmd 2024-10-14 14:23:28 -04:00
10 changed files with 291 additions and 40 deletions

View File

@ -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'

View File

@ -6,7 +6,7 @@ RUN go mod download
COPY . .
RUN go build
FROM restic/restic:0.17.1
FROM restic/restic:0.17.2
RUN apk add --no-cache rclone bash curl docker-cli
COPY --from=builder /app/autorestic /usr/bin/autorestic
ENTRYPOINT []

73
cmd/backup_test.go Normal file
View File

@ -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))
}

View File

@ -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
go.mod
View File

@ -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
)

View File

@ -14,19 +14,20 @@ import (
)
type BackendRest struct {
User string `mapstructure:"user,omitempty"`
Password string `mapstructure:"password,omitempty"`
User string `mapstructure:"user,omitempty" yaml:"user,omitempty"`
Password string `mapstructure:"password,omitempty" yaml:"password,omitempty"`
}
type Backend struct {
name string
Type string `mapstructure:"type,omitempty"`
Path string `mapstructure:"path,omitempty"`
Key string `mapstructure:"key,omitempty"`
RequireKey bool `mapstructure:"requireKey,omitempty"`
Env map[string]string `mapstructure:"env,omitempty"`
Rest BackendRest `mapstructure:"rest,omitempty"`
Options Options `mapstructure:"options,omitempty"`
Type string `mapstructure:"type,omitempty" yaml:"type,omitempty"`
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"`
}
func GetBackend(name string) (Backend, bool) {
@ -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 {

View File

@ -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)
}

View File

@ -23,11 +23,11 @@ type OptionMap map[string][]interface{}
type Options map[string]OptionMap
type Config struct {
Version string `mapstructure:"version"`
Extras interface{} `mapstructure:"extras"`
Locations map[string]Location `mapstructure:"locations"`
Backends map[string]Backend `mapstructure:"backends"`
Global Options `mapstructure:"global"`
Version string `mapstructure:"version" yaml:"version"`
Extras interface{} `mapstructure:"extras" yaml:"extras"`
Locations map[string]Location `mapstructure:"locations" yaml:"locations"`
Backends map[string]Backend `mapstructure:"backends" yaml:"backends"`
Global Options `mapstructure:"global" yaml:"global"`
}
var once sync.Once

View File

@ -1,10 +1,15 @@
package internal
import (
"path"
"reflect"
"strconv"
"strings"
"sync"
"testing"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
)
func TestOptionToString(t *testing.T) {
@ -143,6 +148,48 @@ func TestGetOptionsMultipleKeys(t *testing.T) {
reflect.DeepEqual(result, expected)
}
func TestSaveConfigProducesReadableConfig(t *testing.T) {
workDir := t.TempDir()
viper.SetConfigFile(path.Join(workDir, ".autorestic.yml"))
// Required to appease the config reader
viper.Set("version", 2)
c := Config{
Version: "2",
Locations: map[string]Location{
"test": {
Type: "local",
name: "test",
From: []string{"in-dir"},
To: []string{"test"},
// ForgetOption & ConfigOption have previously marshalled in a way that
// can't get read correctly
ForgetOption: "foo",
CopyOption: map[string][]string{"foo": {"bar"}},
},
},
Backends: map[string]Backend{
"test": {
name: "test",
Type: "local",
Path: "backup-target",
Key: "supersecret",
},
},
}
err := c.SaveConfig()
assert.NoError(t, err)
// Ensure we the config reading logic actually runs
config = nil
once = sync.Once{}
readConfig := GetConfig()
assert.NotNil(t, readConfig)
assert.Equal(t, c, *readConfig)
}
func assertEqual[T comparable](t testing.TB, result, expected T) {
t.Helper()

View File

@ -34,26 +34,26 @@ const (
)
type Hooks struct {
Dir string `mapstructure:"dir"`
PreValidate HookArray `mapstructure:"prevalidate,omitempty"`
Before HookArray `mapstructure:"before,omitempty"`
After HookArray `mapstructure:"after,omitempty"`
Success HookArray `mapstructure:"success,omitempty"`
Failure HookArray `mapstructure:"failure,omitempty"`
Dir string `mapstructure:"dir" yaml:"dir"`
PreValidate HookArray `mapstructure:"prevalidate,omitempty" yaml:"prevalidate,omitempty"`
Before HookArray `mapstructure:"before,omitempty" yaml:"before,omitempty"`
After HookArray `mapstructure:"after,omitempty" yaml:"after,omitempty"`
Success HookArray `mapstructure:"success,omitempty" yaml:"success,omitempty"`
Failure HookArray `mapstructure:"failure,omitempty" yaml:"failure,omitempty"`
}
type LocationCopy = map[string][]string
type Location struct {
name string `mapstructure:",omitempty"`
From []string `mapstructure:"from,omitempty"`
Type string `mapstructure:"type,omitempty"`
To []string `mapstructure:"to,omitempty"`
Hooks Hooks `mapstructure:"hooks,omitempty"`
Cron string `mapstructure:"cron,omitempty"`
Options Options `mapstructure:"options,omitempty"`
ForgetOption LocationForgetOption `mapstructure:"forget,omitempty"`
CopyOption LocationCopy `mapstructure:"copy,omitempty"`
name string `mapstructure:",omitempty" yaml:",omitempty"`
From []string `mapstructure:"from,omitempty" yaml:"from,omitempty"`
Type string `mapstructure:"type,omitempty" yaml:"type,omitempty"`
To []string `mapstructure:"to,omitempty" yaml:"to,omitempty"`
Hooks Hooks `mapstructure:"hooks,omitempty" yaml:"hooks,omitempty"`
Cron string `mapstructure:"cron,omitempty" yaml:"cron,omitempty"`
Options Options `mapstructure:"options,omitempty" yaml:"options,omitempty"`
ForgetOption LocationForgetOption `mapstructure:"forget,omitempty" yaml:"forget,omitempty"`
CopyOption LocationCopy `mapstructure:"copy,omitempty" yaml:"copy,omitempty"`
}
func GetLocation(name string) (Location, bool) {
@ -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 {