From ed0ab7fb90370ebf98a1439f7931877079cd45ad Mon Sep 17 00:00:00 2001 From: decentral1se Date: Sun, 1 Oct 2023 08:02:30 +0200 Subject: [PATCH] WIP: backup revolution [ci skip] See https://git.coopcloud.tech/coop-cloud/organising/issues/485 --- cli/app/backup.go | 439 +++++++++------------------------------------ cli/app/restore.go | 205 +-------------------- 2 files changed, 89 insertions(+), 555 deletions(-) diff --git a/cli/app/backup.go b/cli/app/backup.go index f9a239b1..d132821a 100644 --- a/cli/app/backup.go +++ b/cli/app/backup.go @@ -1,414 +1,147 @@ package app import ( - "archive/tar" "context" "fmt" - "io" - "os" - "path/filepath" - "strings" - "time" "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/client" - "coopcloud.tech/abra/pkg/config" containerPkg "coopcloud.tech/abra/pkg/container" - recipePkg "coopcloud.tech/abra/pkg/recipe" + "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/upstream/container" "github.com/docker/cli/cli/command" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" - dockerClient "github.com/docker/docker/client" - "github.com/docker/docker/pkg/archive" - "github.com/klauspost/pgzip" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) -type backupConfig struct { - preHookCmd string - postHookCmd string - backupPaths []string -} - -var appBackupCommand = cli.Command{ - Name: "backup", - Aliases: []string{"bk"}, - Usage: "Run app backup", - ArgsUsage: " []", +var appBackupListCommand = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, Flags: []cli.Flag{ internal.DebugFlag, internal.OfflineFlag, - internal.ChaosFlag, }, Before: internal.SubCommandBefore, + Usage: "List all backups", BashComplete: autocomplete.AppNameComplete, - Description: ` -Run an app backup. - -A backup command and pre/post hook commands are defined in the recipe -configuration. Abra reads this configuration and run the comands in the context -of the deployed services. Pass if you only want to back up a single -service. All backups are placed in the ~/.abra/backups directory. - -A single backup file is produced for all backup paths specified for a service. -If we have the following backup configuration: - - - "backupbot.backup.path=/var/lib/foo,/var/lib/bar" - -And we run "abra app backup example.com app", Abra will produce a file that -looks like: - - ~/.abra/backups/example_com_app_609341138.tar.gz - -This file is a compressed archive which contains all backup paths. To see paths, run: - - tar -tf ~/.abra/backups/example_com_app_609341138.tar.gz - -(Make sure to change the name of the backup file) - -This single file can be used to restore your app. See "abra app restore" for more. -`, Action: func(c *cli.Context) error { app := internal.ValidateApp(c) - recipe, err := recipePkg.Get(app.Recipe, internal.Offline) - if err != nil { + if err := recipe.EnsureExists(app.Recipe); err != nil { logrus.Fatal(err) } if !internal.Chaos { - if err := recipePkg.EnsureIsClean(app.Recipe); err != nil { + if err := recipe.EnsureIsClean(app.Recipe); err != nil { logrus.Fatal(err) } if !internal.Offline { - if err := recipePkg.EnsureUpToDate(app.Recipe); err != nil { + if err := recipe.EnsureUpToDate(app.Recipe); err != nil { logrus.Fatal(err) } } - if err := recipePkg.EnsureLatest(app.Recipe); err != nil { + if err := recipe.EnsureLatest(app.Recipe); err != nil { logrus.Fatal(err) } } - backupConfigs := make(map[string]backupConfig) - for _, service := range recipe.Config.Services { - if backupsEnabled, ok := service.Deploy.Labels["backupbot.backup"]; ok { - if backupsEnabled == "true" { - fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), service.Name) - bkConfig := backupConfig{} - - logrus.Debugf("backup config detected for %s", fullServiceName) - - if paths, ok := service.Deploy.Labels["backupbot.backup.path"]; ok { - logrus.Debugf("detected backup paths for %s: %s", fullServiceName, paths) - bkConfig.backupPaths = strings.Split(paths, ",") - } - - if preHookCmd, ok := service.Deploy.Labels["backupbot.backup.pre-hook"]; ok { - logrus.Debugf("detected pre-hook command for %s: %s", fullServiceName, preHookCmd) - bkConfig.preHookCmd = preHookCmd - } - - if postHookCmd, ok := service.Deploy.Labels["backupbot.backup.post-hook"]; ok { - logrus.Debugf("detected post-hook command for %s: %s", fullServiceName, postHookCmd) - bkConfig.postHookCmd = postHookCmd - } - - backupConfigs[service.Name] = bkConfig - } - } - } - cl, err := client.New(app.Server) if err != nil { logrus.Fatal(err) } - serviceName := c.Args().Get(1) - if serviceName != "" { - backupConfig, ok := backupConfigs[serviceName] - if !ok { - logrus.Fatalf("no backup config for %s? does %s exist?", serviceName, serviceName) - } + // TODO(d1): figure out which app to query. currently not working - logrus.Infof("running backup for the %s service", serviceName) + filters := filters.NewArgs() + stackAndServiceName := fmt.Sprintf("^%s_%s", app.StackName(), "app") + filters.Add("name", stackAndServiceName) - if err := runBackup(cl, app, serviceName, backupConfig); err != nil { - logrus.Fatal(err) - } - } else { - if len(backupConfigs) == 0 { - logrus.Fatalf("no backup configs discovered for %s?", app.Name) - } - - for serviceName, backupConfig := range backupConfigs { - logrus.Infof("running backup for the %s service", serviceName) - - if err := runBackup(cl, app, serviceName, backupConfig); err != nil { - logrus.Fatal(err) - } - } + targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, false) + if err != nil { + logrus.Fatal(err) } + execCreateOpts := types.ExecConfig{ + AttachStderr: true, + AttachStdin: true, + AttachStdout: true, + Cmd: "backup -- list", + Detach: false, + Env: []string{"SERVICE": app.Domain}, + Tty: true, + } + + // FIXME: avoid instantiating a new CLI + dcli, err := command.NewDockerCli() + if err != nil { + logrus.Fatal(err) + } + + if err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil { + logrus.Fatal(err) + } + }, +} + +var appBackupDownloadCommand = cli.Command{ + Name: "download", + Aliases: []string{"d"}, + Flags: []cli.Flag{ + internal.DebugFlag, + internal.OfflineFlag, + }, + Before: internal.SubCommandBefore, + Usage: "Download a backup", + BashComplete: autocomplete.AppNameComplete, + Action: func(c *cli.Context) error { return nil }, } -// TimeStamp generates a file name friendly timestamp. -func TimeStamp() string { - ts := time.Now().UTC().Format(time.RFC3339) - return strings.Replace(ts, ":", "-", -1) +var appBackupCreateCommand = cli.Command{ + Name: "create", + Aliases: []string{"c"}, + Flags: []cli.Flag{ + internal.DebugFlag, + internal.OfflineFlag, + }, + Before: internal.SubCommandBefore, + Usage: "Create a new backup", + BashComplete: autocomplete.AppNameComplete, + Action: func(c *cli.Context) error { + return nil + }, } -// runBackup does the actual backup logic. -func runBackup(cl *dockerClient.Client, app config.App, serviceName string, bkConfig backupConfig) error { - if len(bkConfig.backupPaths) == 0 { - return fmt.Errorf("backup paths are empty for %s?", serviceName) - } - - // FIXME: avoid instantiating a new CLI - dcli, err := command.NewDockerCli() - if err != nil { - return err - } - - filters := filters.NewArgs() - filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName)) - - targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, true) - if err != nil { - return err - } - - fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName) - if bkConfig.preHookCmd != "" { - splitCmd := internal.SafeSplit(bkConfig.preHookCmd) - - logrus.Debugf("split pre-hook command for %s into %s", fullServiceName, splitCmd) - - preHookExecOpts := types.ExecConfig{ - AttachStderr: true, - AttachStdin: true, - AttachStdout: true, - Cmd: splitCmd, - Detach: false, - Tty: true, - } - - if err := container.RunExec(dcli, cl, targetContainer.ID, &preHookExecOpts); err != nil { - return fmt.Errorf("failed to run %s on %s: %s", bkConfig.preHookCmd, targetContainer.ID, err.Error()) - } - - logrus.Infof("succesfully ran %s pre-hook command: %s", fullServiceName, bkConfig.preHookCmd) - } - - var tempBackupPaths []string - for _, remoteBackupPath := range bkConfig.backupPaths { - sanitisedPath := strings.ReplaceAll(remoteBackupPath, "/", "_") - localBackupPath := filepath.Join(config.BACKUP_DIR, fmt.Sprintf("%s%s_%s.tar.gz", fullServiceName, sanitisedPath, TimeStamp())) - logrus.Debugf("temporarily backing up %s:%s to %s", fullServiceName, remoteBackupPath, localBackupPath) - - logrus.Infof("backing up %s:%s", fullServiceName, remoteBackupPath) - - content, _, err := cl.CopyFromContainer(context.Background(), targetContainer.ID, remoteBackupPath) - if err != nil { - logrus.Debugf("failed to copy %s from container: %s", remoteBackupPath, err.Error()) - if err := cleanupTempArchives(tempBackupPaths); err != nil { - return fmt.Errorf("failed to clean up temporary archives: %s", err.Error()) - } - return fmt.Errorf("failed to copy %s from container: %s", remoteBackupPath, err.Error()) - } - defer content.Close() - - _, srcBase := archive.SplitPathDirEntry(remoteBackupPath) - preArchive := archive.RebaseArchiveEntries(content, srcBase, remoteBackupPath) - if err := copyToFile(localBackupPath, preArchive); err != nil { - logrus.Debugf("failed to create tar archive (%s): %s", localBackupPath, err.Error()) - if err := cleanupTempArchives(tempBackupPaths); err != nil { - return fmt.Errorf("failed to clean up temporary archives: %s", err.Error()) - } - return fmt.Errorf("failed to create tar archive (%s): %s", localBackupPath, err.Error()) - } - - tempBackupPaths = append(tempBackupPaths, localBackupPath) - } - - logrus.Infof("compressing and merging archives...") - - if err := mergeArchives(tempBackupPaths, fullServiceName); err != nil { - logrus.Debugf("failed to merge archive files: %s", err.Error()) - if err := cleanupTempArchives(tempBackupPaths); err != nil { - return fmt.Errorf("failed to clean up temporary archives: %s", err.Error()) - } - return fmt.Errorf("failed to merge archive files: %s", err.Error()) - } - - if err := cleanupTempArchives(tempBackupPaths); err != nil { - return fmt.Errorf("failed to clean up temporary archives: %s", err.Error()) - } - - if bkConfig.postHookCmd != "" { - splitCmd := internal.SafeSplit(bkConfig.postHookCmd) - - logrus.Debugf("split post-hook command for %s into %s", fullServiceName, splitCmd) - - postHookExecOpts := types.ExecConfig{ - AttachStderr: true, - AttachStdin: true, - AttachStdout: true, - Cmd: splitCmd, - Detach: false, - Tty: true, - } - - if err := container.RunExec(dcli, cl, targetContainer.ID, &postHookExecOpts); err != nil { - return err - } - - logrus.Infof("succesfully ran %s post-hook command: %s", fullServiceName, bkConfig.postHookCmd) - } - - return nil +var appBackupSnapshotsCommand = cli.Command{ + Name: "snapshots", + Aliases: []string{"s"}, + Flags: []cli.Flag{ + internal.DebugFlag, + internal.OfflineFlag, + }, + Before: internal.SubCommandBefore, + Usage: "List backup snapshots", + BashComplete: autocomplete.AppNameComplete, + Action: func(c *cli.Context) error { + return nil + }, } -func copyToFile(outfile string, r io.Reader) error { - tmpFile, err := os.CreateTemp(filepath.Dir(outfile), ".tar_temp") - if err != nil { - return err - } - - tmpPath := tmpFile.Name() - - _, err = io.Copy(tmpFile, r) - tmpFile.Close() - - if err != nil { - os.Remove(tmpPath) - return err - } - - if err = os.Rename(tmpPath, outfile); err != nil { - os.Remove(tmpPath) - return err - } - - return nil -} - -func cleanupTempArchives(tarPaths []string) error { - for _, tarPath := range tarPaths { - if err := os.RemoveAll(tarPath); err != nil { - return err - } - - logrus.Debugf("remove temporary archive file %s", tarPath) - } - - return nil -} - -func mergeArchives(tarPaths []string, serviceName string) error { - var out io.Writer - var cout *pgzip.Writer - - localBackupPath := filepath.Join(config.BACKUP_DIR, fmt.Sprintf("%s_%s.tar.gz", serviceName, TimeStamp())) - - fout, err := os.Create(localBackupPath) - if err != nil { - return fmt.Errorf("Failed to open %s: %s", localBackupPath, err) - } - - defer fout.Close() - out = fout - - cout = pgzip.NewWriter(out) - out = cout - - tw := tar.NewWriter(out) - - for _, tarPath := range tarPaths { - if err := addTar(tw, tarPath); err != nil { - return fmt.Errorf("failed to merge %s: %v", tarPath, err) - } - } - - if err := tw.Close(); err != nil { - return fmt.Errorf("failed to close tar writer %v", err) - } - - if cout != nil { - if err := cout.Flush(); err != nil { - return fmt.Errorf("failed to flush: %s", err) - } else if err = cout.Close(); err != nil { - return fmt.Errorf("failed to close compressed writer: %s", err) - } - } - - logrus.Infof("backed up %s to %s", serviceName, localBackupPath) - - return nil -} - -func addTar(tw *tar.Writer, pth string) (err error) { - var tr *tar.Reader - var rc io.ReadCloser - var hdr *tar.Header - - if tr, rc, err = openTarFile(pth); err != nil { - return - } - - for { - if hdr, err = tr.Next(); err != nil { - if err == io.EOF { - err = nil - } - break - } - if err = tw.WriteHeader(hdr); err != nil { - break - } else if _, err = io.Copy(tw, tr); err != nil { - break - } - } - if err == nil { - err = rc.Close() - } else { - rc.Close() - } - return -} - -func openTarFile(pth string) (tr *tar.Reader, rc io.ReadCloser, err error) { - var fin *os.File - var n int - buff := make([]byte, 1024) - - if fin, err = os.Open(pth); err != nil { - return - } - - if n, err = fin.Read(buff); err != nil { - fin.Close() - return - } else if n == 0 { - fin.Close() - err = fmt.Errorf("%s is empty", pth) - return - } - - if _, err = fin.Seek(0, 0); err != nil { - fin.Close() - return - } - - rc = fin - tr = tar.NewReader(rc) - - return tr, rc, nil +var appBackupCommand = cli.Command{ + Name: "backup", + Aliases: []string{"b"}, + Usage: "Manage app backups", + ArgsUsage: "", + Subcommands: []cli.Command{ + appBackupListCommand, + appBackupSnapshotsCommand, + appBackupDownloadCommand, + appBackupCreateCommand, + }, } diff --git a/cli/app/restore.go b/cli/app/restore.go index 1bf9c840..1d6dc1ba 100644 --- a/cli/app/restore.go +++ b/cli/app/restore.go @@ -1,223 +1,24 @@ package app import ( - "context" - "errors" - "fmt" - "os" - "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/autocomplete" - "coopcloud.tech/abra/pkg/client" - "coopcloud.tech/abra/pkg/config" - containerPkg "coopcloud.tech/abra/pkg/container" - "coopcloud.tech/abra/pkg/recipe" - recipePkg "coopcloud.tech/abra/pkg/recipe" - "coopcloud.tech/abra/pkg/upstream/container" - "github.com/docker/cli/cli/command" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/filters" - dockerClient "github.com/docker/docker/client" - "github.com/docker/docker/pkg/archive" - "github.com/sirupsen/logrus" "github.com/urfave/cli" ) -type restoreConfig struct { - preHookCmd string - postHookCmd string -} - var appRestoreCommand = cli.Command{ Name: "restore", Aliases: []string{"rs"}, - Usage: "Run app restore", - ArgsUsage: " ", + Usage: "Restore an app backup", + ArgsUsage: " ", Flags: []cli.Flag{ internal.DebugFlag, internal.OfflineFlag, - internal.ChaosFlag, }, Before: internal.SubCommandBefore, BashComplete: autocomplete.AppNameComplete, - Description: ` -Run an app restore. - -Pre/post hook commands are defined in the recipe configuration. Abra reads this -configuration and run the comands in the context of the service before -restoring the backup. - -Unlike "abra app backup", restore must be run on a per-service basis. You can -not restore all services in one go. Backup files produced by Abra are -compressed archives which use absolute paths. This allows Abra to restore -according to standard tar command logic, i.e. the backup will be restored to -the path it was originally backed up from. - -Example: - - abra app restore example.com app ~/.abra/backups/example_com_app_609341138.tar.gz -`, + Description: `TODO`, Action: func(c *cli.Context) error { - app := internal.ValidateApp(c) - - recipe, err := recipe.Get(app.Recipe, internal.Offline) - if err != nil { - logrus.Fatal(err) - } - - if !internal.Chaos { - if err := recipePkg.EnsureIsClean(app.Recipe); err != nil { - logrus.Fatal(err) - } - - if !internal.Offline { - if err := recipePkg.EnsureUpToDate(app.Recipe); err != nil { - logrus.Fatal(err) - } - } - - if err := recipePkg.EnsureLatest(app.Recipe); err != nil { - logrus.Fatal(err) - } - } - - serviceName := c.Args().Get(1) - if serviceName == "" { - internal.ShowSubcommandHelpAndError(c, errors.New("missing ?")) - } - - backupPath := c.Args().Get(2) - if backupPath == "" { - internal.ShowSubcommandHelpAndError(c, errors.New("missing ?")) - } - - if _, err := os.Stat(backupPath); err != nil { - if os.IsNotExist(err) { - logrus.Fatalf("%s doesn't exist?", backupPath) - } - } - - restoreConfigs := make(map[string]restoreConfig) - for _, service := range recipe.Config.Services { - if restoreEnabled, ok := service.Deploy.Labels["backupbot.restore"]; ok { - if restoreEnabled == "true" { - fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), service.Name) - rsConfig := restoreConfig{} - - logrus.Debugf("restore config detected for %s", fullServiceName) - - if preHookCmd, ok := service.Deploy.Labels["backupbot.restore.pre-hook"]; ok { - logrus.Debugf("detected pre-hook command for %s: %s", fullServiceName, preHookCmd) - rsConfig.preHookCmd = preHookCmd - } - - if postHookCmd, ok := service.Deploy.Labels["backupbot.restore.post-hook"]; ok { - logrus.Debugf("detected post-hook command for %s: %s", fullServiceName, postHookCmd) - rsConfig.postHookCmd = postHookCmd - } - - restoreConfigs[service.Name] = rsConfig - } - } - } - - rsConfig, ok := restoreConfigs[serviceName] - if !ok { - rsConfig = restoreConfig{} - } - - cl, err := client.New(app.Server) - if err != nil { - logrus.Fatal(err) - } - - if err := runRestore(cl, app, backupPath, serviceName, rsConfig); err != nil { - logrus.Fatal(err) - } - return nil }, } - -// runRestore does the actual restore logic. -func runRestore(cl *dockerClient.Client, app config.App, backupPath, serviceName string, rsConfig restoreConfig) error { - // FIXME: avoid instantiating a new CLI - dcli, err := command.NewDockerCli() - if err != nil { - return err - } - - filters := filters.NewArgs() - filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName)) - - targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, true) - if err != nil { - return err - } - - fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName) - if rsConfig.preHookCmd != "" { - splitCmd := internal.SafeSplit(rsConfig.preHookCmd) - - logrus.Debugf("split pre-hook command for %s into %s", fullServiceName, splitCmd) - - preHookExecOpts := types.ExecConfig{ - AttachStderr: true, - AttachStdin: true, - AttachStdout: true, - Cmd: splitCmd, - Detach: false, - Tty: true, - } - - if err := container.RunExec(dcli, cl, targetContainer.ID, &preHookExecOpts); err != nil { - return err - } - - logrus.Infof("succesfully ran %s pre-hook command: %s", fullServiceName, rsConfig.preHookCmd) - } - - backupReader, err := os.Open(backupPath) - if err != nil { - return err - } - - content, err := archive.DecompressStream(backupReader) - if err != nil { - return err - } - - // NOTE(d1): we use absolute paths so tar knows what to do. it will restore - // files according to the paths set in the compressed archive - restorePath := "/" - - copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false} - if err := cl.CopyToContainer(context.Background(), targetContainer.ID, restorePath, content, copyOpts); err != nil { - return err - } - - logrus.Infof("restored %s to %s", backupPath, fullServiceName) - - if rsConfig.postHookCmd != "" { - splitCmd := internal.SafeSplit(rsConfig.postHookCmd) - - logrus.Debugf("split post-hook command for %s into %s", fullServiceName, splitCmd) - - postHookExecOpts := types.ExecConfig{ - AttachStderr: true, - AttachStdin: true, - AttachStdout: true, - Cmd: splitCmd, - Detach: false, - Tty: true, - } - - if err := container.RunExec(dcli, cl, targetContainer.ID, &postHookExecOpts); err != nil { - return err - } - - logrus.Infof("succesfully ran %s post-hook command: %s", fullServiceName, rsConfig.postHookCmd) - } - - return nil -}