Skip to content

Commit

Permalink
feat: btrfs destination
Browse files Browse the repository at this point in the history
  • Loading branch information
sloonz committed Feb 25, 2022
1 parent eab9374 commit 4be7224
Show file tree
Hide file tree
Showing 9 changed files with 283 additions and 13 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ format and keys format.
### 0.4 (next)

* [x] `btrfs` source
* [ ] `btrfs` destintation
* [x] `btrfs` destination

### 0.5

Expand Down
148 changes: 148 additions & 0 deletions destinations/btrfs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package destinations

import (
"github.com/sloonz/uback/container"
"github.com/sloonz/uback/lib"

"errors"
"io"
"os"
"path"
"strings"

"github.com/sirupsen/logrus"
)

var (
ErrBtrfsPath = errors.New("btrfs destination: missing path")
btrfsLog = logrus.WithFields(logrus.Fields{
"destination": "btrfs",
})
)

type btrfsDestination struct {
options *uback.Options
basePath string
snapshotCommand []string
sendCommand []string
receiveCommand []string
deleteCommand []string
}

func newBtrfsDestination(options *uback.Options) (uback.Destination, error) {
basePath := options.String["Path"]
if basePath == "" {
return nil, ErrFSPath
}

err := os.MkdirAll(basePath, 0777)
if err != nil {
return nil, err
}

return &btrfsDestination{
options: options,
basePath: basePath,
snapshotCommand: options.GetCommand("SnapshotCommand", []string{"btrfs", "subvolume", "snapshot"}),
sendCommand: options.GetCommand("SendCommand", []string{"btrfs", "send"}),
receiveCommand: options.GetCommand("ReceiveCommand", []string{"btrfs", "subvolume", "receive"}),
deleteCommand: options.GetCommand("DeleteCommand", []string{"btrfs", "subvolume", "delete"}),
}, nil
}

func (d *btrfsDestination) ListBackups() ([]uback.Backup, error) {
var res []uback.Backup
entries, err := os.ReadDir(d.basePath)
if err != nil {
return nil, err
}

for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".") || strings.HasPrefix(entry.Name(), "_") || !entry.IsDir() {
continue
}

backup, err := uback.ParseBackupFilename(entry.Name()+"-full.ubkp", true)
if err != nil {
btrfsLog.WithFields(logrus.Fields{
"file": entry.Name(),
})
logrus.Warnf("invalid backup file: %v", err)
continue
}

res = append(res, backup)
}

return res, nil
}

func (d *btrfsDestination) RemoveBackup(backup uback.Backup) error {
return uback.RunCommand(btrfsLog, uback.BuildCommand(d.deleteCommand, path.Join(d.basePath, string(backup.Snapshot.Name()))))
}

func (d *btrfsDestination) SendBackup(backup uback.Backup, data io.Reader) error {
cr, err := container.NewReader(data)
if err != nil {
return err
}

err = cr.Unseal(nil)
if err != nil {
return err
}

cmd := uback.BuildCommand(d.receiveCommand, d.basePath)
cmd.Stdin = cr
err = uback.RunCommand(btrfsLog, cmd)
if err != nil {
return err
}

return os.Rename(path.Join(d.basePath, "_tmp-"+backup.Snapshot.Name()), path.Join(d.basePath, backup.Snapshot.Name()))
}

func (d *btrfsDestination) ReceiveBackup(backup uback.Backup) (io.ReadCloser, error) {
pr, pw := io.Pipe()

go func() {
// btrfs source expects snapshots to be named _tmp-(snapshot), create a temporary snapshot
// matching that name
cmd := uback.BuildCommand(d.snapshotCommand, "-r", path.Join(d.basePath, backup.Snapshot.Name()), path.Join(d.basePath, "_tmp-"+backup.Snapshot.Name()))
err := uback.RunCommand(btrfsLog, cmd)
if err != nil {
pw.CloseWithError(err)
return
}
defer func() {
cmd := uback.BuildCommand(d.deleteCommand, path.Join(d.basePath, "_tmp-"+backup.Snapshot.Name()))
_ = uback.RunCommand(btrfsLog, cmd)
}()

cw, err := container.NewWriter(pw, nil, "btrfs", 3)
if err != nil {
pw.CloseWithError(err)
return
}

cmd = uback.BuildCommand(d.sendCommand, path.Join(d.basePath, "_tmp-"+backup.Snapshot.Name()))
cmd.Stdout = cw
err = uback.StartCommand(btrfsLog, cmd)
if err != nil {
pw.CloseWithError(err)
return
}

err = cmd.Wait()

if err != nil {
err = cw.Close()
} else {
_ = cw.Close()
}

pw.CloseWithError(err)
}()

return pr, nil
}
2 changes: 2 additions & 0 deletions destinations/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (

func New(options *uback.Options) (uback.Destination, error) {
switch options.String["Type"] {
case "btrfs":
return newBtrfsDestination(options)
case "fs":
return newFSDestination(options)
case "object-storage":
Expand Down
35 changes: 35 additions & 0 deletions doc/dest-btrfs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# btrfs Destination

A btrfs destination can only receive unencrypted backups from a btrfs
source. It will store the backup as a btrfs subvolume rather than a
backup file.

Note that since backups are stored as btrfs subvolumes, all backups on a
btrfs destination are considered as full backups even if they were sent
as incremental backups: the merge operation between the base backup and
the incremental one is done automatically when the destination receive
the backup.

## Options

### Path

Required.

Note: must be in a btrfs filesystem.

### @SnapshotCommand

Optional, defaults: `[btrfs subvolume snapshot]`

### @SendCommand

Optional, defaults: `[btrfs send]`

### @ReceiveCommand

Optional, defaults: `[btrfs receive]`

### @DeleteCommand

Optional, defaults: `[btrfs subvolume delete]`
15 changes: 14 additions & 1 deletion lib/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,5 +270,18 @@ func BuildCommand(command []string, additionalArgs ...string) *exec.Cmd {
fullArgs := make([]string, 0, len(command)+len(additionalArgs)-1)
fullArgs = append(fullArgs, command[1:]...)
fullArgs = append(fullArgs, additionalArgs...)
return exec.Command(command[0], fullArgs...)
cmd := exec.Command(command[0], fullArgs...)
cmd.Stdout = os.Stderr // default stdout to stderr because we don't want other processes to output stuff on our output
cmd.Stderr = os.Stderr
return cmd
}

func StartCommand(log *logrus.Entry, cmd *exec.Cmd) error {
log.Printf("starting: %s", cmd.String())
return cmd.Start()
}

func RunCommand(log *logrus.Entry, cmd *exec.Cmd) error {
log.Printf("starting: %s", cmd.String())
return cmd.Run()
}
4 changes: 3 additions & 1 deletion tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ def _test_src(self, d, source, dest, restore_opts=None, test_ignore=False, test_
self.assertEqual(set(os.listdir(f"{d}/restore/{s4}")), {"a"})
self._cleanup_restore(d)
time.sleep(0.01)
else:
b4 = None

return b1, b2, b3
return b1, b2, b3, b4

class DestBaseTests:
def _test_dest(self, d, source, dest):
Expand Down
64 changes: 64 additions & 0 deletions tests/dest_btrfs_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from .common import *

class DestBtrfsTests(unittest.TestCase, SrcBaseTests):
def setUp(self):
test_root = os.environ.get("UBACK_BTRFS_TEST_ROOT")
if test_root is None:
self.tmpdir = None
return

ensure_dir(test_root)
self.tmpdir = f"{test_root}/dest-test"
if os.path.exists(self.tmpdir):
raise Exception("UBACK_BTRFS_TEST_ROOT already exists")

subprocess.check_call(["btrfs", "subvolume", "create", self.tmpdir])
subprocess.check_call(["btrfs", "subvolume", "create", f"{self.tmpdir}/source"])
ensure_dir(f"{self.tmpdir}/snapshots")

def tearDown(self):
if self.tmpdir is None:
return

for s in os.listdir(f"{self.tmpdir}/backups"):
subprocess.check_call(["sudo", "btrfs", "subvolume", "delete", f"{self.tmpdir}/backups/{s}"])
for s in os.listdir(f"{self.tmpdir}/snapshots"):
subprocess.check_call(["sudo", "btrfs", "subvolume", "delete", f"{self.tmpdir}/snapshots/{s}"])
for s in os.listdir(f"{self.tmpdir}/restore"):
subprocess.check_call(["sudo", "btrfs", "subvolume", "delete", f"{self.tmpdir}/restore/{s}"])
subprocess.check_call(["sudo", "btrfs", "subvolume", "delete", f"{self.tmpdir}/source"])
subprocess.check_call(["sudo", "btrfs", "subvolume", "delete", self.tmpdir])
try:
os.rmdir(os.environ.get("UBACK_BTRFS_TEST_ROOT"))
except:
pass

def _cleanup_restore(self, d):
for s in os.listdir(f"{d}/restore"):
subprocess.check_call(["sudo", "btrfs", "subvolume", "delete", f"{d}/restore/{s}"])

def test_btrfs_dest(self):
if self.tmpdir is None:
return

source = f"type=btrfs,path={self.tmpdir}/source,no-encryption=1,state-file={self.tmpdir}/state.json,snapshots-path={self.tmpdir}/snapshots,full-interval=weekly," +\
"send-command=sudo btrfs send,delete-command=sudo btrfs subvolume delete"
dest = f"id=test,type=btrfs,path={self.tmpdir}/backups,no-encryption=1," +\
"send-command=sudo btrfs send,receive-command=sudo btrfs receive,delete-command=sudo btrfs subvolume delete"
b1, b2, b3, b4 = self._test_src(self.tmpdir, source, dest, "receive-command=sudo btrfs receive", test_ignore=False, test_delete=True)

# Check that btrfs subvolumes contains the correct data
s1 = b1.split("-")[0]
s2 = b2.split("-")[0]
s3 = b3.split("-")[0]
s4 = b4.split("-")[0]
self.assertEqual(b"av1", read_file(f"{self.tmpdir}/backups/{s1}/a"))
self.assertEqual(set(os.listdir(f"{self.tmpdir}/backups/{s1}")), {"a"})
self.assertEqual(b"av1", read_file(f"{self.tmpdir}/backups/{s2}/a"))
self.assertEqual(b"bv1", read_file(f"{self.tmpdir}/backups/{s2}/b"))
self.assertEqual(set(os.listdir(f"{self.tmpdir}/backups/{s2}")), {"a", "b"})
self.assertEqual(b"av2", read_file(f"{self.tmpdir}/backups/{s3}/a"))
self.assertEqual(b"bv1", read_file(f"{self.tmpdir}/backups/{s3}/b"))
self.assertEqual(set(os.listdir(f"{self.tmpdir}/backups/{s3}")), {"a", "b"})
self.assertEqual(b"av2", read_file(f"{self.tmpdir}/backups/{s4}/a"))
self.assertEqual(set(os.listdir(f"{self.tmpdir}/backups/{s4}")), {"a"})
24 changes: 15 additions & 9 deletions tests/src_btrfs_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,44 @@

class SrcBtrfsTests(unittest.TestCase, SrcBaseTests):
def setUp(self):
self.tmpdir = os.environ.get("UBACK_BTRFS_TEST_ROOT")
if self.tmpdir is None:
test_root = os.environ.get("UBACK_BTRFS_TEST_ROOT")
if test_root is None:
self.tmpdir = None
return

ensure_dir(test_root)
self.tmpdir = f"{test_root}/src-test"
if os.path.exists(self.tmpdir):
raise Exception("UBACK_BTRFS_TEST_ROOT already exists")

subprocess.check_call(["btrfs", "subvolume", "create", self.tmpdir])
subprocess.check_call(["btrfs", "subvolume", "create", f"{self.tmpdir}/snapshots"])
subprocess.check_call(["btrfs", "subvolume", "create", f"{self.tmpdir}/source"])
ensure_dir(f"{self.tmpdir}/snapshots")

def tearDown(self):
if os.environ.get("UBACK_BTRFS_TEST_ROOT") is None:
if self.tmpdir is None:
return

for s in os.listdir(f"{self.tmpdir}/snapshots"):
subprocess.check_call(["sudo", "btrfs", "subvolume", "delete", f"{self.tmpdir}/snapshots/{s}"])
for s in os.listdir(f"{self.tmpdir}/restore"):
subprocess.check_call(["sudo", "btrfs", "subvolume", "delete", f"{self.tmpdir}/restore/{s}"])
subprocess.check_call(["sudo", "btrfs", "subvolume", "delete", f"{self.tmpdir}/snapshots"])
subprocess.check_call(["sudo", "btrfs", "subvolume", "delete", f"{self.tmpdir}/source"])
subprocess.check_call(["sudo", "btrfs", "subvolume", "delete", self.tmpdir])
try:
os.rmdir(os.environ.get("UBACK_BTRFS_TEST_ROOT"))
except:
pass

def _cleanup_restore(self, d):
for s in os.listdir(f"{d}/restore"):
subprocess.check_call(["sudo", "btrfs", "subvolume", "delete", f"{d}/restore/{s}"])

def test_btrfs_source(self):
if os.environ.get("UBACK_BTRFS_TEST_ROOT") is None:
if self.tmpdir is None:
return

source = [f"type=btrfs,path={self.tmpdir}/source,key-file={self.tmpdir}/backup.pub,state-file={self.tmpdir}/state.json,snapshots-path={self.tmpdir}/snapshots,full-interval=weekly",
"send-command=sudo btrfs send", "delete-command=sudo btrfs subvolume delete"]
source = f"type=btrfs,path={self.tmpdir}/source,key-file={self.tmpdir}/backup.pub,state-file={self.tmpdir}/state.json,snapshots-path={self.tmpdir}/snapshots,full-interval=weekly," +\
"send-command=sudo btrfs send,delete-command=sudo btrfs subvolume delete"
dest = f"id=test,type=fs,path={self.tmpdir}/backups,@retention-policy=daily=3,key-file={self.tmpdir}/backup.key"
self._test_src(self.tmpdir, ",".join(source), dest, "receive-command=sudo btrfs receive", test_ignore=False, test_delete=True)
self._test_src(self.tmpdir, source, dest, "receive-command=sudo btrfs receive", test_ignore=False, test_delete=True)
2 changes: 1 addition & 1 deletion tests/src_tar_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ def test_tar_source(self):
with tempfile.TemporaryDirectory() as d:
source = f"type=tar,path={d}/source,key-file={d}/backup.pub,state-file={d}/state.json,snapshots-path={d}/snapshots,full-interval=weekly,@command=tar,@command=--exclude=./c,@command=--exclude=./d"
dest = f"id=test,type=fs,path={d}/backups,@retention-policy=daily=3,key-file={d}/backup.key"
b1, b2, b3 = self._test_src(d, source, dest, test_ignore=True, test_delete=False)
b1, b2, b3, _ = self._test_src(d, source, dest, test_ignore=True, test_delete=False)

# Check that incremental backups are actually incremental
subprocess.run(["tar", "-C", f"{d}/restore", "-x"], input=subprocess.check_output([uback, "container", "extract", "-k", f"{d}/backup.key"], input=read_file(f"{d}/backups/{b2}.ubkp")), check=True)
Expand Down

0 comments on commit 4be7224

Please sign in to comment.