Browse Source

Add more tests and update config examples

metrics
IamTheFij 2 months ago
parent
commit
92209822db
  1. 4
      Makefile
  2. 51
      config.hcl
  3. 22
      job.go
  4. 189
      job_test.go
  5. 24
      main_test.go
  6. 4
      restic_test.go
  7. 149
      shell_test.go
  8. 32
      tasks.go

4
Makefile

@ -27,10 +27,8 @@ build: $(APP_NAME)
# Run all tests
.PHONY: test
test:
go test -coverprofile=coverage.out
go test -coverprofile=coverage.out # -short
go tool cover -func=coverage.out
@go tool cover -func=coverage.out | awk -v target=80.0% \
'/^total:/ { print "Total coverage: " $3 " Minimum coverage: " target; if ($3+0.0 >= target+0.0) print "ok"; else { print "fail"; exit 1; } }'
# Installs pre-commit hooks
.PHONY: install-hooks

51
config.hcl

@ -16,50 +16,49 @@ job "MyApp" {
mysql "DumpMainDB" {
hostname = "foo"
username = "bar"
dump_to = "/data/main.sql"
}
sqlite "DumpSqlite" {
path = "/db/path"
path = "/db/sqlite.db"
dump_to = "/data/sqlite.db.bak"
}
task "RunSomePreScripts" {
script {
task "Create biz file" {
pre_script {
on_backup = <<EOF
echo foo > /biz.txt
echo bar >> /biz.txt
EOF
on_restore = "/foo/bar.sh"
}
script {
post_script {
on_backup = <<EOF
echo bar >> /biz.txt
rm /biz.txt
EOF
}
}
task "ActuallyBackupSomeStuff" {
backup {
files =[
"/foo/bar",
"/biz.txt",
]
task "Run restore shell script" {
pre_script {
on_restore = "/foo/bar.sh"
}
}
backup_opts {
Tags = ["service"]
}
backup {
files =[
"/data",
"/biz.txt",
]
restore_opts {
Verify = true
}
backup_opts {
Tags = ["service"]
}
}
task "RunSomePostScripts" {
script {
on_backup = <<EOF
rm /biz.txt
EOF
restore_opts {
Verify = true
# Since paths are absolute, restore to root
Target = "/"
}
}

22
job.go

@ -10,12 +10,9 @@ import (
"github.com/robfig/cron/v3"
)
const WorkDirPerms = 0770
var (
ErrNoJobsFound = errors.New("no jobs found and at least one job is required")
ErrMissingField = errors.New("missing config field")
ErrMissingBlock = errors.New("missing config block")
ErrMutuallyExclusive = errors.New("mutually exclusive values not valid")
ErrInvalidConfigValue = errors.New("invalid config value")
@ -69,10 +66,6 @@ type Job struct {
}
func (j Job) validateTasks() error {
if len(j.Tasks) == 0 {
return fmt.Errorf("job %s is missing tasks: %w", j.Name, ErrMissingBlock)
}
for _, task := range j.Tasks {
if err := task.Validate(); err != nil {
return fmt.Errorf("job %s has an invalid task: %w", j.Name, err)
@ -88,7 +81,7 @@ func (j Job) Validate() error {
}
if _, err := cron.ParseStandard(j.Schedule); err != nil {
return fmt.Errorf("job %s has an invalid schedule: %w", j.Name, err)
return fmt.Errorf("job %s has an invalid schedule: %v: %w", j.Name, err, ErrInvalidConfigValue)
}
if err := j.Config.Validate(); err != nil {
@ -111,6 +104,10 @@ func (j Job) Validate() error {
}
}
if err := j.Backup.Validate(); err != nil {
return fmt.Errorf("job %s has an invalid backup config: %w", j.Name, err)
}
return nil
}
@ -149,15 +146,8 @@ func (j Job) AllTasks() []ExecutableTask {
return allTasks
}
func (j Job) JobDir() string {
cwd := filepath.Join(JobBaseDir, j.Name)
_ = os.MkdirAll(cwd, WorkDirPerms)
return cwd
}
func (j Job) BackupPaths() []string {
files := j.Backup.Files
files := j.Backup.Paths
for _, t := range j.MySQL {
files = append(files, t.DumpToPath)

189
job_test.go

@ -7,6 +7,15 @@ import (
main "git.iamthefij.com/iamthefij/restic-scheduler"
)
func ValidResticConfig() main.ResticConfig {
return main.ResticConfig{
Passphrase: "shh",
Repo: "./data",
Env: nil,
GlobalOpts: nil,
}
}
func TestResticConfigValidate(t *testing.T) {
t.Parallel()
@ -64,3 +73,183 @@ func TestResticConfigValidate(t *testing.T) {
})
}
}
func TestJobValidation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
job main.Job
expectedErr error
}{
{
name: "Valid job",
job: main.Job{
Name: "Valid job",
Schedule: "@daily",
Config: ValidResticConfig(),
Tasks: []main.JobTask{},
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, // nolint:exhaustivestruct
Forget: nil,
MySQL: []main.JobTaskMySQL{},
Sqlite: []main.JobTaskSqlite{},
},
expectedErr: nil,
},
{
name: "Invalid name",
job: main.Job{
Name: "",
Schedule: "@daily",
Config: ValidResticConfig(),
Tasks: []main.JobTask{},
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, // nolint:exhaustivestruct
Forget: nil,
MySQL: []main.JobTaskMySQL{},
Sqlite: []main.JobTaskSqlite{},
},
expectedErr: main.ErrMissingField,
},
{
name: "Invalid schedule",
job: main.Job{
Name: "Test job",
Schedule: "shrug",
Config: ValidResticConfig(),
Tasks: []main.JobTask{},
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, // nolint:exhaustivestruct
Forget: nil,
MySQL: []main.JobTaskMySQL{},
Sqlite: []main.JobTaskSqlite{},
},
expectedErr: main.ErrInvalidConfigValue,
},
{
name: "Invalid config",
job: main.Job{
Name: "Test job",
Schedule: "@daily",
Config: main.ResticConfig{}, // nolint:exhaustivestruct
Tasks: []main.JobTask{},
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, // nolint:exhaustivestruct
Forget: nil,
MySQL: []main.JobTaskMySQL{},
Sqlite: []main.JobTaskSqlite{},
},
expectedErr: main.ErrMutuallyExclusive,
},
{
name: "Invalid task",
job: main.Job{
Name: "Test job",
Schedule: "@daily",
Config: ValidResticConfig(),
Tasks: []main.JobTask{{}},
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, // nolint:exhaustivestruct
Forget: nil,
MySQL: []main.JobTaskMySQL{},
Sqlite: []main.JobTaskSqlite{},
},
expectedErr: main.ErrMissingField,
},
{
name: "Invalid mysql",
job: main.Job{
Name: "Test job",
Schedule: "@daily",
Config: ValidResticConfig(),
Tasks: []main.JobTask{},
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, // nolint:exhaustivestruct
Forget: nil,
MySQL: []main.JobTaskMySQL{{}},
Sqlite: []main.JobTaskSqlite{},
},
expectedErr: main.ErrMissingField,
},
{
name: "Invalid sqlite",
job: main.Job{
Name: "Test job",
Schedule: "@daily",
Config: ValidResticConfig(),
Tasks: []main.JobTask{},
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, // nolint:exhaustivestruct
Forget: nil,
MySQL: []main.JobTaskMySQL{},
Sqlite: []main.JobTaskSqlite{{}},
},
expectedErr: main.ErrMissingField,
},
}
for _, c := range cases {
testCase := c
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
actual := testCase.job.Validate()
if !errors.Is(actual, testCase.expectedErr) {
t.Errorf("expected %v but found %v", testCase.expectedErr, actual)
}
})
}
}
func TestConfigValidation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
config main.Config
expectedErr error
}{
{
name: "Valid job",
config: main.Config{Jobs: []main.Job{{
Name: "Valid job",
Schedule: "@daily",
Config: ValidResticConfig(),
Tasks: []main.JobTask{},
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, // nolint:exhaustivestruct
MySQL: []main.JobTaskMySQL{},
Sqlite: []main.JobTaskSqlite{},
}}},
expectedErr: nil,
},
{
name: "No jobs",
config: main.Config{Jobs: []main.Job{}},
expectedErr: main.ErrNoJobsFound,
},
{
name: "Invalid name",
config: main.Config{Jobs: []main.Job{{
Name: "",
Schedule: "@daily",
Config: ValidResticConfig(),
Tasks: []main.JobTask{},
Backup: main.BackupFilesTask{Paths: []string{"/test"}}, // nolint:exhaustivestruct
Forget: nil,
MySQL: []main.JobTaskMySQL{},
Sqlite: []main.JobTaskSqlite{},
}}},
expectedErr: main.ErrMissingField,
},
}
for _, c := range cases {
testCase := c
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
actual := testCase.config.Validate()
if !errors.Is(actual, testCase.expectedErr) {
t.Errorf("expected %v but found %v", testCase.expectedErr, actual)
}
})
}
}

24
main_test.go

@ -0,0 +1,24 @@
package main_test
import (
"fmt"
"os"
"testing"
)
const MinCoverage = 0.5
func TestMain(m *testing.M) {
testResult := m.Run()
if testResult == 0 && testing.CoverMode() != "" {
c := testing.Coverage()
if c < MinCoverage {
fmt.Printf("Tests passed but coverage failed at %0.2f and minimum to pass is %0.2f\n", c, MinCoverage)
testResult = -1
}
}
os.Exit(testResult)
}

4
restic_test.go

@ -189,6 +189,10 @@ func TestBuildEnv(t *testing.T) {
func TestResticInterface(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("Skip integration test when running short tests")
}
dataDir := t.TempDir()
repoDir := t.TempDir()
cacheDir := t.TempDir()

149
shell_test.go

@ -0,0 +1,149 @@
package main_test
import (
"bytes"
"log"
"testing"
main "git.iamthefij.com/iamthefij/restic-scheduler"
)
/*
* type TestCase interface {
* Run(*testing.T)
* Name() string
* }
*
* type TestCases []TestCase
*
* func (c TestCases) Run(t *testing.T) {
* t.Helper()
*
* for _, tc := range c {
* testCase := tc
*
* t.Parallel()
*
* t.Run(tc.Name(), tc.Run(t))
* }
* }
*/
func TestGetLogger(t *testing.T) {
t.Parallel()
initialLogger := main.GetLogger("test")
t.Run("initial logger", func(t *testing.T) {
t.Parallel()
AssertEqual(t, "incorrect logger prefix", "test:", initialLogger.Prefix())
})
dupeLogger := main.GetLogger("test")
t.Run("dupe logger", func(t *testing.T) {
t.Parallel()
AssertEqual(t, "incorrect logger prefix", "test:", dupeLogger.Prefix())
if initialLogger != dupeLogger {
t.Error("expected reused instance")
}
})
secondLogger := main.GetLogger("test2")
t.Run("dupe logger", func(t *testing.T) {
t.Parallel()
AssertEqual(t, "incorrect logger prefix", "test2:", secondLogger.Prefix())
if initialLogger == secondLogger {
t.Error("expected new instance")
}
})
}
func TestGetChildLogger(t *testing.T) {
t.Parallel()
parentLogger := main.GetLogger("parent")
childLogger := main.GetChildLogger(parentLogger, "child")
AssertEqual(t, "unexpected child logger prefix", "parent:child:", childLogger.Prefix())
}
func TestCapturedLogWriter(t *testing.T) {
t.Parallel()
buffer := bytes.Buffer{}
logger := log.New(&buffer, "test:", log.Lmsgprefix)
capturedLogWriter := main.NewCapturedLogWriter(logger)
if _, err := capturedLogWriter.Write([]byte("testing")); err != nil {
t.Fatalf("failed to write to captured log writter: %v", err)
}
AssertEqual(t, "buffer contains incorrect values", "test: testing\n", buffer.String())
AssertEqual(t, "lines contains incorrect values", []string{"testing"}, capturedLogWriter.Lines)
}
func TestRunShell(t *testing.T) {
t.Parallel()
cases := []struct {
name string
script string
cwd string
env map[string]string
expectedOutput string
expectedErr bool
}{
{
name: "successful script",
script: "echo $FOO",
cwd: ".",
env: map[string]string{
"FOO": "bar",
},
expectedOutput: "prefix: bar\nprefix: \n",
expectedErr: false,
},
{
name: "failed script",
script: "echo $FOO\nexit 1",
cwd: ".",
env: map[string]string{
"FOO": "bar",
},
expectedOutput: "prefix: bar\nprefix: \n",
expectedErr: true,
},
}
for _, c := range cases {
testCase := c
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
buffer := bytes.Buffer{}
logger := log.New(&buffer, "prefix:", log.Lmsgprefix)
err := main.RunShell(
testCase.script,
testCase.cwd,
testCase.env,
logger,
)
if testCase.expectedErr && err == nil {
t.Error("expected an error but didn't get one")
}
if !testCase.expectedErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
AssertEqual(t, "unexpected output", testCase.expectedOutput, buffer.String())
})
}
}

32
tasks.go

@ -83,17 +83,21 @@ func (t JobTaskMySQL) Paths() []string {
}
func (t JobTaskMySQL) Validate() error {
if t.DumpToPath == "" {
return fmt.Errorf("task %s is missing dump_to path: %w", t.Name, ErrMissingField)
}
if s, err := os.Stat(t.DumpToPath); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("Could not stat dump file path: %w", err)
return fmt.Errorf("task %s: invalid dump_to: could not stat path: %v: %w", t.Name, err, ErrInvalidConfigValue)
}
} else if s.Mode().IsDir() {
return fmt.Errorf("dump_to cannot be a directory: %w", ErrInvalidConfigValue)
return fmt.Errorf("task %s: dump_to cannot be a directory: %w", t.Name, ErrInvalidConfigValue)
}
if len(t.Tables) > 0 && t.Database == "" {
return fmt.Errorf(
"mysql task %s is invalid. Must specify a database to use tables: %w",
"task %s is invalid. Must specify a database to use tables: %w",
t.Name,
ErrMissingField,
)
@ -170,12 +174,16 @@ func (t JobTaskSqlite) Paths() []string {
}
func (t JobTaskSqlite) Validate() error {
if t.DumpToPath == "" {
return fmt.Errorf("task %s is missing dump_to path: %w", t.Name, ErrMissingField)
}
if s, err := os.Stat(t.DumpToPath); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("Could not stat dump file path: %w", err)
return fmt.Errorf("task %s: invalid dump_to: could not stat path: %v: %w", t.Name, err, ErrInvalidConfigValue)
}
} else if s.Mode().IsDir() {
return fmt.Errorf("dump_to cannot be a directory: %w", ErrInvalidConfigValue)
return fmt.Errorf("task %s: dump_to cannot be a directory: %w", t.Name, ErrInvalidConfigValue)
}
return nil
@ -202,7 +210,7 @@ func (t JobTaskSqlite) GetPostTask() ExecutableTask {
}
type BackupFilesTask struct {
Files []string `hcl:"files"`
Paths []string `hcl:"files"`
BackupOpts *BackupOpts `hcl:"backup_opts,block"`
RestoreOpts *RestoreOpts `hcl:"restore_opts,block"`
name string
@ -247,6 +255,14 @@ func (t *BackupFilesTask) SetName(name string) {
t.name = name
}
func (t *BackupFilesTask) Validate() error {
if len(t.Paths) == 0 {
return fmt.Errorf("backup config doesn't include any paths: %w", ErrInvalidConfigValue)
}
return nil
}
// JobTask represents a single task within a backup job.
type JobTask struct {
Name string `hcl:"name,label"`
@ -255,6 +271,10 @@ type JobTask struct {
}
func (t JobTask) Validate() error {
if t.Name == "" {
return fmt.Errorf("task is missing a name: %w", ErrMissingField)
}
return nil
}

Loading…
Cancel
Save