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" "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/runtime" "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/docker/docker/pkg/system" "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: " []", Flags: []cli.Flag{ internal.DebugFlag, }, Before: internal.SubCommandBefore, 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) conf := runtime.New() cl, err := client.New(app.Server) if err != nil { logrus.Fatal(err) } recipe, err := recipe.Get(app.Recipe, conf) if 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 } } } 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) } logrus.Infof("running backup for the %s service", serviceName) 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) } } } return nil }, } // TimeStamp generates a file name friendly timestamp. func TimeStamp() string { ts := time.Now().UTC().Format(time.RFC3339) return strings.Replace(ts, ":", "-", -1) } // 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 } func copyToFile(outfile string, r io.Reader) error { tmpFile, err := system.TempFileSequential(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 }