diff --git a/README.md b/README.md index 703ed47..7707a3b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ Besides creating backups, `brudi` can also be used to restore your data from bac - [CLI](#cli) - [Docker](#docker) - [Configuration](#configuration) - - [Sources](#sources) + - [Sources](#sources) + - [FsBackup](#fsbackup) - [Tar](#tar) - [MySQLDump](#mysqldump) - [MongoDump](#mongodump) @@ -29,6 +30,7 @@ Besides creating backups, `brudi` can also be used to restore your data from bac - [Sensitive data: Environment variables](#sensitive-data-environment-variables) - [Gzip support for binaries without native gzip support](#gzip-support-for-binaries-without-native-gzip-support) - [Restoring from backup](#restoring-from-backup) + - [FsRestore](#fsrestore) - [TarRestore](#tarrestore) - [MongoRestore](#mongorestore) - [MySQLRestore](#mysqlrestore) @@ -65,6 +67,8 @@ Usage: Available Commands: help Help about any command + fsbackup Backs up directories directly. Use it with the --restic flag. + fsrestore Restores directories directly. Use it with the --restic flag. mongodump Creates a mongodump of your desired server mongorestore Restores a server from a mongodump mysqldump Creates a mysqldump of your desired server @@ -127,6 +131,19 @@ In case the same config file has been provided more than once, only the first in #### Sources +##### FsBackup + +```yaml +fsbackup: + options: + path: /srv/data + hostName: autoGeneratedIfEmpty +``` + +Running: `brudi fsbackup -c ${HOME}/.brudi.yml --restic` + +The `fsbackup` command validates that the configured directory exists and then hands it over to `restic backup` without creating intermediate archives. + ##### Tar ```yaml @@ -343,6 +360,24 @@ mysqlrestore: #### Restoring from backup +##### FsRestore + +```yaml +fsrestore: + options: + path: /srv/data + hostName: autoGeneratedIfEmpty +restic: + restore: + flags: + target: /restore-target + id: "latest" +``` + +Running: `brudi fsrestore -c ${HOME}/.brudi.yml --restic` + +`fsrestore` triggers `restic restore` for the stored directory path without any additional processing. The configured `target` controls where the restored files are written. + ##### TarRestore ```yaml @@ -494,6 +529,7 @@ It is also possible to specify concrete snapshot-ids instead of `latest`. ### Source backup methods +- [x] `fsbackup` - [x] `mysqldump` - [x] `mongodump` - [x] `tar` @@ -502,6 +538,7 @@ It is also possible to specify concrete snapshot-ids instead of `latest`. ### Restore backup methods +- [x] `fsrestore` - [x] `mysqlrestore` - [x] `mongorestore` - [x] `tarrestore` diff --git a/cmd/fsbackup.go b/cmd/fsbackup.go new file mode 100644 index 0000000..08bc45d --- /dev/null +++ b/cmd/fsbackup.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "context" + + "github.com/mittwald/brudi/pkg/source" + "github.com/mittwald/brudi/pkg/source/fsbackup" + + "github.com/spf13/cobra" +) + +var ( + fsbackupCmd = &cobra.Command{ + Use: "fsbackup", + Short: "Backs up directories directly with restic", + Long: "Backs up configured directories using restic without creating intermediate archives.", + Run: func(cmd *cobra.Command, args []string) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := source.DoBackupForKind(ctx, fsbackup.Kind, cleanup, useRestic, useResticForget, useResticPrune); err != nil { + panic(err) + } + }, + } +) + +func init() { + rootCmd.AddCommand(fsbackupCmd) +} diff --git a/cmd/fsrestore.go b/cmd/fsrestore.go new file mode 100644 index 0000000..af86dea --- /dev/null +++ b/cmd/fsrestore.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "context" + + "github.com/mittwald/brudi/pkg/source" + "github.com/mittwald/brudi/pkg/source/fsrestore" + + "github.com/spf13/cobra" +) + +var ( + fsrestoreCmd = &cobra.Command{ + Use: "fsrestore", + Short: "Restores directories directly from restic", + Long: "Restores directories from restic snapshots without additional processing.", + Run: func(cmd *cobra.Command, args []string) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := source.DoRestoreForKind(ctx, fsrestore.Kind, cleanup, useRestic); err != nil { + panic(err) + } + }, + } +) + +func init() { + rootCmd.AddCommand(fsrestoreCmd) +} diff --git a/pkg/source/backup.go b/pkg/source/backup.go index 70e20ad..7a9b706 100644 --- a/pkg/source/backup.go +++ b/pkg/source/backup.go @@ -12,6 +12,7 @@ import ( log "github.com/sirupsen/logrus" + "github.com/mittwald/brudi/pkg/source/fsbackup" "github.com/mittwald/brudi/pkg/source/mongodump" "github.com/mittwald/brudi/pkg/source/mysqldump" "github.com/mittwald/brudi/pkg/source/redisdump" @@ -29,6 +30,8 @@ func getGenericBackendForKind(kind string) (Generic, error) { return redisdump.NewConfigBasedBackend() case tar.Kind: return tar.NewConfigBasedBackend() + case fsbackup.Kind: + return fsbackup.NewConfigBasedBackend() default: return nil, fmt.Errorf("unsupported kind '%s'", kind) } diff --git a/pkg/source/fsbackup/backend_config_based.go b/pkg/source/fsbackup/backend_config_based.go new file mode 100644 index 0000000..c869a57 --- /dev/null +++ b/pkg/source/fsbackup/backend_config_based.go @@ -0,0 +1,56 @@ +package fsbackup + +import ( + "context" + "fmt" + "os" + + "github.com/pkg/errors" +) + +type Options struct { + Path string `validate:"min=1"` +} + +type ConfigBasedBackend struct { + cfg *Config +} + +func NewConfigBasedBackend() (*ConfigBasedBackend, error) { + config := &Config{ + Options: &Options{}, + } + + if err := config.InitFromViper(); err != nil { + return nil, err + } + + return &ConfigBasedBackend{cfg: config}, nil +} + +func (b *ConfigBasedBackend) CreateBackup(_ context.Context) error { + path := b.cfg.Options.Path + + info, err := os.Stat(path) + if err != nil { + return errors.WithStack(err) + } + + if !info.IsDir() { + return errors.WithStack(fmt.Errorf("configured path %s is not a directory", path)) + } + + return nil +} + +func (b *ConfigBasedBackend) GetBackupPath() string { + return b.cfg.Options.Path +} + +func (b *ConfigBasedBackend) GetHostname() string { + return b.cfg.HostName +} + +func (b *ConfigBasedBackend) CleanUp() error { + return nil +} diff --git a/pkg/source/fsrestore/backend_config_based.go b/pkg/source/fsrestore/backend_config_based.go new file mode 100644 index 0000000..6cd33c9 --- /dev/null +++ b/pkg/source/fsrestore/backend_config_based.go @@ -0,0 +1,39 @@ +package fsrestore + +import "context" + +type Options struct { + Path string `validate:"min=1"` +} + +type ConfigBasedBackend struct { + cfg *Config +} + +func NewConfigBasedBackend() (*ConfigBasedBackend, error) { + config := &Config{ + Options: &Options{}, + } + + if err := config.InitFromViper(); err != nil { + return nil, err + } + + return &ConfigBasedBackend{cfg: config}, nil +} + +func (b *ConfigBasedBackend) RestoreBackup(context.Context) error { + return nil +} + +func (b *ConfigBasedBackend) GetBackupPath() string { + return b.cfg.Options.Path +} + +func (b *ConfigBasedBackend) GetHostname() string { + return b.cfg.HostName +} + +func (b *ConfigBasedBackend) CleanUp() error { + return nil +} diff --git a/pkg/source/fsrestore/config.go b/pkg/source/fsrestore/config.go new file mode 100644 index 0000000..77d21e2 --- /dev/null +++ b/pkg/source/fsrestore/config.go @@ -0,0 +1,35 @@ +package fsrestore + +import ( + "os" + + "github.com/pkg/errors" + + "github.com/mittwald/brudi/pkg/config" +) + +const ( + // Kind identifies the fsrestore backend in configuration. + Kind = "fsrestore" +) + +type Config struct { + Options *Options + HostName string `validate:"min=1"` +} + +func (c *Config) InitFromViper() error { + err := config.InitializeStructFromViper(Kind, c) + if err != nil { + return errors.WithStack(err) + } + + if c.HostName == "" { + c.HostName, err = os.Hostname() + if err != nil { + return errors.WithStack(err) + } + } + + return config.Validate(c) +} diff --git a/pkg/source/restore.go b/pkg/source/restore.go index 1ccf90f..a95f8a7 100644 --- a/pkg/source/restore.go +++ b/pkg/source/restore.go @@ -7,6 +7,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/mittwald/brudi/pkg/restic" + "github.com/mittwald/brudi/pkg/source/fsrestore" "github.com/mittwald/brudi/pkg/source/mongorestore" "github.com/mittwald/brudi/pkg/source/mysqlrestore" "github.com/mittwald/brudi/pkg/source/pgrestore" @@ -26,6 +27,8 @@ func getGenericRestoreBackendForKind(kind string) (GenericRestore, error) { return tarrestore.NewConfigBasedBackend() case psql.Kind: return psql.NewConfigBasedBackend() + case fsrestore.Kind: + return fsrestore.NewConfigBasedBackend() default: return nil, fmt.Errorf("unsupported kind '%s'", kind) } diff --git a/test/pkg/source/fsbackup/fsbackup_test.go b/test/pkg/source/fsbackup/fsbackup_test.go new file mode 100644 index 0000000..bad41c6 --- /dev/null +++ b/test/pkg/source/fsbackup/fsbackup_test.go @@ -0,0 +1,92 @@ +package fsbackup_test + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mittwald/brudi/pkg/source" + "github.com/mittwald/brudi/pkg/source/fsbackup" + commons "github.com/mittwald/brudi/test/pkg/source/internal" + "github.com/spf13/viper" + "github.com/stretchr/testify/suite" +) + +type FSBackupSuite struct { + suite.Suite +} + +func (s *FSBackupSuite) SetupTest() { + commons.TestSetup() +} + +func (s *FSBackupSuite) TearDownTest() { + viper.Reset() +} + +func (s *FSBackupSuite) TestResticBackupDirectory() { + ctx := context.Background() + + resticContainer, err := commons.NewTestContainerSetup(ctx, &commons.ResticReq, commons.ResticPort) + s.Require().NoError(err) + defer func() { + resticErr := resticContainer.Container.Terminate(ctx) + if resticErr != nil { + s.T().Logf("failed to terminate restic container: %v", resticErr) + } + }() + + sourceDir := s.T().TempDir() + restoreDir := s.T().TempDir() + host := "fsbackup-restic-host" + + filePath := filepath.Join(sourceDir, "sample.txt") + fileContent := []byte("fsbackup restic content") + err = os.WriteFile(filePath, fileContent, 0o600) + s.Require().NoError(err) + + config := createFSBackupConfig(host, sourceDir, resticContainer.Address, resticContainer.Port) + err = viper.ReadConfig(bytes.NewBuffer(config)) + s.Require().NoError(err) + + err = source.DoBackupForKind(ctx, fsbackup.Kind, false, true, false, false) + s.Require().NoError(err) + + err = commons.DoResticRestore(ctx, resticContainer, restoreDir) + s.Require().NoError(err) + + relativeSourcePath := strings.TrimPrefix(sourceDir, string(os.PathSeparator)) + restoredFilePath := filepath.Join(restoreDir, relativeSourcePath, "sample.txt") + + restoredContent, err := os.ReadFile(restoredFilePath) + s.Require().NoError(err) + s.Equal(fileContent, restoredContent) +} + +func TestFSBackupSuite(t *testing.T) { + suite.Run(t, new(FSBackupSuite)) +} + +func createFSBackupConfig(host, path, resticIP, resticPort string) []byte { + return []byte(fmt.Sprintf( + ` +fsbackup: + options: + path: %s + hostName: %s +restic: + global: + flags: + repo: rest:http://%s:%s/ + restore: + flags: + path: %s + target: "/" + id: "latest" +`, path, host, resticIP, resticPort, path, + )) +} diff --git a/test/pkg/source/fsrestore/fsrestore_test.go b/test/pkg/source/fsrestore/fsrestore_test.go new file mode 100644 index 0000000..8a8583b --- /dev/null +++ b/test/pkg/source/fsrestore/fsrestore_test.go @@ -0,0 +1,120 @@ +package fsrestore_test + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mittwald/brudi/pkg/source" + "github.com/mittwald/brudi/pkg/source/fsbackup" + "github.com/mittwald/brudi/pkg/source/fsrestore" + commons "github.com/mittwald/brudi/test/pkg/source/internal" + "github.com/spf13/viper" + "github.com/stretchr/testify/suite" +) + +type FSRestoreSuite struct { + suite.Suite +} + +func (s *FSRestoreSuite) SetupTest() { + commons.TestSetup() +} + +func (s *FSRestoreSuite) TearDownTest() { + viper.Reset() +} + +func (s *FSRestoreSuite) TestResticRestoreDirectory() { + ctx := context.Background() + + resticContainer, err := commons.NewTestContainerSetup(ctx, &commons.ResticReq, commons.ResticPort) + s.Require().NoError(err) + defer func() { + resticErr := resticContainer.Container.Terminate(ctx) + if resticErr != nil { + s.T().Logf("failed to terminate restic container: %v", resticErr) + } + }() + + sourceDir := s.T().TempDir() + host := "fsrestore-restic-host" + + filePath := filepath.Join(sourceDir, "sample.txt") + fileContent := []byte("fsrestore restic content") + err = os.WriteFile(filePath, fileContent, 0o600) + s.Require().NoError(err) + + backupConfig := createFSBackupConfig(host, sourceDir, resticContainer.Address, resticContainer.Port) + err = viper.ReadConfig(bytes.NewBuffer(backupConfig)) + s.Require().NoError(err) + + err = source.DoBackupForKind(ctx, fsbackup.Kind, false, true, false, false) + s.Require().NoError(err) + + restoreRoot := s.T().TempDir() + + viper.Reset() + commons.TestSetup() + + restoreConfig := createFSRestoreConfig(host, sourceDir, restoreRoot, resticContainer.Address, resticContainer.Port) + err = viper.ReadConfig(bytes.NewBuffer(restoreConfig)) + s.Require().NoError(err) + + err = source.DoRestoreForKind(ctx, fsrestore.Kind, false, true) + s.Require().NoError(err) + + relativeSourcePath := strings.TrimPrefix(sourceDir, string(os.PathSeparator)) + restoredFile := filepath.Join(restoreRoot, relativeSourcePath, "sample.txt") + + restoredContent, err := os.ReadFile(restoredFile) + s.Require().NoError(err) + s.Equal(fileContent, restoredContent) +} + +func TestFSRestoreSuite(t *testing.T) { + suite.Run(t, new(FSRestoreSuite)) +} + +func createFSBackupConfig(host, path, resticIP, resticPort string) []byte { + return []byte(fmt.Sprintf( + ` +fsbackup: + options: + path: %s + hostName: %s +restic: + global: + flags: + repo: rest:http://%s:%s/ + restore: + flags: + path: %s + target: "/" + id: "latest" +`, path, host, resticIP, resticPort, path, + )) +} + +func createFSRestoreConfig(host, path, target, resticIP, resticPort string) []byte { + return []byte(fmt.Sprintf( + ` +fsrestore: + options: + path: %s + hostName: %s +restic: + global: + flags: + repo: rest:http://%s:%s/ + restore: + flags: + target: %s + id: "latest" +`, path, host, resticIP, resticPort, target, + )) +}