added live time network backup and restore

This commit is contained in:
wt committed 2025-08-02 17:34:26 +07:00
1 parent 3bef3f76e9
commit 1e3cc446e0
3 files changed
+276 -194

No files matched your search

+125 -97
View File
@@ -3,8 +3,10 @@ package cmd
import (
"archive/tar"
"compress/gzip"
"encoding/binary"
"fmt"
"io"
"net"
"os"
"path/filepath"
"runtime"
@@ -44,23 +46,24 @@ var createCmd = &cobra.Command{
}
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
progress progress.Model
source string
target string
totalBytes int64
processed int64
done bool
err error
startTime time.Time
progressCh chan int64
isDirectory bool
isNetworkTarget bool
}
type progressMsg int64
type doneMsg struct{}
type errorMsg struct{ err error }
// getFileSize возвращает размер файла, диска или директории
// getPathSize возвращает размер файла, диска или директории
func getPathSize(path string) (int64, error) {
fileInfo, err := os.Stat(path)
if err != nil {
@@ -121,26 +124,35 @@ func initialModel(src, dst string) (*model, error) {
progress.WithWidth(40),
)
// Проверяем, является ли источник директорией
srcInfo, err := os.Stat(src)
if err != nil {
return nil, err
}
// Добавляем расширение .tar.gz для директорий
isDirectory := srcInfo.IsDir()
networkTarget := isNetworkAddress(dst)
targetPath := dst
if srcInfo.IsDir() && filepath.Ext(targetPath) != ".tar.gz" {
targetPath += ".tar.gz"
if !networkTarget {
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: srcInfo.IsDir(),
progress: p,
source: src,
target: targetPath,
totalBytes: totalBytes,
startTime: time.Now(),
progressCh: make(chan int64, 100),
isDirectory: isDirectory,
isNetworkTarget: networkTarget,
}, nil
}
@@ -151,57 +163,58 @@ func (m *model) Init() tea.Cmd {
)
}
// getTargetWriter создает io.WriteCloser для файла или сетевого подключения
func (m *model) getTargetWriter() (io.WriteCloser, error) {
if m.isNetworkTarget {
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)
}
// Протокол: сначала отправляем 8 байт (размер) и 1 байт (тип)
// 1. Общий размер (int64)
if err := binary.Write(conn, binary.BigEndian, m.totalBytes); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to send backup size: %w", err)
}
// 2. Тип бэкапа (1 = директория, 0 = файл/диск)
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 {
err := m.backupDirectory()
if err != nil {
m.progressCh <- -1
return
}
m.progressCh <- -2
return
backupErr = m.backupDirectory(targetWriter)
} else {
backupErr = m.backupFileOrDisk(targetWriter)
}
m.target += ".gz"
// Обработка файлов и дисков
srcFile, err := os.Open(m.source)
if err != nil {
if backupErr != nil {
m.err = backupErr
m.progressCh <- -1
return
}
defer srcFile.Close()
dstFile, err := os.Create(m.target)
if err != nil {
m.progressCh <- -1
return
}
defer dstFile.Close()
gzipWriter := gzip.NewWriter(dstFile)
defer gzipWriter.Close()
buf := make([]byte, 32*1024) // 32KB buffer
for {
n, err := srcFile.Read(buf)
if n > 0 {
_, writeErr := gzipWriter.Write(buf[:n])
if writeErr != nil {
m.progressCh <- -1
return
}
m.progressCh <- int64(n)
}
if err == io.EOF {
break
}
if err != nil {
m.progressCh <- -1
return
}
}
m.progressCh <- -2 // Сигнал завершения
}()
@@ -209,14 +222,37 @@ func (m *model) startBackup() tea.Msg {
return nil
}
func (m *model) backupDirectory() error {
dstFile, err := os.Create(m.target)
func (m *model) backupFileOrDisk(w io.Writer) error {
srcFile, err := os.Open(m.source)
if err != nil {
return err
}
defer dstFile.Close()
defer srcFile.Close()
gzipWriter := gzip.NewWriter(dstFile)
gzipWriter := gzip.NewWriter(w)
defer gzipWriter.Close()
buf := make([]byte, 32*1024) // 32KB buffer
for {
n, err := srcFile.Read(buf)
if n > 0 {
if _, writeErr := gzipWriter.Write(buf[:n]); writeErr != nil {
return writeErr
}
m.progressCh <- int64(n)
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return nil
}
func (m *model) backupDirectory(w io.Writer) error {
gzipWriter := gzip.NewWriter(w)
defer gzipWriter.Close()
tarWriter := tar.NewWriter(gzipWriter)
@@ -227,37 +263,31 @@ func (m *model) backupDirectory() error {
return err
}
// Создаем заголовок для файла/директории
header, err := tar.FileInfoHeader(fi, file)
if err != nil {
return err
}
// Устанавливаем относительный путь
relPath, err := filepath.Rel(m.source, 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()
// Копируем содержимое файла в архив
buf := make([]byte, 32*1024)
for {
n, err := srcFile.Read(buf)
@@ -267,16 +297,13 @@ func (m *model) backupDirectory() error {
}
m.progressCh <- int64(n)
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return nil
})
}
@@ -286,7 +313,7 @@ func (m *model) progressListener() tea.Msg {
case n := <-m.progressCh:
switch {
case n == -1: // Ошибка
return errorMsg{fmt.Errorf("backup error")}
return errorMsg{m.err}
case n == -2: // Завершение
return doneMsg{}
default: // Прогресс
@@ -309,21 +336,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
// Устанавливаем 100% и завершаем
cmd := m.progress.SetPercent(1.0)
return m, tea.Batch(cmd, tea.Quit)
@@ -342,27 +363,34 @@ func (m *model) View() string {
if m.done {
duration := time.Since(m.startTime)
ratio := float64(m.processed) / float64(m.totalBytes) * 100
backupType := "file/disk"
// ratio := float64(m.processed) / float64(m.totalBytes) * 100
backupType := "File/disk"
if m.isDirectory {
backupType = "directory"
backupType = "Directory"
}
if m.isNetworkTarget {
backupType += " network"
}
return fmt.Sprintf("\n✅ %s backup complete!\n\n"+
"Source: %s\n"+
"Backup: %s\n"+
"Size: %s → %s (%.1f%%)\n"+
"Target: %s\n"+
"Size: %s\n"+
"Time: %v\n\n",
backupType, m.source, m.target,
formatBytes(m.totalBytes), formatBytes(m.processed), ratio,
formatBytes(m.processed),
duration.Round(time.Millisecond))
}
backupType := "Backing up"
operation := "Backing up"
if m.isDirectory {
backupType = "Archiving"
operation = "Archiving"
}
title := fmt.Sprintf("%s %s → %s", backupType, m.source, m.target)
if m.isNetworkTarget {
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)
@@ -380,7 +408,7 @@ func (m *model) View() string {
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 (required)")
createCmd.Flags().StringVarP(&target, "target", "t", "", "Target backup file or network address (e.g., /path/to/backup.tar.gz or 127.0.0.1:8080) (required)")
createCmd.MarkFlagRequired("source")
createCmd.MarkFlagRequired("target")
}
+142 -96
View File
@@ -3,8 +3,10 @@ package cmd
import (
"archive/tar"
"compress/gzip"
"encoding/binary"
"fmt"
"io"
"net"
"os"
"path/filepath"
"runtime"
@@ -26,7 +28,7 @@ var (
var restoreCmd = &cobra.Command{
Use: "restore",
Short: "Restore a backup",
Long: "Restore a backup from an archive to a file, disk, or directory.",
Long: "Restore a backup from a local archive or network stream to a file, disk, or directory.",
Run: func(cmd *cobra.Command, args []string) {
model, err := initialRestoreModel(restoreSource, restoreTarget)
if err != nil {
@@ -43,16 +45,18 @@ var restoreCmd = &cobra.Command{
}
type restoreModel struct {
progress progress.Model
source string
target string
totalBytes int64
processed int64
done bool
err error
startTime time.Time
progressCh chan int64
isDirectory bool
progress progress.Model
source string
target string
totalBytes int64
processed int64
done bool
err error
startTime time.Time
progressCh chan int64
isDirectory bool
isNetworkSource bool
statusMessage string
}
type restoreProgressMsg int64
@@ -60,36 +64,50 @@ type restoreDoneMsg struct{}
type restoreErrorMsg struct{ err error }
func initialRestoreModel(src, dst string) (*restoreModel, error) {
fileInfo, err := os.Stat(src)
if err != nil {
return nil, err
}
isNetwork := isNetworkAddress(src)
p := progress.New(
progress.WithDefaultGradient(),
progress.WithWidth(40),
)
isDirectory := strings.HasSuffix(src, ".tar.gz")
var totalBytes int64 = 1 // Placeholder for network source, to avoid division by zero.
var isDir bool
targetPath := dst
status := "Initializing..."
if isDirectory && targetPath == "" {
targetPath, err = os.Getwd()
if isNetwork {
if targetPath == "" {
return nil, fmt.Errorf("target flag -t is required for a network source")
}
status = fmt.Sprintf("Listening on %s...", src)
} else {
fileInfo, err := os.Stat(src)
if err != nil {
return nil, fmt.Errorf("failed to get current directory: %w", err)
return nil, fmt.Errorf("failed to stat source %s: %w", src, err)
}
totalBytes = fileInfo.Size()
isDir = strings.HasSuffix(src, ".tar.gz")
if isDir && targetPath == "" {
targetPath, err = os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get current directory: %w", err)
}
} else if !isDir && targetPath == "" {
return nil, fmt.Errorf("target flag -t is required for file or disk restoration")
}
} else if !isDirectory && targetPath == "" {
return nil, fmt.Errorf("target flag is required for file or disk restoration")
}
return &restoreModel{
progress: p,
source: src,
target: targetPath,
totalBytes: fileInfo.Size(),
startTime: time.Now(),
progressCh: make(chan int64, 100),
isDirectory: isDirectory,
progress: p,
source: src,
target: targetPath,
totalBytes: totalBytes,
startTime: time.Now(),
progressCh: make(chan int64, 100),
isDirectory: isDir,
isNetworkSource: isNetwork,
statusMessage: status,
}, nil
}
@@ -100,85 +118,108 @@ func (m *restoreModel) Init() tea.Cmd {
)
}
func (m *restoreModel) startRestore() tea.Msg {
go func() {
if m.isDirectory {
if err := m.restoreDirectory(); err != nil {
m.progressCh <- -1 // Error signal
return
}
m.progressCh <- -2 // Done signal
return
func (m *restoreModel) getSourceReader() (io.ReadCloser, error) {
if m.isNetworkSource {
listener, err := net.Listen("tcp", m.source)
if err != nil {
return nil, fmt.Errorf("failed to listen on %s: %w", m.source, err)
}
defer listener.Close() // Close listener after accepting one connection
m.statusMessage = fmt.Sprintf("Waiting for connection on %s", m.source)
// This is a blocking call
conn, err := listener.Accept()
if err != nil {
return nil, fmt.Errorf("failed to accept connection: %w", err)
}
m.statusMessage = "Client connected. Receiving data..."
// Protocol: Read 8 bytes for size, 1 byte for type
if err := binary.Read(conn, binary.BigEndian, &m.totalBytes); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to read backup size: %w", err)
}
if m.totalBytes == 0 {
m.totalBytes = 1 // Avoid division by zero for empty files
}
// Fallback to original file/disk restoration logic
srcFile, err := os.Open(m.source)
typeByte := make([]byte, 1)
if _, err := io.ReadFull(conn, typeByte); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to read backup type: %w", err)
}
m.isDirectory = (typeByte[0] == 1)
return conn, nil
}
// Logic for local file
m.statusMessage = fmt.Sprintf("Opening archive %s", m.source)
return os.Open(m.source)
}
func (m *restoreModel) startRestore() tea.Msg {
go func() {
sourceReader, err := m.getSourceReader()
if err != nil {
m.err = err
m.progressCh <- -1
return
}
defer srcFile.Close()
defer sourceReader.Close()
progressReader := &restoreProgressReader{
reader: srcFile,
total: m.totalBytes,
reader: sourceReader,
progressCh: m.progressCh,
}
gzipReader, err := gzip.NewReader(progressReader)
if err != nil {
m.progressCh <- -1
return
}
defer gzipReader.Close()
var dstFile *os.File
if isBlockDevice(m.target) {
dstFile, err = os.OpenFile(m.target, os.O_WRONLY|os.O_SYNC, 0)
if err != nil {
m.progressCh <- -1
return
}
var restoreErr error
if m.isDirectory {
restoreErr = m.restoreDirectory(progressReader)
} else {
dstFile, err = os.Create(m.target)
if err != nil {
m.progressCh <- -1
return
}
restoreErr = m.restoreFileOrDisk(progressReader)
}
defer dstFile.Close()
_, err = io.Copy(dstFile, gzipReader)
if err != nil {
if restoreErr != nil {
m.err = restoreErr
m.progressCh <- -1
return
}
if err := dstFile.Sync(); err != nil {
m.progressCh <- -1
return
}
m.progressCh <- -2 // Сигнал завершения
m.progressCh <- -2 // Done signal
}()
return nil
}
func (m *restoreModel) restoreDirectory() error {
srcFile, err := os.Open(m.source)
func (m *restoreModel) restoreFileOrDisk(r io.Reader) error {
gzipReader, err := gzip.NewReader(r)
if err != nil {
return err
}
defer srcFile.Close()
defer gzipReader.Close()
progressReader := &restoreProgressReader{
reader: srcFile,
total: m.totalBytes,
progressCh: m.progressCh,
var dstFile *os.File
if isBlockDevice(m.target) {
dstFile, err = os.OpenFile(m.target, os.O_WRONLY|os.O_SYNC, 0)
} else {
if err := os.MkdirAll(filepath.Dir(m.target), 0755); err != nil {
return err
}
dstFile, err = os.Create(m.target)
}
if err != nil {
return err
}
defer dstFile.Close()
gzipReader, err := gzip.NewReader(progressReader)
if _, err = io.Copy(dstFile, gzipReader); err != nil {
return err
}
return dstFile.Sync()
}
func (m *restoreModel) restoreDirectory(r io.Reader) error {
gzipReader, err := gzip.NewReader(r)
if err != nil {
return err
}
@@ -210,11 +251,14 @@ func (m *restoreModel) restoreDirectory() error {
if err != nil {
return err
}
if _, err := io.Copy(outFile, tarReader); err != nil {
outFile.Close()
return err
_, copyErr := io.Copy(outFile, tarReader)
closeErr := outFile.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
outFile.Close()
}
}
return nil
@@ -222,7 +266,6 @@ func (m *restoreModel) restoreDirectory() error {
type restoreProgressReader struct {
reader io.Reader
total int64
progressCh chan int64
read int64
}
@@ -241,7 +284,10 @@ func (m *restoreModel) restoreProgressListener() tea.Msg {
case n := <-m.progressCh:
switch {
case n == -1: // Ошибка
return restoreErrorMsg{fmt.Errorf("restoration error")}
if m.err == nil {
m.err = fmt.Errorf("unknown restoration error")
}
return restoreErrorMsg{m.err}
case n == -2: // Завершение
return restoreDoneMsg{}
default: // Прогресс
@@ -268,9 +314,7 @@ func (m *restoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if progressVal > 1.0 {
progressVal = 1.0
}
cmd := m.progress.SetPercent(progressVal)
return m, tea.Batch(cmd, m.restoreProgressListener)
case restoreDoneMsg:
@@ -282,7 +326,6 @@ func (m *restoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err
return m, tea.Quit
}
return m, nil
}
@@ -298,13 +341,19 @@ func (m *restoreModel) View() string {
restoreType = "Directory"
}
return fmt.Sprintf("\n✅ %s restoration complete!\n\n"+
"Archive: %s → %s\n"+
"Source: %s\n"+
"Target: %s\n"+
"Time: %v\n\n",
restoreType, m.source, m.target,
duration.Round(time.Millisecond))
}
title := fmt.Sprintf("Restoring %s → %s", m.source, m.target)
// Show status message before progress starts
if m.processed == 0 && (m.isNetworkSource || m.statusMessage != "") {
return m.statusMessage + "\n\nPress Ctrl+C to cancel"
}
title := fmt.Sprintf("Restoring from %s → %s", 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)
@@ -323,23 +372,20 @@ func isBlockDevice(path string) bool {
if runtime.GOOS != "linux" {
return false
}
fileInfo, err := os.Stat(path)
if err != nil {
return false
}
stat, ok := fileInfo.Sys().(*syscall.Stat_t)
if !ok {
return false
}
return (stat.Mode & syscall.S_IFMT) == syscall.S_IFBLK
}
func init() {
rootCmd.AddCommand(restoreCmd)
restoreCmd.Flags().StringVarP(&restoreSource, "source", "s", "", "Source archive (required)")
restoreCmd.Flags().StringVarP(&restoreTarget, "target", "t", "", "Target file, disk, or directory. Defaults to current directory for archives.")
restoreCmd.Flags().StringVarP(&restoreSource, "source", "s", "", "Source archive or network address (e.g., /path/to/archive.tar.gz or 0.0.0.0:8080) (required)")
restoreCmd.Flags().StringVarP(&restoreTarget, "target", "t", "", "Target file, disk, or directory. Required for network sources.")
restoreCmd.MarkFlagRequired("source")
}
+9 -1
View File
@@ -1,6 +1,14 @@
package cmd
import "fmt"
import (
"fmt"
"strings"
)
// isNetworkAddress checks if the address is in host:port format
func isNetworkAddress(addr string) bool {
return strings.Contains(addr, ":")
}
func formatBytes(b int64) string {
const unit = 1024