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,115 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatcher(t *testing.T) {
|
||||
m := NewMatcher([]string{"/opt/stoat/data/db", "/etc/x-ui/x-ui.db*", "*.swp", "/opt/p/pgdata/"})
|
||||
cases := map[string]bool{
|
||||
"/opt/stoat/data/db": true,
|
||||
"/opt/stoat/data/db/WiredTiger": true,
|
||||
"/opt/stoat/data/dbx": false,
|
||||
"/etc/x-ui/x-ui.db": true,
|
||||
"/etc/x-ui/x-ui.db-wal": true,
|
||||
"/etc/x-ui/config.json": false,
|
||||
"/var/scripts/.a.sh.swp": true,
|
||||
"/opt/p/pgdata/base/1": true,
|
||||
"/opt/p/pgdata2": false,
|
||||
}
|
||||
for p, want := range cases {
|
||||
if got := m.Match(p); got != want {
|
||||
t.Errorf("Match(%q) = %v, want %v", p, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundtrip(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
must(t, os.MkdirAll(filepath.Join(src, "a", "b"), 0o750))
|
||||
must(t, os.WriteFile(filepath.Join(src, "a", "b", "f.txt"), []byte("hello"), 0o640))
|
||||
must(t, os.WriteFile(filepath.Join(src, "a", "skip.swp"), []byte("x"), 0o644))
|
||||
must(t, os.MkdirAll(filepath.Join(src, "a", "pgdata"), 0o700))
|
||||
must(t, os.WriteFile(filepath.Join(src, "a", "pgdata", "PG_VERSION"), []byte("17"), 0o600))
|
||||
symlinks := runtime.GOOS != "windows"
|
||||
if symlinks {
|
||||
must(t, os.Symlink("b/f.txt", filepath.Join(src, "a", "link")))
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w, err := NewWriter(&buf)
|
||||
must(t, err)
|
||||
must(t, w.AddBytes("manifest.json", []byte(`{"domain":"x.example"}`), 0o644))
|
||||
skip := NewMatcher([]string{"*.swp", filepath.ToSlash(filepath.Join(src, "a", "pgdata"))})
|
||||
must(t, w.AddTree(filepath.Join(src, "a"), func(p string) string {
|
||||
rel, _ := filepath.Rel(src, p)
|
||||
return "cat/files/" + filepath.ToSlash(rel)
|
||||
}, skip))
|
||||
must(t, w.Close())
|
||||
if len(w.Warnings) > 0 {
|
||||
t.Fatalf("warnings: %v", w.Warnings)
|
||||
}
|
||||
|
||||
var m struct{ Domain string }
|
||||
must(t, ReadManifest(bytes.NewReader(buf.Bytes()), &m))
|
||||
if m.Domain != "x.example" {
|
||||
t.Fatalf("manifest domain = %q", m.Domain)
|
||||
}
|
||||
|
||||
dst := t.TempDir()
|
||||
e := &Extractor{Route: func(name string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(name, "cat/files/")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Join(dst, filepath.FromSlash(rest)), true
|
||||
}}
|
||||
must(t, e.Extract(bytes.NewReader(buf.Bytes())))
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dst, "a", "b", "f.txt"))
|
||||
must(t, err)
|
||||
if string(data) != "hello" {
|
||||
t.Fatalf("content = %q", data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dst, "a", "skip.swp")); !os.IsNotExist(err) {
|
||||
t.Fatalf("skip.swp должен быть исключён")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dst, "a", "pgdata")); !os.IsNotExist(err) {
|
||||
t.Fatalf("pgdata должен быть исключён")
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
fi, err := os.Stat(filepath.Join(dst, "a", "b", "f.txt"))
|
||||
must(t, err)
|
||||
if fi.Mode().Perm() != 0o640 {
|
||||
t.Fatalf("mode = %v", fi.Mode().Perm())
|
||||
}
|
||||
fi, err = os.Stat(filepath.Join(dst, "a", "b"))
|
||||
must(t, err)
|
||||
if fi.Mode().Perm() != 0o750 {
|
||||
t.Fatalf("dir mode = %v", fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
if symlinks {
|
||||
target, err := os.Readlink(filepath.Join(dst, "a", "link"))
|
||||
must(t, err)
|
||||
if target != "b/f.txt" {
|
||||
t.Fatalf("symlink = %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
// повторная распаковка поверх существующих файлов
|
||||
e2 := &Extractor{Route: e.Route}
|
||||
must(t, e2.Extract(bytes.NewReader(buf.Bytes())))
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user