initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
go.sum
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"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
|
||||
)
|
||||
|
||||
var createCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new backup",
|
||||
Long: "Create a new backup of a file or disk",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
model, err := initialModel(source, target+".gz")
|
||||
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
|
||||
}
|
||||
|
||||
type progressMsg int64
|
||||
type doneMsg struct{}
|
||||
type errorMsg struct{ err error }
|
||||
|
||||
// getFileSize возвращает размер файла или диска
|
||||
func getFileSize(path string) (int64, error) {
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Для Linux: проверка, является ли источник блочным устройством
|
||||
if runtime.GOOS == "linux" {
|
||||
stat, ok := fileInfo.Sys().(*syscall.Stat_t)
|
||||
if ok && (stat.Mode&syscall.S_IFMT) == syscall.S_IFBLK {
|
||||
// Это блочное устройство - получаем размер через ioctl
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Для обычных файлов и других систем
|
||||
return fileInfo.Size(), nil
|
||||
}
|
||||
|
||||
func initialModel(src, dst string) (*model, error) {
|
||||
totalBytes, err := getFileSize(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := progress.New(
|
||||
progress.WithDefaultGradient(),
|
||||
progress.WithWidth(40),
|
||||
)
|
||||
|
||||
return &model{
|
||||
progress: p,
|
||||
source: src,
|
||||
target: dst,
|
||||
totalBytes: totalBytes,
|
||||
startTime: time.Now(),
|
||||
progressCh: make(chan int64, 100),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.startCompression,
|
||||
m.progressListener,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *model) startCompression() tea.Msg {
|
||||
// Переносим всю работу с файлами внутрь горутины
|
||||
go func() {
|
||||
srcFile, err := os.Open(m.source)
|
||||
if err != nil {
|
||||
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 // Сигнал завершения
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *model) progressListener() tea.Msg {
|
||||
select {
|
||||
case n := <-m.progressCh:
|
||||
switch {
|
||||
case n == -1: // Ошибка
|
||||
return errorMsg{fmt.Errorf("compression error")}
|
||||
case n == -2: // Завершение
|
||||
return doneMsg{}
|
||||
default: // Прогресс
|
||||
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
|
||||
// Устанавливаем 100% и завершаем
|
||||
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)
|
||||
ratio := float64(m.processed) / float64(m.totalBytes) * 100
|
||||
return fmt.Sprintf("\n✅ Backup complete!\n\n"+
|
||||
"Source: %s\n"+
|
||||
"Backup: %s\n"+
|
||||
"Size: %s → %s (%.1f%%)\n"+
|
||||
"Time: %v\n\n",
|
||||
m.source, m.target,
|
||||
formatBytes(m.totalBytes), formatBytes(m.processed), ratio,
|
||||
duration.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Backing up %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)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
progressView,
|
||||
"",
|
||||
stats,
|
||||
"\nPress Ctrl+C to cancel",
|
||||
)
|
||||
}
|
||||
|
||||
func formatBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(createCmd)
|
||||
createCmd.Flags().StringVarP(&source, "source", "s", "", "Source file or disk (e.g., /dev/sda) (required)")
|
||||
createCmd.Flags().StringVarP(&target, "target", "t", "", "Target backup file (required)")
|
||||
createCmd.MarkFlagRequired("source")
|
||||
createCmd.MarkFlagRequired("target")
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
restoreSource string
|
||||
restoreTarget string
|
||||
)
|
||||
|
||||
var restoreCmd = &cobra.Command{
|
||||
Use: "restore",
|
||||
Short: "Restore a backup",
|
||||
Long: "Restore a backup from a gzip archive to a file or disk",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
model, err := initialRestoreModel(restoreSource, restoreTarget)
|
||||
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 restoreModel struct {
|
||||
progress progress.Model
|
||||
source string
|
||||
target string
|
||||
totalBytes int64
|
||||
processed int64
|
||||
done bool
|
||||
err error
|
||||
startTime time.Time
|
||||
progressCh chan int64
|
||||
}
|
||||
|
||||
type restoreProgressMsg int64
|
||||
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
|
||||
}
|
||||
|
||||
p := progress.New(
|
||||
progress.WithDefaultGradient(),
|
||||
progress.WithWidth(40),
|
||||
)
|
||||
|
||||
return &restoreModel{
|
||||
progress: p,
|
||||
source: src,
|
||||
target: dst,
|
||||
totalBytes: fileInfo.Size(),
|
||||
startTime: time.Now(),
|
||||
progressCh: make(chan int64, 100),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *restoreModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.startRestore,
|
||||
m.restoreProgressListener,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *restoreModel) startRestore() tea.Msg {
|
||||
go func() {
|
||||
srcFile, err := os.Open(m.source)
|
||||
if err != nil {
|
||||
m.progressCh <- -1
|
||||
return
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// Создаем кастомный reader для отслеживания прогресса
|
||||
progressReader := &restoreProgressReader{
|
||||
reader: srcFile,
|
||||
total: m.totalBytes,
|
||||
progressCh: m.progressCh,
|
||||
}
|
||||
|
||||
// Создаем gzip reader на основе кастомного reader'а
|
||||
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
|
||||
}
|
||||
} else {
|
||||
// Обычный файл: создаем/усекаем
|
||||
dstFile, err = os.Create(m.target)
|
||||
if err != nil {
|
||||
m.progressCh <- -1
|
||||
return
|
||||
}
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
// Копируем распакованные данные в целевой файл/устройство
|
||||
_, err = io.Copy(dstFile, gzipReader)
|
||||
if err != nil {
|
||||
m.progressCh <- -1
|
||||
return
|
||||
}
|
||||
|
||||
// Синхронизируем, чтобы все данные точно записались
|
||||
if err := dstFile.Sync(); err != nil {
|
||||
m.progressCh <- -1
|
||||
return
|
||||
}
|
||||
|
||||
m.progressCh <- -2 // Сигнал завершения
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreProgressReader отслеживает прогресс чтения архива
|
||||
type restoreProgressReader struct {
|
||||
reader io.Reader
|
||||
total int64
|
||||
progressCh chan int64
|
||||
read int64
|
||||
}
|
||||
|
||||
func (r *restoreProgressReader) Read(p []byte) (int, error) {
|
||||
n, err := r.reader.Read(p)
|
||||
if n > 0 {
|
||||
r.read += int64(n)
|
||||
r.progressCh <- r.read
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (m *restoreModel) restoreProgressListener() tea.Msg {
|
||||
select {
|
||||
case n := <-m.progressCh:
|
||||
switch {
|
||||
case n == -1: // Ошибка
|
||||
return restoreErrorMsg{fmt.Errorf("restoration error")}
|
||||
case n == -2: // Завершение
|
||||
return restoreDoneMsg{}
|
||||
default: // Прогресс
|
||||
m.processed = n
|
||||
return restoreProgressMsg(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *restoreModel) 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 restoreProgressMsg:
|
||||
// Обновляем прогрессбар
|
||||
progressVal := float64(msg) / float64(m.totalBytes)
|
||||
if progressVal > 1.0 {
|
||||
progressVal = 1.0
|
||||
}
|
||||
|
||||
// Создаем команду для обновления прогрессбара
|
||||
cmd := m.progress.SetPercent(progressVal)
|
||||
|
||||
// Продолжаем слушать обновления
|
||||
return m, tea.Batch(cmd, m.restoreProgressListener)
|
||||
|
||||
case restoreDoneMsg:
|
||||
m.done = true
|
||||
// Устанавливаем 100% и завершаем
|
||||
cmd := m.progress.SetPercent(1.0)
|
||||
return m, tea.Batch(cmd, tea.Quit)
|
||||
|
||||
case restoreErrorMsg:
|
||||
m.err = msg.err
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *restoreModel) View() string {
|
||||
if m.err != nil {
|
||||
return fmt.Sprintf("\n❌ Error: %v\n\n", m.err)
|
||||
}
|
||||
|
||||
if m.done {
|
||||
duration := time.Since(m.startTime)
|
||||
return fmt.Sprintf("\n✅ Restoration complete!\n\n"+
|
||||
"File: %s → %s\n"+
|
||||
"Time: %v\n\n",
|
||||
m.source, m.target,
|
||||
duration.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Restoring %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)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
progressView,
|
||||
"",
|
||||
stats,
|
||||
"\nPress Ctrl+C to cancel",
|
||||
)
|
||||
}
|
||||
|
||||
// isBlockDevice проверяет, является ли путь блочным устройством (только для Linux)
|
||||
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 or disk (e.g., /dev/sdb) (required)")
|
||||
restoreCmd.MarkFlagRequired("source")
|
||||
restoreCmd.MarkFlagRequired("target")
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "backup",
|
||||
Short: "Backup your data",
|
||||
Long: "Backup your data locally or remotely",
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
module backup
|
||||
|
||||
go 1.24.5
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.4
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
Reference in New Issue
Block a user