Профили серверов: несколько миров с раздельными настройками

Профиль — это отдельный сервер: свой мир, .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:
2026-09-04 00:27:12 +03:00
co-authored by Claude Opus 5
parent c384d60416
commit 277ab7af4a
13 changed files with 1317 additions and 183 deletions
+166 -38
View File
@@ -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 {