commit 1f0f94e103111d87fdc3442002a794a77ec5ed2a Author: nxshock Date: Sat Mar 11 14:13:35 2023 +0500 Initial commit 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)) +}