Initial commit

This commit is contained in:
nxshock 2023-03-11 14:13:35 +05:00
commit 1f0f94e103
19 changed files with 1090 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

21
.gitignore vendored Normal file
View File

@ -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

21
LICENSE Normal file
View File

@ -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.

2
README.md Normal file
View File

@ -0,0 +1,2 @@
# backuper

415
backuper.go Normal file
View File

@ -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
}

113
config.go Normal file
View File

@ -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
}

9
consts.go Normal file
View File

@ -0,0 +1,9 @@
package main
const (
// Расширение для файлов архивов
defaultExt = ".tar.zst"
// Формат времени для сообщений
defaultTimeFormat = "02.01.06 15:04"
)

10
examples/config.toml Normal file
View File

@ -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

85
extractionplan.go Normal file
View File

@ -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
}

19
file.go Normal file
View File

@ -0,0 +1,19 @@
package main
import (
"os"
)
type File struct {
// Исходный путь
SourcePath string
// Путь в архиве
DestinationPath string
// Путь к архиву
ArchiveFile string
// Информация о файле
Info os.FileInfo
}

28
filehistory.go Normal file
View File

@ -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())
}

16
go.mod Normal file
View File

@ -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
)

23
go.sum Normal file
View File

@ -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=

87
index.go Normal file
View File

@ -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
}

60
log.go Normal file
View File

@ -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...)
}

108
main.go Normal file
View File

@ -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 <config file path> - do incremental backup\n", bin)
fmt.Fprintf(os.Stderr, "%s f <config file path> - do full backup\n", bin)
fmt.Fprintf(os.Stderr, "%s s <config file path> <mask> - search file(s) in backup\n", bin)
fmt.Fprintf(os.Stderr, "%s r <config file path> <mask> <dd.mm.yyyy hh:mm> <path> - recover file(s) from backup\n", bin)
fmt.Fprintf(os.Stderr, "%s t <config file path> - test archive for errors\n", bin)
}

4
todo.go Normal file
View File

@ -0,0 +1,4 @@
package main
// TODO: Инкрементальный бекап должен быть вычисен от последнего полного бекапа, а не от всех предыдущих бекапов
// TODO: Проверить ситуацию, может ли быть в tar-архиве два файла с одним именем

55
utils.go Normal file
View File

@ -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
}

12
utils_test.go Normal file
View File

@ -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))
}