initial commit

This commit is contained in:
wt committed 2025-08-02 12:51:53 +07:00
commit 91d4989531
6 files changed
+613

No files matched your search

+279
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}