Compare commits

..

9 Commits

Author SHA1 Message Date
338c84e36b Merge branch 'master' into ai-testing 2026-04-06 00:08:52 +02:00
496a8cc3af mise (#474) 2026-04-06 00:07:30 +02:00
f9e9688798 file permission test 2026-04-06 00:00:01 +02:00
a2046b9c8f fix test 2026-04-05 23:26:45 +02:00
82fc2c9191 ai testing 2026-04-05 23:21:42 +02:00
dependabot[bot]
9cf919b42b Bump golang from 1.24-alpine to 1.25-alpine (#454)
Bumps golang from 1.24-alpine to 1.25-alpine.

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.25-alpine
  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-08-31 13:48:49 +02: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
34 changed files with 1073 additions and 716 deletions

View File

@@ -1,21 +1,19 @@
name: Main name: Release
on: on:
push: push:
tags: tags:
- 'v*.*.*' - "v*.*.*"
jobs: jobs:
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Set up QEMU - uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v2 - uses: docker/setup-buildx-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Docker Labels - name: Docker Labels
id: meta id: meta
uses: crazy-max/ghaction-docker-meta@v4 uses: docker/metadata-action@v6
with: with:
images: cupcakearmy/autorestic images: cupcakearmy/autorestic
tags: | tags: |
@@ -23,12 +21,12 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v2 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v3 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
@@ -37,10 +35,9 @@ jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v6
- uses: actions/setup-go@v3 - uses: jdx/mise-action@v3
with: - run: mise run build
go-version: '^1.21'
- name: Build - name: Build
run: go run build/build.go run: go run build/build.go
- name: Release - name: Release

View File

@@ -2,23 +2,28 @@ name: CI
on: on:
pull_request: pull_request:
push: push:
branches: [master] branches:
- main
- master
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MISE_EXPERIMENTAL: true
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v6
- uses: actions/setup-go@v3 - uses: jdx/mise-action@v3
with: - run: mise run test
go-version: '^1.21'
- run: go test -v ./...
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v6
- uses: actions/setup-go@v3 - uses: jdx/mise-action@v3
with: - run: mise run build
go-version: '^1.21'
- run: go build -v .

3
.gitignore vendored
View File

@@ -9,4 +9,5 @@
test test
autorestic autorestic
data data
dist dist
coverage*

View File

@@ -1,4 +1,4 @@
FROM golang:1.23-alpine as builder FROM golang:1.25-alpine as builder
WORKDIR /app WORKDIR /app
COPY go.* . COPY go.* .

View File

@@ -6,6 +6,7 @@ import (
"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/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -14,12 +15,14 @@ var backupCmd = &cobra.Command{
Short: "Create backups for given locations", Short: "Create backups for given locations",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
internal.GetConfig() internal.GetConfig()
err := internal.Lock() err := lock.Lock()
CheckErr(err) CheckErr(err)
defer internal.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, "@")
@@ -28,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++
@@ -43,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

@@ -3,6 +3,7 @@ package cmd
import ( import (
"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/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -11,9 +12,9 @@ var checkCmd = &cobra.Command{
Short: "Check if everything is setup", Short: "Check if everything is setup",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
internal.GetConfig() internal.GetConfig()
err := internal.Lock() err := lock.Lock()
CheckErr(err) CheckErr(err)
defer internal.Unlock() defer lock.Unlock()
CheckErr(internal.CheckConfig()) CheckErr(internal.CheckConfig())

View File

@@ -3,6 +3,7 @@ package cmd
import ( import (
"github.com/cupcakearmy/autorestic/internal" "github.com/cupcakearmy/autorestic/internal"
"github.com/cupcakearmy/autorestic/internal/flags" "github.com/cupcakearmy/autorestic/internal/flags"
"github.com/cupcakearmy/autorestic/internal/lock"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -12,9 +13,9 @@ 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()
err := internal.Lock() err := lock.Lock()
CheckErr(err) CheckErr(err)
defer internal.Unlock() defer lock.Unlock()
err = internal.RunCron() err = internal.RunCron()
CheckErr(err) CheckErr(err)

View File

@@ -5,6 +5,7 @@ import (
"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/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -13,9 +14,9 @@ var execCmd = &cobra.Command{
Short: "Execute arbitrary native restic commands for given backends", Short: "Execute arbitrary native restic commands for given backends",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
internal.GetConfig() internal.GetConfig()
err := internal.Lock() err := lock.Lock()
CheckErr(err) CheckErr(err)
defer internal.Unlock() defer lock.Unlock()
selected, err := internal.GetAllOrSelected(cmd, true) selected, err := internal.GetAllOrSelected(cmd, true)
CheckErr(err) CheckErr(err)

View File

@@ -2,6 +2,7 @@ package cmd
import ( import (
"github.com/cupcakearmy/autorestic/internal" "github.com/cupcakearmy/autorestic/internal"
"github.com/cupcakearmy/autorestic/internal/lock"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -10,9 +11,9 @@ var forgetCmd = &cobra.Command{
Short: "Forget and optionally prune snapshots according the specified policies", Short: "Forget and optionally prune snapshots according the specified policies",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
internal.GetConfig() internal.GetConfig()
err := internal.Lock() err := lock.Lock()
CheckErr(err) CheckErr(err)
defer internal.Unlock() defer lock.Unlock()
selected, err := internal.GetAllOrSelected(cmd, false) selected, err := internal.GetAllOrSelected(cmd, false)
CheckErr(err) CheckErr(err)

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"github.com/cupcakearmy/autorestic/internal" "github.com/cupcakearmy/autorestic/internal"
"github.com/cupcakearmy/autorestic/internal/lock"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -13,9 +14,9 @@ var restoreCmd = &cobra.Command{
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
internal.GetConfig() internal.GetConfig()
err := internal.Lock() err := lock.Lock()
CheckErr(err) CheckErr(err)
defer internal.Unlock() defer lock.Unlock()
location, _ := cmd.Flags().GetString("location") location, _ := cmd.Flags().GetString("location")
l, ok := internal.GetLocation(location) l, ok := internal.GetLocation(location)

View File

@@ -8,6 +8,7 @@ import (
"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" "github.com/cupcakearmy/autorestic/internal/flags"
"github.com/cupcakearmy/autorestic/internal/lock"
"github.com/spf13/cobra" "github.com/spf13/cobra"
homedir "github.com/mitchellh/go-homedir" homedir "github.com/mitchellh/go-homedir"
@@ -17,7 +18,7 @@ import (
func CheckErr(err error) { func CheckErr(err error) {
if err != nil { if err != nil {
colors.Error.Fprintln(os.Stderr, "Error:", err) colors.Error.Fprintln(os.Stderr, "Error:", err)
internal.Unlock() lock.Unlock()
os.Exit(1) os.Exit(1)
} }
} }
@@ -41,7 +42,6 @@ func init() {
rootCmd.PersistentFlags().BoolVarP(&flags.VERBOSE, "verbose", "v", false, "verbose mode") rootCmd.PersistentFlags().BoolVarP(&flags.VERBOSE, "verbose", "v", false, "verbose mode")
rootCmd.PersistentFlags().StringVar(&flags.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") rootCmd.PersistentFlags().StringVar(&flags.DOCKER_IMAGE, "docker-image", "cupcakearmy/autorestic:"+internal.VERSION, "specify a custom docker image")
rootCmd.PersistentFlags().StringVar(&flags.LOCKFILE, "lockfile", "", "specify a custom path for the lockfile (defaults to .autorestic.lock.yml next to the loaded autorestic config file)")
cobra.OnInitialize(initConfig) cobra.OnInitialize(initConfig)
} }

View File

@@ -9,6 +9,7 @@ import (
"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/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -32,7 +33,7 @@ To check you can run "ps aux | grep autorestic".`,
} }
} }
err := internal.Unlock() err := lock.Unlock()
if err != nil { if err != nil {
colors.Error.Println("Could not unlock:", err) colors.Error.Println("Could not unlock:", err)
return return

View File

@@ -1 +1 @@
v22.7.0 v24

2
docs/mise.toml Normal file
View File

@@ -0,0 +1,2 @@
[settings]
idiomatic_version_file_enable_tools = ["node", "pnpm"]

View File

@@ -5,11 +5,11 @@
"start": "NEXT_TELEMETRY_DISABLED=1 next start" "start": "NEXT_TELEMETRY_DISABLED=1 next start"
}, },
"dependencies": { "dependencies": {
"next": "^14.2.7", "next": "^14.2.35",
"nextra": "^2.13.4", "nextra": "^2.13.4",
"nextra-theme-docs": "^2.13.4", "nextra-theme-docs": "^2.13.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"packageManager": "pnpm@9.9.0" "packageManager": "pnpm@10.33.0"
} }

View File

@@ -3,7 +3,6 @@
"quick": "Quick Start", "quick": "Quick Start",
"installation": "Installation", "installation": "Installation",
"config": "Configuration", "config": "Configuration",
"lockfile": "Lockfile",
"location": "Locations", "location": "Locations",
"backend": "Backend", "backend": "Backend",
"cli": "CLI", "cli": "CLI",

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

View File

@@ -34,11 +34,3 @@ With `--restic-bin` you can specify to run a specific restic binary. This can be
```bash ```bash
autorestic --restic-bin /some/path/to/my/custom/restic/binary autorestic --restic-bin /some/path/to/my/custom/restic/binary
``` ```
## `--lockfile`
Specify the path for the [lockfile](../lockfile.md) used by `autorestic`. If omitted, this will default to `.autorestic.lock.yml` next to the loaded config file.
```bash
autorestic --lockfile /path/to/my/.autorestic.lock.yml
```

View File

@@ -1,14 +0,0 @@
# Lockfile
Under the hood, `autorestic` uses a lockfile to ensure that only one instance is running and to keep track of when [cronjobs](./location/cron.md) were last run.
By default, the lockfile is stored next to your [configuration file](./config.md) as `.autorestic.lock.yml`. In other words, if your config file is located at `/some/path/.autorestic.yml`, then the lockfile will be located at `/some/path/.autorestic.lock.yml`.
## Customization
The path to the lockfile can be customized if need be. This can be done is a few ways:
1. Using the `--lockfile ...` command line flag
1. Setting `lockfile: ...` in the configuration file
Note that `autorestic` will check for a customized lockfile path in the order listed above. This means that if you specify a lockfile path in multiple places, the method that's higher in the list will win.

1040
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/cupcakearmy/autorestic module github.com/cupcakearmy/autorestic
go 1.21 go 1.26.1
require ( require (
github.com/blang/semver/v4 v4.0.0 github.com/blang/semver/v4 v4.0.0

View File

@@ -10,6 +10,7 @@ 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/cupcakearmy/autorestic/internal/lock"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/mitchellh/go-homedir" "github.com/mitchellh/go-homedir"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -23,7 +24,6 @@ type Options map[string]OptionMap
type Config struct { type Config struct {
Version string `mapstructure:"version" yaml:"version"` Version string `mapstructure:"version" yaml:"version"`
Lockfile string `mapstructure:"lockfile,omitempty" yaml:"lockfile,omitempty"`
Extras interface{} `mapstructure:"extras" yaml:"extras"` Extras interface{} `mapstructure:"extras" yaml:"extras"`
Locations map[string]Location `mapstructure:"locations" yaml:"locations"` Locations map[string]Location `mapstructure:"locations" yaml:"locations"`
Backends map[string]Backend `mapstructure:"backends" yaml:"backends"` Backends map[string]Backend `mapstructure:"backends" yaml:"backends"`
@@ -40,7 +40,7 @@ func exitConfig(err error, msg string) {
if msg != "" { if msg != "" {
colors.Error.Println(msg) colors.Error.Println(msg)
} }
Unlock() lock.Unlock()
os.Exit(1) os.Exit(1)
} }
@@ -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,9 @@
package flags package flags
var ( var (
CI bool = false CI bool = false
VERBOSE bool = false VERBOSE bool = false
CRON_LEAN bool = false CRON_LEAN bool = false
RESTIC_BIN string RESTIC_BIN string
DOCKER_IMAGE string DOCKER_IMAGE string
LOCKFILE string
) )

View File

@@ -11,6 +11,7 @@ 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/cupcakearmy/autorestic/internal/lock"
"github.com/cupcakearmy/autorestic/internal/metadata" "github.com/cupcakearmy/autorestic/internal/metadata"
"github.com/robfig/cron" "github.com/robfig/cron"
) )
@@ -167,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)
@@ -227,6 +228,9 @@ func (l Location) Backup(cron bool, specificBackend string) []error {
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,
@@ -441,12 +445,12 @@ func (l Location) RunCron() error {
if err != nil { if err != nil {
return err return err
} }
last := time.Unix(GetCron(l.name), 0) last := time.Unix(lock.GetCron(l.name), 0)
next := schedule.Next(last) next := schedule.Next(last)
now := time.Now() now := time.Now()
if now.After(next) { if now.After(next) {
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...))
} }

View File

@@ -1,6 +1,14 @@
package internal package internal
import "testing" import (
"os"
"path"
"sync"
"testing"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
)
func TestGetType(t *testing.T) { func TestGetType(t *testing.T) {
@@ -91,3 +99,46 @@ func TestBuildRestoreCommand(t *testing.T) {
expected := []string{"restore", "--target", "to", "--tag", "ar:location:foo", "snapshot", "options"} expected := []string{"restore", "--target", "to", "--tag", "ar:location:foo", "snapshot", "options"}
assertSliceEqual(t, result, expected) assertSliceEqual(t, result, expected)
} }
func TestLocationBackupWithMock(t *testing.T) {
// Backup original
originalExecutor := DefaultExecutor
defer func() { DefaultExecutor = originalExecutor }()
// Inject mock
mock := &MockExecutor{
ExecuteResticFunc: func(options ExecuteOptions, args ...string) (int, string, error) {
assert.Equal(t, "backup", args[0])
return 0, "success", nil
},
}
DefaultExecutor = mock
// Setup dummy config
workDir := t.TempDir()
configFile := path.Join(workDir, ".autorestic.yml")
err := os.WriteFile(configFile, []byte("version: 2"), 0644)
assert.NoError(t, err)
viper.Reset()
viper.SetConfigFile(configFile)
viper.Set("version", 2)
// Register test-backend
viper.Set("backends.test-backend.type", "local")
viper.Set("backends.test-backend.path", workDir)
config = nil
once = sync.Once{}
loc := Location{
name: "test-location",
To: []string{"test-backend"},
From: []string{"/"},
Type: "local",
}
errs := loc.Backup(false, false, "")
if len(errs) != 0 {
t.Errorf("expected no error, got %v", errs)
}
}

View File

@@ -1,9 +1,8 @@
package internal package lock
import ( import (
"os" "os"
"path" "path"
"path/filepath"
"sync" "sync"
"github.com/cupcakearmy/autorestic/internal/colors" "github.com/cupcakearmy/autorestic/internal/colors"
@@ -13,47 +12,24 @@ import (
var lock *viper.Viper var lock *viper.Viper
var file string var file string
var lockOnce sync.Once var once sync.Once
const ( const (
RUNNING = "running" RUNNING = "running"
) )
// getLockfilePath returns the path to the lockfile. The path for the lockfile
// can be sources from multiple places If flags.LOCKFILE is set, its value is
// used; if the config has the `lockfile` option set, its value is used;
// otherwise the path is generated relative to the config file.
func getLockfilePath() string {
if flags.LOCKFILE != "" {
abs, err := filepath.Abs(flags.LOCKFILE)
if err != nil {
return flags.LOCKFILE
}
return abs
}
if lockfile := GetConfig().Lockfile; lockfile != "" {
abs, err := filepath.Abs(lockfile)
if err != nil {
return lockfile
}
return abs
}
p := viper.ConfigFileUsed()
if p == "" {
colors.Error.Println("cannot lock before reading config location")
os.Exit(1)
}
return path.Join(path.Dir(p), ".autorestic.lock.yml")
}
func getLock() *viper.Viper { func getLock() *viper.Viper {
if lock == nil { if lock == nil {
lockOnce.Do(func() {
once.Do(func() {
lock = viper.New() lock = viper.New()
lock.SetDefault("running", false) lock.SetDefault("running", false)
file = getLockfilePath() p := viper.ConfigFileUsed()
if p == "" {
colors.Error.Println("cannot lock before reading config location")
os.Exit(1)
}
file = path.Join(path.Dir(p), ".autorestic.lock.yml")
if !flags.CRON_LEAN { if !flags.CRON_LEAN {
colors.Faint.Println("Using lock:\t", file) colors.Faint.Println("Using lock:\t", file)
} }

View File

@@ -1,88 +1,30 @@
package internal package lock
import ( import (
"log"
"os" "os"
"os/exec" "os/exec"
"path"
"strconv" "strconv"
"sync"
"testing" "testing"
"github.com/cupcakearmy/autorestic/internal/flags"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/stretchr/testify/assert"
) )
var testDirectory = "autorestic_test_tmp"
// All tests must share the same lock file as it is only initialized once // All tests must share the same lock file as it is only initialized once
func setup(t *testing.T) { func setup(t *testing.T) {
t.Helper() d, err := os.MkdirTemp("", testDirectory)
cleanup := func() { if err != nil {
flags.LOCKFILE = "" log.Fatalf("error creating temp dir: %v", err)
config = nil return
once = sync.Once{}
viper.Reset()
} }
// set config file location
cleanup()
d := t.TempDir()
viper.SetConfigFile(d + "/.autorestic.yml") viper.SetConfigFile(d + "/.autorestic.yml")
viper.Set("version", 2)
viper.WriteConfig()
t.Cleanup(cleanup) t.Cleanup(func() {
} os.RemoveAll(d)
viper.Reset()
func TestGetLockfilePath(t *testing.T) {
t.Run("user specified", func(t *testing.T) {
testCases := []struct {
name string
flag string
config string
expected string
}{
{
name: "flag and config",
flag: "/flag.lock.yml",
config: "/config.lock.yml",
expected: "/flag.lock.yml",
},
{
name: "flag only",
flag: "/flag.lock.yml",
config: "",
expected: "/flag.lock.yml",
},
{
name: "config only",
flag: "",
config: "/config.lock.yml",
expected: "/config.lock.yml",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
setup(t)
flags.LOCKFILE = testCase.flag
if testCase.config != "" {
viper.Set("lockfile", testCase.config)
err := viper.WriteConfig()
assert.NoError(t, err)
}
p := getLockfilePath()
assert.Equal(t, testCase.expected, p)
})
}
})
t.Run("default", func(t *testing.T) {
setup(t)
configPath := viper.ConfigFileUsed()
expectedLockfile := path.Join(path.Dir(configPath), ".autorestic.lock.yml")
p := getLockfilePath()
assert.Equal(t, expectedLockfile, p)
}) })
} }

View File

@@ -0,0 +1,20 @@
package internal
type MockExecutor struct {
ExecuteFunc func(options ExecuteOptions, args ...string) (int, string, error)
ExecuteResticFunc func(options ExecuteOptions, args ...string) (int, string, error)
}
func (m *MockExecutor) Execute(options ExecuteOptions, args ...string) (int, string, error) {
if m.ExecuteFunc != nil {
return m.ExecuteFunc(options, args...)
}
return 0, "", nil
}
func (m *MockExecutor) ExecuteRestic(options ExecuteOptions, args ...string) (int, string, error) {
if m.ExecuteResticFunc != nil {
return m.ExecuteResticFunc(options, args...)
}
return 0, "", nil
}

View File

@@ -39,7 +39,14 @@ func (w ColoredWriter) Write(p []byte) (n int, err error) {
return len(p), nil return len(p), nil
} }
func ExecuteCommand(options ExecuteOptions, args ...string) (int, string, error) { type Executor interface {
Execute(options ExecuteOptions, args ...string) (int, string, error)
ExecuteRestic(options ExecuteOptions, args ...string) (int, string, error)
}
type RealExecutor struct{}
func (e *RealExecutor) Execute(options ExecuteOptions, args ...string) (int, string, error) {
cmd := exec.Command(options.Command, args...) cmd := exec.Command(options.Command, args...)
env := os.Environ() env := os.Environ()
for k, v := range options.Envs { for k, v := range options.Envs {
@@ -76,12 +83,22 @@ func ExecuteCommand(options ExecuteOptions, args ...string) (int, string, error)
return 0, out.String(), nil return 0, out.String(), nil
} }
func ExecuteResticCommand(options ExecuteOptions, args ...string) (int, string, error) { func (e *RealExecutor) ExecuteRestic(options ExecuteOptions, args ...string) (int, string, error) {
options.Command = flags.RESTIC_BIN options.Command = flags.RESTIC_BIN
var c = GetConfig() var c = GetConfig()
var optionsAsString = getOptions(c.Global, []string{"all"}) var optionsAsString = getOptions(c.Global, []string{"all"})
args = append(optionsAsString, args...) args = append(optionsAsString, args...)
return ExecuteCommand(options, args...) return e.Execute(options, args...)
}
var DefaultExecutor Executor = &RealExecutor{}
func ExecuteCommand(options ExecuteOptions, args ...string) (int, string, error) {
return DefaultExecutor.Execute(options, args...)
}
func ExecuteResticCommand(options ExecuteOptions, args ...string) (int, string, error) {
return DefaultExecutor.ExecuteRestic(options, args...)
} }
func CopyFile(from, to string) error { func CopyFile(from, to string) error {

27
internal/utils_test.go Normal file
View File

@@ -0,0 +1,27 @@
package internal
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestExecuteCommandWithMock(t *testing.T) {
// Backup original
originalExecutor := DefaultExecutor
defer func() { DefaultExecutor = originalExecutor }()
// Inject mock
mock := &MockExecutor{
ExecuteFunc: func(options ExecuteOptions, args ...string) (int, string, error) {
assert.Equal(t, "docker", options.Command)
return 0, "mock output", nil
},
}
DefaultExecutor = mock
code, out, err := ExecuteCommand(ExecuteOptions{Command: "docker"}, "info")
assert.NoError(t, err)
assert.Equal(t, 0, code)
assert.Equal(t, "mock output", out)
}

View File

@@ -22,7 +22,7 @@ import (
"syscall" "syscall"
"github.com/cupcakearmy/autorestic/cmd" "github.com/cupcakearmy/autorestic/cmd"
"github.com/cupcakearmy/autorestic/internal" "github.com/cupcakearmy/autorestic/internal/lock"
) )
func handleCtrlC() { func handleCtrlC() {
@@ -31,7 +31,7 @@ func handleCtrlC() {
go func() { go func() {
sig := <-c sig := <-c
fmt.Println("Signal:", sig) fmt.Println("Signal:", sig)
internal.Unlock() lock.Unlock()
os.Exit(0) os.Exit(0)
}() }()
} }

14
mise.toml Normal file
View File

@@ -0,0 +1,14 @@
[tools]
go = "latest"
restic = "latest"
[tasks]
build = { description = "Build the project", run = "go build -v ." }
test = { description = "Run tests", run = "go test -v ./..." }
coverage = { description = "Generate coverage report", run = "go test -coverprofile=coverage.out ./... && go tool cover -func=coverage.out && go tool cover -html=coverage.out -o coverage.html", depends = [
"test"
] }
clean = { run = "rm -f coverage.*", description = "Clean up coverage files" }

280
tests/integration_test.go Normal file
View File

@@ -0,0 +1,280 @@
package integration_test
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func runAutorestic(t *testing.T, dir string, configPath string, args ...string) string {
// Find project root dynamically
wd, err := os.Getwd()
assert.NoError(t, err)
root := filepath.Dir(wd)
mainGo := filepath.Join(root, "main.go")
// Get restic path from mise
resticPath := "/Users/cupcakearmy/.local/share/mise/installs/restic/0.18.1/restic"
// Convert configPath to absolute path
absConfigPath, err := filepath.Abs(configPath)
assert.NoError(t, err)
cmd := exec.Command("go", append([]string{"run", mainGo, "--restic-bin", resticPath, "-c", absConfigPath}, args...)...)
// Run from root to find go.mod
cmd.Dir = root
// Add mise path to environment and set dummy password
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "PATH="+os.Getenv("PATH"))
cmd.Env = append(cmd.Env, "RESTIC_PASSWORD=password")
output, err := cmd.CombinedOutput()
// NOTE: We don't assert NoError here because tests might expect failures
return string(output)
}
func initRepo(t *testing.T, repoPath string) {
resticPath := "/Users/cupcakearmy/.local/share/mise/installs/restic/0.18.1/restic"
cmd := exec.Command(resticPath, "-r", repoPath, "init")
cmd.Env = append(os.Environ(), "RESTIC_PASSWORD=password")
output, err := cmd.CombinedOutput()
assert.NoError(t, err, string(output))
}
func TestAutoresticCheck(t *testing.T) {
tempDir := t.TempDir()
configContent := `
version: 2
locations:
my-data:
from: ` + tempDir + `
to: local
backends:
local:
type: local
path: ` + filepath.Join(tempDir, "repo") + `
key: password
`
configPath := filepath.Join(tempDir, "autorestic.yml")
err := os.WriteFile(configPath, []byte(configContent), 0644)
assert.NoError(t, err)
_ = runAutorestic(t, tempDir, configPath, "check")
}
func TestBackupRestore(t *testing.T) {
tempDir := t.TempDir()
// 1. Create a source file
sourceFile := "source.txt"
err := os.WriteFile(filepath.Join(tempDir, sourceFile), []byte("hello world"), 0644)
assert.NoError(t, err)
repoPath := filepath.Join(tempDir, "repo")
initRepo(t, repoPath)
configContent := `
version: 2
locations:
my-data:
from:
- ` + sourceFile + `
to: local
backends:
local:
type: local
path: ` + repoPath + `
key: password
`
configPath := filepath.Join(tempDir, "autorestic.yml")
err = os.WriteFile(configPath, []byte(configContent), 0644)
assert.NoError(t, err)
// 2. Backup
output := runAutorestic(t, tempDir, configPath, "backup", "-l", "my-data")
assert.Contains(t, output, "Done")
// 3. Restore
restoreDir := filepath.Join(tempDir, "restore")
err = os.MkdirAll(restoreDir, 0755)
assert.NoError(t, err)
output = runAutorestic(t, tempDir, configPath, "restore", "-l", "my-data", "--to", restoreDir)
t.Logf("Restore output: %s", output)
// DEBUG: List files in restoreDir and subdirectories
err = filepath.Walk(restoreDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
t.Logf("Found: %s", path)
return nil
})
assert.NoError(t, err)
// 4. Verify
// It might be nested depending on how it was backed up
// Let's look for source.txt in any subdirectory
var restoredFile string
err = filepath.Walk(restoreDir, func(path string, info os.FileInfo, err error) error {
if !info.IsDir() && info.Name() == "source.txt" {
restoredFile = path
}
return nil
})
assert.NoError(t, err)
assert.NotEmpty(t, restoredFile, "source.txt not found")
content, err := os.ReadFile(restoredFile)
assert.NoError(t, err)
assert.Equal(t, "hello world", string(content))
}
func TestHooks(t *testing.T) {
tempDir := t.TempDir()
// Create a dummy file to back up
sourceFile := "source.txt"
err := os.WriteFile(filepath.Join(tempDir, sourceFile), []byte("data"), 0644)
assert.NoError(t, err)
repoPath := filepath.Join(tempDir, "repo")
initRepo(t, repoPath)
configContent := `
version: 2
locations:
my-data:
from:
- ` + sourceFile + `
to: local
hooks:
before:
- touch before.txt
after:
- touch after.txt
backends:
local:
type: local
path: ` + repoPath + `
key: password
`
configPath := filepath.Join(tempDir, "autorestic.yml")
err = os.WriteFile(configPath, []byte(configContent), 0644)
assert.NoError(t, err)
// Run backup
runAutorestic(t, tempDir, configPath, "backup", "-l", "my-data")
// Verify
assert.FileExists(t, filepath.Join(tempDir, "before.txt"))
assert.FileExists(t, filepath.Join(tempDir, "after.txt"))
}
func TestCopy(t *testing.T) {
tempDir := t.TempDir()
// Create a dummy file to back up
sourceFile := "source.txt"
err := os.WriteFile(filepath.Join(tempDir, sourceFile), []byte("data"), 0644)
assert.NoError(t, err)
repoPath := filepath.Join(tempDir, "repo")
initRepo(t, repoPath)
remoteRepoPath := filepath.Join(tempDir, "remote_repo")
initRepo(t, remoteRepoPath)
configContent := `
version: 2
locations:
my-data:
from:
- ` + sourceFile + `
to: local
copy:
local:
- remote
backends:
local:
type: local
path: ` + repoPath + `
key: password
remote:
type: local
path: ` + remoteRepoPath + `
key: password
`
configPath := filepath.Join(tempDir, "autorestic.yml")
err = os.WriteFile(configPath, []byte(configContent), 0644)
assert.NoError(t, err)
// Run backup
output := runAutorestic(t, tempDir, configPath, "backup", "-l", "my-data")
// Verify copy in output
assert.Contains(t, output, "Copying local → remote")
}
func TestPermissions(t *testing.T) {
tempDir := t.TempDir()
// 1. Create a source file with specific permissions (0600)
sourceFile := "source.txt"
sourcePath := filepath.Join(tempDir, sourceFile)
err := os.WriteFile(sourcePath, []byte("data"), 0600)
assert.NoError(t, err)
repoPath := filepath.Join(tempDir, "repo")
initRepo(t, repoPath)
configContent := `
version: 2
locations:
my-data:
from:
- ` + sourceFile + `
to: local
backends:
local:
type: local
path: ` + repoPath + `
key: password
`
configPath := filepath.Join(tempDir, "autorestic.yml")
err = os.WriteFile(configPath, []byte(configContent), 0644)
assert.NoError(t, err)
// 2. Backup
runAutorestic(t, tempDir, configPath, "backup", "-l", "my-data")
// 3. Restore
restoreDir := filepath.Join(tempDir, "restore")
err = os.MkdirAll(restoreDir, 0755)
assert.NoError(t, err)
runAutorestic(t, tempDir, configPath, "restore", "-l", "my-data", "--to", restoreDir)
// 4. Verify permissions
// Use walk to find the restored file
var restoredFile string
err = filepath.Walk(restoreDir, func(path string, info os.FileInfo, err error) error {
if !info.IsDir() && info.Name() == "source.txt" {
restoredFile = path
}
return nil
})
assert.NoError(t, err)
assert.NotEmpty(t, restoredFile, "source.txt not found")
info, err := os.Stat(restoredFile)
assert.NoError(t, err)
// Check permissions (masking only for permission bits)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
}

14
tests/version_test.go Normal file
View File

@@ -0,0 +1,14 @@
package integration_test
import (
"github.com/stretchr/testify/assert"
"os/exec"
"testing"
)
func TestVersion(t *testing.T) {
cmd := exec.Command("go", "run", "../main.go", "--version")
out, err := cmd.CombinedOutput()
assert.NoError(t, err)
assert.Contains(t, string(out), "autorestic")
}