- Added --exclude flag to allow excluding files/directories from backups - Implemented pattern-based exclusion logic for directory backups - Modified size calculation to account for excluded items - Updated progress tracking to reflect excluded content - Added helper function for path exclusion checks - Ensured consistent exclusion handling across size calculation and backup Key changes: 1. Added excludePatterns variable and flag binding 2. Updated getPathSize to skip excluded paths 3. Implemented shouldExcludePath with multiple matching strategies: - Full path matching - Prefix matching for directories - Glob pattern matching - Base name matching 4. Integrated exclusion checks in backupDirectory 5. Propagated excludePatterns through initialModel 6. Maintained progress accuracy by adjusting total size calculation The new functionality allows users to specify exclusion patterns: backup-tool create -s /data -t backup.tar.gz -e "*.tmp" -e "cache/"
504 lines
11 KiB
Go
504 lines
11 KiB
Go
package cmd
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"github.com/charmbracelet/bubbles/progress"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
var (
|
|
source string
|
|
target string
|
|
excludePatterns []string
|
|
)
|
|
|
|
var createCmd = &cobra.Command{
|
|
Use: "create",
|
|
Short: "Create a new backup",
|
|
Long: "Create a new backup of a file, directory, or disk",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
model, err := initialModel(source, target, excludePatterns)
|
|
if err != nil {
|
|
fmt.Println("Error:", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
p := tea.NewProgram(model, tea.WithAltScreen())
|
|
if _, err := p.Run(); err != nil {
|
|
fmt.Println("Error:", err)
|
|
os.Exit(1)
|
|
}
|
|
},
|
|
}
|
|
|
|
type model struct {
|
|
progress progress.Model
|
|
source string
|
|
target string
|
|
totalBytes int64
|
|
processed int64
|
|
done bool
|
|
err error
|
|
startTime time.Time
|
|
progressCh chan int64
|
|
isDirectory bool
|
|
httpTargetInfo *ServerTargetInfo
|
|
isP2PTarget bool // Флаг для старого P2P-режима
|
|
excludePatterns []string
|
|
}
|
|
|
|
type progressMsg int64
|
|
type doneMsg struct{}
|
|
type errorMsg struct{ err error }
|
|
|
|
func getPathSize(path string, excludePatterns []string) (int64, error) {
|
|
fileInfo, err := os.Stat(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if runtime.GOOS == "linux" {
|
|
stat, ok := fileInfo.Sys().(*syscall.Stat_t)
|
|
if ok && (stat.Mode&syscall.S_IFMT) == syscall.S_IFBLK {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer f.Close()
|
|
|
|
var size uint64
|
|
_, _, errno := unix.Syscall(
|
|
unix.SYS_IOCTL,
|
|
f.Fd(),
|
|
unix.BLKGETSIZE64,
|
|
uintptr(unsafe.Pointer(&size)),
|
|
)
|
|
if errno != 0 {
|
|
return 0, fmt.Errorf("ioctl BLKGETSIZE64 failed: %v", errno)
|
|
}
|
|
return int64(size), nil
|
|
}
|
|
}
|
|
|
|
if fileInfo.IsDir() {
|
|
var total int64
|
|
err := filepath.Walk(path, func(current string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
relPath, err := filepath.Rel(path, current)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if relPath == "." {
|
|
return nil
|
|
}
|
|
|
|
if shouldExcludePath(relPath, excludePatterns) {
|
|
if info.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if !info.IsDir() {
|
|
total += info.Size()
|
|
}
|
|
return nil
|
|
})
|
|
return total, err
|
|
}
|
|
|
|
return fileInfo.Size(), nil
|
|
}
|
|
|
|
func initialModel(src, dst string, excludePatterns []string) (*model, error) {
|
|
totalBytes, err := getPathSize(src, excludePatterns)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
p := progress.New(
|
|
progress.WithDefaultGradient(),
|
|
progress.WithWidth(40),
|
|
)
|
|
|
|
srcInfo, err := os.Stat(src)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
isDirectory := srcInfo.IsDir()
|
|
|
|
targetInfo, isHttp := parseServerTarget(dst)
|
|
isP2P := false
|
|
targetPath := dst
|
|
|
|
if isHttp {
|
|
// Новый HTTP-режим
|
|
if isDirectory && !strings.HasSuffix(targetInfo.Filename, ".tar.gz") {
|
|
targetInfo.Filename += ".tar.gz"
|
|
} else if !isDirectory && !strings.HasSuffix(targetInfo.Filename, ".gz") {
|
|
targetInfo.Filename += ".gz"
|
|
}
|
|
targetInfo.URL = fmt.Sprintf("http://%s/backup/%s", targetInfo.Address, targetInfo.Filename)
|
|
targetPath = targetInfo.URL
|
|
} else if isNetworkAddress(dst) {
|
|
// Старый P2P-режим
|
|
isP2P = true
|
|
} else {
|
|
// Локальный файл
|
|
if isDirectory {
|
|
if filepath.Ext(targetPath) != ".tar.gz" {
|
|
targetPath += ".tar.gz"
|
|
}
|
|
} else {
|
|
if filepath.Ext(targetPath) != ".gz" {
|
|
targetPath += ".gz"
|
|
}
|
|
}
|
|
}
|
|
|
|
return &model{
|
|
progress: p,
|
|
source: src,
|
|
target: targetPath,
|
|
totalBytes: totalBytes,
|
|
startTime: time.Now(),
|
|
progressCh: make(chan int64, 100),
|
|
isDirectory: isDirectory,
|
|
httpTargetInfo: targetInfo,
|
|
isP2PTarget: isP2P,
|
|
excludePatterns: excludePatterns,
|
|
}, nil
|
|
}
|
|
|
|
func (m *model) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
m.startBackup,
|
|
m.progressListener,
|
|
)
|
|
}
|
|
|
|
func (m *model) getTargetWriter() (io.WriteCloser, error) {
|
|
if m.httpTargetInfo != nil {
|
|
// Новый HTTP-режим
|
|
pipeReader, pipeWriter := io.Pipe()
|
|
req, err := http.NewRequest("POST", m.httpTargetInfo.URL, pipeReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create http request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
req.ContentLength = -1 // Stream upload
|
|
|
|
go func() {
|
|
client := &http.Client{} // No timeout for uploads
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
pipeWriter.CloseWithError(fmt.Errorf("http request failed: %w", err))
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
err := fmt.Errorf("server returned non-200 status: %s\n%s", resp.Status, string(bodyBytes))
|
|
pipeWriter.CloseWithError(err)
|
|
}
|
|
}()
|
|
|
|
return pipeWriter, nil
|
|
|
|
} else if m.isP2PTarget {
|
|
// Старый P2P-режим
|
|
conn, err := net.DialTimeout("tcp", m.target, 10*time.Second)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to %s: %w", m.target, err)
|
|
}
|
|
|
|
// Отправляем бинарный заголовок (размер + тип)
|
|
if err := binary.Write(conn, binary.BigEndian, m.totalBytes); err != nil {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("failed to send backup size: %w", err)
|
|
}
|
|
var typeByte byte = 0
|
|
if m.isDirectory {
|
|
typeByte = 1
|
|
}
|
|
if _, err := conn.Write([]byte{typeByte}); err != nil {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("failed to send backup type: %w", err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
// Локальный файл
|
|
return os.Create(m.target)
|
|
}
|
|
|
|
func (m *model) startBackup() tea.Msg {
|
|
go func() {
|
|
targetWriter, err := m.getTargetWriter()
|
|
if err != nil {
|
|
m.err = err
|
|
m.progressCh <- -1
|
|
return
|
|
}
|
|
defer targetWriter.Close()
|
|
|
|
var backupErr error
|
|
if m.isDirectory {
|
|
backupErr = m.backupDirectory(targetWriter)
|
|
} else {
|
|
backupErr = m.backupFileOrDisk(targetWriter)
|
|
}
|
|
|
|
if backupErr != nil {
|
|
m.err = backupErr
|
|
m.progressCh <- -1
|
|
return
|
|
}
|
|
|
|
m.progressCh <- -2
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *model) backupFileOrDisk(w io.Writer) error {
|
|
srcFile, err := os.Open(m.source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
progressWriter := &progressTracker{Writer: w, progressCh: m.progressCh}
|
|
gzipWriter := gzip.NewWriter(progressWriter)
|
|
defer gzipWriter.Close()
|
|
|
|
_, err = io.Copy(gzipWriter, srcFile)
|
|
return err
|
|
}
|
|
|
|
// Вспомогательная функция для проверки исключений
|
|
func shouldExcludePath(path string, patterns []string) bool {
|
|
cleanPath := filepath.Clean(path)
|
|
|
|
for _, pattern := range patterns {
|
|
// Проверка полного совпадения
|
|
if cleanPath == pattern {
|
|
return true
|
|
}
|
|
|
|
// Проверка префикса (исключение вложенных путей)
|
|
if strings.HasPrefix(cleanPath, pattern+string(filepath.Separator)) {
|
|
return true
|
|
}
|
|
|
|
// Проверка glob-паттернов
|
|
if match, _ := filepath.Match(pattern, cleanPath); match {
|
|
return true
|
|
}
|
|
|
|
// Проверка на соответствие базовому имени
|
|
if match, _ := filepath.Match(pattern, filepath.Base(cleanPath)); match {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *model) shouldExclude(path string) bool {
|
|
return shouldExcludePath(path, m.excludePatterns)
|
|
}
|
|
|
|
func (m *model) backupDirectory(w io.Writer) error {
|
|
progressWriter := &progressTracker{Writer: w, progressCh: m.progressCh}
|
|
gzipWriter := gzip.NewWriter(progressWriter)
|
|
defer gzipWriter.Close()
|
|
|
|
tarWriter := tar.NewWriter(gzipWriter)
|
|
defer tarWriter.Close()
|
|
|
|
return filepath.Walk(m.source, func(file string, fi os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Получаем относительный путь
|
|
relPath, err := filepath.Rel(m.source, file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if relPath != "." {
|
|
if m.shouldExclude(relPath) {
|
|
if fi.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil // Пропускаем файл
|
|
}
|
|
}
|
|
header, err := tar.FileInfoHeader(fi, file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
header.Name = relPath
|
|
|
|
if err := tarWriter.WriteHeader(header); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !fi.Mode().IsRegular() {
|
|
return nil
|
|
}
|
|
|
|
srcFile, err := os.Open(file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
_, err = io.Copy(tarWriter, srcFile)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (m *model) progressListener() tea.Msg {
|
|
n := <-m.progressCh
|
|
if n < 0 {
|
|
if n == -1 {
|
|
return errorMsg{m.err}
|
|
}
|
|
return doneMsg{}
|
|
}
|
|
|
|
m.processed += n
|
|
return progressMsg(m.processed)
|
|
}
|
|
|
|
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
if msg.Type == tea.KeyCtrlC {
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.progress.Width = msg.Width - 4
|
|
return m, nil
|
|
|
|
case progressMsg:
|
|
progressVal := float64(msg) / float64(m.totalBytes)
|
|
if progressVal > 1.0 {
|
|
progressVal = 1.0
|
|
}
|
|
cmd := m.progress.SetPercent(progressVal)
|
|
return m, tea.Batch(cmd, m.progressListener)
|
|
|
|
case doneMsg:
|
|
m.done = true
|
|
cmd := m.progress.SetPercent(1.0)
|
|
return m, tea.Batch(cmd, tea.Quit)
|
|
|
|
case errorMsg:
|
|
m.err = msg.err
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *model) View() string {
|
|
if m.err != nil {
|
|
return fmt.Sprintf("\n❌ Error: %v\n\n", m.err)
|
|
}
|
|
|
|
if m.done {
|
|
duration := time.Since(m.startTime)
|
|
backupType := "File/disk"
|
|
if m.isDirectory {
|
|
backupType = "Directory"
|
|
}
|
|
if m.httpTargetInfo != nil || m.isP2PTarget {
|
|
backupType += " network"
|
|
}
|
|
|
|
return fmt.Sprintf("\n✅ %s backup complete!\n\n"+
|
|
"Source: %s\n"+
|
|
"Target: %s\n"+
|
|
"Size: %s\n"+
|
|
"Time: %v\n\n",
|
|
backupType, m.source, m.target,
|
|
formatBytes(m.processed),
|
|
duration.Round(time.Millisecond))
|
|
}
|
|
|
|
operation := "Backing up"
|
|
if m.isDirectory {
|
|
operation = "Archiving"
|
|
}
|
|
if m.httpTargetInfo != nil || m.isP2PTarget {
|
|
operation = "Sending"
|
|
}
|
|
|
|
title := fmt.Sprintf("%s %s → %s", operation, m.source, m.target)
|
|
progressVal := float64(m.processed) / float64(m.totalBytes)
|
|
progressView := m.progress.ViewAs(progressVal)
|
|
stats := fmt.Sprintf("%s / %s (%.1f%%)", formatBytes(m.processed), formatBytes(m.totalBytes), progressVal*100)
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left,
|
|
title,
|
|
"",
|
|
progressView,
|
|
"",
|
|
stats,
|
|
"\nPress Ctrl+C to cancel",
|
|
)
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(createCmd)
|
|
createCmd.Flags().StringVarP(
|
|
&source,
|
|
"source",
|
|
"s",
|
|
"",
|
|
"Source file, directory or disk (e.g., /dev/sda) (required)",
|
|
)
|
|
createCmd.Flags().StringVarP(
|
|
&target,
|
|
"target",
|
|
"t",
|
|
"",
|
|
"Target backup file or network address (e.g., /path/to/backup.tar.gz, 127.0.0.1:8080/backup.gz, or 127.0.0.1:8080) (required)",
|
|
)
|
|
createCmd.Flags().StringSliceVarP(
|
|
&excludePatterns,
|
|
"exclude",
|
|
"e",
|
|
[]string{},
|
|
"Patterns to exclude (can be specified multiple times)",
|
|
)
|
|
createCmd.MarkFlagRequired("source")
|
|
createCmd.MarkFlagRequired("target")
|
|
}
|