Compare commits

..

19 Commits

Author SHA1 Message Date
74979e9a2a Update config.go 2022-10-17 15:03:17 +02:00
Romain de Laage
3732dcf6ff Check for errors on forget after having backuped (#241) 2022-10-17 15:02:47 +02:00
Andreas Wagner
874ed52e3b add more architectures for linux build process (#243)
* add more architectures for linux build process

equivalent to 7d665fa1f4/helpers/build-release-binaries/main.go

* add solaris and s390x

Co-authored-by: Nicco <hi@nicco.io>
2022-10-06 15:41:01 +01:00
4d9a2b828e Update config.go 2022-09-13 15:16:21 +02:00
Chosto
b830667264 Use options when calling check or init (#199) 2022-09-13 15:15:02 +02:00
John Burkhardt
83eeb847ac Add command line flag to override docker image. (#233) 2022-09-13 15:04:12 +02:00
shahvirb
a89ba5a40a Update forget.md keep-within argument to be '14d' (#236)
keep-within duration can only have units 'y', 'm', 'd', and 'h'. The current documentation of '2w' yields:
```invalid argument "2w" for "--keep-within" flag: invalid unit 'w' found after number 2```

https://restic.readthedocs.io/en/latest/060_forget.html#removing-snapshots-according-to-a-policy
2022-09-13 14:59:46 +02:00
Romain de Laage
6990bf6adc Check for errors and forward on exec command (#227)
fix #226
2022-08-26 17:09:26 +02:00
Romain de Laage
2f407cf211 Add forget for copy backend (#223)
fix #221
2022-08-24 12:09:32 +02:00
489f3078fe bump version 2022-08-24 12:03:11 +02:00
Romain de Laage
e07dd0d991 Don't check if path is a directory (#220)
We only run a stat on the path to check there is no error
2022-08-22 17:28:38 +02:00
kenc
2b9dc9f17c Add test for locking behaviour (#211) 2022-06-27 09:03:34 +02:00
kenc
465bc037c2 Add lock tests (#209)
* Add lock tests

* Refactor setLock to accept key value pairs

This allows SetCron and Lock to use the same function setLockValue. It
also removes the need to call getLock explicitly in tests by returning
the lock object.
2022-06-06 12:59:47 +02:00
kenc
37a043afff Add location unit tests (#208) 2022-06-02 17:05:44 +02:00
kenc
e91b632181 Add backend unit tests (#207) 2022-06-01 14:58:38 +02:00
kenc
2b30998b9a Add options parsing unit tests (#205)
* Add options parsing unit tests

* Refactor into subtests
2022-05-30 12:56:25 +02:00
Varac
49b37a0a9a Add varacs ansible role (#201)
Co-authored-by: Nicco <hi@nicco.io>
2022-05-24 13:34:10 +02:00
3bc091f826 lean flag 2022-04-27 00:55:31 +02:00
5bcf5c9217 1.7.0 (#188)
* stream the output (#186)

* dont duplicate global flags (#187)

* docs for tagging

* fix self update path (#190)

* version bump & changelog
2022-04-27 00:48:52 +02:00
19 changed files with 752 additions and 87 deletions

View File

@@ -5,11 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.1] - 2022-04-27
### Fixed
- #178 Lean flag not working properly.
## [1.7.0] - 2022-04-27
### Changed
- #147 Stream output instead of buffering.
### Fixed
- #184 duplicate global options.
- #154 add docs for migration.
- #182 fix bug with upgrading custom restic with custom path.
## [1.6.2] - 2022-04-14 ## [1.6.2] - 2022-04-14
### Fixed ### Fixed
- Version bump in code - Version bump in code.
## [1.6.1] - 2022-04-14 ## [1.6.1] - 2022-04-14

View File

@@ -17,11 +17,14 @@ import (
var DIR, _ = filepath.Abs("./dist") var DIR, _ = filepath.Abs("./dist")
var targets = map[string][]string{ var targets = map[string][]string{
// "aix": {"ppc64"}, // Not supported by fsnotify
"darwin": {"amd64", "arm64"}, "darwin": {"amd64", "arm64"},
"freebsd": {"386", "amd64", "arm"}, "freebsd": {"386", "amd64", "arm"},
"linux": {"386", "amd64", "arm", "arm64"}, "linux": {"386", "amd64", "arm", "arm64", "ppc64le", "mips", "mipsle", "mips64", "mips64le", "s390x"},
"netbsd": {"386", "amd64"}, "netbsd": {"386", "amd64"},
"openbsd": {"386", "amd64"}, "openbsd": {"386", "amd64"},
// "windows": {"386", "amd64"}, // Not supported by autorestic
"solaris": {"amd64"},
} }
type buildOptions struct { type buildOptions struct {

View File

@@ -13,7 +13,6 @@ var cronCmd = &cobra.Command{
Long: `Intended to be mainly triggered by an automated system like systemd or crontab. For each location checks if a cron backup is due and runs it.`, Long: `Intended to be mainly triggered by an automated system like systemd or crontab. For each location checks if a cron backup is due and runs it.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
internal.GetConfig() internal.GetConfig()
flags.CRON_LEAN, _ = cmd.Flags().GetBool("lean")
err := lock.Lock() err := lock.Lock()
CheckErr(err) CheckErr(err)
defer lock.Unlock() defer lock.Unlock()
@@ -25,5 +24,5 @@ var cronCmd = &cobra.Command{
func init() { func init() {
rootCmd.AddCommand(cronCmd) rootCmd.AddCommand(cronCmd)
cronCmd.Flags().Bool("lean", false, "only output information about actual backups") cronCmd.Flags().BoolVar(&flags.CRON_LEAN, "lean", false, "only output information about actual backups")
} }

View File

@@ -1,6 +1,8 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/cupcakearmy/autorestic/internal" "github.com/cupcakearmy/autorestic/internal"
"github.com/cupcakearmy/autorestic/internal/colors" "github.com/cupcakearmy/autorestic/internal/colors"
"github.com/cupcakearmy/autorestic/internal/lock" "github.com/cupcakearmy/autorestic/internal/lock"
@@ -18,10 +20,23 @@ var execCmd = &cobra.Command{
selected, err := internal.GetAllOrSelected(cmd, true) selected, err := internal.GetAllOrSelected(cmd, true)
CheckErr(err) CheckErr(err)
var errors []error
for _, name := range selected { for _, name := range selected {
colors.PrimaryPrint(" Executing on \"%s\" ", name) colors.PrimaryPrint(" Executing on \"%s\" ", name)
backend, _ := internal.GetBackend(name) backend, _ := internal.GetBackend(name)
backend.Exec(args) err := backend.Exec(args)
if err != nil {
errors = append(errors, err)
}
}
if len(errors) > 0 {
for _, err := range errors {
colors.Error.Printf("%s\n\n", err)
}
CheckErr(fmt.Errorf("%d errors were found", len(errors)))
} }
}, },
} }

View File

@@ -40,7 +40,8 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file (default is $HOME/.autorestic.yml or ./.autorestic.yml)") rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file (default is $HOME/.autorestic.yml or ./.autorestic.yml)")
rootCmd.PersistentFlags().BoolVar(&flags.CI, "ci", false, "CI mode disabled interactive mode and colors and enables verbosity") rootCmd.PersistentFlags().BoolVar(&flags.CI, "ci", false, "CI mode disabled interactive mode and colors and enables verbosity")
rootCmd.PersistentFlags().BoolVarP(&flags.VERBOSE, "verbose", "v", false, "verbose mode") rootCmd.PersistentFlags().BoolVarP(&flags.VERBOSE, "verbose", "v", false, "verbose mode")
rootCmd.PersistentFlags().StringVar(&internal.RESTIC_BIN, "restic-bin", "restic", "specify custom restic binary") rootCmd.PersistentFlags().StringVar(&flags.RESTIC_BIN, "restic-bin", "restic", "specify custom restic binary")
rootCmd.PersistentFlags().StringVar(&flags.DOCKER_IMAGE, "docker-image", "cupcakearmy/autorestic:"+internal.VERSION, "specify a custom docker image")
cobra.OnInitialize(initConfig) cobra.OnInitialize(initConfig)
} }

View File

@@ -2,10 +2,11 @@
A list of community driven projects. (No official affiliation) A list of community driven projects. (No official affiliation)
- SystemD Units: https://gitlab.com/py_crash/autorestic-systemd-units - SystemD Units: <https://gitlab.com/py_crash/autorestic-systemd-units>
- Docker image: https://github.com/pascaliske/docker-autorestic - Docker image: <https://github.com/pascaliske/docker-autorestic>
- Ansible Role: https://github.com/adsanz/ansible-restic-role - Ansible Role: <https://github.com/adsanz/ansible-restic-role>
- Ansible Role: https://github.com/ItsNotGoodName/ansible-role-autorestic - Ansible Role: <https://github.com/ItsNotGoodName/ansible-role-autorestic>
- Ansible Role: https://github.com/FuzzyMistborn/ansible-role-autorestic - Ansible Role: <https://github.com/FuzzyMistborn/ansible-role-autorestic>
- Ansible Role: <https://0xacab.org/varac-projects/ansible-role-autorestic>
> :ToCPrevNext > :ToCPrevNext

View File

@@ -21,7 +21,7 @@ locations:
keep-weekly: 1 # keep 1 last weekly snapshots keep-weekly: 1 # keep 1 last weekly snapshots
keep-monthly: 12 # keep 12 last monthly snapshots keep-monthly: 12 # keep 12 last monthly snapshots
keep-yearly: 7 # keep 7 last yearly snapshots keep-yearly: 7 # keep 7 last yearly snapshots
keep-within: '2w' # keep snapshots from the last 2 weeks keep-within: '14d' # keep snapshots from the last 14 days
``` ```
## Globally ## Globally

View File

@@ -57,4 +57,12 @@ locations:
type: volume type: volume
``` ```
## Tagging
Autorestic changed the way backups are referenced. Before we took the paths as the identifying information. Now autorestic uses native restic tags to reference them. This means that old backups are not referenced. You can the old snapshots manually. An example can be shown below.
```bash
autorestic exec -va -- tag --add ar:location:LOCATION_NAME # Only if you have only one location
```
> :ToCPrevNext > :ToCPrevNext

View File

@@ -120,18 +120,19 @@ func (b Backend) validate() error {
if err != nil { if err != nil {
return err return err
} }
options := ExecuteOptions{Envs: env} options := ExecuteOptions{Envs: env, Silent: true}
// Check if already initialized // Check if already initialized
_, _, err = ExecuteResticCommand(options, "snapshots") cmd := []string{"check"}
cmd = append(cmd, combineBackendOptions("check", b)...)
_, _, err = ExecuteResticCommand(options, cmd...)
if err == nil { if err == nil {
return nil return nil
} else { } else {
// If not initialize // If not initialize
colors.Body.Printf("Initializing backend \"%s\"...\n", b.name) colors.Body.Printf("Initializing backend \"%s\"...\n", b.name)
_, out, err := ExecuteResticCommand(options, "init") cmd := []string{"init"}
if flags.VERBOSE { cmd = append(cmd, combineBackendOptions("init", b)...)
colors.Faint.Println(out) _, _, err := ExecuteResticCommand(options, cmd...)
}
return err return err
} }
} }
@@ -147,9 +148,6 @@ func (b Backend) Exec(args []string) error {
colors.Error.Println(out) colors.Error.Println(out)
return err return err
} }
if flags.VERBOSE {
colors.Faint.Println(out)
}
return nil return nil
} }
@@ -167,7 +165,6 @@ func (b Backend) ExecDocker(l Location, args []string) (int, string, error) {
args = append([]string{"restic"}, args...) args = append([]string{"restic"}, args...)
docker := []string{ docker := []string{
"run", "--rm", "run", "--rm",
"--pull", "always",
"--entrypoint", "ash", "--entrypoint", "ash",
"--workdir", dir, "--workdir", dir,
"--volume", volume + ":" + dir, "--volume", volume + ":" + dir,
@@ -201,6 +198,7 @@ func (b Backend) ExecDocker(l Location, args []string) (int, string, error) {
for key, value := range env { for key, value := range env {
docker = append(docker, "--env", key+"="+value) docker = append(docker, "--env", key+"="+value)
} }
docker = append(docker, "cupcakearmy/autorestic:"+VERSION, "-c", strings.Join(args, " "))
docker = append(docker, flags.DOCKER_IMAGE, "-c", strings.Join(args, " "))
return ExecuteCommand(options, docker...) return ExecuteCommand(options, docker...)
} }

225
internal/backend_test.go Normal file
View File

@@ -0,0 +1,225 @@
package internal
import (
"fmt"
"os"
"testing"
"github.com/spf13/viper"
)
func TestGenerateRepo(t *testing.T) {
t.Run("empty backend", func(t *testing.T) {
b := Backend{
name: "empty backend",
Type: "",
}
_, err := b.generateRepo()
if err == nil {
t.Errorf("Error expected for empty backend type")
}
})
t.Run("local backend", func(t *testing.T) {
b := Backend{
name: "local backend",
Type: "local",
Path: "/foo/bar",
}
result, err := b.generateRepo()
if err != nil {
t.Errorf("unexpected error %v", err)
}
assertEqual(t, result, "/foo/bar")
})
t.Run("local backend with homedir prefix", func(t *testing.T) {
b := Backend{
name: "local backend",
Type: "local",
Path: "~/foo/bar",
}
result, err := b.generateRepo()
if err != nil {
t.Errorf("unexpected error %v", err)
}
assertEqual(t, result, fmt.Sprintf("%s/foo/bar", os.Getenv("HOME")))
})
t.Run("local backend with config file", func(t *testing.T) {
// config file path should always be present from initConfig
viper.SetConfigFile("/tmp/.autorestic.yml")
defer viper.Reset()
b := Backend{
name: "local backend",
Type: "local",
}
result, err := b.generateRepo()
if err != nil {
t.Errorf("unexpected error %v", err)
}
assertEqual(t, result, "/tmp")
})
t.Run("rest backend with valid path", func(t *testing.T) {
b := Backend{
name: "rest backend",
Type: "rest",
Path: "http://localhost:8000/foo",
}
result, err := b.generateRepo()
if err != nil {
t.Errorf("unexpected error %v", err)
}
assertEqual(t, result, "rest:http://localhost:8000/foo")
})
t.Run("rest backend with user", func(t *testing.T) {
b := Backend{
name: "rest backend",
Type: "rest",
Path: "http://localhost:8000/foo",
Rest: BackendRest{
User: "user",
Password: "",
},
}
result, err := b.generateRepo()
if err != nil {
t.Errorf("unexpected error %v", err)
}
assertEqual(t, result, "rest:http://user@localhost:8000/foo")
})
t.Run("rest backend with user and password", func(t *testing.T) {
b := Backend{
name: "rest backend",
Type: "rest",
Path: "http://localhost:8000/foo",
Rest: BackendRest{
User: "user",
Password: "pass",
},
}
result, err := b.generateRepo()
if err != nil {
t.Errorf("unexpected error %v", err)
}
assertEqual(t, result, "rest:http://user:pass@localhost:8000/foo")
})
backendTests := []struct {
name string
backend Backend
want string
}{
{name: "b2 backend", backend: Backend{name: "b2", Type: "b2", Path: "foo"}, want: "b2:foo"},
{name: "azure backend", backend: Backend{name: "azure", Type: "azure", Path: "foo"}, want: "azure:foo"},
{name: "gs backend", backend: Backend{name: "gs", Type: "gs", Path: "foo"}, want: "gs:foo"},
{name: "s3 backend", backend: Backend{name: "s3", Type: "s3", Path: "foo"}, want: "s3:foo"},
{name: "sftp backend", backend: Backend{name: "sftp", Type: "sftp", Path: "foo"}, want: "sftp:foo"},
{name: "rclone backend", backend: Backend{name: "rclone", Type: "rclone", Path: "foo"}, want: "rclone:foo"},
}
for _, tt := range backendTests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.backend.generateRepo()
if err != nil {
t.Errorf("unexpected error %v", err)
}
assertEqual(t, got, tt.want)
})
}
}
func TestGetEnv(t *testing.T) {
t.Run("env in key field", func(t *testing.T) {
b := Backend{
name: "",
Type: "local",
Path: "/foo/bar",
Key: "secret123",
}
result, err := b.getEnv()
if err != nil {
t.Errorf("unexpected error %v", err)
}
assertEqual(t, result["RESTIC_REPOSITORY"], "/foo/bar")
assertEqual(t, result["RESTIC_PASSWORD"], "secret123")
})
t.Run("env in config file", func(t *testing.T) {
b := Backend{
name: "",
Type: "local",
Path: "/foo/bar",
Env: map[string]string{
"B2_ACCOUNT_ID": "foo123",
"B2_ACCOUNT_KEY": "foo456",
},
}
result, err := b.getEnv()
if err != nil {
t.Errorf("unexpected error %v", err)
}
assertEqual(t, result["RESTIC_REPOSITORY"], "/foo/bar")
assertEqual(t, result["RESTIC_PASSWORD"], "")
assertEqual(t, result["B2_ACCOUNT_ID"], "foo123")
assertEqual(t, result["B2_ACCOUNT_KEY"], "foo456")
})
t.Run("env in Envfile or env vars", func(t *testing.T) {
// generate env variables
// TODO better way to teardown
defer os.Unsetenv("AUTORESTIC_FOO_RESTIC_PASSWORD")
defer os.Unsetenv("AUTORESTIC_FOO_B2_ACCOUNT_ID")
defer os.Unsetenv("AUTORESTIC_FOO_B2_ACCOUNT_KEY")
os.Setenv("AUTORESTIC_FOO_RESTIC_PASSWORD", "secret123")
os.Setenv("AUTORESTIC_FOO_B2_ACCOUNT_ID", "foo123")
os.Setenv("AUTORESTIC_FOO_B2_ACCOUNT_KEY", "foo456")
b := Backend{
name: "foo",
Type: "local",
Path: "/foo/bar",
}
result, err := b.getEnv()
if err != nil {
t.Errorf("unexpected error %v", err)
}
assertEqual(t, result["RESTIC_REPOSITORY"], "/foo/bar")
assertEqual(t, result["RESTIC_PASSWORD"], "secret123")
assertEqual(t, result["B2_ACCOUNT_ID"], "foo123")
assertEqual(t, result["B2_ACCOUNT_KEY"], "foo456")
})
}
func TestValidate(t *testing.T) {
t.Run("no type given", func(t *testing.T) {
b := Backend{
name: "foo",
Type: "",
Path: "/foo/bar",
}
err := b.validate()
if err == nil {
t.Error("expected to get error")
}
assertEqual(t, err.Error(), "Backend \"foo\" has no \"type\"")
})
t.Run("no path given", func(t *testing.T) {
b := Backend{
name: "foo",
Type: "local",
Path: "",
}
err := b.validate()
if err == nil {
t.Error("expected to get error")
}
assertEqual(t, err.Error(), "Backend \"foo\" has no \"path\"")
})
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/blang/semver/v4" "github.com/blang/semver/v4"
"github.com/cupcakearmy/autorestic/internal" "github.com/cupcakearmy/autorestic/internal"
"github.com/cupcakearmy/autorestic/internal/colors" "github.com/cupcakearmy/autorestic/internal/colors"
"github.com/cupcakearmy/autorestic/internal/flags"
) )
const INSTALL_PATH = "/usr/local/bin" const INSTALL_PATH = "/usr/local/bin"
@@ -128,10 +129,9 @@ func InstallRestic() error {
} }
func upgradeRestic() error { func upgradeRestic() error {
_, out, err := internal.ExecuteCommand(internal.ExecuteOptions{ _, _, err := internal.ExecuteCommand(internal.ExecuteOptions{
Command: "restic", Command: flags.RESTIC_BIN,
}, "self-update") }, "self-update")
colors.Faint.Println(out)
return err return err
} }

View File

@@ -17,7 +17,7 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
) )
const VERSION = "1.6.2" const VERSION = "1.7.4"
type OptionMap map[string][]interface{} type OptionMap map[string][]interface{}
type Options map[string]OptionMap type Options map[string]OptionMap
@@ -185,7 +185,7 @@ func CheckConfig() error {
return fmt.Errorf("config could not be loaded/found") return fmt.Errorf("config could not be loaded/found")
} }
if !CheckIfResticIsCallable() { if !CheckIfResticIsCallable() {
return fmt.Errorf(`%s was not found. Install either with "autorestic install" or manually`, 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 { for name, backend := range c.Backends {
backend.name = name backend.name = name
@@ -295,24 +295,30 @@ func appendOptionsToSlice(str *[]string, options OptionMap) {
} }
} }
func getOptions(options Options, key string) []string { func getOptions(options Options, keys []string) []string {
var selected []string var selected []string
var keys = []string{"all"}
if key != "" {
keys = append(keys, key)
}
for _, key := range keys { for _, key := range keys {
appendOptionsToSlice(&selected, options[key]) appendOptionsToSlice(&selected, options[key])
} }
return selected return selected
} }
func combineOptions(key string, l Location, b Backend) []string { func combineBackendOptions(key string, b Backend) []string {
// Priority: backend > global
var options []string
gFlags := getOptions(GetConfig().Global, []string{key})
bFlags := getOptions(b.Options, []string{"all", key})
options = append(options, gFlags...)
options = append(options, bFlags...)
return options
}
func combineAllOptions(key string, l Location, b Backend) []string {
// Priority: location > backend > global // Priority: location > backend > global
var options []string var options []string
gFlags := getOptions(GetConfig().Global, key) gFlags := getOptions(GetConfig().Global, []string{key})
bFlags := getOptions(b.Options, key) bFlags := getOptions(b.Options, []string{"all", key})
lFlags := getOptions(l.Options, key) lFlags := getOptions(l.Options, []string{"all", key})
options = append(options, gFlags...) options = append(options, gFlags...)
options = append(options, bFlags...) options = append(options, bFlags...)
options = append(options, lFlags...) options = append(options, lFlags...)

164
internal/config_test.go Normal file
View File

@@ -0,0 +1,164 @@
package internal
import (
"reflect"
"strconv"
"strings"
"testing"
)
func TestOptionToString(t *testing.T) {
t.Run("no prefix", func(t *testing.T) {
opt := "test"
result := optionToString(opt)
assertEqual(t, result, "--test")
})
t.Run("single prefix", func(t *testing.T) {
opt := "-test"
result := optionToString(opt)
assertEqual(t, result, "-test")
})
t.Run("double prefix", func(t *testing.T) {
opt := "--test"
result := optionToString(opt)
assertEqual(t, result, "--test")
})
}
func TestAppendOneOptionToSlice(t *testing.T) {
t.Run("string flag", func(t *testing.T) {
result := []string{}
optionMap := OptionMap{"string-flag": []interface{}{"/root"}}
appendOptionsToSlice(&result, optionMap)
expected := []string{
"--string-flag", "/root",
}
assertSliceEqual(t, result, expected)
})
t.Run("bool flag", func(t *testing.T) {
result := []string{}
optionMap := OptionMap{"boolean-flag": []interface{}{true}}
appendOptionsToSlice(&result, optionMap)
expected := []string{
"--boolean-flag",
}
assertSliceEqual(t, result, expected)
})
t.Run("int flag", func(t *testing.T) {
result := []string{}
optionMap := OptionMap{"int-flag": []interface{}{123}}
appendOptionsToSlice(&result, optionMap)
expected := []string{
"--int-flag", "123",
}
assertSliceEqual(t, result, expected)
})
}
func TestAppendMultipleOptionsToSlice(t *testing.T) {
result := []string{}
optionMap := OptionMap{
"string-flag": []interface{}{"/root"},
"int-flag": []interface{}{123},
}
appendOptionsToSlice(&result, optionMap)
expected := []string{
"--string-flag", "/root",
"--int-flag", "123",
}
if len(result) != len(expected) {
t.Errorf("got length %d, want length %d", len(result), len(expected))
}
// checks that expected option comes after flag, regardless of key order in map
for i, v := range expected {
v = strings.TrimPrefix(v, "--")
if value, ok := optionMap[v]; ok {
if val, ok := value[0].(int); ok {
if expected[i+1] != strconv.Itoa(val) {
t.Errorf("Flags and options order are mismatched. got %v, want %v", result, expected)
}
}
}
}
}
func TestAppendOptionWithMultipleValuesToSlice(t *testing.T) {
result := []string{}
optionMap := OptionMap{
"string-flag": []interface{}{"/root", "/bin"},
}
appendOptionsToSlice(&result, optionMap)
expected := []string{
"--string-flag", "/root",
"--string-flag", "/bin",
}
assertSliceEqual(t, result, expected)
}
func TestGetOptionsOneKey(t *testing.T) {
optionMap := OptionMap{
"string-flag": []interface{}{"/root"},
}
options := Options{"backend": optionMap}
keys := []string{"backend"}
result := getOptions(options, keys)
expected := []string{
"--string-flag", "/root",
}
assertSliceEqual(t, result, expected)
}
func TestGetOptionsMultipleKeys(t *testing.T) {
firstOptionMap := OptionMap{
"string-flag": []interface{}{"/root"},
}
secondOptionMap := OptionMap{
"boolean-flag": []interface{}{true},
"int-flag": []interface{}{123},
}
options := Options{
"all": firstOptionMap,
"forget": secondOptionMap,
}
keys := []string{"all", "forget"}
result := getOptions(options, keys)
expected := []string{
"--string-flag", "/root",
"--boolean-flag",
"--int-flag", "123",
}
reflect.DeepEqual(result, expected)
}
func assertEqual[T comparable](t testing.TB, result, expected T) {
t.Helper()
if result != expected {
t.Errorf("got %v, want %v", result, expected)
}
}
func assertSliceEqual(t testing.TB, result, expected []string) {
t.Helper()
if len(result) != len(expected) {
t.Errorf("got length %d, want length %d", len(result), len(expected))
}
for i := range result {
assertEqual(t, result[i], expected[i])
}
}

View File

@@ -1,5 +1,9 @@
package flags package flags
var CI bool = false var (
var VERBOSE bool = false CI bool = false
var CRON_LEAN bool = false VERBOSE bool = false
CRON_LEAN bool = false
RESTIC_BIN string
DOCKER_IMAGE string
)

View File

@@ -74,12 +74,8 @@ func (l Location) validate() error {
if from, err := GetPathRelativeToConfig(path); err != nil { if from, err := GetPathRelativeToConfig(path); err != nil {
return err return err
} else { } else {
if stat, err := os.Stat(from); err != nil { if _, err := os.Stat(from); err != nil {
return err return err
} else {
if !stat.IsDir() {
return fmt.Errorf("\"%s\" is not valid directory for location \"%s\"", from, l.name)
}
} }
} }
} }
@@ -146,9 +142,6 @@ func (l Location) ExecuteHooks(commands []string, options ExecuteOptions) error
colors.Error.Println(out) colors.Error.Println(out)
return err return err
} }
if flags.VERBOSE {
colors.Faint.Println(out)
}
} }
colors.Body.Println("") colors.Body.Println("")
return nil return nil
@@ -223,7 +216,7 @@ func (l Location) Backup(cron bool, specificBackend string) []error {
} }
cmd := []string{"backup"} cmd := []string{"backup"}
cmd = append(cmd, combineOptions("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"))
} }
@@ -284,24 +277,16 @@ func (l Location) Backup(cron bool, specificBackend string) []error {
for k, v := range env2 { for k, v := range env2 {
env[k+"2"] = v env[k+"2"] = v
} }
_, out, err := ExecuteResticCommand(ExecuteOptions{ _, _, err := ExecuteResticCommand(ExecuteOptions{
Envs: env, Envs: env,
}, "copy", md.SnapshotID) }, "copy", md.SnapshotID)
if flags.VERBOSE {
colors.Faint.Println(out)
}
if err != nil { if err != nil {
errors = append(errors, err) errors = append(errors, err)
} }
} }
} }
} }
if flags.VERBOSE {
colors.Faint.Println(out)
}
} }
// After hooks // After hooks
@@ -323,7 +308,10 @@ after:
// Forget and optionally prune // Forget and optionally prune
if isSuccess && l.ForgetOption != "" && l.ForgetOption != LocationForgetNo { if isSuccess && l.ForgetOption != "" && l.ForgetOption != LocationForgetNo {
l.Forget(l.ForgetOption == LocationForgetPrune, false) err := l.Forget(l.ForgetOption == LocationForgetPrune, false)
if err != nil {
errors = append(errors, err)
}
} }
if len(errors) == 0 { if len(errors) == 0 {
@@ -335,7 +323,12 @@ after:
func (l Location) Forget(prune bool, dry bool) error { func (l Location) Forget(prune bool, dry bool) error {
colors.PrimaryPrint("Forgetting for location \"%s\"", l.name) colors.PrimaryPrint("Forgetting for location \"%s\"", l.name)
for _, to := range l.To { backendsToForget := l.To
for _, copyBackends := range l.CopyOption {
backendsToForget = append(backendsToForget, copyBackends...)
}
for _, to := range backendsToForget {
backend, _ := GetBackend(to) backend, _ := GetBackend(to)
colors.Secondary.Printf("For backend \"%s\"\n", backend.name) colors.Secondary.Printf("For backend \"%s\"\n", backend.name)
env, err := backend.getEnv() env, err := backend.getEnv()
@@ -352,11 +345,8 @@ func (l Location) Forget(prune bool, dry bool) error {
if dry { if dry {
cmd = append(cmd, "--dry-run") cmd = append(cmd, "--dry-run")
} }
cmd = append(cmd, combineOptions("forget", l, backend)...) cmd = append(cmd, combineAllOptions("forget", l, backend)...)
_, out, err := ExecuteResticCommand(options, cmd...) _, _, err = ExecuteResticCommand(options, cmd...)
if flags.VERBOSE {
colors.Faint.Println(out)
}
if err != nil { if err != nil {
return err return err
} }

93
internal/location_test.go Normal file
View File

@@ -0,0 +1,93 @@
package internal
import "testing"
func TestGetType(t *testing.T) {
t.Run("TypeLocal", func(t *testing.T) {
l := Location{
Type: "local",
}
result, err := l.getType()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
assertEqual(t, result, TypeLocal)
})
t.Run("TypeVolume", func(t *testing.T) {
l := Location{
Type: "volume",
}
result, err := l.getType()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
assertEqual(t, result, TypeVolume)
})
t.Run("Empty type", func(t *testing.T) {
l := Location{
Type: "",
}
result, err := l.getType()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
assertEqual(t, result, TypeLocal)
})
t.Run("Invalid type", func(t *testing.T) {
l := Location{
Type: "foo",
}
_, err := l.getType()
if err == nil {
t.Error("expected error")
}
})
}
func TestBuildTag(t *testing.T) {
result := buildTag("foo", "bar")
expected := "ar:foo:bar"
assertEqual(t, result, expected)
}
func TestGetLocationTags(t *testing.T) {
l := Location{
name: "foo",
}
result := l.getLocationTags()
expected := "ar:location:foo"
assertEqual(t, result, expected)
}
func TestHasBackend(t *testing.T) {
t.Run("backend present", func(t *testing.T) {
l := Location{
name: "foo",
To: []string{"foo", "bar"},
}
result := l.hasBackend("foo")
assertEqual(t, result, true)
})
t.Run("backend absent", func(t *testing.T) {
l := Location{
name: "foo",
To: []string{"bar", "baz"},
}
result := l.hasBackend("foo")
assertEqual(t, result, false)
})
}
func TestBuildRestoreCommand(t *testing.T) {
l := Location{
name: "foo",
}
result := buildRestoreCommand(l, "to", "snapshot", []string{"options"})
expected := []string{"restore", "--target", "to", "--tag", "ar:location:foo", "snapshot", "options"}
assertSliceEqual(t, result, expected)
}

View File

@@ -14,6 +14,10 @@ var lock *viper.Viper
var file string var file string
var once sync.Once var once sync.Once
const (
RUNNING = "running"
)
func getLock() *viper.Viper { func getLock() *viper.Viper {
if lock == nil { if lock == nil {
@@ -37,36 +41,38 @@ func getLock() *viper.Viper {
return lock return lock
} }
func setLock(locked bool) error { func setLockValue(key string, value interface{}) (*viper.Viper, error) {
lock := getLock() lock := getLock()
if locked {
running := lock.GetBool("running") if key == RUNNING {
if running { value := value.(bool)
if value && lock.GetBool(key) {
colors.Error.Println("an instance is already running. exiting") colors.Error.Println("an instance is already running. exiting")
os.Exit(1) os.Exit(1)
} }
} }
lock.Set("running", locked)
lock.Set(key, value)
if err := lock.WriteConfigAs(file); err != nil { if err := lock.WriteConfigAs(file); err != nil {
return err return nil, err
} }
return nil return lock, nil
} }
func GetCron(location string) int64 { func GetCron(location string) int64 {
lock := getLock() return getLock().GetInt64("cron." + location)
return lock.GetInt64("cron." + location)
} }
func SetCron(location string, value int64) { func SetCron(location string, value int64) {
lock.Set("cron."+location, value) setLockValue("cron."+location, value)
lock.WriteConfigAs(file)
} }
func Lock() error { func Lock() error {
return setLock(true) _, err := setLockValue(RUNNING, true)
return err
} }
func Unlock() error { func Unlock() error {
return setLock(false) _, err := setLockValue(RUNNING, false)
return err
} }

114
internal/lock/lock_test.go Normal file
View File

@@ -0,0 +1,114 @@
package lock
import (
"log"
"os"
"os/exec"
"strconv"
"testing"
"github.com/spf13/viper"
)
var testDirectory = "autorestic_test_tmp"
// All tests must share the same lock file as it is only initialized once
func setup(t *testing.T) {
d, err := os.MkdirTemp("", testDirectory)
if err != nil {
log.Fatalf("error creating temp dir: %v", err)
return
}
// set config file location
viper.SetConfigFile(d + "/.autorestic.yml")
t.Cleanup(func() {
os.RemoveAll(d)
viper.Reset()
})
}
func TestLock(t *testing.T) {
setup(t)
t.Run("getLock", func(t *testing.T) {
result := getLock().GetBool(RUNNING)
if result {
t.Errorf("got %v, want %v", result, false)
}
})
t.Run("lock", func(t *testing.T) {
lock, err := setLockValue(RUNNING, true)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
result := lock.GetBool(RUNNING)
if !result {
t.Errorf("got %v, want %v", result, true)
}
})
t.Run("unlock", func(t *testing.T) {
lock, err := setLockValue(RUNNING, false)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
result := lock.GetBool(RUNNING)
if result {
t.Errorf("got %v, want %v", result, false)
}
})
// locking a locked instance exits the instance
// this trick to capture os.Exit(1) is discussed here:
// https://talks.golang.org/2014/testing.slide#23
t.Run("lock twice", func(t *testing.T) {
if os.Getenv("CRASH") == "1" {
err := Lock()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// should fail
Lock()
}
cmd := exec.Command(os.Args[0], "-test.run=TestLock/lock_twice")
cmd.Env = append(os.Environ(), "CRASH=1")
err := cmd.Run()
err, ok := err.(*exec.ExitError)
if !ok {
t.Error("unexpected error")
}
expected := "exit status 1"
if err.Error() != expected {
t.Errorf("got %q, want %q", err.Error(), expected)
}
})
t.Run("set cron", func(t *testing.T) {
expected := int64(5)
SetCron("foo", expected)
result, err := strconv.ParseInt(getLock().GetString("cron.foo"), 10, 64)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if result != expected {
t.Errorf("got %d, want %d", result, expected)
}
})
t.Run("get cron", func(t *testing.T) {
expected := int64(5)
result := GetCron("foo")
if result != expected {
t.Errorf("got %d, want %d", result, expected)
}
})
}

View File

@@ -9,23 +9,34 @@ import (
"github.com/cupcakearmy/autorestic/internal/colors" "github.com/cupcakearmy/autorestic/internal/colors"
"github.com/cupcakearmy/autorestic/internal/flags" "github.com/cupcakearmy/autorestic/internal/flags"
"github.com/fatih/color"
) )
var RESTIC_BIN string
func CheckIfCommandIsCallable(cmd string) bool { func CheckIfCommandIsCallable(cmd string) bool {
_, err := exec.LookPath(cmd) _, err := exec.LookPath(cmd)
return err == nil return err == nil
} }
func CheckIfResticIsCallable() bool { func CheckIfResticIsCallable() bool {
return CheckIfCommandIsCallable(RESTIC_BIN) return CheckIfCommandIsCallable(flags.RESTIC_BIN)
} }
type ExecuteOptions struct { type ExecuteOptions struct {
Command string Command string
Envs map[string]string Envs map[string]string
Dir string Dir string
Silent bool
}
type ColoredWriter struct {
target io.Writer
color *color.Color
}
func (w ColoredWriter) Write(p []byte) (n int, err error) {
colored := []byte(w.color.Sprint(string(p)))
w.target.Write(colored)
return len(p), nil
} }
func ExecuteCommand(options ExecuteOptions, args ...string) (int, string, error) { func ExecuteCommand(options ExecuteOptions, args ...string) (int, string, error) {
@@ -43,23 +54,32 @@ func ExecuteCommand(options ExecuteOptions, args ...string) (int, string, error)
var out bytes.Buffer var out bytes.Buffer
var error bytes.Buffer var error bytes.Buffer
cmd.Stdout = &out if flags.VERBOSE && !options.Silent {
var colored ColoredWriter = ColoredWriter{
target: os.Stdout,
color: colors.Faint,
}
mw := io.MultiWriter(colored, &out)
cmd.Stdout = mw
} else {
cmd.Stdout = &out
}
cmd.Stderr = &error cmd.Stderr = &error
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
code := -1
if exitError, ok := err.(*exec.ExitError); ok { if exitError, ok := err.(*exec.ExitError); ok {
return exitError.ExitCode(), error.String(), err code = exitError.ExitCode()
} else {
return -1, error.String(), err
} }
return code, error.String(), err
} }
return 0, out.String(), nil return 0, out.String(), nil
} }
func ExecuteResticCommand(options ExecuteOptions, args ...string) (int, string, error) { func ExecuteResticCommand(options ExecuteOptions, args ...string) (int, string, error) {
options.Command = RESTIC_BIN options.Command = flags.RESTIC_BIN
var c = GetConfig() var c = GetConfig()
var optionsAsString = getOptions(c.Global, "") var optionsAsString = getOptions(c.Global, []string{"all"})
args = append(optionsAsString, args...) args = append(optionsAsString, args...)
return ExecuteCommand(options, args...) return ExecuteCommand(options, args...)
} }