mirror of
https://github.com/nxshock/backuper.git
synced 2024-11-27 00:11:01 +05:00
Initial commit
This commit is contained in:
commit
1f0f94e103
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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.
|
415
backuper.go
Normal file
415
backuper.go
Normal 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
113
config.go
Normal 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
9
consts.go
Normal file
@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
const (
|
||||
// Расширение для файлов архивов
|
||||
defaultExt = ".tar.zst"
|
||||
|
||||
// Формат времени для сообщений
|
||||
defaultTimeFormat = "02.01.06 15:04"
|
||||
)
|
10
examples/config.toml
Normal file
10
examples/config.toml
Normal 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
85
extractionplan.go
Normal 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
19
file.go
Normal 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
28
filehistory.go
Normal 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
16
go.mod
Normal 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
23
go.sum
Normal 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
87
index.go
Normal 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
60
log.go
Normal 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
108
main.go
Normal 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
4
todo.go
Normal file
@ -0,0 +1,4 @@
|
||||
package main
|
||||
|
||||
// TODO: Инкрементальный бекап должен быть вычисен от последнего полного бекапа, а не от всех предыдущих бекапов
|
||||
// TODO: Проверить ситуацию, может ли быть в tar-архиве два файла с одним именем
|
55
utils.go
Normal file
55
utils.go
Normal 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
12
utils_test.go
Normal 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))
|
||||
}
|
Loading…
Reference in New Issue
Block a user