| package report |
| |
| import ( |
| "fmt" |
| "io" |
| "os" |
| "regexp" |
| "sort" |
| "strings" |
| "time" |
| |
| "github.com/apache/cloudberry-backup/history" |
| "github.com/apache/cloudberry-backup/utils" |
| "github.com/apache/cloudberry-go-libs/cluster" |
| "github.com/apache/cloudberry-go-libs/dbconn" |
| "github.com/apache/cloudberry-go-libs/gplog" |
| "github.com/apache/cloudberry-go-libs/iohelper" |
| "github.com/apache/cloudberry-go-libs/operating" |
| "github.com/blang/semver/v4" |
| "github.com/pkg/errors" |
| "gopkg.in/yaml.v2" |
| ) |
| |
| /* |
| * This struct holds information that will be printed to the report file |
| * after a backup, as well as information printed to the configuration |
| * file that we will want to read in for a restore. |
| */ |
| type Report struct { |
| BackupParamsString string |
| DatabaseSize string |
| history.BackupConfig |
| } |
| |
| type LineInfo struct { |
| Key string |
| Value string |
| } |
| |
| func ParseErrorMessage(errStr string) string { |
| if errStr == "" { |
| return "" |
| } |
| errLevelStr := "[CRITICAL]:-" |
| headerIndex := strings.Index(errStr, errLevelStr) |
| errMsg := errStr[headerIndex+len(errLevelStr):] |
| return errMsg |
| } |
| |
| func (report *Report) ConstructBackupParamsString() { |
| filterStr := "" |
| if report.IncludeSchemaFiltered { |
| filterStr = "Include Schema Filter " |
| } |
| if report.IncludeTableFiltered { |
| filterStr = "Include Table Filter" |
| } |
| if report.ExcludeSchemaFiltered { |
| filterStr = "Exclude Schema Filter" |
| } |
| if report.ExcludeTableFiltered { |
| filterStr += "Exclude Table Filter" |
| } |
| if filterStr == "" { |
| filterStr = "None" |
| } |
| compressStr := "None" |
| program := utils.GetPipeThroughProgram() |
| if report.Compressed { |
| compressStr = program.Name |
| } |
| pluginStr := "None" |
| if report.Plugin != "" { |
| pluginStr = report.Plugin |
| } |
| sectionStr := "All Sections" |
| if report.DataOnly { |
| sectionStr = "Data Only" |
| } |
| if report.MetadataOnly { |
| sectionStr = "Metadata Only" |
| } |
| filesStr := "Multiple Data Files Per Segment" |
| if report.MetadataOnly { |
| filesStr = "No Data Files" |
| } else if report.SingleDataFile { |
| filesStr = "Single Data File Per Segment" |
| } |
| statsStr := "No" |
| if report.WithStatistics { |
| statsStr = "Yes" |
| } |
| backupParamsTemplate := `compression: %s |
| plugin executable: %s |
| backup section: %s |
| object filtering: %s |
| includes statistics: %s |
| data file format: %s |
| %s` |
| report.BackupParamsString = fmt.Sprintf(backupParamsTemplate, compressStr, pluginStr, sectionStr, filterStr, |
| statsStr, filesStr, report.constructIncrementalSection()) |
| } |
| |
| func (report *Report) constructIncrementalSection() string { |
| if !report.Incremental { |
| return "incremental: False" |
| } |
| backupTimestamps := make([]string, 0) |
| for _, restorePlanEntry := range report.RestorePlan { |
| backupTimestamps = append(backupTimestamps, restorePlanEntry.Timestamp) |
| } |
| return fmt.Sprintf(`incremental: True |
| incremental backup set: |
| %s`, strings.Join(backupTimestamps, "\n")) |
| } |
| |
| func (report *Report) WriteBackupReportFile(reportFilename string, timestamp string, endtime time.Time, objectCounts map[string]int, errMsg string) { |
| reportFile, err := iohelper.OpenFileForWriting(reportFilename) |
| if err != nil { |
| gplog.Error("Unable to open backup report file %s", reportFilename) |
| return |
| } |
| |
| gpbackupCommandLine := strings.Join(os.Args, " ") |
| start, end, duration := GetDurationInfo(timestamp, endtime) |
| |
| reportInfo := make([]LineInfo, 0) |
| reportInfo = append(reportInfo, |
| LineInfo{Key: "timestamp key:", Value: timestamp}, |
| LineInfo{Key: "database version:", Value: report.DatabaseVersion}, |
| LineInfo{Key: "gpbackup version:", Value: fmt.Sprintf("%s\n", report.BackupVersion)}, |
| LineInfo{Key: "database name:", Value: report.DatabaseName}, |
| LineInfo{Key: "command line:", Value: gpbackupCommandLine}, |
| ) |
| |
| AppendBackupParams(&reportInfo, report.BackupParamsString) |
| |
| reportInfo = append(reportInfo, |
| LineInfo{}, |
| LineInfo{Key: "start time:", Value: start}, |
| LineInfo{Key: "end time:", Value: end}, |
| LineInfo{Key: "duration:", Value: duration}) |
| |
| if errMsg != "" { |
| reportInfo = append(reportInfo, |
| LineInfo{}, |
| LineInfo{Key: "backup status:", Value: history.BackupStatusFailed}, |
| LineInfo{Key: "backup error:", Value: errMsg}) |
| } else { |
| reportInfo = append(reportInfo, |
| LineInfo{}, |
| LineInfo{Key: "backup status:", Value: history.BackupStatusSucceed}) |
| } |
| reportInfo = append(reportInfo, LineInfo{}) |
| if report.DatabaseSize != "" { |
| reportInfo = append(reportInfo, |
| LineInfo{Key: "database size:", Value: strings.ToUpper(report.DatabaseSize)}) |
| } |
| reportInfo = append(reportInfo, |
| LineInfo{Key: "segment count:", Value: fmt.Sprintf("%d", report.SegmentCount)}) |
| |
| _, err = fmt.Fprint(reportFile, "Apache Cloudberry Backup Report\n\n") |
| if err != nil { |
| gplog.Error("Unable to write backup report file %s", reportFilename) |
| return |
| } |
| |
| logOutputReport(reportFile, reportInfo) |
| |
| PrintObjectCounts(reportFile, objectCounts) |
| |
| err = reportFile.Close() |
| gplog.FatalOnError(err) |
| _ = operating.System.Chmod(reportFilename, 0444) |
| } |
| |
| func WriteRestoreReportFile(reportFilename string, backupTimestamp string, startTimestamp string, connectionPool *dbconn.DBConn, restoreVersion string, origSize int, destSize int, errMsg string) { |
| reportFile, err := iohelper.OpenFileForWriting(reportFilename) |
| if err != nil { |
| gplog.Warn("Unable to open restore report file %s: %s, skipping report creation", reportFilename, err) |
| return |
| } |
| |
| gprestoreCommandLine := strings.Join(os.Args, " ") |
| start, end, duration := GetDurationInfo(startTimestamp, operating.System.Now()) |
| |
| utils.MustPrintf(reportFile, "Apache Cloudberry Restore Report\n\n") |
| |
| reportInfo := make([]LineInfo, 0) |
| reportInfo = append(reportInfo, |
| LineInfo{Key: "timestamp key:", Value: backupTimestamp}, |
| LineInfo{Key: "database version:", Value: connectionPool.Version.VersionString}, |
| LineInfo{Key: "gprestore version:", Value: fmt.Sprintf("%s\n", restoreVersion)}, |
| LineInfo{Key: "database name:", Value: connectionPool.DBName}, |
| LineInfo{Key: "command line:", Value: fmt.Sprintf("%s\n", gprestoreCommandLine)}, |
| LineInfo{Key: "backup segment count:", Value: fmt.Sprintf("%d", origSize)}, |
| LineInfo{Key: "restore segment count:", Value: fmt.Sprintf("%d", destSize)}, |
| LineInfo{Key: "start time:", Value: start}, |
| LineInfo{Key: "end time:", Value: end}, |
| LineInfo{Key: "duration:", Value: duration}, |
| ) |
| |
| var restoreStatus string |
| errorCode := gplog.GetErrorCode() |
| if errorCode == 1 { |
| restoreStatus = fmt.Sprintf("Success but non-fatal errors occurred. See log file %s for details.", gplog.GetLogFilePath()) |
| reportInfo = append(reportInfo, |
| LineInfo{}, |
| LineInfo{Key: "restore status:", Value: restoreStatus}) |
| } else if errMsg != "" { |
| reportInfo = append(reportInfo, |
| LineInfo{}, |
| LineInfo{Key: "restore status:", Value: "Failure"}, |
| LineInfo{Key: "restore error:", Value: errMsg}) |
| } else { |
| reportInfo = append(reportInfo, |
| LineInfo{}, |
| LineInfo{Key: "restore status:", Value: "Success"}) |
| } |
| |
| logOutputReport(reportFile, reportInfo) |
| |
| err = reportFile.Close() |
| gplog.FatalOnError(err) |
| _ = operating.System.Chmod(reportFilename, 0444) |
| } |
| |
| func logOutputReport(reportFile io.WriteCloser, reportInfo []LineInfo) { |
| maxSize := 0 |
| for _, lineInfo := range reportInfo { |
| k := lineInfo.Key |
| if len(k) > maxSize { |
| maxSize = len(k) |
| } |
| } |
| |
| for _, lineInfo := range reportInfo { |
| if lineInfo.Key == "" { |
| utils.MustPrintf(reportFile, "\n") |
| } else { |
| utils.MustPrintf(reportFile, "%-*s%s\n", maxSize+3, lineInfo.Key, lineInfo.Value) |
| } |
| } |
| } |
| |
| func GetDurationInfo(timestamp string, endTime time.Time) (string, string, string) { |
| startTime, _ := time.ParseInLocation("20060102150405", timestamp, operating.System.Local) |
| duration := reformatDuration(endTime.Sub(startTime)) |
| startTimestamp := startTime.Format("Mon Jan 02 2006 15:04:05") |
| endTimestamp := endTime.Format("Mon Jan 02 2006 15:04:05") |
| return startTimestamp, endTimestamp, duration |
| } |
| |
| // Turns "1h2m3.456s" into "1:02:03" |
| func reformatDuration(duration time.Duration) string { |
| hour := duration / time.Hour |
| duration -= hour * time.Hour |
| min := duration / time.Minute |
| duration -= min * time.Minute |
| sec := duration / time.Second |
| return fmt.Sprintf("%d:%02d:%02d", hour, min, sec) |
| } |
| |
| func PrintObjectCounts(reportFile io.WriteCloser, objectCounts map[string]int) { |
| objectStr := "\ncount of database objects in backup:\n" |
| objectSlice := make([]string, 0) |
| maxSize := 0 |
| for k := range objectCounts { |
| objectSlice = append(objectSlice, k) |
| if len(k) > maxSize { |
| maxSize = len(k) |
| } |
| } |
| sort.Strings(objectSlice) |
| for _, object := range objectSlice { |
| if object == "Database GUC's" { |
| objectStr += fmt.Sprintf("%-*s%d\n", maxSize+3, "database GUC's", objectCounts[object]) |
| } else { |
| objectStr += fmt.Sprintf("%-*s%d\n", maxSize+3, strings.ToLower(object), objectCounts[object]) |
| } |
| } |
| utils.MustPrintf(reportFile, "%s", objectStr) |
| } |
| |
| /* |
| * This function will not error out if the user has gprestore X.Y.Z |
| * and gpbackup X.Y.Z+dev, when technically the uncommitted code changes |
| * in the +dev version of gpbackup may have incompatibilities with the |
| * committed version of gprestore. |
| * |
| * We assume this condition will never arise in practice, as gpbackup and |
| * gprestore will be built with identical versions during development, and |
| * users will never use a +dev version in production. |
| */ |
| func EnsureBackupVersionCompatibility(backupVersion string, restoreVersion string) { |
| backupSemVer, err := semver.Make(backupVersion) |
| gplog.FatalOnError(err) |
| restoreSemVer, err := semver.Make(restoreVersion) |
| gplog.FatalOnError(err) |
| if backupSemVer.GT(restoreSemVer) { |
| gplog.Fatal(errors.Errorf("gprestore %s cannot restore a backup taken with gpbackup %s; please use gprestore %s or later.", |
| restoreVersion, backupVersion, backupVersion), "") |
| } |
| } |
| |
| func EnsureDatabaseVersionCompatibility(backupGPDBVersion string, restoreGPDBVersion dbconn.GPDBVersion) { |
| // Use the standard library to parse the backup's version string |
| var backupVersionInfo dbconn.GPDBVersion |
| backupVersionInfo.ParseVersionInfo(backupGPDBVersion) |
| |
| // Special case to allow restoring a GPDB backup to CBDB. |
| if backupVersionInfo.IsGPDB() && restoreGPDBVersion.IsCBDB() { |
| gplog.Info("Performing a restore from Greenplum to Cloudberry. Skipping database version compatibility check.") |
| return |
| } |
| |
| pattern := regexp.MustCompile(`\d+\.\d+\.\d+`) |
| threeDigitVersion := pattern.FindStringSubmatch(backupGPDBVersion)[0] |
| backupGPDBSemVer, err := semver.Make(threeDigitVersion) |
| gplog.FatalOnError(err) |
| if backupGPDBSemVer.Major > restoreGPDBVersion.SemVer.Major { |
| gplog.Fatal(errors.Errorf("Cannot restore from GPDB version %s to %s due to catalog incompatibilities.", backupGPDBVersion, restoreGPDBVersion.VersionString), "") |
| } |
| } |
| |
| type ContactFile struct { |
| Contacts map[string][]EmailContact |
| } |
| |
| type EmailContact struct { |
| Address string |
| Status map[string]bool |
| } |
| |
| func GetContacts(filename string, utility string) string { |
| contactFile := &ContactFile{} |
| contents, err := operating.System.ReadFile(filename) |
| gplog.FatalOnError(err) |
| err = yaml.Unmarshal(contents, contactFile) |
| if err != nil { |
| gplog.Warn("Unable to send email report: Error reading email contacts file.") |
| gplog.Warn("Please ensure that the email contacts file is in valid YAML format.") |
| return "" |
| } |
| |
| errorCode := gplog.GetErrorCode() |
| exitStatus := "success" |
| if errorCode == 1 { |
| exitStatus = "success_with_errors" |
| } else if errorCode == 2 { |
| exitStatus = "failure" |
| } |
| |
| contactList := make([]string, 0) |
| for _, contact := range contactFile.Contacts[utility] { |
| if contact.Status[exitStatus] { |
| contactList = append(contactList, contact.Address) |
| } |
| } |
| return strings.Join(contactList, " ") |
| } |
| |
| func ConstructEmailMessage(timestamp string, contactList string, reportFilePath string, utility string, status bool, dbname string) string { |
| hostname, _ := operating.System.Hostname() |
| statusString := history.BackupStatusSucceed |
| if !status { |
| statusString = history.BackupStatusFailed |
| } |
| emailHeader := fmt.Sprintf(`To: %s |
| Subject: %s %s of database %s on %s completed: %s |
| Content-Type: text/html |
| Content-Disposition: inline |
| <html> |
| <body> |
| <pre style=\"font: monospace\"> |
| `, contactList, utility, timestamp, dbname, hostname, statusString) |
| |
| emailFooter := ` |
| </pre> |
| </body> |
| </html>` |
| fileContents := strings.Join(iohelper.MustReadLinesFromFile(reportFilePath), "\n") |
| return emailHeader + fileContents + emailFooter |
| } |
| |
| func EmailReport(c *cluster.Cluster, timestamp string, reportFilePath string, utility string, status bool, dbname string) { |
| contactsFilename := "gp_email_contacts.yaml" |
| gphomeFile := fmt.Sprintf("%s/bin/%s", operating.System.Getenv("GPHOME"), contactsFilename) |
| homeFile := fmt.Sprintf("%s/%s", operating.System.Getenv("HOME"), contactsFilename) |
| _, homeErr := c.ExecuteLocalCommand(fmt.Sprintf("test -f %s", homeFile)) |
| if homeErr != nil { |
| _, gphomeErr := c.ExecuteLocalCommand(fmt.Sprintf("test -f %s", gphomeFile)) |
| if gphomeErr != nil { |
| gplog.Info("Found neither %s nor %s", gphomeFile, homeFile) |
| gplog.Info("Email containing %s report %s will not be sent", utility, reportFilePath) |
| return |
| } |
| contactsFilename = gphomeFile |
| } else { |
| contactsFilename = homeFile |
| } |
| gplog.Info("%s list found, %s will be sent", contactsFilename, reportFilePath) |
| contactList := GetContacts(contactsFilename, utility) |
| if contactList == "" { |
| return |
| } |
| message := ConstructEmailMessage(timestamp, contactList, reportFilePath, utility, status, dbname) |
| gplog.Verbose("Sending email report to the following addresses: %s", contactList) |
| output, sendErr := c.ExecuteLocalCommand(fmt.Sprintf(`echo "%s" | sendmail -t`, message)) |
| if sendErr != nil { |
| gplog.Warn("Unable to send email report: %s", output) |
| } |
| } |
| |
| func AppendBackupParams(infoArr *[]LineInfo, paramsStr string) { |
| paramsStr = strings.Trim(paramsStr, "\n") |
| params := strings.Split(paramsStr, "\n") |
| for _, param := range params { |
| if strings.Contains(param, ":") { |
| tup := strings.Split(param, ":") |
| k := strings.TrimSpace(tup[0]) |
| v := strings.TrimSpace(tup[1]) |
| *infoArr = append(*infoArr, LineInfo{Key: k + ":", Value: v}) |
| } else { |
| // timestamps following 'incremental backup set' do not have colons |
| *infoArr = append(*infoArr, LineInfo{Key: param, Value: ""}) |
| } |
| } |
| } |