Профили серверов: несколько миров с раздельными настройками
Профиль — это отдельный сервер: свой мир, .ini, песочница, набор модов и лимит памяти JVM. Project Zomboid различает их по -servername, поэтому файлы профилей не пересекаются. Работает всегда один профиль: серверные файлы, игровые порты и файл запуска JVM общие, так что переключение разрешено только на остановленном сервере. - config: список профилей вместо одиночных server_name/admin_password/ java_memory; старые поля переносятся в первый профиль при загрузке; - supervisor: запускает активный профиль и выставляет его -Xmx на каждом старте, потому что файл запуска общий; - бэкапы: архив снимается с одного профиля (мир + его конфиги), имя профиля попадает в имя файла, ротация считается по профилям; - API профилей: список, создание с копированием настроек из другого профиля, правка, удаление и переключение активного; - панель: селектор профиля в шапке и вкладка со списком профилей. Удаление профиля убирает его только из панели: мир и конфиги остаются на диске, чтобы случайное нажатие не стоило сохранений. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+166
-38
@@ -1,5 +1,5 @@
|
||||
// Package backup архивирует миры и конфиги Project Zomboid и восстанавливает
|
||||
// их обратно.
|
||||
// их обратно. Каждый архив относится к одному профилю сервера.
|
||||
package backup
|
||||
|
||||
import (
|
||||
@@ -22,13 +22,15 @@ import (
|
||||
// Archive — один файл бэкапа на диске.
|
||||
type Archive struct {
|
||||
Name string `json:"name"`
|
||||
Profile string `json:"profile"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// nameRe ограничивает имена архивов, которые принимает API: имя приходит от
|
||||
// клиента и используется как путь.
|
||||
var nameRe = regexp.MustCompile(`^pz-[0-9]{8}-[0-9]{6}(-[a-zA-Z0-9_-]+)?\.tar\.gz$`)
|
||||
// nameRe разбирает имена архивов: имя приходит от клиента и используется как
|
||||
// путь, поэтому набор символов жёстко ограничен. Часть с профилем
|
||||
// необязательна — так читаются архивы, снятые до появления профилей.
|
||||
var nameRe = regexp.MustCompile(`^pz-(?:([A-Za-z0-9_-]+)-)?[0-9]{8}-[0-9]{6}(?:-[a-zA-Z0-9_-]+)?\.tar\.gz$`)
|
||||
|
||||
// Manager владеет директорией с бэкапами.
|
||||
type Manager struct {
|
||||
@@ -53,7 +55,11 @@ func (m *Manager) List() ([]Archive, error) {
|
||||
|
||||
out := make([]Archive, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !nameRe.MatchString(e.Name()) {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
match := nameRe.FindStringSubmatch(e.Name())
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
@@ -62,6 +68,7 @@ func (m *Manager) List() ([]Archive, error) {
|
||||
}
|
||||
out = append(out, Archive{
|
||||
Name: e.Name(),
|
||||
Profile: match[1],
|
||||
SizeBytes: info.Size(),
|
||||
CreatedAt: info.ModTime().Unix(),
|
||||
})
|
||||
@@ -70,6 +77,21 @@ func (m *Manager) List() ([]Archive, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListForProfile отбирает архивы одного профиля.
|
||||
func (m *Manager) ListForProfile(profileID string) ([]Archive, error) {
|
||||
all, err := m.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Archive, 0, len(all))
|
||||
for _, a := range all {
|
||||
if a.Profile == profileID {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Path возвращает полный путь к архиву, проверив имя.
|
||||
func (m *Manager) Path(name string) (string, error) {
|
||||
if !nameRe.MatchString(name) {
|
||||
@@ -78,20 +100,34 @@ func (m *Manager) Path(name string) (string, error) {
|
||||
return filepath.Join(m.cfg.Backup.Dir, name), nil
|
||||
}
|
||||
|
||||
// Create собирает архив из Saves и серверных конфигов. Суффикс note попадает в
|
||||
// имя файла, чтобы ручные бэкапы было легко отличить.
|
||||
func (m *Manager) Create(ctx context.Context, log func(string), note string) (Archive, error) {
|
||||
// ProfileOf возвращает профиль, которому принадлежит архив. Для архивов,
|
||||
// снятых до появления профилей, возвращает пустую строку.
|
||||
func (m *Manager) ProfileOf(name string) string {
|
||||
match := nameRe.FindStringSubmatch(name)
|
||||
if match == nil {
|
||||
return ""
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
// Create собирает архив мира профиля и его серверных конфигов. Суффикс note
|
||||
// попадает в имя файла, чтобы ручные бэкапы было легко отличить.
|
||||
func (m *Manager) Create(ctx context.Context, log func(string), profileID, note string) (Archive, error) {
|
||||
if err := config.ValidateProfileID(profileID); err != nil {
|
||||
return Archive{}, err
|
||||
}
|
||||
if err := os.MkdirAll(m.cfg.Backup.Dir, 0o755); err != nil {
|
||||
return Archive{}, err
|
||||
}
|
||||
name := fmt.Sprintf("pz-%s", time.Now().Format("20060102-150405"))
|
||||
|
||||
name := fmt.Sprintf("pz-%s-%s", profileID, time.Now().Format("20060102-150405"))
|
||||
if note = sanitizeNote(note); note != "" {
|
||||
name += "-" + note
|
||||
}
|
||||
name += ".tar.gz"
|
||||
target := filepath.Join(m.cfg.Backup.Dir, name)
|
||||
|
||||
log("Создаю архив " + name)
|
||||
log(fmt.Sprintf("Создаю архив %s (профиль %s)", name, profileID))
|
||||
// Пишем во временный файл: недописанный архив не должен попасть в список.
|
||||
tmp := target + ".part"
|
||||
f, err := os.Create(tmp)
|
||||
@@ -102,22 +138,35 @@ func (m *Manager) Create(ctx context.Context, log func(string), note string) (Ar
|
||||
gz := gzip.NewWriter(f)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
// abort закрывает всё начатое и убирает недописанный файл.
|
||||
abort := func(err error) (Archive, error) {
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return Archive{}, err
|
||||
}
|
||||
|
||||
var written int64
|
||||
sources := []string{m.cfg.SavesDir(), filepath.Join(m.cfg.ZomboidDir, "Server")}
|
||||
for _, src := range sources {
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
log("Пропускаю " + src + " — нет такой директории")
|
||||
continue
|
||||
}
|
||||
n, err := addTree(ctx, tw, m.cfg.ZomboidDir, src, log)
|
||||
if world := m.cfg.WorldDirFor(profileID); dirExists(world) {
|
||||
n, err := addTree(ctx, tw, m.cfg.ZomboidDir, world, log)
|
||||
written += n
|
||||
if err != nil {
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return Archive{}, err
|
||||
return abort(err)
|
||||
}
|
||||
} else {
|
||||
log("Мир ещё не создан — архивирую только конфиги")
|
||||
}
|
||||
|
||||
configs, err := profileConfigFiles(m.cfg.ServerDirPath(), profileID)
|
||||
if err != nil {
|
||||
return abort(err)
|
||||
}
|
||||
for _, file := range configs {
|
||||
if err := addFile(tw, m.cfg.ZomboidDir, file); err != nil {
|
||||
return abort(err)
|
||||
}
|
||||
written++
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
@@ -145,19 +194,86 @@ func (m *Manager) Create(ctx context.Context, log func(string), note string) (Ar
|
||||
}
|
||||
log(fmt.Sprintf("Архив готов: %s (%s, файлов: %d)", name, humanSize(info.Size()), written))
|
||||
|
||||
if err := m.Prune(m.cfg.Backup.Keep, log); err != nil {
|
||||
if err := m.Prune(profileID, m.cfg.Backup.Keep, log); err != nil {
|
||||
log("Ротация не удалась: " + err.Error())
|
||||
}
|
||||
return Archive{Name: name, SizeBytes: info.Size(), CreatedAt: info.ModTime().Unix()}, nil
|
||||
return Archive{
|
||||
Name: name,
|
||||
Profile: profileID,
|
||||
SizeBytes: info.Size(),
|
||||
CreatedAt: info.ModTime().Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// profileConfigFiles находит серверные файлы профиля: <id>.ini,
|
||||
// <id>_SandboxVars.lua, <id>_spawnpoints.lua и прочие, которые создаёт игра.
|
||||
// Разделитель после префикса проверяется явно: иначе профиль "test" утащил бы
|
||||
// файлы профиля "test2".
|
||||
func profileConfigFiles(serverDir, profileID string) ([]string, error) {
|
||||
entries, err := os.ReadDir(serverDir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasSuffix(name, ".bak") || strings.HasSuffix(name, ".tmp") {
|
||||
continue
|
||||
}
|
||||
rest, ok := strings.CutPrefix(name, profileID)
|
||||
if !ok || rest == "" {
|
||||
continue
|
||||
}
|
||||
if rest[0] != '.' && rest[0] != '_' {
|
||||
continue
|
||||
}
|
||||
out = append(out, filepath.Join(serverDir, name))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addFile кладёт один файл в архив, сохраняя путь относительно root.
|
||||
func addFile(tw *tar.Writer, root, filePath string) error {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr.Name = filepath.ToSlash(rel)
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(tw, f)
|
||||
return err
|
||||
}
|
||||
|
||||
// addTree кладёт директорию в архив, сохраняя пути относительно root.
|
||||
func addTree(ctx context.Context, tw *tar.Writer, root, dir string, log func(string)) (int64, error) {
|
||||
var count int64
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
err := filepath.Walk(dir, func(filePath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
// Файл мог исчезнуть, пока идёт обход, — это не повод падать.
|
||||
log("Пропускаю " + path + ": " + err.Error())
|
||||
log("Пропускаю " + filePath + ": " + err.Error())
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
@@ -169,7 +285,7 @@ func addTree(ctx context.Context, tw *tar.Writer, root, dir string, log func(str
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(root, path)
|
||||
rel, err := filepath.Rel(root, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -188,7 +304,7 @@ func addTree(ctx context.Context, tw *tar.Writer, root, dir string, log func(str
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
// Блокировка файла сервером не должна ронять весь бэкап.
|
||||
log("Не прочитан " + rel + ": " + err.Error())
|
||||
@@ -204,14 +320,16 @@ func addTree(ctx context.Context, tw *tar.Writer, root, dir string, log func(str
|
||||
return count, err
|
||||
}
|
||||
|
||||
// Restore распаковывает архив поверх ~/Zomboid. Сервер должен быть остановлен —
|
||||
// это проверяет вызывающий код.
|
||||
// Restore распаковывает архив поверх ~/Zomboid. Пути внутри архива уже
|
||||
// содержат имя профиля, поэтому восстановление не зависит от того, какой
|
||||
// профиль сейчас активен. Сервер должен быть остановлен — это проверяет
|
||||
// вызывающий код.
|
||||
func (m *Manager) Restore(ctx context.Context, log func(string), name string) error {
|
||||
path, err := m.Path(name)
|
||||
archivePath, err := m.Path(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -272,23 +390,28 @@ func (m *Manager) Restore(ctx context.Context, log func(string), name string) er
|
||||
|
||||
// Delete удаляет архив.
|
||||
func (m *Manager) Delete(name string) error {
|
||||
path, err := m.Path(name)
|
||||
archivePath, err := m.Path(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(path)
|
||||
return os.Remove(archivePath)
|
||||
}
|
||||
|
||||
// Prune оставляет только keep самых свежих архивов (keep <= 0 — не трогать).
|
||||
func (m *Manager) Prune(keep int, log func(string)) error {
|
||||
// Prune оставляет только keep самых свежих архивов профиля (keep <= 0 — не
|
||||
// трогать). Ротация идёт по профилям отдельно: иначе частые бэкапы активного
|
||||
// сервера вытеснили бы архивы остальных.
|
||||
func (m *Manager) Prune(profileID string, keep int, log func(string)) error {
|
||||
if keep <= 0 {
|
||||
return nil
|
||||
}
|
||||
archives, err := m.List()
|
||||
archives, err := m.ListForProfile(profileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range archives[min(keep, len(archives)):] {
|
||||
if len(archives) <= keep {
|
||||
return nil
|
||||
}
|
||||
for _, a := range archives[keep:] {
|
||||
if err := m.Delete(a.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -332,6 +455,11 @@ func sanitizeNote(note string) string {
|
||||
return strings.Trim(b.String(), "_-")
|
||||
}
|
||||
|
||||
func dirExists(p string) bool {
|
||||
info, err := os.Stat(p)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func humanSize(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
|
||||
+113
-30
@@ -39,59 +39,114 @@ func TestSanitizeNote(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameValidation(t *testing.T) {
|
||||
func TestNameValidationAndProfileParsing(t *testing.T) {
|
||||
m := New(&config.Config{})
|
||||
|
||||
if _, err := m.Path("../../etc/passwd"); err == nil {
|
||||
t.Error("имя с переходом по каталогам принято")
|
||||
}
|
||||
if _, err := m.Path("pz-20250903-181500-auto.tar.gz"); err != nil {
|
||||
if _, err := m.Path("pz-vanilla-20260904-181500-auto.tar.gz"); err != nil {
|
||||
t.Errorf("корректное имя отклонено: %v", err)
|
||||
}
|
||||
// Архивы, снятые до появления профилей, тоже должны читаться.
|
||||
if _, err := m.Path("pz-20250903-214141.tar.gz"); err != nil {
|
||||
t.Errorf("архив старого формата отклонён: %v", err)
|
||||
}
|
||||
|
||||
cases := map[string]string{
|
||||
"pz-vanilla-20260904-181500-auto.tar.gz": "vanilla",
|
||||
"pz-modded_b42-20260904-181500.tar.gz": "modded_b42",
|
||||
"pz-20250903-214141.tar.gz": "",
|
||||
"не-архив.tar.gz": "",
|
||||
}
|
||||
for name, want := range cases {
|
||||
if got := m.ProfileOf(name); got != want {
|
||||
t.Errorf("ProfileOf(%q) = %q, ожидалось %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateAndRestore проверяет полный цикл: архивируем дерево, портим файл,
|
||||
// восстанавливаем и убеждаемся, что содержимое вернулось.
|
||||
// TestProfileConfigFiles проверяет, что профиль забирает только свои файлы:
|
||||
// префиксы имён профилей часто пересекаются.
|
||||
func TestProfileConfigFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := []string{
|
||||
"test.ini", "test_SandboxVars.lua", "test_spawnpoints.lua",
|
||||
"test2.ini", "test2_SandboxVars.lua",
|
||||
"testing.ini", "test.ini.bak", "other.ini",
|
||||
}
|
||||
for _, name := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := profileConfigFiles(dir, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]bool{
|
||||
"test.ini": true, "test_SandboxVars.lua": true, "test_spawnpoints.lua": true,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("выбрано %d файлов, ожидалось %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for _, path := range got {
|
||||
if !want[filepath.Base(path)] {
|
||||
t.Errorf("лишний файл в архиве профиля: %s", filepath.Base(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateAndRestore проверяет полный цикл для одного профиля: архивируем
|
||||
// мир и конфиг, портим их, восстанавливаем и убеждаемся, что вернулось
|
||||
// содержимое именно этого профиля.
|
||||
func TestCreateAndRestore(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
zomboid := filepath.Join(root, "Zomboid")
|
||||
savePath := filepath.Join(zomboid, "Saves", "Multiplayer", "world.bin")
|
||||
world := filepath.Join(zomboid, "Saves", "Multiplayer", "vanilla", "map_t.bin")
|
||||
iniPath := filepath.Join(zomboid, "Server", "vanilla.ini")
|
||||
otherWorld := filepath.Join(zomboid, "Saves", "Multiplayer", "modded", "map_t.bin")
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(savePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(savePath, []byte("исходный мир"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, world, "исходный мир")
|
||||
writeFile(t, iniPath, "MaxPlayers=32")
|
||||
writeFile(t, otherWorld, "чужой мир")
|
||||
|
||||
cfg := &config.Config{ZomboidDir: zomboid}
|
||||
cfg.Backup.Dir = filepath.Join(root, "backups")
|
||||
m := New(cfg)
|
||||
|
||||
archive, err := m.Create(context.Background(), func(string) {}, "test")
|
||||
archive, err := m.Create(context.Background(), func(string) {}, "vanilla", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if archive.SizeBytes == 0 {
|
||||
t.Error("архив пустой")
|
||||
if archive.Profile != "vanilla" {
|
||||
t.Errorf("профиль архива = %q", archive.Profile)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(savePath, []byte("испорчено"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, world, "испорчено")
|
||||
writeFile(t, iniPath, "MaxPlayers=1")
|
||||
writeFile(t, otherWorld, "чужой мир изменён")
|
||||
|
||||
if err := m.Restore(context.Background(), func(string) {}, archive.Name); err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
restored, err := os.ReadFile(savePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if got := readFile(t, world); got != "исходный мир" {
|
||||
t.Errorf("мир после восстановления = %q", got)
|
||||
}
|
||||
if string(restored) != "исходный мир" {
|
||||
t.Errorf("после восстановления содержимое = %q", restored)
|
||||
if got := readFile(t, iniPath); got != "MaxPlayers=32" {
|
||||
t.Errorf("конфиг после восстановления = %q", got)
|
||||
}
|
||||
// Мир другого профиля восстановление трогать не должно.
|
||||
if got := readFile(t, otherWorld); got != "чужой мир изменён" {
|
||||
t.Errorf("затронут мир чужого профиля: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneKeepsNewest(t *testing.T) {
|
||||
// TestPruneIsPerProfile проверяет, что ротация не выкидывает архивы соседних
|
||||
// профилей: у каждого профиля своя история.
|
||||
func TestPruneIsPerProfile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := &config.Config{ZomboidDir: filepath.Join(root, "Zomboid")}
|
||||
cfg.Backup.Dir = filepath.Join(root, "backups")
|
||||
@@ -101,24 +156,52 @@ func TestPruneKeepsNewest(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := []string{
|
||||
"pz-20250101-000000.tar.gz",
|
||||
"pz-20250102-000000.tar.gz",
|
||||
"pz-20250103-000000.tar.gz",
|
||||
"pz-vanilla-20260101-000000.tar.gz",
|
||||
"pz-vanilla-20260102-000000.tar.gz",
|
||||
"pz-vanilla-20260103-000000.tar.gz",
|
||||
"pz-modded-20260101-000000.tar.gz",
|
||||
}
|
||||
for _, name := range names {
|
||||
writeEmptyArchive(t, filepath.Join(cfg.Backup.Dir, name))
|
||||
}
|
||||
|
||||
if err := m.Prune(2, func(string) {}); err != nil {
|
||||
if err := m.Prune("vanilla", 2, func(string) {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
left, err := m.List()
|
||||
|
||||
vanilla, err := m.ListForProfile("vanilla")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) != 2 {
|
||||
t.Fatalf("осталось %d архивов, ожидалось 2: %v", len(left), left)
|
||||
if len(vanilla) != 2 {
|
||||
t.Errorf("у vanilla осталось %d архивов, ожидалось 2", len(vanilla))
|
||||
}
|
||||
modded, err := m.ListForProfile("modded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(modded) != 1 {
|
||||
t.Errorf("ротация зацепила архивы профиля modded: осталось %d", len(modded))
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// writeEmptyArchive создаёт валидный пустой tar.gz — List проверяет имя, а не
|
||||
|
||||
Reference in New Issue
Block a user