backup-master: сервер бэкапов, агент и восстановление VPS
Один бинарник backupctl: - server: API для агентов и restore, веб-морда (один админ), SQLite, ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh; - backup/plan: сбор по каталогу категорий (files/systemd/xui/compose), горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома, digest образов, проверка crontab, потоковая отправка tar.zst; - restore: провижининг на основе vps_installer с теми же версиями пакетов, recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose с заливкой дампов и закреплением образов по digest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1d44d067e9
53 files changed
+7278
No files matched your search
@@ -0,0 +1,167 @@
|
||||
// Package archive пишет и читает архивы бэкапа: tar (PAX) поверх zstd.
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
type Writer struct {
|
||||
zw *zstd.Encoder
|
||||
tw *tar.Writer
|
||||
seen map[string]bool
|
||||
|
||||
Files int
|
||||
Bytes int64
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
func NewWriter(w io.Writer) (*Writer, error) {
|
||||
zw, err := zstd.NewWriter(w, zstd.WithEncoderLevel(zstd.SpeedDefault))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Writer{zw: zw, tw: tar.NewWriter(zw), seen: map[string]bool{}}, nil
|
||||
}
|
||||
|
||||
func (w *Writer) warn(format string, a ...any) {
|
||||
w.Warnings = append(w.Warnings, fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// AddBytes пишет в архив файл из памяти.
|
||||
func (w *Writer) AddBytes(name string, data []byte, mode int64) error {
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Mode: mode,
|
||||
Size: int64(len(data)),
|
||||
ModTime: time.Now(),
|
||||
Typeflag: tar.TypeReg,
|
||||
Format: tar.FormatPAX,
|
||||
}
|
||||
if err := w.tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := w.tw.Write(data)
|
||||
w.seen[name] = true
|
||||
w.Files++
|
||||
w.Bytes += hdr.Size
|
||||
return err
|
||||
}
|
||||
|
||||
// AddFiles добавляет абсолютный путь src (файл или каталог) под именами prefix+<путь без ведущего />.
|
||||
func (w *Writer) AddFiles(prefix, src string, skip *Matcher) error {
|
||||
return w.AddTree(src, func(p string) string {
|
||||
return prefix + strings.TrimPrefix(filepath.ToSlash(p), "/")
|
||||
}, skip)
|
||||
}
|
||||
|
||||
// AddTree обходит src (не следуя симлинкам) и пишет каждую запись под именем name(path).
|
||||
// Ошибки отдельных файлов (нет доступа, исчез во время обхода) становятся предупреждениями.
|
||||
func (w *Writer) AddTree(src string, name func(p string) string, skip *Matcher) error {
|
||||
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) && p != src {
|
||||
return nil
|
||||
}
|
||||
w.warn("%s: %v", p, err)
|
||||
if d != nil && d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if skip.Match(filepath.ToSlash(p)) {
|
||||
if d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return w.addEntry(p, name(p))
|
||||
})
|
||||
}
|
||||
|
||||
func (w *Writer) addEntry(p, name string) error {
|
||||
fi, err := os.Lstat(p)
|
||||
if err != nil {
|
||||
w.warn("%s: %v", p, err)
|
||||
return nil
|
||||
}
|
||||
mode := fi.Mode()
|
||||
if mode&(fs.ModeSocket|fs.ModeDevice|fs.ModeCharDevice|fs.ModeNamedPipe|fs.ModeIrregular) != 0 {
|
||||
return nil
|
||||
}
|
||||
if fi.IsDir() {
|
||||
name = strings.TrimSuffix(name, "/") + "/"
|
||||
}
|
||||
if w.seen[name] {
|
||||
return nil
|
||||
}
|
||||
var link string
|
||||
if mode&fs.ModeSymlink != 0 {
|
||||
if link, err = os.Readlink(p); err != nil {
|
||||
w.warn("%s: %v", p, err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
hdr, err := tar.FileInfoHeader(fi, link)
|
||||
if err != nil {
|
||||
w.warn("%s: %v", p, err)
|
||||
return nil
|
||||
}
|
||||
hdr.Name = name
|
||||
hdr.Format = tar.FormatPAX
|
||||
|
||||
var f *os.File
|
||||
if hdr.Typeflag == tar.TypeReg {
|
||||
// открываем до заголовка: недоступный файл просто пропускаем
|
||||
if f, err = os.Open(p); err != nil {
|
||||
w.warn("%s: %v", p, err)
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
}
|
||||
if err := w.tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
w.seen[name] = true
|
||||
w.Files++
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
n, err := io.CopyN(w.tw, f, hdr.Size)
|
||||
w.Bytes += n
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("%s: %w", p, err)
|
||||
}
|
||||
// файл уменьшился во время чтения — добиваем нулями до размера из заголовка
|
||||
w.warn("%s: файл изменился во время чтения (%d из %d байт)", p, n, hdr.Size)
|
||||
if _, err := io.CopyN(w.tw, zeroReader{}, hdr.Size-n); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Writer) Close() error {
|
||||
if err := w.tw.Close(); err != nil {
|
||||
w.zw.Close()
|
||||
return err
|
||||
}
|
||||
return w.zw.Close()
|
||||
}
|
||||
|
||||
type zeroReader struct{}
|
||||
|
||||
func (zeroReader) Read(p []byte) (int, error) {
|
||||
clear(p)
|
||||
return len(p), nil
|
||||
}
|
||||
Reference in new issue
Block a user