diff --git a/backuper.go b/backuper.go index a83f8e8..e162dc8 100644 --- a/backuper.go +++ b/backuper.go @@ -7,166 +7,12 @@ import ( "io/fs" "os" "path/filepath" - "strings" "time" "github.com/klauspost/compress/zstd" - "github.com/nxshock/progressmessage" ) -type Pattern struct { - Path string - - // Маски имени файла - FileNamePatternList []string - - // Маски пути - FilePathPatternList []string - - // Вкючать файлы в покаталогах - Recursive bool -} - -// FindAll возвращает индекс файлов, совпавших по маске -func (b *Config) FindAll(mask string) (*Index, error) { - b.logf(LogLevelDebug, "Поиск маски %s...", mask) - index, err := b.index(true) - if err != nil { - return nil, fmt.Errorf("index: %v", err) - } - - result := &Index{Files: make(map[string]FileHistory)} - - for path, info := range index.Files { - matched, err := filepath.Match(strings.ToLower(mask), strings.ToLower(filepath.ToSlash(path))) - if err != nil { - return nil, fmt.Errorf("filepath.Match: %v", err) - } - if matched { - result.Files[path] = append(result.Files[path], info...) - } - } - - return result, nil -} - -// IncrementalBackup выполняет инкрементальный бекап. -// В случае, если бекап выполняется впервые, выполняется полный бекап. -func (b *Config) IncrementalBackup() error { - index, err := b.index(false) - if err != nil { - return err - } - - return b.doBackup(index) -} - -// FullBackup выполняет полное резервное копирование -func (b *Config) FullBackup() error { - return b.doBackup(nil) -} - -func (b *Config) doBackup(index *Index) error { - var suffix string - if index == nil || index.ItemCount() == 0 { - suffix = "f" // Full backup - полный бекап - } else { - suffix = "i" // Инкрементальный бекап - } - - filePath := filepath.Join(filepath.Dir(b.filePath), b.FileName+"_"+time.Now().Local().Format(defaulFileNameTimeFormat)+suffix+defaultExt) - - var err error - filePath, err = filepath.Abs(filePath) - if err != nil { - return fmt.Errorf("ошибка при создании файла архива: %v", err) - } - b.logf(LogLevelProgress, "Создание нового файла бекапа %s...", filePath) - - if _, err = os.Stat(filepath.Dir(filePath)); os.IsNotExist(err) { - err = os.MkdirAll(filepath.Dir(filePath), 0644) - if err != nil { - return fmt.Errorf("ошибка при создании каталога для архива: %v", err) - } - } - - resultArchiveFile, err := os.Create(filePath) - if err != nil { - return fmt.Errorf("ошибка при создании файла архива: %v", err) - } - - compressor, err := zstd.NewWriter(resultArchiveFile, zstd.WithEncoderLevel(zstd.SpeedBestCompression)) - if err != nil { - return fmt.Errorf("ошибка при создании инициализации архиватора: %v", err) - } - - tarWriter := tar.NewWriter(compressor) - - b.log(LogLevelInfo, "Копирование файлов...") - - pm := progressmessage.New("Добавлено %d файлов, %s...") - if b.Logger.MinimalLogLevel <= LogLevelProgress { - pm.Start() - } - - i := 0 // счётчик обработанных файлов - addSize := int64(0) // добавлено байт - for k := range b.planChan(index) { - i++ - addSize += k.Info.Size() - err := b.addFileToTarWriter(k.SourcePath, tarWriter) - if err != nil { - b.logf(LogLevelWarning, "ошибка при добавлении файла %s: %v\n", k.SourcePath, err) - if b.StopOnAnyError { - compressor.Close() - resultArchiveFile.Close() - os.Remove(filePath) - return fmt.Errorf("ошибка при добавлении файла в архив: %v", err) // TODO: организовать закрытие и удаление частичного файла - } - } - - if b.Logger.MinimalLogLevel <= LogLevelProgress { - pm.Update(i, sizeToApproxHuman(addSize)) - } - } - - if b.Logger.MinimalLogLevel <= LogLevelProgress { - pm.Stop() - } - - err = tarWriter.Close() - if err != nil { - compressor.Close() - resultArchiveFile.Close() - os.Remove(filePath) - return fmt.Errorf("ошибка при закрытии tar-архива: %v", err) - } - - err = compressor.Close() - if err != nil { - resultArchiveFile.Close() - os.Remove(filePath) - return fmt.Errorf("ошибка при закрытии архива: %v", err) - } - - if b.Logger.MinimalLogLevel <= LogLevelProgress { - fmt.Fprintf(os.Stderr, "\rДобавлено %d файлов, %s.\n", i, sizeToApproxHuman(addSize)) - } - - err = resultArchiveFile.Close() - if err != nil { - return fmt.Errorf("ошибка при закрытии файла архива: %v", err) - } - - // если не было обновлений, удалить пустой файл - if i == 0 { - os.Remove(filePath) - } - - return nil -} - -func (b *Config) fileList(fileNames chan File) { +func (b *Config) fileList(fileNames chan FileInfo) { errorCount := 0 for _, mask := range b.Patterns { @@ -174,7 +20,7 @@ func (b *Config) fileList(fileNames chan File) { err := filepath.WalkDir(mask.Path, func(path string, d fs.DirEntry, err error) error { if err != nil { errorCount++ - b.logf(LogLevelCritical, "Ошибка при поиске файлов: %v\n", err) + b.logf(Error, "Ошибка при поиске файлов: %v\n", err) if b.StopOnAnyError { return fmt.Errorf("ошибка при переборе файлов: %v", err) } @@ -196,36 +42,36 @@ func (b *Config) fileList(fileNames chan File) { info, err := os.Stat(path) if err != nil { errorCount++ - b.logf(LogLevelCritical, "Ошибка при получении информации о файле: %v\n", err) + b.logf(Error, "get file info error: %v", err) if b.StopOnAnyError { - return fmt.Errorf("ошибка при получении информации о файле: %v", err) + return fmt.Errorf("get file info error: %v", err) } } - file := File{ - SourcePath: path, - DestinationPath: filepath.ToSlash(path), - Info: info} + file := FileInfo{ + filePath: path, + ModificationTime: info.ModTime(), + fileSize: info.Size()} fileNames <- file } } return nil }) if err != nil { - b.logf(LogLevelCritical, "Ошибка при получении списка файлов: %v\n", err) + b.logf(Error, "get file list error: %v\n", err) } } else { allFilesAndDirs, err := filepath.Glob(filepath.Join(mask.Path, "*")) if err != nil { errorCount++ - b.logf(LogLevelCritical, "Ошибка при получении списка файлов: %v\n", err) + b.logf(Error, "get file list error: %v\n", err) } for _, fileOrDirPath := range allFilesAndDirs { info, err := os.Stat(fileOrDirPath) if err != nil { errorCount++ - b.logf(LogLevelCritical, "Ошибка при получении информации об объекте: %v\n", err) + b.logf(Error, "get object info error: %v\n", err) continue } @@ -233,15 +79,11 @@ func (b *Config) fileList(fileNames chan File) { continue } - //fileName := filepath.Base(fileOrDirPath) - fileName := fileOrDirPath // TODO: тестирование, маска должна накладываться на путь - - if isFilePathMatchPatterns(mask.FilePathPatternList, fileName) && isFileNameMatchPatterns(mask.FileNamePatternList, fileName) { - if !isFilePathMatchPatterns(b.GlobalExcludeFilePathPatterns, fileName) && !isFileNameMatchPatterns(b.GlobalExcludeFileNamePatterns, fileName) { - file := File{ - SourcePath: fileOrDirPath, - DestinationPath: filepath.ToSlash(fileOrDirPath), - Info: info} + if isFilePathMatchPatterns(mask.FilePathPatternList, fileOrDirPath) && isFileNameMatchPatterns(mask.FileNamePatternList, fileOrDirPath) { + if !isFilePathMatchPatterns(b.GlobalExcludeFilePathPatterns, fileOrDirPath) && !isFileNameMatchPatterns(b.GlobalExcludeFileNamePatterns, fileOrDirPath) { + file := FileInfo{ + filePath: fileOrDirPath, + ModificationTime: info.ModTime()} fileNames <- file } } @@ -250,14 +92,131 @@ func (b *Config) fileList(fileNames chan File) { } if errorCount > 0 { - b.logf(LogLevelCritical, "Ошибок: %d\n", errorCount) + b.logf(Error, "Ошибок: %d\n", errorCount) } close(fileNames) } +func (b *Config) FullBackup() error { + return b.doBackup(make(Index)) +} + +func (b *Config) IncrementalBackup() error { + index, err := b.index(false) + if err != nil { + return err + } + + return b.doBackup(index) +} + +func (b *Config) doBackup(index Index) error { + var suffix string + if len(index) == 0 { + suffix = "f" // Full backup - полный бекап + } else { + suffix = "i" // Инкрементальный бекап + } + + filePath := filepath.Join(filepath.Dir(b.filePath), b.FileName+"_"+time.Now().Local().Format(defaulFileNameTimeFormat)+suffix+defaultExt) + + var err error + filePath, err = filepath.Abs(filePath) + if err != nil { + return fmt.Errorf("ошибка при создании файла архива: %v", err) + } + b.logf(Info, "Creating new file %s...", filepath.Base(filePath)) + + resultArchiveFile, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("ошибка при создании файла архива: %v", err) + } + + compressor, err := zstd.NewWriter(resultArchiveFile, zstd.WithEncoderLevel(zstd.SpeedBestCompression)) + if err != nil { + return fmt.Errorf("ошибка при создании инициализации архиватора: %v", err) + } + + tarWriter := tar.NewWriter(compressor) + + b.log(Info, "Copying files...") + + addedFileIndex := make(Index) + + i := 0 // processed file count + addSize := int64(0) // added bytes + for k := range b.planChan(index) { + i++ + addSize += k.fileSize + err := b.addFileToTarWriter(k.filePath, tarWriter) + if err != nil { + b.logf(Error, "add file error %s: %v\n", k.filePath, err) + if b.StopOnAnyError { + compressor.Close() + resultArchiveFile.Close() + os.Remove(filePath) + return fmt.Errorf("add file error: %v", err) // TODO: организовать закрытие и удаление частичного файла + } + } + addedFileIndex.AddFile(k.filePath, filepath.Base(filePath), k.ModificationTime) + } + + err = tarWriter.Close() + if err != nil { + compressor.Close() + resultArchiveFile.Close() + os.Remove(filePath) + return fmt.Errorf("close tar file error: %v", err) + } + + err = compressor.Close() + if err != nil { + resultArchiveFile.Close() + os.Remove(filePath) + return fmt.Errorf("close compressor error: %v", err) + } + + err = resultArchiveFile.Close() + if err != nil { + return fmt.Errorf("close file error: %v", err) + } + + if i == 0 { + b.logf(Info, "No new or updated files found.") + } else if i == 1 { + b.logf(Info, "%d file added, %s.", i, sizeToApproxHuman(addSize)) + } else { + b.logf(Info, "%d files added, %s.", i, sizeToApproxHuman(addSize)) + } + + // если не было обновлений, удалить пустой файл + if i == 0 { + err = os.Remove(filePath) + if err != nil { + return err + } + } + + // если были обновления - обновить индексный файл + if i > 0 { + for fileName, fileHistory := range addedFileIndex { + for _, historyItem := range fileHistory { + index.AddFile(fileName, historyItem.ArchiveFileName, historyItem.ModificationTime) + } + } + + err = index.Save(filepath.Join(filepath.Dir(b.filePath), indexFileName)) + if err != nil { + return err + } + } + + return nil +} + func (b *Config) addFileToTarWriter(filePath string, tarWriter *tar.Writer) error { - b.logf(LogLevelDebug, "Добавление файла %s...\n", filePath) + b.logf(Debug, "Adding file %s...\n", filePath) file, err := os.Open(filePath) if err != nil { @@ -288,147 +247,3 @@ func (b *Config) addFileToTarWriter(filePath string, tarWriter *tar.Writer) erro return nil } - -// GetFileWithTime возвращает содержимое файла на указанную дату. -func (b *Config) GetFileWithTime(path string, t time.Time, w io.Writer) error { - index, err := b.index(true) - if err != nil { - return fmt.Errorf("ошибка при построении индекса: %v", err) - } - - file, err := index.GetFileWithTime(path, t) - if err != nil { - return fmt.Errorf("ошибка при получении информации из индекса: %v", err) - } - - f, err := os.Open(file.ArchiveFile) - if err != nil { - return fmt.Errorf("ошибка при чтении файла архива: %v", err) - } - defer f.Close() - - decoder, err := zstd.NewReader(f) - if err != nil { - return fmt.Errorf("ошибка при инициализации разархиватора: %v", err) - } - defer decoder.Close() - - tarReader := tar.NewReader(decoder) - - for { - header, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("ошибка при чтении tar-содержимого: %v", err) - } - if header.Name == path { - _, err = io.Copy(w, tarReader) - if err != nil { - return fmt.Errorf("ошибка при извлечении файла из tar-архива: %v", err) - } - return nil - } - } - - return nil -} - -// fullIndex - true = все файлы, false = только от последнего полного -func (b *Config) index(fullIndex bool) (*Index, error) { - b.logf(LogLevelInfo, "Построение индекса текущего архива из %s...", filepath.Dir(b.filePath)) - allFileMask := filepath.Join(filepath.Dir(b.filePath), b.FileName+"*"+defaultExt) - onlyFullBackupFileMask := filepath.Join(filepath.Dir(b.filePath), b.FileName+"*f"+defaultExt) - - // Get last full backup name - lastFullBackupFileName := "" - err := filepath.WalkDir(filepath.Dir(b.filePath), func(path string, info os.DirEntry, err error) error { - matched, err := filepath.Match(onlyFullBackupFileMask, path) - if err != nil { - return fmt.Errorf("filepath.WalkDir: %v", err) - } - if !matched { - return nil - } - - lastFullBackupFileName = path - - return nil - }) - if err != nil { - return nil, fmt.Errorf("filepath.WalkDir: %v", err) - } - - if !fullIndex { - b.logf(LogLevelDebug, "Отсчёт производится от %s.", filepath.Base(lastFullBackupFileName)) - } - - var files []string - err = filepath.WalkDir(filepath.Dir(b.filePath), func(path string, info os.DirEntry, err error) error { - matched, err := filepath.Match(allFileMask, path) - if err != nil { - return fmt.Errorf("filepath.Match: %v", err) - } - if matched && path >= lastFullBackupFileName { - if fullIndex || path >= lastFullBackupFileName { - files = append(files, path) - } - } - return nil - }) - if err != nil { - return nil, fmt.Errorf("filepath.Walk: %v", err) - } - - index := &Index{Files: make(map[string]FileHistory)} - - for i, file := range files { - if b.logger.MinimalLogLevel <= LogLevelProgress { - fmt.Fprintf(os.Stderr, "\r[%d%%] Чтение файла %s...", (100 * i / len(files)), filepath.Base(file)) - } - f, err := os.Open(file) - if err != nil { - return nil, fmt.Errorf("os.Open: %v", err) - } - defer f.Close() - - decoder, err := zstd.NewReader(f) - if err != nil { - return nil, fmt.Errorf("zstd.NewReader: %v", err) - } - - tarReader := tar.NewReader(decoder) - - for { - tarHeader, err := tarReader.Next() - if err != nil { - if err == io.EOF { - break - } else { - return nil, fmt.Errorf("ошибка при чтении списка файлов из архива %s: %v", file, err) - } - } - - b.logf(LogLevelDebug, "Найден файл %s...\n", tarHeader.Name) - - index.Files[tarHeader.Name] = append(index.Files[tarHeader.Name], File{ - DestinationPath: tarHeader.Name, - Info: tarHeader.FileInfo(), - ArchiveFile: file}) - } - decoder.Close() - } - if b.logger.MinimalLogLevel <= LogLevelProgress && len(files) > 0 { - fmt.Fprintf(os.Stderr, "\r[%d%%] Чтение файлов завершено.\n", 100) // TODO: нужна очистка строки, т.к. данная строка короче имени файлов - } - - return index, nil -} - -// Test осуществляет проверку архивов и возвращает первую встретившуюся ошибку -func (b *Config) Test() error { - _, err := b.index(true) // TODO: улучшить реализацию - - return err -} diff --git a/config.go b/config.go index e14cfef..93c817f 100644 --- a/config.go +++ b/config.go @@ -2,18 +2,16 @@ package main import ( "fmt" - "log" "os" "path/filepath" + "strings" "time" + "github.com/tidwall/match" + "github.com/BurntSushi/toml" ) -type Backuper struct { - Config *Config -} - type Config struct { // Имя файлов бекапа без расширения FileName string @@ -27,19 +25,13 @@ type Config struct { // Маски путей для исключения GlobalExcludeFilePathPatterns []string - // Логгер - Logger LoggerConfig - logger Logger - // Останавливать обработку при любой ошибке StopOnAnyError bool - filePath string -} + // Уровень логирования + LogLevel LogLevel -type LoggerConfig struct { - Name string - MinimalLogLevel LogLevel + filePath string } func (config *Config) Save(filepath string) error { @@ -71,8 +63,6 @@ func LoadConfig(filePath string) (*Config, error) { return nil, fmt.Errorf("decode file: %v", err) } - config.logger = Logger{logger: log.New(os.Stderr, "", 0), MinimalLogLevel: config.Logger.MinimalLogLevel} - for _, mask := range config.Patterns { if len(mask.FilePathPatternList) == 0 { mask.FilePathPatternList = []string{"*"} @@ -89,9 +79,9 @@ func LoadConfig(filePath string) (*Config, error) { } // planChan возвращает канал, в который засылает список файлов для добавления/обновления -func (b *Config) planChan(index *Index) chan File { - allFilesChan := make(chan File, 64) // TODO: размер очереди? - addFilesChan := make(chan File, 64) // TODO: размер очереди? +func (b *Config) planChan(index Index) chan FileInfo { + allFilesChan := make(chan FileInfo, 64) // TODO: размер очереди? + addFilesChan := make(chan FileInfo, 64) // TODO: размер очереди? go func() { b.fileList(allFilesChan) }() @@ -103,13 +93,13 @@ func (b *Config) planChan(index *Index) chan File { continue } - existingFile, exists := index.Files[file.DestinationPath] + existingFile, exists := index[file.filePath] if !exists { addFilesChan <- file continue } - if file.Info.ModTime().Truncate(time.Second).After(existingFile.Latest().Info.ModTime().Truncate(time.Second)) { + if file.ModificationTime.Truncate(time.Second).After(existingFile.Latest().ModificationTime.Truncate(time.Second)) { addFilesChan <- file continue } @@ -120,3 +110,23 @@ func (b *Config) planChan(index *Index) chan File { return addFilesChan } + +// FindAll возвращает индекс файлов, совпавших по маске +func (b *Config) FindAll(pattern string) (Index, error) { + index, err := b.index(true) + if err != nil { + return nil, fmt.Errorf("index: %v", err) + } + + result := make(Index) + + for path, info := range index { + if match.Match(strings.ToLower(path), pattern) { + for _, historyItem := range info { + result.AddFile(path, historyItem.ArchiveFileName, historyItem.ModificationTime) + } + } + } + + return result, nil +} diff --git a/consts.go b/consts.go index 9a25305..9528785 100644 --- a/consts.go +++ b/consts.go @@ -9,4 +9,7 @@ const ( // Формат времени для файлов defaulFileNameTimeFormat = "2006-01-02_15-04-05" + + // + indexFileName = "index.csv.zst" ) diff --git a/examples/config.toml b/examples/config.toml deleted file mode 100644 index c2e158c..0000000 --- a/examples/config.toml +++ /dev/null @@ -1,10 +0,0 @@ -FileName = "backup-go-projects" -StopOnAnyError = false - -[Logger] -MinimalLogLevel = 1 - -[[Patterns]] -Path = "/home/user/go/src" -FileNamePatternList = ["*.go", "go.mod", "go.sum"] -Recursive = true diff --git a/extractionplan.go b/extractionplan.go index f5a88bb..f22bca0 100644 --- a/extractionplan.go +++ b/extractionplan.go @@ -4,6 +4,7 @@ import ( "archive/tar" "fmt" "io" + "log" "os" "path/filepath" "time" @@ -13,8 +14,8 @@ import ( type ExtractionPlan map[string][]string // filepath - array of internal paths -func (b *Backuper) extractionPlan(mask string, t time.Time) (ExtractionPlan, error) { - index, err := b.Config.index(true) +func (b *Config) extractionPlan(mask string, t time.Time) (ExtractionPlan, error) { + index, err := b.index(true) if err != nil { return nil, fmt.Errorf("extractionPlan: %v", err) } @@ -27,15 +28,16 @@ func (b *Backuper) extractionPlan(mask string, t time.Time) (ExtractionPlan, err plan := make(ExtractionPlan) for _, file := range files { - plan[file.ArchiveFile] = append(plan[file.ArchiveFile], file.DestinationPath) + plan[file.ArchiveFileName] = append(plan[file.ArchiveFileName], file.filePath) } return plan, nil } -func (b *Backuper) extract(extractionPlan ExtractionPlan, toDir string) error { +func (b *Config) extract(extractionPlan ExtractionPlan, toDir string) error { for archiveFile, files := range extractionPlan { - f, err := os.Open(archiveFile) + log.Printf("Восстановление из архивного файла %s...", filepath.Join(filepath.Dir(b.filePath), archiveFile)) + f, err := os.Open(filepath.Join(filepath.Dir(b.filePath), archiveFile)) if err != nil { return fmt.Errorf("ошибка при чтении файла архива: %v", err) } @@ -58,6 +60,7 @@ func (b *Backuper) extract(extractionPlan ExtractionPlan, toDir string) error { return fmt.Errorf("ошибка при чтении tar-содержимого: %v", err) } if inArr, i := stringIn(header.Name, files); inArr { + log.Printf("Восстановление файла %s...", header.Name) resultFilePath := filepath.Join(toDir, clean(header.Name)) os.MkdirAll(filepath.Dir(resultFilePath), 0644) f, err := os.Create(resultFilePath) diff --git a/file.go b/file.go deleted file mode 100644 index 7a30958..0000000 --- a/file.go +++ /dev/null @@ -1,19 +0,0 @@ -package main - -import ( - "os" -) - -type File struct { - // Исходный путь - SourcePath string - - // Путь в архиве - DestinationPath string - - // Путь к архиву - ArchiveFile string - - // Информация о файле - Info os.FileInfo -} diff --git a/filehistory.go b/filehistory.go index 2dac25d..e02c0fb 100644 --- a/filehistory.go +++ b/filehistory.go @@ -1,14 +1,14 @@ package main // FileHistory содержит историю изменения файла -type FileHistory []File +type FileHistory []FileInfo // Latest возвращает информацию о последней версии файла -func (fileHistory FileHistory) Latest() File { +func (fileHistory FileHistory) Latest() FileInfo { file := fileHistory[0] for i := 1; i < len(fileHistory); i++ { - if fileHistory[i].Info.ModTime().After(file.Info.ModTime()) { + if fileHistory[i].ModificationTime.After(file.ModificationTime) { file = fileHistory[i] } } @@ -24,5 +24,5 @@ func (fileHistory FileHistory) Swap(i, j int) { } func (fileHistory FileHistory) Less(i, j int) bool { - return fileHistory[i].Info.ModTime().Before(fileHistory[j].Info.ModTime()) + return fileHistory[i].ModificationTime.Before(fileHistory[j].ModificationTime) } diff --git a/go.mod b/go.mod index 9327a7f..dc75287 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,7 @@ go 1.20 require ( github.com/BurntSushi/toml v1.2.1 - github.com/klauspost/compress v1.16.0 - github.com/nxshock/progressmessage v0.0.0-20210730035634-63cec26e1e83 + github.com/klauspost/compress v1.16.3 github.com/stretchr/testify v1.8.2 github.com/tidwall/match v1.1.1 ) diff --git a/go.sum b/go.sum index 9c4658c..cc9c001 100644 --- a/go.sum +++ b/go.sum @@ -3,10 +3,8 @@ github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= -github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/nxshock/progressmessage v0.0.0-20210730035634-63cec26e1e83 h1:WjqT/HWUQp14LpSodOU0AX1Gd2N2AsasmDQEXQFdhvU= -github.com/nxshock/progressmessage v0.0.0-20210730035634-63cec26e1e83/go.mod h1:QZBXJ8qLaGwgOeBsNLTV9++wQONNSlDz9y55S5UJ+EM= +github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= +github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/index.go b/index.go index dc48b6d..9ce9947 100644 --- a/index.go +++ b/index.go @@ -1,84 +1,264 @@ package main import ( - "bytes" - "errors" + "archive/tar" + "encoding/csv" "fmt" + "io" + "os" + "path/filepath" "sort" + "strconv" "time" + + "github.com/klauspost/compress/zstd" ) -type Index struct { - Files map[string]FileHistory // Путь - +type FileInfo struct { + ArchiveFileName string + ModificationTime time.Time + + filePath string + fileSize int64 } -func (fileHistory FileHistory) String() string { - var b bytes.Buffer +type Index map[string]FileHistory - b.WriteString("[") - for i := 0; i < len(fileHistory); i++ { - if i > 0 { - fmt.Fprintf(&b, ", %s", fileHistory[i].Info.ModTime().Local().Format(defaultTimeFormat)) - } else { - fmt.Fprintf(&b, "%s", fileHistory[i].Info.ModTime().Local().Format(defaultTimeFormat)) +func (index Index) AddFile(fileName string, archiveFileName string, modTime time.Time) { + fileInfo := FileInfo{ArchiveFileName: archiveFileName, ModificationTime: modTime} + + if eFileInfo, exists := index[fileName]; exists { + index[fileName] = append(eFileInfo, fileInfo) + return + } + + index[fileName] = FileHistory{fileInfo} +} + +func (index Index) ViewFileVersions(w io.Writer) error { + for filePath, fileHistory := range index { + _, err := fmt.Fprintf(w, "%s\n", filePath) + if err != nil { + return err } - } - b.WriteString("]") - return b.String() -} + for _, v := range fileHistory { + _, err := fmt.Fprintf(w, "\t%s %s\n", v.ModificationTime.Format(defaultTimeFormat), v.ArchiveFileName) + if err != nil { + return err + } -func (index *Index) ItemCount() int { - return len(index.Files) -} - -func (index *Index) GetFileWithTime(path string, t time.Time) (File, error) { - files, exists := index.Files[path] - if !exists { - return File{}, errors.New("not exists") - } - - file := files[0] - - for i := 1; i < len(files); i++ { - if files[i].Info.ModTime().Before(t) && files[i].Info.ModTime().Sub(t) > file.Info.ModTime().Sub(t) { // Больше, т.к. отрицательные числа - file = files[i] } } - return file, nil + return nil } -func (index *Index) String() string { - var b bytes.Buffer - - for path, info := range index.Files { - sort.Sort(info) - - fmt.Fprintf(&b, "%s %s\n", path, info) +func (index Index) Save(fileName string) error { + f, err := os.Create(fileName) + if err != nil { + return err } - if b.Len() > 0 { - b.Truncate(b.Len() - 1) + enc, err := zstd.NewWriter(f, zstd.WithEncoderLevel(zstd.SpeedBestCompression)) + if err != nil { + f.Close() // TODO: удалить частичный файл? + return err } - return b.String() + files := make([]string, 0, len(index)) + for fileName := range index { + files = append(files, fileName) + } + + // Sort file list for better compression + sort.Strings(files) + + csvWriter := csv.NewWriter(enc) + csvWriter.Comma = ';' + + for _, fileName := range files { + for _, historyItem := range index[fileName] { + err := csvWriter.Write([]string{fileName, historyItem.ArchiveFileName, strconv.Itoa(int(historyItem.ModificationTime.Unix()))}) + if err != nil { + enc.Close() + f.Close() // TODO: удалить частичный файл? + return err + } + } + } + + csvWriter.Flush() + if err := csvWriter.Error(); err != nil { + enc.Close() + f.Close() // TODO: удалить частичный файл? + return err + } + + err = enc.Close() + if err != nil { + f.Close() // TODO: удалить частичный файл? + return err + } + + err = f.Close() + if err != nil { + return err + } + + return err } -func (index *Index) GetFilesLocation(mask string, t time.Time) ([]File, error) { - var files2 []File +func (b *Config) index(fullIndex bool) (Index, error) { + index, err := b.indexFromFile() + if err == nil { + b.logf(Debug, "Index file contains %d of files.", len(index)) + return index, nil + } + b.logf(Error, "index file read error: %v", err) - for fileName := range index.Files { + return b.indexFromDisk(fullIndex) +} + +func (b *Config) indexFromFile() (Index, error) { + index := make(Index) + + indexFileName := filepath.Join(filepath.Dir(b.filePath), indexFileName) + + f, err := os.Open(indexFileName) + if err != nil { + return nil, err + } + defer f.Close() + + dec, err := zstd.NewReader(f) + if err != nil { + return nil, err + } + defer dec.Close() + + csvReader := csv.NewReader(dec) + csvReader.Comma = ';' + csvReader.FieldsPerRecord = 3 + for { + data, err := csvReader.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + unixTime, err := strconv.Atoi(data[2]) + if err != nil { + return nil, err + } + + index.AddFile(data[0], data[1], time.Unix(int64(unixTime), 0).Local()) + } + + return index, nil +} + +func (b *Config) indexFromDisk(fullIndex bool) (Index, error) { + b.logf(Info, "Rebuilding index from %s...", filepath.Dir(b.filePath)) + allFileMask := filepath.Join(filepath.Dir(b.filePath), b.FileName+"*"+defaultExt) + onlyFullBackupFileMask := filepath.Join(filepath.Dir(b.filePath), b.FileName+"*f"+defaultExt) + + // Get last full backup name + lastFullBackupFileName := "" + err := filepath.WalkDir(filepath.Dir(b.filePath), func(path string, info os.DirEntry, err error) error { + matched, err := filepath.Match(onlyFullBackupFileMask, path) + if err != nil { + return fmt.Errorf("filepath.WalkDir: %v", err) + } + if !matched { + return nil + } + + lastFullBackupFileName = path + + return nil + }) + if err != nil { + return nil, fmt.Errorf("filepath.WalkDir: %v", err) + } + + if !fullIndex { + b.logf(Debug, "Diff will be calculated from %s.", filepath.Base(lastFullBackupFileName)) + } + + var files []string + err = filepath.WalkDir(filepath.Dir(b.filePath), func(path string, info os.DirEntry, err error) error { + matched, err := filepath.Match(allFileMask, path) + if err != nil { + return fmt.Errorf("filepath.Match: %v", err) + } + + if matched && (fullIndex || path >= lastFullBackupFileName) { + files = append(files, path) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("filepath.Walk: %v", err) + } + + index := make(Index) + + for i, file := range files { + b.logf(Debug, "[%3d%%] Reading file %s...", (100 * i / len(files)), filepath.Base(file)) + f, err := os.Open(file) + if err != nil { + return nil, fmt.Errorf("os.Open: %v", err) + } + defer f.Close() + + decoder, err := zstd.NewReader(f) + if err != nil { + return nil, fmt.Errorf("zstd.NewReader: %v", err) + } + + tarReader := tar.NewReader(decoder) + + for { + tarHeader, err := tarReader.Next() + if err != nil { + if err == io.EOF { + break + } else { + return nil, fmt.Errorf("ошибка при чтении списка файлов из архива %s: %v", file, err) + } + } + + index[tarHeader.Name] = append(index[tarHeader.Name], FileInfo{ + filePath: tarHeader.Name, + ModificationTime: tarHeader.FileInfo().ModTime(), + fileSize: tarHeader.FileInfo().Size(), + ArchiveFileName: filepath.Base(file)}) + } + decoder.Close() + } + + return index, nil +} + +func (index Index) GetFilesLocation(mask string, t time.Time) ([]FileInfo, error) { + var files2 []FileInfo + + for fileName := range index { if isFilePathMatchPatterns([]string{mask}, fileName) { - files := index.Files[fileName] + files := index[fileName] file := files[0] for i := 1; i < len(files); i++ { - if files[i].Info.ModTime().Before(t) && files[i].Info.ModTime().Sub(t) > file.Info.ModTime().Sub(t) { // Больше, т.к. отрицательные числа + if files[i].ModificationTime.Before(t) && files[i].ModificationTime.Sub(t) > file.ModificationTime.Sub(t) { // Больше, т.к. отрицательные числа file = files[i] } } + file.filePath = fileName + files2 = append(files2, file) } } diff --git a/index_test.go b/index_test.go new file mode 100644 index 0000000..cd10bfb --- /dev/null +++ b/index_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestIndexAddFile(t *testing.T) { + index := make(Index) + assert.Len(t, index, 0) + + fileName := "file" + archiveFileName := "archive" + modTime := time.Now() + + index.AddFile(fileName, archiveFileName, modTime) + assert.Len(t, index, 1) + assert.Len(t, index[fileName], 1) + + expectedFileInfo := FileInfo{ + ArchiveFileName: archiveFileName, + ModificationTime: modTime} + + assert.Equal(t, expectedFileInfo, index[fileName][0]) +} diff --git a/log.go b/log.go deleted file mode 100644 index a9f92db..0000000 --- a/log.go +++ /dev/null @@ -1,60 +0,0 @@ -package main - -import ( - "log" -) - -type LogLevel int - -const ( - LogLevelDebug = iota // 0 - LogLevelProgress // 1 - LogLevelInfo // 2 - LogLevelWarning // 3 - LogLevelCritical // 4 -) - -type Logger struct { - logger *log.Logger - MinimalLogLevel LogLevel -} - -func (logger *Logger) log(logLevel LogLevel, a ...interface{}) { - if logLevel < logger.MinimalLogLevel { - return - } - - logger.logger.Print(a...) -} - -func (logger *Logger) logf(logLevel LogLevel, s string, a ...interface{}) { - if logLevel < logger.MinimalLogLevel { - return - } - - logger.logger.Printf(s, a...) -} - -func (logger *Logger) logln(logLevel LogLevel, a ...interface{}) { - if logLevel < logger.MinimalLogLevel { - return - } - - logger.logger.Println(a...) -} - -func (logger *Logger) fatalln(a ...interface{}) { - logger.logger.Fatalln(a...) -} - -func (b *Config) log(logLevel LogLevel, a ...interface{}) { - b.logger.log(logLevel, a...) -} - -func (b *Config) logf(logLevel LogLevel, s string, a ...interface{}) { - b.logger.logf(logLevel, s, a...) -} - -func (b *Config) fatalln(a ...interface{}) { - b.logger.fatalln(a...) -} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..1acc0c4 --- /dev/null +++ b/logger.go @@ -0,0 +1,32 @@ +package main + +import "log" + +type LogLevel int + +const ( + Debug LogLevel = iota + Info + Warn + Error +) + +func (b *Config) log(l LogLevel, a ...interface{}) { + if l < b.LogLevel { + return + } + + log.Print(a...) +} + +func (b *Config) logf(l LogLevel, s string, a ...interface{}) { + if l < b.LogLevel { + return + } + + log.Printf(s, a...) +} + +func (b *Config) fatalln(a ...interface{}) { + log.Fatalln(a...) +} diff --git a/main.go b/main.go index ab0fcc7..2b9a74f 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "log" "os" "path/filepath" @@ -13,22 +12,12 @@ func init() { } func main() { - if len(os.Args) <= 1 { + if len(os.Args) < 2 { printUsage() os.Exit(1) } switch os.Args[1] { - case "i": - config, err := LoadConfig(os.Args[2]) - if err != nil { - log.Fatalln("ошибка при чтении конфига:", err) - } - - err = config.IncrementalBackup() - if err != nil { - config.fatalln("ошибка инкрементального бекапа:", err) - } case "f": config, err := LoadConfig(os.Args[2]) if err != nil { @@ -37,47 +26,62 @@ func main() { err = config.FullBackup() if err != nil { - config.fatalln("ошибка полного бекапа:", err) + log.Fatalln(err) + } + case "i": + config, err := LoadConfig(os.Args[2]) + if err != nil { + log.Fatalln(err) + } + + err = config.IncrementalBackup() + if err != nil { + log.Fatalln(err) } case "s": config, err := LoadConfig(os.Args[2]) if err != nil { - log.Fatalln("ошибка при чтении конфига:", err) + log.Fatalln("read config error:", err) } - config.logf(LogLevelProgress, "Поиск файлов по маске %s...\n", os.Args[3]) - config.logf(LogLevelProgress, "Создание индекса...\n") idx, err := config.FindAll(os.Args[3]) if err != nil { - config.fatalln("ошибка поиска:", err) + config.fatalln("search error:", err) } - config.logf(LogLevelProgress, "Создание индекса завершено.\n") - fmt.Println(idx) + idx.ViewFileVersions(os.Stdout) case "r": config, err := LoadConfig(os.Args[2]) if err != nil { log.Fatalln(err) } - /*idx, err := config.FindAll(os.Args[3]) - if err != nil { - config.fatalln(err) - }*/ - - t, err := time.Parse("02.01.2006 15:04", os.Args[4]) - if err != nil { - config.fatalln("ошибка парсинга времени:", err) + var t time.Time + switch len(os.Args[4]) { + case len("02.01.2006"): + t, err = time.Parse("02.01.2006 15:04", os.Args[4]) + if err != nil { + config.fatalln("time parse error:", err) + } + case len("02.01.2006 15:04"): + t, err = time.Parse("02.01.2006 15:04", os.Args[4]) + if err != nil { + config.fatalln("time parse error:", err) + } + case len("02.01.2006 15:04:05"): + t, err = time.Parse("02.01.2006 15:04:05", os.Args[4]) + if err != nil { + config.fatalln("time parse error:", err) + } + default: + config.fatalln(`wrong time format, must be ["DD.MM.YYYY", "DD.MM.YYYY hh:mm", "DD.MM.YYYY hh:mm:ss"]`) } - // - - b := &Backuper{Config: config} - plan, err := b.extractionPlan(os.Args[3], t) + plan, err := config.extractionPlan(os.Args[3], t) if err != nil { log.Fatalln(err) } - err = b.extract(plan, os.Args[5]) + err = config.extract(plan, os.Args[5]) if err != nil { log.Fatalln(err) } @@ -86,11 +90,11 @@ func main() { if err != nil { log.Fatalln(err) } - err = config.Test() + + _, err = config.index(true) if err != nil { - log.Fatalln("ошибка тестирования:", err) + log.Fatalln(err) } - log.Println("Ошибок нет.") default: printUsage() } @@ -99,10 +103,10 @@ func main() { func printUsage() { bin := filepath.Base(os.Args[0]) - fmt.Fprintf(os.Stderr, "Usage:\n") - fmt.Fprintf(os.Stderr, "%s i - do incremental backup\n", bin) - fmt.Fprintf(os.Stderr, "%s f - do full backup\n", bin) - fmt.Fprintf(os.Stderr, "%s s - search file(s) in backup\n", bin) - fmt.Fprintf(os.Stderr, "%s r - recover file(s) from backup\n", bin) - fmt.Fprintf(os.Stderr, "%s t - test archive for errors\n", bin) + log.Print("Usage:\n") + log.Printf("%s i - do incremental backup\n", bin) + log.Printf("%s f - do full backup\n", bin) + log.Printf("%s s - search file(s) in backup\n", bin) + log.Printf("%s r - recover file(s) from backup\n", bin) + log.Printf("%s t - test archive for errors\n", bin) } diff --git a/pattern.go b/pattern.go new file mode 100644 index 0000000..7678f45 --- /dev/null +++ b/pattern.go @@ -0,0 +1,15 @@ +package main + +type Pattern struct { + // Root directory + Path string + + // List of file name patterns + FileNamePatternList []string + + // List of file path patterns + FilePathPatternList []string + + // Recursive search + Recursive bool +} diff --git a/utils.go b/utils.go index 66b403d..f0ef4d8 100644 --- a/utils.go +++ b/utils.go @@ -8,11 +8,6 @@ import ( "github.com/tidwall/match" ) -/*func winPathToRelative(s string) string { - ss := strings.Split(s, string(os.PathSeparator)) - return filepath.Join(ss[1:]...) -}*/ - func sizeToApproxHuman(s int64) string { t := []struct { Name string