Skip to content

Commit

Permalink
add support for zfs datasets and zvols
Browse files Browse the repository at this point in the history
  • Loading branch information
choffmeister committed Jun 10, 2022
1 parent f47e132 commit 7e49b91
Show file tree
Hide file tree
Showing 12 changed files with 450 additions and 55 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# restic-plus

This is a simple wrapper around the normal [Restic](https://github.com/restic/restic) binary.

## Additions

* YAML based configuration file, see [here](restic-plus.yaml.example).
* ZFS datasets and ZFS zvols support while leveraging ZFS snapshots to garuantuee consistent backups.

## Restrictions

* Currently is opinionated and only works with SFTP.

## Usage

* `restic-plus backup`: Run backups
* `restic-plus cron`: Run backups and do cleanup afterwards
* `restic-plus -- xxx`: Forward to original restic binary
34 changes: 31 additions & 3 deletions cmd/backup.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package cmd

import (
"fmt"
"strconv"

"github.com/choffmeister/restic-plus/internal"
"github.com/spf13/cobra"
)
Expand All @@ -9,12 +12,37 @@ var (
backupCmd = &cobra.Command{
Use: "backup",
RunE: func(cmd *cobra.Command, args []string) error {
for _, target := range rootContext.Config.Targets {
if err := internal.Restic(rootContext, "backup", target); err != nil {
return err
config := rootContext.Config
targets := config.Targets
bandwidth := config.Bandwidth
failed := false

for _, target := range targets {
internal.LogInfo.Printf("Backing up %s...\n", target.Implementation.String())
_, source, err := target.Implementation.Pre()
defer target.Implementation.Post()
if err != nil {
internal.LogError.Printf("Pre for target %s failed: %v", target.Type, err)
failed = true
continue
}

args := []string{"backup", source}
if bandwidth.Download > 0 {
args = append(args, "--limit-downlowd", strconv.Itoa(bandwidth.Download))
}
if bandwidth.Upload > 0 {
args = append(args, "--limit-upload", strconv.Itoa(bandwidth.Upload))
}
if err := internal.ExecRestic(rootContext, args...); err != nil {
internal.LogError.Printf("Backup of target %s failed: %v", target.Type, err)
failed = true
}
}

if failed {
return fmt.Errorf("some backup targets have failed")
}
return nil
},
}
Expand Down
43 changes: 43 additions & 0 deletions cmd/cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cmd

import (
"strconv"

"github.com/choffmeister/restic-plus/internal"
"github.com/spf13/cobra"
)

var (
cleanupCmd = &cobra.Command{
Use: "cleanup",
RunE: func(cmd *cobra.Command, args []string) error {
internal.LogInfo.Printf("Cleaning up...\n")
config := rootContext.Config
cleanup := config.Cron.Cleanup
if cleanup.Enabled {
args := []string{"forget", "--prune"}
if cleanup.Keep.Last > 0 {
args = append(args, "--keep-last", strconv.Itoa(cleanup.Keep.Last))
}
if cleanup.Keep.Daily > 0 {
args = append(args, "--keep-daily", strconv.Itoa(cleanup.Keep.Daily))
}
if cleanup.Keep.Weekly > 0 {
args = append(args, "--keep-weekly", strconv.Itoa(cleanup.Keep.Weekly))
}
if cleanup.Keep.Monthly > 0 {
args = append(args, "--keep-monthly", strconv.Itoa(cleanup.Keep.Monthly))
}
if cleanup.Keep.Yearly > 0 {
args = append(args, "--keep-yearly", strconv.Itoa(cleanup.Keep.Yearly))
}

if err := internal.ExecRestic(rootContext, args...); err != nil {
return err
}
}

return nil
},
}
)
30 changes: 5 additions & 25 deletions cmd/cron.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package cmd

import (
"strconv"
"io/ioutil"
"log"

"github.com/choffmeister/restic-plus/internal"
"github.com/spf13/cobra"
Expand All @@ -11,34 +12,13 @@ var (
cronCmd = &cobra.Command{
Use: "cron",
RunE: func(cmd *cobra.Command, args []string) error {
internal.LogInfo = log.New(ioutil.Discard, "", 0)
if err := backupCmd.RunE(cmd, []string{}); err != nil {
return err
}

cleanup := rootContext.Config.Cron.Cleanup
if cleanup.Enabled {
forgetArgs := []string{"forget", "--prune"}
if cleanup.Keep.Last > 0 {
forgetArgs = append(forgetArgs, "--keep-last", strconv.Itoa(cleanup.Keep.Last))
}
if cleanup.Keep.Daily > 0 {
forgetArgs = append(forgetArgs, "--keep-daily", strconv.Itoa(cleanup.Keep.Daily))
}
if cleanup.Keep.Weekly > 0 {
forgetArgs = append(forgetArgs, "--keep-weekly", strconv.Itoa(cleanup.Keep.Weekly))
}
if cleanup.Keep.Monthly > 0 {
forgetArgs = append(forgetArgs, "--keep-monthly", strconv.Itoa(cleanup.Keep.Monthly))
}
if cleanup.Keep.Yearly > 0 {
forgetArgs = append(forgetArgs, "--keep-yearly", strconv.Itoa(cleanup.Keep.Yearly))
}

if err := internal.Restic(rootContext, forgetArgs...); err != nil {
return err
}
if err := cleanupCmd.RunE(cmd, []string{}); err != nil {
return err
}

return nil
},
}
Expand Down
8 changes: 6 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"io/ioutil"
"log"
"os"

"github.com/choffmeister/restic-plus/internal"
"github.com/spf13/cobra"
Expand All @@ -15,7 +16,8 @@ var (
Use: "restic-plus",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if !rootCmdVerbose {
internal.Debug = log.New(ioutil.Discard, "", log.LstdFlags)
internal.LogDebug = log.New(ioutil.Discard, "", 0)
internal.LogRestic = log.New(ioutil.Discard, "", 0)
}
context, err := internal.NewContext(rootCmdConfig)
if err != nil {
Expand All @@ -28,7 +30,8 @@ var (
if len(args) == 0 {
return cmd.Usage()
}
if err := internal.Restic(rootContext, args...); err != nil {
internal.LogRestic = log.New(os.Stdout, "", 0)
if err := internal.ExecRestic(rootContext, args...); err != nil {
return err
}
return nil
Expand All @@ -47,5 +50,6 @@ func init() {
rootCmd.AddCommand(versionCmd)

rootCmd.AddCommand(backupCmd)
rootCmd.AddCommand(cleanupCmd)
rootCmd.AddCommand(cronCmd)
}
81 changes: 81 additions & 0 deletions internal/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package internal

import (
"bytes"
"fmt"
"io"
"log"
"os"
"os/exec"
"time"
)

type ExecCommandOpts struct {
Name string
Args []string
Env []string
Logger *log.Logger
}

func ExecCommand(name string, args ...string) (string, int, error) {
return ExecCommandWithOpts(ExecCommandOpts{
Name: name,
Args: args,
})
}

func ExecCommandWithOpts(opts ExecCommandOpts) (string, int, error) {
logger := opts.Logger
if logger == nil {
logger = LogDebug
}

LogDebug.Printf("Executing command %s %v\n", opts.Name, opts.Args)
cmd := exec.Command(opts.Name, opts.Args...)

var outputBuffer bytes.Buffer
logWriter := NewLogWriter(logger)
writer := io.MultiWriter(&outputBuffer, logWriter)
cmd.Stdout = writer
cmd.Stderr = writer
cmd.Env = append(os.Environ(), opts.Env...)

err := cmd.Run()
outputStr := outputBuffer.String()
if err != nil {
exitError, ok := err.(*exec.ExitError)
if !ok {
return outputStr, 0, err
}
return outputStr, exitError.ExitCode(), fmt.Errorf("%w\n%s", exitError, outputStr)
}
return outputStr, 0, nil
}

func ExecCommandRetry(name string, args ...string) (string, int, error) {
return ExecCommandRetryWithOpts(ExecCommandOpts{
Name: name,
Args: args,
})
}

func ExecCommandRetryWithOpts(opts ExecCommandOpts) (string, int, error) {
maxAttempts := 10
delay := 1000 * time.Millisecond
lastOutput := ""
lastCode := 0
lastErr := (error)(nil)
attempt := 0
for attempt < maxAttempts {
if output, code, err := ExecCommandWithOpts(opts); err == nil {
return output, code, err
} else {
lastOutput = output
lastCode = code
lastErr = err
}
attempt = attempt + 1
time.Sleep(delay)
}
return lastOutput, lastCode, lastErr
}
54 changes: 50 additions & 4 deletions internal/config.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,56 @@
package internal

import (
"fmt"

"gopkg.in/yaml.v3"
)

type Config struct {
Targets []string `yaml:"targets"`
Restic ConfigRestic `yaml:"restic"`
SFTP ConfigSFTP `yaml:"sftp"`
Cron ConfigCron `yaml:"cron"`
Targets []ConfigTarget `yaml:"targets"`
Restic ConfigRestic `yaml:"restic"`
SFTP ConfigSFTP `yaml:"sftp"`
Cron ConfigCron `yaml:"cron"`
Bandwidth ConfigBandwidth `yaml:"bandwidth"`
}

type ConfigTarget struct {
Type string `yaml:"type"`
Implementation Target
}

func (ct *ConfigTarget) UnmarshalYAML(value *yaml.Node) error {
type rawConfigTarget ConfigTarget
if err := value.Decode((*rawConfigTarget)(ct)); err != nil {
return err
}

switch ct.Type {
case "":
fallthrough
case DirectoryTargetType:
implementation := &DirectoryTarget{}
if err := value.Decode(implementation); err != nil {
return fmt.Errorf("invalid configuration for target %s: %w", ct.Type, err)
}
ct.Implementation = implementation
case ZFSDatasetTargetType:
implementation := &ZFSDatasetTarget{}
if err := value.Decode(implementation); err != nil {
return fmt.Errorf("invalid configuration for target %s: %w", ct.Type, err)
}
ct.Implementation = implementation
case ZFSZvolTargetType:
implementation := &ZFSZvolTarget{}
if err := value.Decode(implementation); err != nil {
return fmt.Errorf("invalid configuration for target %s: %w", ct.Type, err)
}
ct.Implementation = implementation
default:
return fmt.Errorf("unknown type %s", ct.Type)
}

return nil
}

type ConfigRestic struct {
Expand Down
42 changes: 38 additions & 4 deletions internal/log.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,47 @@
package internal

import (
"bytes"
"log"
"os"
"sync"
)

var (
Debug = log.New(os.Stderr, "DEBUG: ", log.Ltime|log.Lshortfile)
Warn = log.New(os.Stderr, "WARN: ", log.Ltime|log.Lshortfile)
Info = log.New(os.Stderr, "INFO: ", log.Ltime|log.Lshortfile)
Error = log.New(os.Stderr, "ERROR: ", log.Ltime|log.Lshortfile)
LogDebug = log.New(os.Stdout, "DEBUG: ", 0)
LogInfo = log.New(os.Stdout, "INFO: ", 0)
LogWarn = log.New(os.Stdout, "WARN: ", 0)
LogError = log.New(os.Stdout, "ERROR: ", log.Lshortfile)
LogRestic = log.New(os.Stdout, "RESTIC: ", 0)
)

type LogWriter struct {
mu sync.Mutex
logger *log.Logger
buffer []byte
}

func NewLogWriter(logger *log.Logger) *LogWriter {
return &LogWriter{logger: logger}
}

func (l *LogWriter) Write(p []byte) (n int, err error) {
newline := byte('\n')
breakline := []byte("\r")
empty := []byte("")

l.mu.Lock()
defer l.mu.Unlock()

l.buffer = append(l.buffer, bytes.ReplaceAll(p, breakline, empty)...)

newlineIndex := bytes.IndexByte(l.buffer, newline)
for newlineIndex >= 0 {
line := l.buffer[0:newlineIndex]
l.logger.Printf("%s\n", string(line))
l.buffer = l.buffer[newlineIndex+1:]
newlineIndex = bytes.IndexByte(l.buffer, newline)
}

return len(p), nil
}
Loading

0 comments on commit 7e49b91

Please sign in to comment.