Compare commits

...

5 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
15 changed files with 982 additions and 552 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 +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"
} }

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

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

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

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