package cmd import ( "bytes" "context" "fmt" "log" "math" "net/smtp" "os" "strings" "time" "gitea.urkob.com/urko/backblaze-backup/internal/services/backblaze" "gitea.urkob.com/urko/backblaze-backup/internal/services/email" "gitea.urkob.com/urko/backblaze-backup/kit" "gitea.urkob.com/urko/backblaze-backup/kit/config" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) var Check = &cobra.Command{ Use: "check", Short: "Compares local backup files with those in a Backblaze B2 bucket and sends a summary email.", Long: `This command compares the list of files in a local backup directory against the files in a specified Backblaze B2 bucket. The operation is performed concurrently and is time-bound, set by default to 5 minutes. If discrepancies are found, i.e., some files exist only locally or only on the cloud, these are logged and sent via email as attachments. The email contains two text attachments: - 'Local-Files-Not-In-B2.txt': Lists files that are in the local backup directory but not in the B2 bucket. - 'B2-Files-Not-In-Local.txt': Lists files that are in the B2 bucket but not in the local backup directory. The command requires two flags: - '--dir': The path of the local backup directory - '--bucket': The name of the Backblaze B2 bucket An environment variable 'BACKBLAZE_ENV' can be set to 'dev' to load environment variables from a .env file in the root directory.`, Run: func(cmd *cobra.Command, args []string) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) // or any appropriate time defer cancel() log.SetFlags(log.Ldate | log.Lmicroseconds) backupDir, err := cmd.Flags().GetString("dir") if err != nil { panic(fmt.Errorf("dir %w", err)) } bucketName, err := cmd.Flags().GetString("bucket") if err != nil { panic(fmt.Errorf("bucket %w", err)) } envFile := "" if os.Getenv("BACKBLAZE_ENV") == "dev" { envFile = kit.RootDir() + "/.env" } cfg := config.NewConfig(envFile) mailSrv := email.NewMailService(email.MailServiceConfig{ Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost), Host: cfg.MailHost, Port: cfg.MailPort, From: cfg.MailFrom, }, ) logger := logrus.New() logger.SetLevel(logrus.Level(cfg.LogLevel)) b, err := backblaze.NewBackBlaze(ctx, logger, cfg.BbId, cfg.BbKey) if err != nil { panic(fmt.Errorf("NewBackBlaze %w", err)) } logger.Info("start check") defer logger.Info("end check") localChan := make(chan backblaze.B2Local) b2Chan := make(chan backblaze.B2Local) doneChan := make(chan interface{}, 1) go b.CompareConcurrent(ctx, backupDir, bucketName, localChan, b2Chan, doneChan) reportBuilder := strings.Builder{} countLocal := 0 countB2 := 0 countOK := 0 reportBuilder.WriteString(fmt.Sprintf("Local files within `%s` path already in `%s` bucket:\n", backupDir, bucketName)) cloudBuilder := strings.Builder{} cloudBuilder.WriteString(fmt.Sprintf("B2 files within `%s` bucket\n", bucketName)) cloudBuilder.WriteString("| B2 File Name | Size | ModTime | Status |\n") cloudBuilder.WriteString("|----------------------------------------------------------|------------------|--------------------------------|------------------------------------------\n") countNotInLocal := 0 localBuilder := strings.Builder{} localBuilder.WriteString(fmt.Sprintf("Local files in `%s`\n", backupDir)) localBuilder.WriteString("| Local File Name | Size | ModTime | Status |\n") localBuilder.WriteString("|----------------------------------------------------------|------------------|--------------------------------|------------------------------------------\n") countNotInCloud := 0 loop: for { select { case <-ctx.Done(): // Handle the timeout or cancellation if ctx.Err() == context.DeadlineExceeded { logger.Error("Operation timed out") } else if ctx.Err() == context.Canceled { logger.Error("Operation canceled") } break loop case <-doneChan: logger.Debugln("done chan") break loop case localMsg, ok := <-localChan: if !ok { continue } countLocal++ logger.Debugln("localChan", localMsg.File) writeTableRow(&localBuilder, &countNotInCloud, localMsg.File, localMsg.Err) case b2Msg, ok := <-b2Chan: if !ok { continue } countB2++ logger.Debugln("cloudBuilder", b2Msg.File) writeTableRow(&cloudBuilder, &countNotInLocal, b2Msg.File, b2Msg.Err) } } reportBuilder.WriteString("\n") reportBuilder.WriteString(cloudBuilder.String()) reportBuilder.WriteString("\n") reportBuilder.WriteString(localBuilder.String()) if err := mailSrv.SendOK(email.EmailWithAttachments{ To: cfg.MailTo, Bucket: bucketName, BackupDir: backupDir, Count: map[string]int{ "count_ok": countOK, "count_local": countLocal, "count_b2": countB2, "count_not_in_local": countNotInLocal, "count_not_in_cloud": countNotInCloud, }, Attachments: []email.EmailAttachment{{File: bytes.NewReader([]byte(reportBuilder.String())), Title: fmt.Sprintf("%s-check-report.txt", bucketName)}}, }); err != nil { panic(fmt.Errorf("error while send email: %w", err)) } }, } // New function to write a table row func writeTableRow(builder *strings.Builder, count *int, file backblaze.File, err error) { status := "OK" if err != nil { status = err.Error() (*count)++ } builder.WriteString(fmt.Sprintf("| %-56s | %-16s | %-30s | %-40s |\n", file.Path, prettyByteSize(file.Size), file.ModTime.Format("2006-01-02 15:04:05.000"), status)) } func prettyByteSize(b int) string { bf := float64(b) for _, unit := range []string{"", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"} { if math.Abs(bf) < 1024.0 { return fmt.Sprintf("%3.1f%sB", bf, unit) } bf /= 1024.0 } return fmt.Sprintf("%.1fYiB", bf) }