From 1f0f94e103111d87fdc3442002a794a77ec5ed2a Mon Sep 17 00:00:00 2001 From: nxshock Date: Sat, 11 Mar 2023 14:13:35 +0500 Subject: [PATCH] Initial commit --- .gitattributes | 2 + .gitignore | 21 +++ LICENSE | 21 +++ README.md | 2 + backuper.go | 415 +++++++++++++++++++++++++++++++++++++++++++ config.go | 113 ++++++++++++ consts.go | 9 + examples/config.toml | 10 ++ extractionplan.go | 85 +++++++++ file.go | 19 ++ filehistory.go | 28 +++ go.mod | 16 ++ go.sum | 23 +++ index.go | 87 +++++++++ log.go | 60 +++++++ main.go | 108 +++++++++++ todo.go | 4 + utils.go | 55 ++++++ utils_test.go | 12 ++ 19 files changed, 1090 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backuper.go create mode 100644 config.go create mode 100644 consts.go create mode 100644 examples/config.toml create mode 100644 extractionplan.go create mode 100644 file.go create mode 100644 filehistory.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 index.go create mode 100644 log.go create mode 100644 main.go create mode 100644 todo.go create mode 100644 utils.go create mode 100644 utils_test.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b735ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a626811 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 nxshock + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e91e1fb --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# backuper + diff --git a/backuper.go b/backuper.go new file mode 100644 index 0000000..566ed88 --- /dev/null +++ b/backuper.go @@ -0,0 +1,415 @@ +package main + +import ( + "archive/tar" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/klauspost/compress/zstd" + "github.com/nxshock/progressmessage" +) + +type Mask struct { + Path string + + // Маски имени файла + MaskList []string + + // Вкючать файлы в покаталогах + Recursive bool +} + +// FindAll возвращает индекс файлов, совпавших по маске +func (b *Config) FindAll(mask string) (*Index, error) { + b.logf(LogLevelDebug, "Поиск маски %s...", mask) + index, err := b.index() + 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() + 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("2006-01-02_15-04-05")+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) { + errorCount := 0 + + for _, v := range b.Masks { + if v.Recursive { + err := filepath.WalkDir(v.Path, func(path string, d fs.DirEntry, err error) error { + if err != nil { + errorCount++ + b.logf(LogLevelCritical, "Ошибка при поиске файлов: %v\n", err) + if b.StopOnAnyError { + return fmt.Errorf("ошибка при переборе файлов: %v", err) + } + return nil + } + + if d.IsDir() { + return nil + } + + if !v.Recursive && filepath.Dir(path) != v.Path { + return nil + } + + // fileName := filepath.Base(path) + fileName := path // TODO: тестирование - маска действует на весь путь + + if isFileMatchMasks(v.MaskList, fileName) { + if !isFileMatchMasks(b.GlobalExcludeMasks, fileName) { + info, err := os.Stat(path) + if err != nil { + errorCount++ + b.logf(LogLevelCritical, "Ошибка при получении информации о файле: %v\n", err) + if b.StopOnAnyError { + return fmt.Errorf("ошибка при получении информации о файле: %v", err) + } + } + + file := File{ + SourcePath: path, + DestinationPath: filepath.ToSlash(path), + Info: info} + fileNames <- file + } + } + return nil + }) + if err != nil { + b.logf(LogLevelCritical, "Ошибка при получении списка файлов: %v\n", err) + } + } else { + allFilesAndDirs, err := filepath.Glob(filepath.Join(v.Path, "*")) + if err != nil { + errorCount++ + b.logf(LogLevelCritical, "Ошибка при получении списка файлов: %v\n", err) + } + + for _, fileOrDirPath := range allFilesAndDirs { + info, err := os.Stat(fileOrDirPath) + if err != nil { + errorCount++ + b.logf(LogLevelCritical, "Ошибка при получении информации об объекте: %v\n", err) + continue + } + + if info.IsDir() { + continue + } + + //fileName := filepath.Base(fileOrDirPath) + fileName := fileOrDirPath // TODO: тестирование, маска должна накладываться на путь + + if isFileMatchMasks(v.MaskList, fileName) { + if !isFileMatchMasks(b.GlobalExcludeMasks, fileName) { + file := File{ + SourcePath: fileOrDirPath, + DestinationPath: filepath.ToSlash(fileOrDirPath), + Info: info} + fileNames <- file + } + } + } + } + } + + if errorCount > 0 { + b.logf(LogLevelCritical, "Ошибок: %d\n", errorCount) + } + + close(fileNames) +} + +func isFileMatchMasks(masks []string, fileName string) bool { + for _, mask := range masks { + if match, _ := filepath.Match(filepath.ToSlash(mask), filepath.ToSlash(fileName)); match { + return true + } + } + + return false +} + +func (b *Config) addFileToTarWriter(filePath string, tarWriter *tar.Writer) error { + b.logf(LogLevelDebug, "Добавление файла %s...\n", filePath) + + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("Could not open file '%s', got error '%s'", filePath, err.Error()) + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("Could not get stat for file '%s', got error '%s'", filePath, err.Error()) + } + + header := &tar.Header{ + Format: tar.FormatGNU, + Name: filepath.ToSlash(filePath), + Size: stat.Size(), + ModTime: stat.ModTime()} + + err = tarWriter.WriteHeader(header) + if err != nil { + return fmt.Errorf("Could not write header for file '%s', got error '%s'", filePath, err.Error()) + } + + _, err = io.Copy(tarWriter, file) + if err != nil { + return fmt.Errorf("Could not copy the file '%s' data to the tarball, got error '%s'", filePath, err.Error()) + } + + return nil +} + +// GetFileWithTime возвращает содержимое файла на указанную дату. +func (b *Config) GetFileWithTime(path string, t time.Time, w io.Writer) error { + index, err := b.index() + 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 +} + +func (b *Config) index() (*Index, error) { + b.logf(LogLevelInfo, "Построение индекса текущего архива из %s...", filepath.Dir(b.filePath)) + fileMask := filepath.Join(filepath.Dir(b.filePath), b.FileName+"*"+defaultExt) + + var files []string + err := filepath.Walk(filepath.Dir(b.filePath), func(path string, info os.FileInfo, err error) error { + matched, err := filepath.Match(fileMask, path) + if err != nil { + return fmt.Errorf("filepath.Match: %v", err) + } + if matched { + 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() // TODO: улучшить реализацию + + return err +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..1669630 --- /dev/null +++ b/config.go @@ -0,0 +1,113 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/BurntSushi/toml" +) + +type Backuper struct { + Config *Config +} + +type Config struct { + // Имя файлов бекапа без расширения + FileName string + + // Маски файлов для включения в архив + Masks []Mask + + // Маски файлов/путей для исключения из всех масок + GlobalExcludeMasks []string + + // Логгер + Logger LoggerConfig + logger Logger + + // Останавливать обработку при любой ошибке + StopOnAnyError bool + + filePath string +} + +type LoggerConfig struct { + Name string + MinimalLogLevel LogLevel +} + +func (config *Config) Save(filepath string) error { + f, err := os.Create(filepath) + if err != nil { + return err + } + + err = toml.NewEncoder(f).Encode(config) + if err != nil { + f.Close() + return err + } + + return f.Close() +} + +func LoadConfig(filePath string) (*Config, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("open file: %v", err) + } + defer f.Close() + + var config Config + + _, err = toml.DecodeReader(f, &config) + if err != nil { + return nil, fmt.Errorf("decode file: %v", err) + } + + config.logger = Logger{logger: log.New(os.Stderr, "", 0), MinimalLogLevel: config.Logger.MinimalLogLevel} + + configFilePath, err := filepath.Abs(filePath) + if err != nil { + return nil, err + } + config.filePath = configFilePath + + return &config, nil +} + +// planChan возвращает канал, в который засылает список файлов для добавления/обновления +func (b *Config) planChan(index *Index) chan File { + allFilesChan := make(chan File, 64) // TODO: размер очереди? + addFilesChan := make(chan File, 64) // TODO: размер очереди? + + go func() { b.fileList(allFilesChan) }() + + go func() { + for file := range allFilesChan { + // Если индекса нет, добавляются все файлы + if index == nil { + addFilesChan <- file + continue + } + + existingFile, exists := index.Files[file.DestinationPath] + if !exists { + addFilesChan <- file + continue + } + + if file.Info.ModTime().Truncate(time.Second).After(existingFile.Latest().Info.ModTime().Truncate(time.Second)) { + addFilesChan <- file + continue + } + + } + close(addFilesChan) + }() + + return addFilesChan +} diff --git a/consts.go b/consts.go new file mode 100644 index 0000000..9a2a8c9 --- /dev/null +++ b/consts.go @@ -0,0 +1,9 @@ +package main + +const ( + // Расширение для файлов архивов + defaultExt = ".tar.zst" + + // Формат времени для сообщений + defaultTimeFormat = "02.01.06 15:04" +) diff --git a/examples/config.toml b/examples/config.toml new file mode 100644 index 0000000..bd09ddb --- /dev/null +++ b/examples/config.toml @@ -0,0 +1,10 @@ +FileName = "backup-go-projects" +StopOnAnyError = false + +[Logger] +MinimalLogLevel = 1 + +[[Masks]] +Path = "/home/user/go/src" +MaskList = ["*.go", "*/go.mod", "*/go.sum"] +Recursive = true diff --git a/extractionplan.go b/extractionplan.go new file mode 100644 index 0000000..68940cf --- /dev/null +++ b/extractionplan.go @@ -0,0 +1,85 @@ +package main + +import ( + "archive/tar" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/klauspost/compress/zstd" +) + +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() + if err != nil { + return nil, fmt.Errorf("extractionPlan: %v", err) + } + + files, err := index.GetFilesLocation(mask, t) + if err != nil { + return nil, fmt.Errorf("extractionPlan: %v", err) + } + + plan := make(ExtractionPlan) + + for _, file := range files { + plan[file.ArchiveFile] = append(plan[file.ArchiveFile], file.DestinationPath) + } + + return plan, nil +} + +func (b *Backuper) extract(extractionPlan ExtractionPlan, toDir string) error { + for archiveFile, files := range extractionPlan { + f, err := os.Open(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 inArr, i := stringIn(header.Name, files); inArr { + resultFilePath := filepath.Join(toDir, clean(header.Name)) + os.MkdirAll(filepath.Dir(resultFilePath), 0644) + f, err := os.Create(resultFilePath) + if err != nil { + return err + } + + _, err = io.Copy(f, tarReader) + if err != nil { + f.Close() // TODO: удалять частичный файл? + return fmt.Errorf("ошибка при извлечении файла из tar-архива: %v", err) + } + + f.Close() + if err != nil { + return err + } + + files[i] = files[len(files)-1] + files = files[:len(files)-1] + } + } + } + return nil +} diff --git a/file.go b/file.go new file mode 100644 index 0000000..7a30958 --- /dev/null +++ b/file.go @@ -0,0 +1,19 @@ +package main + +import ( + "os" +) + +type File struct { + // Исходный путь + SourcePath string + + // Путь в архиве + DestinationPath string + + // Путь к архиву + ArchiveFile string + + // Информация о файле + Info os.FileInfo +} diff --git a/filehistory.go b/filehistory.go new file mode 100644 index 0000000..2dac25d --- /dev/null +++ b/filehistory.go @@ -0,0 +1,28 @@ +package main + +// FileHistory содержит историю изменения файла +type FileHistory []File + +// Latest возвращает информацию о последней версии файла +func (fileHistory FileHistory) Latest() File { + file := fileHistory[0] + + for i := 1; i < len(fileHistory); i++ { + if fileHistory[i].Info.ModTime().After(file.Info.ModTime()) { + file = fileHistory[i] + } + } + return file +} + +func (fileHistory FileHistory) Len() int { + return len(fileHistory) +} + +func (fileHistory FileHistory) Swap(i, j int) { + fileHistory[i], fileHistory[j] = fileHistory[j], fileHistory[i] +} + +func (fileHistory FileHistory) Less(i, j int) bool { + return fileHistory[i].Info.ModTime().Before(fileHistory[j].Info.ModTime()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a12ec01 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/nxshock/backuper + +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/stretchr/testify v1.8.2 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1881fba --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +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/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= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/index.go b/index.go new file mode 100644 index 0000000..54f5d9e --- /dev/null +++ b/index.go @@ -0,0 +1,87 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "sort" + "time" +) + +type Index struct { + Files map[string]FileHistory // Путь - +} + +func (fileHistory FileHistory) String() string { + var b bytes.Buffer + + 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)) + } + } + b.WriteString("]") + + return b.String() +} + +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 +} + +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) + } + + if b.Len() > 0 { + b.Truncate(b.Len() - 1) + } + + return b.String() +} + +func (index *Index) GetFilesLocation(mask string, t time.Time) ([]File, error) { + var files2 []File + + for fileName := range index.Files { + if isFileMatchMasks([]string{mask}, fileName) { + files := index.Files[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) { // Больше, т.к. отрицательные числа + file = files[i] + } + } + + files2 = append(files2, file) + } + } + + return files2, nil +} diff --git a/log.go b/log.go new file mode 100644 index 0000000..a9f92db --- /dev/null +++ b/log.go @@ -0,0 +1,60 @@ +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/main.go b/main.go new file mode 100644 index 0000000..ab0fcc7 --- /dev/null +++ b/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" +) + +func init() { + log.SetFlags(0) +} + +func main() { + if len(os.Args) <= 1 { + 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 { + log.Fatalln(err) + } + + err = config.FullBackup() + if err != nil { + config.fatalln("ошибка полного бекапа:", err) + } + case "s": + config, err := LoadConfig(os.Args[2]) + if err != nil { + log.Fatalln("ошибка при чтении конфига:", 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.logf(LogLevelProgress, "Создание индекса завершено.\n") + + fmt.Println(idx) + 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) + } + + // + + b := &Backuper{Config: config} + plan, err := b.extractionPlan(os.Args[3], t) + if err != nil { + log.Fatalln(err) + } + err = b.extract(plan, os.Args[5]) + if err != nil { + log.Fatalln(err) + } + case "t": + config, err := LoadConfig(os.Args[2]) + if err != nil { + log.Fatalln(err) + } + err = config.Test() + if err != nil { + log.Fatalln("ошибка тестирования:", err) + } + log.Println("Ошибок нет.") + default: + printUsage() + } +} + +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) +} diff --git a/todo.go b/todo.go new file mode 100644 index 0000000..efd3bbd --- /dev/null +++ b/todo.go @@ -0,0 +1,4 @@ +package main + +// TODO: Инкрементальный бекап должен быть вычисен от последнего полного бекапа, а не от всех предыдущих бекапов +// TODO: Проверить ситуацию, может ли быть в tar-архиве два файла с одним именем diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..6ccbc67 --- /dev/null +++ b/utils.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "strings" +) + +/*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 + Val int64 + }{ + {"EiB", 1 << 60}, + {"PiB", 1 << 50}, + {"TiB", 1 << 40}, + {"GiB", 1 << 30}, + {"MiB", 1 << 20}, + {"KiB", 1 << 10}} + + for i := 0; i < len(t); i++ { + v := float64(s) / float64(t[i].Val) + if v < 1.0 { + continue + } + + return fmt.Sprintf("%.1f %s", v, t[i].Name) + } + + return fmt.Sprintf("%d B", s) +} + +// clean убирает невозможнын комбинации символов из пути +func clean(s string) string { + s = strings.ReplaceAll(s, ":", "") + s = strings.ReplaceAll(s, `\\`, `\`) + s = strings.ReplaceAll(s, `//`, `/`) + + return s +} + +// stringIn - аналог оператора in +func stringIn(s string, ss []string) (bool, int) { + for i, v := range ss { + if v == s { + return true, i + } + } + + return false, -1 +} diff --git a/utils_test.go b/utils_test.go new file mode 100644 index 0000000..6bad3fc --- /dev/null +++ b/utils_test.go @@ -0,0 +1,12 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSizeToApproxHuman(t *testing.T) { + assert.Equal(t, "1.0 KiB", sizeToApproxHuman(1024)) + assert.Equal(t, "1.1 KiB", sizeToApproxHuman(1126)) +}