Один бинарник 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>
116 lines
3.4 KiB
Go
116 lines
3.4 KiB
Go
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)
|
|
}
|
|
}
|