Compare commits

..

7 Commits

Author SHA1 Message Date
dependabot[bot]
d147602b2e Bump restic/restic from 0.17.3 to 0.18.0
Bumps restic/restic from 0.17.3 to 0.18.0.

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-31 21:43:15 +00:00
Duru Can Celasun
bb29a98614 feat: Run PreValidate hooks for check cmd (#437)
PreValidate can be used to mount remote directories (e.g. via NFS) so
they must executed first before running any restic commands.

This was done for the backup command in 13aa560, but not for check. This
commit fixes that.
2025-03-22 19:12:36 +01:00
Duru Can Celasun
39f4f87ce3 feat: Add --dry-run to backup command (#438)
Restic supports --dry-run for backups since 0.13.0 [1] and this adds
support for that.

[1] bc97a3d1f9
2025-03-22 19:10:51 +01:00
dependabot[bot]
bd36bbe429 Bump golang from 1.23-alpine to 1.24-alpine (#429)
Bumps golang from 1.23-alpine to 1.24-alpine.

---
updated-dependencies:
- dependency-name: golang
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-25 13:24:12 +01:00
dependabot[bot]
48fa20b482 Bump restic/restic from 0.17.2 to 0.17.3 (#410)
Bumps restic/restic from 0.17.2 to 0.17.3.

---
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-14 17:35:03 +01: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
12 changed files with 122 additions and 254 deletions

View File

@@ -4,25 +4,11 @@ on:
push: push:
branches: [master] branches: [master]
env:
RESTIC_VERSION: "0.17.1"
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - 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 - uses: actions/setup-go@v3
with: with:
go-version: '^1.21' go-version: '^1.21'

View File

@@ -1,4 +1,4 @@
FROM golang:1.23-alpine as builder FROM golang:1.24-alpine as builder
WORKDIR /app WORKDIR /app
COPY go.* . COPY go.* .
@@ -6,7 +6,7 @@ RUN go mod download
COPY . . COPY . .
RUN go build RUN go build
FROM restic/restic:0.17.1 FROM restic/restic:0.18.0
RUN apk add --no-cache rclone bash curl docker-cli RUN apk add --no-cache rclone bash curl docker-cli
COPY --from=builder /app/autorestic /usr/bin/autorestic COPY --from=builder /app/autorestic /usr/bin/autorestic
ENTRYPOINT [] ENTRYPOINT []

View File

@@ -18,9 +18,11 @@ var backupCmd = &cobra.Command{
err := lock.Lock() err := lock.Lock()
CheckErr(err) CheckErr(err)
defer lock.Unlock() defer lock.Unlock()
dry, _ := cmd.Flags().GetBool("dry-run")
selected, err := internal.GetAllOrSelected(cmd, false) selected, err := internal.GetAllOrSelected(cmd, false)
CheckErr(err) CheckErr(err)
errors := 0 errors := 0
for _, name := range selected { for _, name := range selected {
var splitted = strings.Split(name, "@") var splitted = strings.Split(name, "@")
@@ -29,7 +31,7 @@ var backupCmd = &cobra.Command{
specificBackend = splitted[1] specificBackend = splitted[1]
} }
location, _ := internal.GetLocation(splitted[0]) location, _ := internal.GetLocation(splitted[0])
errs := location.Backup(false, specificBackend) errs := location.Backup(false, dry, specificBackend)
for _, err := range errs { for _, err := range errs {
colors.Error.Printf("%s\n\n", err) colors.Error.Printf("%s\n\n", err)
errors++ errors++
@@ -44,4 +46,5 @@ var backupCmd = &cobra.Command{
func init() { func init() {
rootCmd.AddCommand(backupCmd) rootCmd.AddCommand(backupCmd)
internal.AddFlagsToCommand(backupCmd, false) internal.AddFlagsToCommand(backupCmd, false)
backupCmd.Flags().Bool("dry-run", false, "do not write changes, show what would be affected")
} }

View File

@@ -1,73 +0,0 @@
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,19 +38,3 @@ backends:
``` ```
With this setting, if a key is missing, `autorestic` will crash instead of generating a new key and updating your config file. 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.

View File

@@ -1,11 +1,14 @@
# Backup # Backup
```bash ```bash
autorestic backup [-l, --location] [-a, --all] autorestic backup [-l, --location] [-a, --all] [--dry-run]
``` ```
Performs a backup of all locations if the `-a` flag is passed. To only backup some locations pass one or more `-l` or `--location` flags. Performs a backup of all locations if the `-a` flag is passed. To only backup some locations pass one or more `-l` or `--location` flags.
The `--dry-run` flag will do a dry run showing what would have been deleted, but won't touch the actual data.
```bash ```bash
# All # All
autorestic backup -a autorestic backup -a

2
go.mod
View File

@@ -12,7 +12,6 @@ require (
github.com/spf13/cobra v1.4.0 github.com/spf13/cobra v1.4.0
github.com/spf13/viper v1.11.0 github.com/spf13/viper v1.11.0
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
@@ -36,4 +35,5 @@ require (
golang.org/x/text v0.3.8 // indirect golang.org/x/text v0.3.8 // indirect
gopkg.in/ini.v1 v1.66.4 // indirect gopkg.in/ini.v1 v1.66.4 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -14,20 +14,19 @@ import (
) )
type BackendRest struct { type BackendRest struct {
User string `mapstructure:"user,omitempty"` User string `mapstructure:"user,omitempty" yaml:"user,omitempty"`
Password string `mapstructure:"password,omitempty"` Password string `mapstructure:"password,omitempty" yaml:"password,omitempty"`
} }
type Backend struct { type Backend struct {
name string name string
Type string `mapstructure:"type,omitempty"` Type string `mapstructure:"type,omitempty" yaml:"type,omitempty"`
Path string `mapstructure:"path,omitempty"` Path string `mapstructure:"path,omitempty" yaml:"path,omitempty"`
Key string `mapstructure:"key,omitempty"` Key string `mapstructure:"key,omitempty" yaml:"key,omitempty"`
RequireKey bool `mapstructure:"requireKey,omitempty"` RequireKey bool `mapstructure:"requireKey,omitempty" yaml:"requireKey,omitempty"`
Init bool `mapstructure:"init,omitempty"` Env map[string]string `mapstructure:"env,omitempty" yaml:"env,omitempty"`
Env map[string]string `mapstructure:"env,omitempty"` Rest BackendRest `mapstructure:"rest,omitempty" yaml:"rest,omitempty"`
Rest BackendRest `mapstructure:"rest,omitempty"` Options Options `mapstructure:"options,omitempty" yaml:"options,omitempty"`
Options Options `mapstructure:"options,omitempty"`
} }
func GetBackend(name string) (Backend, bool) { func GetBackend(name string) (Backend, bool) {
@@ -131,44 +130,20 @@ func (b Backend) validate() error {
return err return err
} }
options := ExecuteOptions{Envs: env, Silent: true} options := ExecuteOptions{Envs: env, Silent: true}
// Check if already initialized
err = b.EnsureInit()
if err != nil {
return err
}
cmd := []string{"check"} cmd := []string{"check"}
cmd = append(cmd, combineBackendOptions("check", b)...) cmd = append(cmd, combineBackendOptions("check", b)...)
_, _, err = ExecuteResticCommand(options, cmd...) _, _, err = ExecuteResticCommand(options, cmd...)
return err if err == nil {
} return nil
} else {
// EnsureInit initializes the backend if it is not already initialized // If not initialize
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) colors.Body.Printf("Initializing backend \"%s\"...\n", b.name)
initCmd := []string{"init"} cmd := []string{"init"}
initCmd = append(initCmd, combineBackendOptions("init", b)...) cmd = append(cmd, combineBackendOptions("init", b)...)
_, _, err := ExecuteResticCommand(options, initCmd...) _, _, err := ExecuteResticCommand(options, cmd...)
return err return err
} }
return err
} }
func (b Backend) Exec(args []string) error { func (b Backend) Exec(args []string) error {

View File

@@ -3,10 +3,8 @@ package internal
import ( import (
"fmt" "fmt"
"os" "os"
"path"
"testing" "testing"
"github.com/cupcakearmy/autorestic/internal/flags"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -265,69 +263,3 @@ func TestValidate(t *testing.T) {
assert.EqualError(t, err, "backend foo requires a key but none was provided") 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 Options map[string]OptionMap
type Config struct { type Config struct {
Version string `mapstructure:"version"` Version string `mapstructure:"version" yaml:"version"`
Extras interface{} `mapstructure:"extras"` Extras interface{} `mapstructure:"extras" yaml:"extras"`
Locations map[string]Location `mapstructure:"locations"` Locations map[string]Location `mapstructure:"locations" yaml:"locations"`
Backends map[string]Backend `mapstructure:"backends"` Backends map[string]Backend `mapstructure:"backends" yaml:"backends"`
Global Options `mapstructure:"global"` Global Options `mapstructure:"global" yaml:"global"`
} }
var once sync.Once var once sync.Once
@@ -188,15 +188,31 @@ func CheckConfig() error {
if !CheckIfResticIsCallable() { if !CheckIfResticIsCallable() {
return fmt.Errorf(`%s was not found. Install either with "autorestic install" or manually`, flags.RESTIC_BIN) return fmt.Errorf(`%s was not found. Install either with "autorestic install" or manually`, flags.RESTIC_BIN)
} }
for name, backend := range c.Backends {
backend.name = name cwd, _ := GetPathRelativeToConfig(".")
if err := backend.validate(); err != nil { for name, location := range c.Locations {
location.name = name
// Hooks before location validation
options := ExecuteOptions{
Command: "bash",
Dir: cwd,
Envs: map[string]string{
"AUTORESTIC_LOCATION": location.name,
},
}
if err := location.ExecuteHooks(location.Hooks.PreValidate, options); err != nil {
return err
}
if err := location.validate(); err != nil {
return err return err
} }
} }
for name, location := range c.Locations {
location.name = name for name, backend := range c.Backends {
if err := location.validate(); err != nil { backend.name = name
if err := backend.validate(); err != nil {
return err return err
} }
} }

View File

@@ -1,10 +1,15 @@
package internal package internal
import ( import (
"path"
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
"sync"
"testing" "testing"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
) )
func TestOptionToString(t *testing.T) { func TestOptionToString(t *testing.T) {
@@ -143,6 +148,48 @@ func TestGetOptionsMultipleKeys(t *testing.T) {
reflect.DeepEqual(result, expected) 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) { func assertEqual[T comparable](t testing.TB, result, expected T) {
t.Helper() t.Helper()

View File

@@ -34,26 +34,26 @@ const (
) )
type Hooks struct { type Hooks struct {
Dir string `mapstructure:"dir"` Dir string `mapstructure:"dir" yaml:"dir"`
PreValidate HookArray `mapstructure:"prevalidate,omitempty"` PreValidate HookArray `mapstructure:"prevalidate,omitempty" yaml:"prevalidate,omitempty"`
Before HookArray `mapstructure:"before,omitempty"` Before HookArray `mapstructure:"before,omitempty" yaml:"before,omitempty"`
After HookArray `mapstructure:"after,omitempty"` After HookArray `mapstructure:"after,omitempty" yaml:"after,omitempty"`
Success HookArray `mapstructure:"success,omitempty"` Success HookArray `mapstructure:"success,omitempty" yaml:"success,omitempty"`
Failure HookArray `mapstructure:"failure,omitempty"` Failure HookArray `mapstructure:"failure,omitempty" yaml:"failure,omitempty"`
} }
type LocationCopy = map[string][]string type LocationCopy = map[string][]string
type Location struct { type Location struct {
name string `mapstructure:",omitempty"` name string `mapstructure:",omitempty" yaml:",omitempty"`
From []string `mapstructure:"from,omitempty"` From []string `mapstructure:"from,omitempty" yaml:"from,omitempty"`
Type string `mapstructure:"type,omitempty"` Type string `mapstructure:"type,omitempty" yaml:"type,omitempty"`
To []string `mapstructure:"to,omitempty"` To []string `mapstructure:"to,omitempty" yaml:"to,omitempty"`
Hooks Hooks `mapstructure:"hooks,omitempty"` Hooks Hooks `mapstructure:"hooks,omitempty" yaml:"hooks,omitempty"`
Cron string `mapstructure:"cron,omitempty"` Cron string `mapstructure:"cron,omitempty" yaml:"cron,omitempty"`
Options Options `mapstructure:"options,omitempty"` Options Options `mapstructure:"options,omitempty" yaml:"options,omitempty"`
ForgetOption LocationForgetOption `mapstructure:"forget,omitempty"` ForgetOption LocationForgetOption `mapstructure:"forget,omitempty" yaml:"forget,omitempty"`
CopyOption LocationCopy `mapstructure:"copy,omitempty"` CopyOption LocationCopy `mapstructure:"copy,omitempty" yaml:"copy,omitempty"`
} }
func GetLocation(name string) (Location, bool) { func GetLocation(name string) (Location, bool) {
@@ -168,7 +168,7 @@ func (l Location) getLocationTags() string {
return buildTag("location", l.name) return buildTag("location", l.name)
} }
func (l Location) Backup(cron bool, specificBackend string) []error { func (l Location) Backup(cron bool, dry bool, specificBackend string) []error {
var errors []error var errors []error
var backends []string var backends []string
colors.PrimaryPrint(" Backing up location \"%s\" ", l.name) colors.PrimaryPrint(" Backing up location \"%s\" ", l.name)
@@ -223,19 +223,14 @@ func (l Location) Backup(cron bool, specificBackend string) []error {
continue continue
} }
if backend.Init {
err = backend.EnsureInit()
if err != nil {
errors = append(errors, err)
continue
}
}
cmd := []string{"backup"} cmd := []string{"backup"}
cmd = append(cmd, combineAllOptions("backup", l, backend)...) cmd = append(cmd, combineAllOptions("backup", l, backend)...)
if cron { if cron {
cmd = append(cmd, "--tag", buildTag("cron")) cmd = append(cmd, "--tag", buildTag("cron"))
} }
if dry {
cmd = append(cmd, "--dry-run")
}
cmd = append(cmd, "--tag", l.getLocationTags()) cmd = append(cmd, "--tag", l.getLocationTags())
backupOptions := ExecuteOptions{ backupOptions := ExecuteOptions{
Envs: env, Envs: env,
@@ -455,7 +450,7 @@ func (l Location) RunCron() error {
now := time.Now() now := time.Now()
if now.After(next) { if now.After(next) {
lock.SetCron(l.name, now.Unix()) lock.SetCron(l.name, now.Unix())
errs := l.Backup(true, "") errs := l.Backup(true, false, "")
if len(errs) > 0 { if len(errs) > 0 {
return fmt.Errorf("Failed to backup location \"%s\":\n%w", l.name, errors.Join(errs...)) return fmt.Errorf("Failed to backup location \"%s\":\n%w", l.name, errors.Join(errs...))
} }