From a1969b681a9067c5df77e92d38ce846502f3e027 Mon Sep 17 00:00:00 2001 From: Ian Fijolek Date: Tue, 22 Feb 2022 16:39:01 -0800 Subject: [PATCH] Some basic functionality --- config.hcl | 73 +++++++++--- job.go | 341 +++++++++++++++++++++++++++++++++++++++++++++-------- main.go | 17 ++- restic.go | 321 +++++++++++++++++++++++++++++++++++++++++++++++++ run.go | 169 -------------------------- shell.go | 78 ++++++++++++ test.hcl | 40 +++++++ utils.go | 13 ++ 8 files changed, 819 insertions(+), 233 deletions(-) create mode 100644 restic.go delete mode 100644 run.go create mode 100644 shell.go create mode 100644 test.hcl create mode 100644 utils.go diff --git a/config.hcl b/config.hcl index 6cf7534..2849cb5 100644 --- a/config.hcl +++ b/config.hcl @@ -1,30 +1,73 @@ -job "My App" { +// Example job file +job "MyApp" { schedule = "* * * * *" config { repo = "s3://..." passphrase = "foo" - } - - task "Dump mysql" { - mysql { - hostname = "foo" - username = "bar" + env = { + "foo" = "bar", + } + options { + VerboseLevel = 3 } } - task "Create biz file" { - on_backup { - body = < /biz.txt EOF + + on_restore = "/foo/bar.sh" + } + + script { + on_backup = <> /biz.txt + EOF } } - task "Backup data files" { - files = [ - "/foo/bar", - "/biz.txt", - ] + task "ActuallyBackupSomeStuff" { + backup { + files =[ + "/foo/bar", + "/biz.txt", + ] + + backup_opts { + Tags = ["service"] + } + + restore_opts { + Verify = true + } + } + } + + task "RunSomePostScripts" { + script { + on_backup = < './%s.sql'", + t.Hostname, + t.Username, + t.Password, + t.Database, + t.Name, + ), + FromJobDir: true, + } +} + +func (t JobTaskMySQL) GetPostTask() ExecutableTask { + return JobTaskScript{ + name: t.Name, + OnRestore: fmt.Sprintf( + "mysql -h '%s' -u '%s' -p '%s' '%s' << './%s.sql'", + t.Hostname, + t.Username, + t.Password, + t.Database, + t.Name, + ), + FromJobDir: true, + } +} + // JobTaskSqlite is a sqlite backup task that performs required pre and post tasks type JobTaskSqlite struct { - Path string `hcl:"path,label"` + Name string `hcl:"name,label"` + Path string `hcl:"path"` +} + +func (t JobTaskSqlite) GetPreTask() ExecutableTask { + return JobTaskScript{ + name: t.Name, + OnBackup: fmt.Sprintf( + "sqlite3 %s '.backup $RESTIC_JOB_DIR/%s.bak'", + t.Path, t.Name, + ), + } +} + +func (t JobTaskSqlite) GetPostTask() ExecutableTask { + return JobTaskScript{ + name: t.Name, + OnRestore: fmt.Sprintf("cp '$RESTIC_JOB_DIR/%s.bak' '%s'", t.Name, t.Path), + } +} + +type BackupFilesTask struct { + Files []string `hcl:"files"` + BackupOpts *BackupOpts `hcl:"backup_opts,block"` + RestoreOpts *RestoreOpts `hcl:"restore_opts,block"` + name string +} + +func (t BackupFilesTask) RunBackup(cfg TaskConfig) error { + if err := cfg.Restic.Backup(t.Files, t.BackupOpts); err != nil { + err = fmt.Errorf("failed backing up files: %w", err) + cfg.Logger.Fatal(err) + + return err + } + + return nil +} + +func (t BackupFilesTask) RunRestore(cfg TaskConfig) error { + if err := cfg.Restic.Restore("latest", t.RestoreOpts); err != nil { + err = fmt.Errorf("failed restoring files: %w", err) + cfg.Logger.Fatal(err) + + return err + } + + return nil +} + +func (t BackupFilesTask) Name() string { + return t.name +} + +func (t *BackupFilesTask) SetName(name string) { + t.name = name } // JobTask represents a single task within a backup job type JobTask struct { - Name string `hcl:"name,label"` - OnBackup []JobTaskScript `hcl:"on_backup,block"` - OnRestore []JobTaskScript `hcl:"on_restore,block"` - MySql []JobTaskMySQL `hcl:"mysql,block"` - Sqlite []JobTaskSqlite `hcl:"sqlite,block"` - Files []string `hcl:"files,optional"` + Name string `hcl:"name,label"` + Scripts []JobTaskScript `hcl:"script,block"` + Backup *BackupFilesTask `hcl:"backup,block"` +} + +func (t JobTask) GetTasks() []ExecutableTask { + allTasks := []ExecutableTask{} + + for _, exTask := range t.Scripts { + exTask.SetName(t.Name) + allTasks = append(allTasks, exTask) + } + + if t.Backup != nil { + t.Backup.SetName(t.Name) + allTasks = append(allTasks, t.Backup) + } + + return allTasks } // Job contains all configuration required to construct and run a backup // and restore job type Job struct { - Name string `hcl:"name,label"` - Schedule string `hcl:"schedule"` - Config JobConfig `hcl:"config,block"` - Tasks []JobTask `hcl:"task,block"` - Validate bool `hcl:"validate,optional"` + Name string `hcl:"name,label"` + Schedule string `hcl:"schedule"` + Config ResticConfig `hcl:"config,block"` + Tasks []JobTask `hcl:"task,block"` + Validate bool `hcl:"validate,optional"` + Forget *ForgetOpts `hcl:"forget,block"` + + // Meta Tasks + MySql []JobTaskMySQL `hcl:"mysql,block"` + Sqlite []JobTaskSqlite `hcl:"sqlite,block"` } -func (job Job) NewRestic() ResticCmd { - return ResticCmd{ - LogPrefix: job.Name, - Repo: job.Config.Repo, - Env: job.Config.Env, - Passphrase: job.Config.Passphrase, +func (j Job) AllTasks() []ExecutableTask { + allTasks := []ExecutableTask{} + + // Pre tasks + for _, mysql := range j.MySql { + allTasks = append(allTasks, mysql.GetPreTask()) + } + + for _, sqlite := range j.Sqlite { + allTasks = append(allTasks, sqlite.GetPreTask()) + } + + // Get ordered tasks + for _, jobTask := range j.Tasks { + allTasks = append(allTasks, jobTask.GetTasks()...) + } + + // Post tasks + for _, mysql := range j.MySql { + allTasks = append(allTasks, mysql.GetPreTask()) + } + + for _, sqlite := range j.Sqlite { + allTasks = append(allTasks, sqlite.GetPreTask()) + } + + return allTasks +} + +func (j Job) JobDir() string { + cwd := filepath.Join("/restic_backup", j.Name) + _ = os.MkdirAll(cwd, WorkDirPerms) + + return cwd +} + +func (j Job) RunTasks() error { + logger := GetLogger(j.Name) + restic := j.NewRestic() + jobDir := j.JobDir() + + if err := restic.EnsureInit(); err != nil { + return fmt.Errorf("failed to init restic for job %s: %w", j.Name, err) + } + + for _, exTask := range j.AllTasks() { + taskCfg := TaskConfig{ + JobDir: jobDir, + Logger: GetChildLogger(logger, exTask.Name()), + Restic: restic, + } + + if err := exTask.RunBackup(taskCfg); err != nil { + return fmt.Errorf("failed running job %s: %w", j.Name, err) + } + } + + if j.Forget != nil { + restic.Forget(j.Forget) + } + + return nil +} + +func (j Job) NewRestic() *ResticCmd { + return &ResticCmd{ + Logger: GetLogger(j.Name), + Repo: j.Config.Repo, + Env: j.Config.Env, + Passphrase: j.Config.Passphrase, + GlobalOpts: j.Config.GlobalOpts, } } @@ -63,33 +308,33 @@ type Config struct { /*** job "My App" { - schedule = "* * * * *" - config { - repo = "s3://..." - passphrase = "foo" - } + schedule = "* * * * *" + config { + repo = "s3://..." + passphrase = "foo" + } - task "Dump mysql" { - mysql { - hostname = "foo" - username = "bar" - } - } + task "Dump mysql" { + mysql { + hostname = "foo" + username = "bar" + } + } - task "Create biz file" { - on_backup { - body = < /biz.txt - EOF - } - } + task "Create biz file" { + on_backup { + body = < /biz.txt + EOF + } + } - task "Backup data files" { - files = [ - "/foo/bar", - "/biz.txt", - ] - } + task "Backup data files" { + files = [ + "/foo/bar", + "/biz.txt", + ] + } } ***/ diff --git a/main.go b/main.go index 6e587e9..cacfae8 100644 --- a/main.go +++ b/main.go @@ -26,9 +26,24 @@ func main() { var config Config - if err := hclsimple.DecodeFile("config.hcl", nil, &config); err != nil { + args := flag.Args() + if len(args) == 0 { + log.Fatalf("Requires a path to a job file, but found none") + } + + if err := hclsimple.DecodeFile(args[0], nil, &config); err != nil { log.Fatalf("Failed to load configuration: %s", err) } log.Printf("Configuration is %#v", config) + + if len(config.Jobs) == 0 { + log.Fatalf("No jobs defined in config") + } + + for _, job := range config.Jobs { + if err := job.RunTasks(); err != nil { + log.Fatalf("%v", err) + } + } } diff --git a/restic.go b/restic.go new file mode 100644 index 0000000..d50e6eb --- /dev/null +++ b/restic.go @@ -0,0 +1,321 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "strings" + "time" +) + +type CommandOptions interface { + ToArgs() []string +} + +type NoOpts struct{} + +func (_ NoOpts) ToArgs() []string { + return []string{} +} + +type ResticGlobalOpts struct { + CaCertFile string `hcl:"CaCertFile,optional"` + CacheDir string `hcl:"CacheDir,optional"` + PasswordFile string `hcl:"PasswordFile,optional"` + TlsClientCertFile string `hcl:"TlsClientCertFile,optional"` + LimitDownload int `hcl:"LimitDownload,optional"` + LimitUpload int `hcl:"LimitUpload,optional"` + VerboseLevel int `hcl:"VerboseLevel,optional"` + CleanupCache bool `hcl:"CleanupCache,optional"` + NoCache bool `hcl:"NoCache,optional"` + NoLock bool `hcl:"NoLock,optional"` +} + +func (glo ResticGlobalOpts) ToArgs() (args []string) { + if glo.CaCertFile != "" { + args = append(args, "--cacert", glo.CaCertFile) + } + + if glo.CacheDir != "" { + args = append(args, "--cache-dir", glo.CacheDir) + } + + if glo.CleanupCache { + args = append(args, "--cleanup-cache") + } + + if glo.LimitDownload > 0 { + args = append(args, "--limit-download", fmt.Sprint(glo.LimitDownload)) + } + + if glo.LimitUpload > 0 { + args = append(args, "--limit-upload", fmt.Sprint(glo.LimitUpload)) + } + + if glo.NoCache { + args = append(args, "--no-cache") + } + + if glo.NoLock { + args = append(args, "--no-lock") + } + + if glo.PasswordFile != "" { + args = append(args, "--password-file", glo.PasswordFile) + } + + if glo.TlsClientCertFile != "" { + args = append(args, "--tls-client-cert", glo.TlsClientCertFile) + } + + if glo.VerboseLevel > 0 { + args = append(args, "--verbose", fmt.Sprint(glo.VerboseLevel)) + } + + return args +} + +type ResticCmd struct { + Logger *log.Logger + Repo string + Env map[string]string + Passphrase string + GlobalOpts *ResticGlobalOpts + Cwd string +} + +func (rcmd ResticCmd) BuildEnv() []string { + if rcmd.Env == nil { + rcmd.Env = map[string]string{} + } + + if rcmd.Passphrase != "" { + rcmd.Env["RESTIC_PASSWORD"] = rcmd.Passphrase + } + + envList := os.Environ() + + for name, value := range rcmd.Env { + envList = append(envList, fmt.Sprintf("%s=%s", name, value)) + } + + return envList +} + +func (rcmd ResticCmd) RunRestic(command string, options CommandOptions, commandArgs ...string) error { + args := []string{} + if rcmd.GlobalOpts != nil { + args = rcmd.GlobalOpts.ToArgs() + } + + args = append(args, "--repo", rcmd.Repo, command) + args = append(args, options.ToArgs()...) + args = append(args, commandArgs...) + + cmd := exec.Command("restic", args...) + + cmd.Stdout = NewLogWriter(rcmd.Logger) + cmd.Stderr = cmd.Stdout + cmd.Env = rcmd.BuildEnv() + cmd.Dir = rcmd.Cwd + + err := cmd.Run() + + return err +} + +type BackupOpts struct { + Exclude []string `hcl:"Exclude,optional"` + Include []string `hcl:"Include,optional"` + Tags []string `hcl:"Tags,optional"` + Host string `hcl:"Host,optional"` +} + +func (bo BackupOpts) ToArgs() (args []string) { + for _, tag := range bo.Tags { + args = append(args, "--tag", tag) + } + + if bo.Host != "" { + args = append(args, "--host", bo.Host) + } + + return +} + +func (rcmd ResticCmd) Backup(files []string, options *BackupOpts) error { + if options == nil { + options = &BackupOpts{} + } + + err := rcmd.RunRestic("backup", options, files...) + + return err +} + +type RestoreOpts struct { + Exclude []string `hcl:"Exclude,optional"` + Host []string `hcl:"Host,optional"` + Include []string `hcl:"Include,optional"` + Path string `hcl:"Path,optional"` + Tags []string `hcl:"Tags,optional"` + Target string `hcl:"Target,optional"` + Verify bool `hcl:"Verify,optional"` +} + +func (ro RestoreOpts) ToArgs() (args []string) { + for _, exclude := range ro.Exclude { + args = append(args, "--exclude", exclude) + } + + for _, include := range ro.Include { + args = append(args, "--include", include) + } + + for _, host := range ro.Host { + args = append(args, "--host", host) + } + + if ro.Path != "" { + args = append(args, "--path", ro.Path) + } + + for _, tag := range ro.Tags { + args = append(args, "--tag", tag) + } + + if ro.Target != "" { + args = append(args, "--target", ro.Target) + } + + if ro.Verify { + args = append(args, "--verify") + } + + return +} + +func (rcmd ResticCmd) Restore(snapshot string, opts *RestoreOpts) error { + if opts == nil { + opts = &RestoreOpts{} + } + + err := rcmd.RunRestic("restore", opts, snapshot) + + return err +} + +type ForgetOpts struct { + KeepLast int `hcl:"KeepLast,optional"` + KeepHourly int `hcl:"KeepHourly,optional"` + KeepDaily int `hcl:"KeepDaily,optional"` + KeepWeekly int `hcl:"KeepWeekly,optional"` + KeepMonthly int `hcl:"KeepMonthly,optional"` + KeepYearly int `hcl:"KeepYearly,optional"` + + KeepWithin time.Duration `hcl:"KeepWithin,optional"` + KeepWithinHourly time.Duration `hcl:"KeepWithinHourly,optional"` + KeepWithinDaily time.Duration `hcl:"KeepWithinDaily,optional"` + KeepWithinWeekly time.Duration `hcl:"KeepWithinWeekly,optional"` + KeepWithinMonthly time.Duration `hcl:"KeepWithinMonthly,optional"` + KeepWithinYearly time.Duration `hcl:"KeepWithinYearly,optional"` + + Tags []string `hcl:"Tags,optional"` + KeepTags []string `hcl:"KeepTags,optional"` + + Prune bool `hcl:"Prune,optional"` +} + +func (fo ForgetOpts) ToArgs() (args []string) { + // Add keep-* + if fo.KeepLast > 0 { + args = append(args, "--keep-last", fmt.Sprint(fo.KeepLast)) + } + + if fo.KeepHourly > 0 { + args = append(args, "--keep-hourly", fmt.Sprint(fo.KeepHourly)) + } + + if fo.KeepDaily > 0 { + args = append(args, "--keep-daily", fmt.Sprint(fo.KeepDaily)) + } + + if fo.KeepWeekly > 0 { + args = append(args, "--keep-weekly", fmt.Sprint(fo.KeepWeekly)) + } + + if fo.KeepMonthly > 0 { + args = append(args, "--keep-monthly", fmt.Sprint(fo.KeepMonthly)) + } + + if fo.KeepYearly > 0 { + args = append(args, "--keep-yearly", fmt.Sprint(fo.KeepYearly)) + } + + if fo.KeepWithin > 0 { + args = append(args, "--keep-within", fmt.Sprint(fo.KeepWithin)) + } + + // Add keep-within-* + if fo.KeepWithinHourly > 0 { + args = append(args, "--keep-within-hourly", fo.KeepWithinHourly.String()) + } + + if fo.KeepWithinDaily > 0 { + args = append(args, "--keep-within-daily", fo.KeepWithinDaily.String()) + } + + if fo.KeepWithinWeekly > 0 { + args = append(args, "--keep-within-weekly", fo.KeepWithinWeekly.String()) + } + + if fo.KeepWithinMonthly > 0 { + args = append(args, "--keep-within-monthly", fo.KeepWithinMonthly.String()) + } + + if fo.KeepWithinYearly > 0 { + args = append(args, "--keep-within-yearly", fo.KeepWithinYearly.String()) + } + + // Add tags + if len(fo.Tags) > 0 { + args = append(args, "--tag", strings.Join(fo.Tags, ",")) + } + + if len(fo.KeepTags) > 0 { + args = append(args, "--keep-tag", strings.Join(fo.Tags, ",")) + } + + // Add prune options + + if fo.Prune { + args = append(args, "--prune") + } + + return args +} + +func (rcmd ResticCmd) Forget(forgetOpts *ForgetOpts) error { + if forgetOpts == nil { + forgetOpts = &ForgetOpts{} + } + + err := rcmd.RunRestic("forget", forgetOpts) + + return err +} + +func (rcmd ResticCmd) Check() error { + err := rcmd.RunRestic("check", NoOpts{}) + + return err +} + +func (rcmd ResticCmd) EnsureInit() error { + if err := rcmd.RunRestic("snapshots", NoOpts{}); err != nil { + return rcmd.RunRestic("init", NoOpts{}) + } + + return nil +} diff --git a/run.go b/run.go deleted file mode 100644 index 9bf3cc2..0000000 --- a/run.go +++ /dev/null @@ -1,169 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "os/exec" - "strings" - "time" -) - -var defaultFlags = log.LstdFlags | log.Lmsgprefix - -type ResticCmd struct { - LogPrefix string - Repo string - Env map[string]string - Passphrase string -} - -func (rcmd ResticCmd) BuildEnv() []string { - rcmd.Env["RESTIC_PASSWORD"] = rcmd.Passphrase - - envList := []string{} - - for name, value := range rcmd.Env { - envList = append(envList, fmt.Sprintf("%s=%s", name, value)) - } - - return envList -} - -func (rcmd ResticCmd) RunRestic(args []string) error { - cmd := exec.Command("restic", args...) - - cmd.Stdout = rcmd.Logger().Writer() - cmd.Stderr = cmd.Stdout - cmd.Env = rcmd.BuildEnv() - - err := cmd.Run() - - return err -} - -func (rcmd ResticCmd) Logger() *log.Logger { - logger := log.New(os.Stderr, rcmd.LogPrefix, defaultFlags) - - return logger -} - -func (rcmd ResticCmd) Backup(path string, args []string) error { - args = append([]string{"--repo", rcmd.Repo, "backup"}, args...) - args = append(args, path) - - err := rcmd.RunRestic(args) - - return err -} - -type ForgetOpts struct { - KeepLast int - KeepHourly int - KeepDaily int - KeepWeekly int - KeepMonthly int - KeepYearly int - - KeepWithin time.Duration - KeepWithinHourly time.Duration - KeepWithinDaily time.Duration - KeepWithinWeekly time.Duration - KeepWithinMonthly time.Duration - KeepWithinYearly time.Duration - - Tags []string - KeepTags []string - - Prune bool -} - -func (fo ForgetOpts) ToArgs() []string { - args := []string{} - - // Add keep-* - - if fo.KeepLast > 0 { - args = append(args, "--keep-last", fmt.Sprint(fo.KeepLast)) - } - - if fo.KeepHourly > 0 { - args = append(args, "--keep-hourly", fmt.Sprint(fo.KeepHourly)) - } - - if fo.KeepDaily > 0 { - args = append(args, "--keep-daily", fmt.Sprint(fo.KeepDaily)) - } - - if fo.KeepWeekly > 0 { - args = append(args, "--keep-weekly", fmt.Sprint(fo.KeepWeekly)) - } - - if fo.KeepMonthly > 0 { - args = append(args, "--keep-monthly", fmt.Sprint(fo.KeepMonthly)) - } - - if fo.KeepYearly > 0 { - args = append(args, "--keep-yearly", fmt.Sprint(fo.KeepYearly)) - } - - if fo.KeepWithin > 0 { - args = append(args, "--keep-within", fmt.Sprint(fo.KeepWithin)) - } - - // Add keep-within-* - - if fo.KeepWithinHourly > 0 { - args = append(args, "--keep-within-hourly", fo.KeepWithinHourly.String()) - } - - if fo.KeepWithinDaily > 0 { - args = append(args, "--keep-within-daily", fo.KeepWithinDaily.String()) - } - - if fo.KeepWithinWeekly > 0 { - args = append(args, "--keep-within-weekly", fo.KeepWithinWeekly.String()) - } - - if fo.KeepWithinMonthly > 0 { - args = append(args, "--keep-within-monthly", fo.KeepWithinMonthly.String()) - } - - if fo.KeepWithinYearly > 0 { - args = append(args, "--keep-within-yearly", fo.KeepWithinYearly.String()) - } - - // Add tags - - if len(fo.Tags) > 0 { - args = append(args, "--tag", strings.Join(fo.Tags, ",")) - } - - if len(fo.KeepTags) > 0 { - args = append(args, "--keep-tag", strings.Join(fo.Tags, ",")) - } - - // Add prune options - - if fo.Prune { - args = append(args, "--prune") - } - - return args -} - -func (rcmd ResticCmd) Cleanup(forgetOpts ForgetOpts) error { - args := append([]string{"--repo", rcmd.Repo, "forget"}, forgetOpts.ToArgs()...) - - err := rcmd.RunRestic(args) - - return err -} - -func (rcmd ResticCmd) Check() error { - args := []string{"--repo", rcmd.Repo, "check"} - - err := rcmd.RunRestic(args) - - return err -} diff --git a/shell.go b/shell.go new file mode 100644 index 0000000..6e1efe3 --- /dev/null +++ b/shell.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "strings" +) + +var ( + loggerFlags = log.LstdFlags | log.Lmsgprefix + loggers = map[string]*log.Logger{} +) + +func GetLogger(name string) *log.Logger { + if logger, ok := loggers[name]; ok { + return logger + } + + logger := log.New(os.Stderr, name+":", loggerFlags) + loggers[name] = logger + + return logger +} + +func GetChildLogger(parent *log.Logger, name string) *log.Logger { + childName := fmt.Sprintf("%s%s", parent.Prefix(), name) + + return GetLogger(childName) +} + +type logWriter struct { + logger *log.Logger +} + +func NewLogWriter(logger *log.Logger) *logWriter { + return &logWriter{logger} +} + +func (w logWriter) Write(p []byte) (n int, err error) { + message := fmt.Sprintf("%s", p) + for _, line := range strings.Split(message, "\n") { + w.logger.Printf(" %s", line) + } + + return len(p), nil +} + +func RunShell(script string, cwd string, env map[string]string, logger *log.Logger) error { + cmd := exec.Command("sh", "-c", strings.TrimSpace(script)) + + // Make both stderr and stdout go to logger + // fmt.Println("LOGGER PREFIX", logger.Prefix()) + // logger.Println("From logger") + cmd.Stdout = NewLogWriter(logger) + cmd.Stderr = cmd.Stdout + + // Set working directory + cmd.Dir = cwd + + // Convert env to list if values provided + if len(env) > 0 { + envList := os.Environ() + + for name, value := range env { + envList = append(envList, fmt.Sprintf("%s=%s", name, value)) + } + + cmd.Env = envList + } + + if err := cmd.Run(); err != nil { + return fmt.Errorf("shell execution failed: %w", err) + } + + return nil +} diff --git a/test.hcl b/test.hcl new file mode 100644 index 0000000..a30c4da --- /dev/null +++ b/test.hcl @@ -0,0 +1,40 @@ +job "TestBackup" { + schedule = "1 * * * *" + + config { + repo = "./backups" + passphrase = "supersecret" + + options { + CacheDir = "./cache" + } + } + + task "before script" { + script { + on_backup = "echo before backup!" + } + } + + task "backup" { + backup { + files = [ + "./data" + ] + + backup_opts { + Tags = ["foo"] + } + } + } + + task "after script" { + script { + on_backup = "echo after backup!" + } + } + + forget { + KeepLast = 2 + } +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..c551b19 --- /dev/null +++ b/utils.go @@ -0,0 +1,13 @@ +package main + +func MergeEnv(parent, child map[string]string) (result map[string]string) { + for key, value := range parent { + result[key] = value + } + + for key, value := range child { + result[key] = value + } + + return +}