diff --git a/go.mod b/go.mod index 73b3e0e..cbda03c 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/go-test/deep v1.0.8 github.com/hashicorp/hcl/v2 v2.11.1 github.com/robfig/cron/v3 v3.0.1 + github.com/zclconf/go-cty v1.8.0 ) require ( @@ -13,6 +14,5 @@ require ( github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/google/go-cmp v0.3.1 // indirect github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect - github.com/zclconf/go-cty v1.8.0 // indirect golang.org/x/text v0.3.5 // indirect ) diff --git a/job.go b/job.go index ee284df..238a23c 100644 --- a/job.go +++ b/job.go @@ -147,17 +147,17 @@ func (j Job) AllTasks() []ExecutableTask { } func (j Job) BackupPaths() []string { - files := j.Backup.Paths + paths := j.Backup.Paths for _, t := range j.MySQL { - files = append(files, t.DumpToPath) + paths = append(paths, t.DumpToPath) } for _, t := range j.Sqlite { - files = append(files, t.DumpToPath) + paths = append(paths, t.DumpToPath) } - return files + return paths } func (j Job) RunBackup() error { diff --git a/main.go b/main.go index af63c48..0b2d232 100644 --- a/main.go +++ b/main.go @@ -4,9 +4,13 @@ import ( "flag" "fmt" "log" + "os" "strings" + "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsimple" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" ) var ( @@ -14,10 +18,43 @@ var ( version = "dev" ) -func parseConfig(path string) ([]Job, error) { +func ParseConfig(path string) ([]Job, error) { var config Config - if err := hclsimple.DecodeFile(path, nil, &config); err != nil { + ctx := hcl.EvalContext{ + Variables: nil, + Functions: map[string]function.Function{ + "env": function.New(&function.Spec{ + Params: []function.Parameter{{ + Name: "var", + Type: cty.String, + }}, + VarParam: nil, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return cty.StringVal(os.Getenv(args[0].AsString())), nil + }, + }), + "readfile": function.New(&function.Spec{ + Params: []function.Parameter{{ + Name: "path", + Type: cty.String, + }}, + VarParam: nil, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + content, err := os.ReadFile(args[0].AsString()) + if err != nil { + return cty.StringVal(""), err + } + + return cty.StringVal(string(content)), nil + }, + }), + }, + } + + if err := hclsimple.DecodeFile(path, &ctx, &config); err != nil { return nil, fmt.Errorf("%s: Failed to decode file: %w", path, err) } @@ -36,11 +73,11 @@ func parseConfig(path string) ([]Job, error) { return config.Jobs, nil } -func readJobs(paths []string) ([]Job, error) { +func ReadJobs(paths []string) ([]Job, error) { allJobs := []Job{} for _, path := range paths { - jobs, err := parseConfig(path) + jobs, err := ParseConfig(path) if err != nil { return nil, err } @@ -113,7 +150,7 @@ func main() { log.Fatalf("Requires a path to a job file, but found none") } - jobs, err := readJobs(flag.Args()) + jobs, err := ReadJobs(flag.Args()) if err != nil { log.Fatalf("Failed to read jobs from files: %v", err) } diff --git a/main_test.go b/main_test.go index 09062b7..f91a25f 100644 --- a/main_test.go +++ b/main_test.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "testing" + + main "git.iamthefij.com/iamthefij/restic-scheduler" ) const MinCoverage = 0.5 @@ -22,3 +24,17 @@ func TestMain(m *testing.M) { os.Exit(testResult) } + +func TestReadJobs(t *testing.T) { + t.Parallel() + + jobs, err := main.ReadJobs([]string{"./test/sample.hcl"}) + + if err != nil { + t.Errorf("Unexpected error reading jobs: %v", err) + } + + if len(jobs) == 0 { + t.Error("Expected read jobs but found none") + } +} diff --git a/tasks.go b/tasks.go index 07ee609..7ce6999 100644 --- a/tasks.go +++ b/tasks.go @@ -210,7 +210,7 @@ func (t JobTaskSqlite) GetPostTask() ExecutableTask { } type BackupFilesTask struct { - Paths []string `hcl:"files"` + Paths []string `hcl:"paths"` BackupOpts *BackupOpts `hcl:"backup_opts,block"` RestoreOpts *RestoreOpts `hcl:"restore_opts,block"` name string @@ -222,7 +222,7 @@ func (t BackupFilesTask) RunBackup(cfg TaskConfig) error { } if err := cfg.Restic.Backup(cfg.BackupPaths, *t.BackupOpts); err != nil { - err = fmt.Errorf("failed backing up files: %w", err) + err = fmt.Errorf("failed backing up paths: %w", err) cfg.Logger.Fatal(err) return err @@ -238,7 +238,7 @@ func (t BackupFilesTask) RunRestore(cfg TaskConfig) error { // TODO: Make the snapshot configurable if err := cfg.Restic.Restore("latest", *t.RestoreOpts); err != nil { - err = fmt.Errorf("failed restoring files: %w", err) + err = fmt.Errorf("failed restoring paths: %w", err) cfg.Logger.Fatal(err) return err diff --git a/test/sample.hcl b/test/sample.hcl new file mode 100644 index 0000000..44aa47c --- /dev/null +++ b/test/sample.hcl @@ -0,0 +1,97 @@ +// A simple backup job +job "BackupDataDir" { + schedule = "@daily" + + config { + repo = "./backups" + passphrase = "secret phrase" + } + + backup { + paths = ["./data"] + + restore_opts { + // Since backup paths are relative to cwd, we're going to restore relative to cwd as well + Target = "." + } + + } + + forget { + KeepLast = 2 + Prune = true + } +} + +job "PassphraseFile" { + schedule = "@daily" + + config { + repo = "./backups" + options { + // A more secure method of specifying password + PasswordFile = "./test/samplepassphrase.txt" + } + } + + backup { + paths = ["./data"] + + restore_opts { + // Since backup paths are relative to cwd, we're going to restore relative to cwd as well + Target = "." + } + + } +} + +job "BackupDataAndSqlite" { + schedule = "@daily" + + config { + repo = "./backups" + // Another safe way of not inlining the passphrase + passphrase = readfile("./test/samplepassphrase.txt") + } + + sqlite "Backup database" { + path = "./sqlite.db" + dump_to = "./data/sqlite.db.bak" + } + + backup { + paths = ["./data"] + + restore_opts { + // Since backup paths are relative to cwd, we're going to restore relative to cwd as well + Target = "." + } + } +} + +job "BackupMySQLDatabase" { + schedule = "@daily" + + config { + repo = "./backups" + passphrase = "secret phrase" + } + + mysql "Backup database" { + hostname = "localhost" + database = "dbname" + username = "username" + // Values can be read from the env to avoid inlining as well + password = env("TEST_PASSWORD") + dump_to = "./data/sqlite.db.bak" + } + + backup { + paths = ["./data"] + + restore_opts { + // Since backup paths are relative to cwd, we're going to restore relative to cwd as well + Target = "." + } + } +} diff --git a/test/samplepassphrase.txt b/test/samplepassphrase.txt new file mode 100644 index 0000000..b5f9078 --- /dev/null +++ b/test/samplepassphrase.txt @@ -0,0 +1 @@ +supersecret