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

Профиль — это отдельный сервер: свой мир, .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
+16 -37
View File
@@ -111,27 +111,22 @@ func (s *Server) handlePutSandbox(w http.ResponseWriter, r *http.Request) {
}
// managerConfigDTO — вид конфига менеджера, удобный для формы в браузере:
// длительности в секундах, пароль наружу не отдаётся.
// длительности в секундах. Всё, что относится к конкретному серверу (имя,
// пароль админа, память JVM), живёт в профилях и правится отдельно.
type managerConfigDTO struct {
Listen string `json:"listen"`
ServerDir string `json:"server_dir"`
ZomboidDir string `json:"zomboid_dir"`
SteamCMDPath string `json:"steamcmd_path"`
ServerName string `json:"server_name"`
JavaMemory string `json:"java_memory"`
ExtraArgs []string `json:"extra_args"`
AutoStart bool `json:"autostart"`
AutoRestart bool `json:"autorestart"`
StopTimeoutSec int64 `json:"stop_timeout_sec"`
LogBufferLines int `json:"log_buffer_lines"`
BackupDir string `json:"backup_dir"`
BackupScheduleMin int64 `json:"backup_schedule_min"`
BackupKeep int `json:"backup_keep"`
BackupStopServer bool `json:"backup_stop_server"`
HasAdminPassword bool `json:"has_admin_password"`
AdminPassword string `json:"admin_password,omitempty"`
ConfigPath string `json:"config_path"`
RequiresRestartMsg string `json:"requires_restart_msg,omitempty"`
Listen string `json:"listen"`
ServerDir string `json:"server_dir"`
ZomboidDir string `json:"zomboid_dir"`
SteamCMDPath string `json:"steamcmd_path"`
AutoStart bool `json:"autostart"`
AutoRestart bool `json:"autorestart"`
StopTimeoutSec int64 `json:"stop_timeout_sec"`
LogBufferLines int `json:"log_buffer_lines"`
BackupDir string `json:"backup_dir"`
BackupScheduleMin int64 `json:"backup_schedule_min"`
BackupKeep int `json:"backup_keep"`
BackupStopServer bool `json:"backup_stop_server"`
ConfigPath string `json:"config_path"`
}
func (s *Server) handleGetManagerConfig(w http.ResponseWriter, r *http.Request) {
@@ -141,9 +136,6 @@ func (s *Server) handleGetManagerConfig(w http.ResponseWriter, r *http.Request)
ServerDir: c.ServerDir,
ZomboidDir: c.ZomboidDir,
SteamCMDPath: c.SteamCMDPath,
ServerName: c.ServerName,
JavaMemory: c.JavaMemory,
ExtraArgs: c.ExtraArgs,
AutoStart: c.AutoStart,
AutoRestart: c.AutoRestart,
StopTimeoutSec: c.StopTimeout.Seconds(),
@@ -152,14 +144,11 @@ func (s *Server) handleGetManagerConfig(w http.ResponseWriter, r *http.Request)
BackupScheduleMin: int64(c.Backup.Schedule.Duration().Minutes()),
BackupKeep: c.Backup.Keep,
BackupStopServer: c.Backup.StopServer,
HasAdminPassword: c.AdminPassword != "",
ConfigPath: c.Path(),
})
}
// handlePutManagerConfig сохраняет настройки менеджера. Пустой admin_password
// означает "оставить прежний", иначе его нельзя было бы не потерять при
// каждом сохранении формы.
// handlePutManagerConfig сохраняет общие настройки менеджера.
func (s *Server) handlePutManagerConfig(w http.ResponseWriter, r *http.Request) {
var dto managerConfigDTO
if !decodeJSON(w, r, &dto) {
@@ -171,13 +160,6 @@ func (s *Server) handlePutManagerConfig(w http.ResponseWriter, r *http.Request)
c.ServerDir = dto.ServerDir
c.ZomboidDir = dto.ZomboidDir
c.SteamCMDPath = dto.SteamCMDPath
c.ServerName = dto.ServerName
c.JavaMemory = dto.JavaMemory
// Пустой список аргументов от формы означает "не трогать": ExtraArgs
// правятся только через файл конфигурации.
if dto.ExtraArgs != nil {
c.ExtraArgs = dto.ExtraArgs
}
c.AutoStart = dto.AutoStart
c.AutoRestart = dto.AutoRestart
c.LogBufferLines = dto.LogBufferLines
@@ -188,9 +170,6 @@ func (s *Server) handlePutManagerConfig(w http.ResponseWriter, r *http.Request)
c.StopTimeout = config.Duration(time.Duration(dto.StopTimeoutSec) * time.Second)
}
c.Backup.Schedule = config.Duration(time.Duration(dto.BackupScheduleMin) * time.Minute)
if dto.AdminPassword != "" {
c.AdminPassword = dto.AdminPassword
}
if err := c.Save(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
+223
View File
@@ -0,0 +1,223 @@
package api
import (
"fmt"
"io"
"net/http"
"os"
"github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/pzserver"
)
// profileDTO — профиль в том виде, в каком его показывает панель. Пароль
// наружу не отдаётся, только признак того, что он задан.
type profileDTO struct {
ID string `json:"id"`
Title string `json:"title"`
JavaMemory string `json:"java_memory"`
ExtraArgs []string `json:"extra_args"`
HasAdminPassword bool `json:"has_admin_password"`
AdminPassword string `json:"admin_password,omitempty"`
// Признаки состояния на диске: по ним панель подсказывает, что профиль
// ещё ни разу не запускался.
HasConfig bool `json:"has_config"`
WorldReady bool `json:"world_ready"`
Active bool `json:"active"`
// CopyFrom заполняется только при создании профиля.
CopyFrom string `json:"copy_from,omitempty"`
}
func (s *Server) profileToDTO(p config.Profile) profileDTO {
return profileDTO{
ID: p.ID,
Title: p.Title,
JavaMemory: p.JavaMemory,
ExtraArgs: p.ExtraArgs,
HasAdminPassword: p.AdminPassword != "",
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)),
Active: p.ID == s.cfg.Active,
}
}
func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request) {
profiles := make([]profileDTO, 0, len(s.cfg.Profiles))
for _, p := range s.cfg.Profiles {
profiles = append(profiles, s.profileToDTO(p))
}
writeJSON(w, http.StatusOK, map[string]any{
"profiles": profiles,
"active": s.cfg.Active,
// Переключать профиль можно только на остановленном сервере.
"can_switch": s.sup.State() == pzserver.StateStopped,
})
}
// handleCreateProfile заводит новый профиль и, если попросили, копирует в него
// конфиг и настройки песочницы существующего.
func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
var dto profileDTO
if !decodeJSON(w, r, &dto) {
return
}
profile := config.Profile{
ID: dto.ID,
Title: dto.Title,
JavaMemory: dto.JavaMemory,
ExtraArgs: dto.ExtraArgs,
AdminPassword: dto.AdminPassword,
}
if err := s.cfg.AddProfile(profile); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if dto.CopyFrom != "" {
if err := s.copyProfileConfigs(dto.CopyFrom, profile.ID); err != nil {
// Профиль уже добавлен в память — откатываем, чтобы не остался
// наполовину настроенный.
_ = s.cfg.DeleteProfile(profile.ID)
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
if err := s.cfg.Save(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.sup.AppendLog("manager", fmt.Sprintf("Создан профиль %q", profile.Name()))
writeJSON(w, http.StatusCreated, s.profileToDTO(profile))
}
// copyProfileConfigs дублирует .ini и SandboxVars.lua исходного профиля под
// именем нового: так новый сервер стартует с уже знакомыми настройками и
// списком модов, но со своим миром.
func (s *Server) copyProfileConfigs(from, to string) error {
if _, ok := s.cfg.Profile(from); !ok {
return fmt.Errorf("профиль-источник %q не найден", from)
}
pairs := [][2]string{
{s.cfg.ServerConfigPathFor(from), s.cfg.ServerConfigPathFor(to)},
{s.cfg.SandboxConfigPathFor(from), s.cfg.SandboxConfigPathFor(to)},
}
copied := 0
for _, pair := range pairs {
err := copyFile(pair[0], pair[1])
if os.IsNotExist(err) {
continue
}
if err != nil {
return fmt.Errorf("скопировать настройки: %w", err)
}
copied++
}
if copied == 0 {
return fmt.Errorf("у профиля %q ещё нет файлов настроек — сначала запустите его хотя бы раз", from)
}
s.sup.AppendLog("manager", fmt.Sprintf("Настройки профиля %q скопированы в %q", from, to))
return nil
}
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
current, ok := s.cfg.Profile(id)
if !ok {
writeError(w, http.StatusNotFound, "профиль не найден")
return
}
var dto profileDTO
if !decodeJSON(w, r, &dto) {
return
}
updated := config.Profile{
ID: id,
Title: dto.Title,
JavaMemory: dto.JavaMemory,
ExtraArgs: dto.ExtraArgs,
// Пустой пароль означает "оставить прежний": наружу мы его не отдаём,
// и форма присылает пустую строку, пока его не меняли.
AdminPassword: current.AdminPassword,
}
if dto.AdminPassword != "" {
updated.AdminPassword = dto.AdminPassword
}
if err := s.cfg.UpdateProfile(id, updated); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.cfg.Save(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
note := "Профиль сохранён"
if s.sup.State() != pzserver.StateStopped && id == s.cfg.Active {
note = "Профиль сохранён. Память и аргументы запуска применятся после перезапуска сервера."
}
writeJSON(w, http.StatusOK, map[string]any{"saved": true, "note": note})
}
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.cfg.DeleteProfile(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.cfg.Save(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.sup.AppendLog("manager", fmt.Sprintf(
"Профиль %q удалён из панели. Мир и конфиги остались на диске: %s",
id, s.cfg.WorldDirFor(id)))
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
// handleActivateProfile переключает активный профиль. Делать это на живом
// сервере нельзя: профили делят игровые порты и файлы запуска.
func (s *Server) handleActivateProfile(w http.ResponseWriter, r *http.Request) {
if s.sup.State() != pzserver.StateStopped {
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
return
}
id := r.PathValue("id")
if err := s.cfg.SetActive(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.cfg.Save(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.sup.AppendLog("manager", fmt.Sprintf("Активный профиль: %s", s.cfg.ActiveProfile().Name()))
writeJSON(w, http.StatusOK, map[string]any{"active": id})
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
// O_EXCL: перезаписывать чужой конфиг при создании профиля нельзя.
out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
+47 -12
View File
@@ -58,23 +58,49 @@ func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"backups": archives, "dir": s.backups.Dir()})
// Фильтр по профилю необязателен: панель показывает архивы всех профилей,
// помечая каждый, но умеет и сузить список.
if profile := r.URL.Query().Get("profile"); profile != "" {
filtered := archives[:0]
for _, a := range archives {
if a.Profile == profile {
filtered = append(filtered, a)
}
}
archives = filtered
}
writeJSON(w, http.StatusOK, map[string]any{
"backups": archives,
"dir": s.backups.Dir(),
"active": s.cfg.Active,
})
}
func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
var body struct {
Note string `json:"note"`
Note string `json:"note"`
Profile string `json:"profile"`
}
if r.ContentLength > 0 && !decodeJSON(w, r, &body) {
return
}
if body.Profile == "" {
body.Profile = s.cfg.Active
}
if _, ok := s.cfg.Profile(body.Profile); !ok {
writeError(w, http.StatusBadRequest, "профиль не найден")
return
}
// Живой сервер держит мир в памяти, и снимок чужого профиля тут ни при
// чём: остановка нужна только для профиля, который сейчас работает.
profileIsRunning := body.Profile == s.cfg.Active && s.sup.State() != pzserver.StateStopped
name := "Резервное копирование мира"
name := "Резервное копирование мира: " + body.Profile
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
// Опциональная остановка сервера даёт согласованный снимок: PZ
// сбрасывает мир на диск только при сохранении.
restart := false
if s.cfg.Backup.StopServer && s.sup.State() != pzserver.StateStopped {
if s.cfg.Backup.StopServer && profileIsRunning {
log("Останавливаю сервер перед бэкапом")
if err := s.sup.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) {
return err
@@ -82,7 +108,7 @@ func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
restart = true
}
_, err := s.backups.Create(ctx, log, body.Note)
_, err := s.backups.Create(ctx, log, body.Profile, body.Note)
if restart {
log("Поднимаю сервер обратно")
@@ -144,13 +170,19 @@ func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Страховочную копию снимаем с того профиля, поверх которого ляжет архив;
// у старых архивов профиль в имени не записан — берём активный.
restoreProfile := s.backups.ProfileOf(name)
if _, ok := s.cfg.Profile(restoreProfile); !ok {
restoreProfile = s.cfg.Active
}
taskName := "Восстановление из архива " + name
err := s.jobs.Start(taskName, func(ctx context.Context, log func(string)) error {
// Перед перезаписью снимаем страховочную копию текущего состояния:
// восстановление не туда — самая дорогая ошибка в этой панели.
log("Делаю страховочный бэкап текущего мира")
if _, err := s.backups.Create(ctx, log, "pre-restore"); err != nil {
if _, err := s.backups.Create(ctx, log, restoreProfile, "pre-restore"); err != nil {
return fmt.Errorf("страховочный бэкап не создан, восстановление отменено: %w", err)
}
return s.backups.Restore(ctx, log, name)
@@ -185,9 +217,10 @@ func StartBackupScheduler(ctx context.Context, s *Server) {
s.sup.AppendLog("manager", "Плановый бэкап пропущен: выполняется другая задача")
continue
}
err := s.jobs.Start("Плановое резервное копирование",
profile := s.cfg.Active
err := s.jobs.Start("Плановое резервное копирование: "+profile,
func(ctx context.Context, log func(string)) error {
_, err := s.backups.Create(ctx, log, "auto")
_, err := s.backups.Create(ctx, log, profile, "auto")
return err
})
if err != nil && !errors.Is(err, job.ErrBusy) {
@@ -198,15 +231,17 @@ func StartBackupScheduler(ctx context.Context, s *Server) {
}()
}
// applyJavaMemory переносит лимит памяти из конфига менеджера в файл запуска
// applyJavaMemory переносит лимит памяти активного профиля в файл запуска
// сервера. Вызывается после установки: SteamCMD перезаписывает этот файл.
// При обычном старте то же самое делает супервизор.
func (s *Server) applyJavaMemory(log func(string)) error {
if s.cfg.JavaMemory == "" {
profile := s.cfg.ActiveProfile()
if profile.JavaMemory == "" {
return nil
}
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, s.cfg.JavaMemory); err != nil {
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory); err != nil {
return err
}
log("Лимит памяти JVM выставлен: " + s.cfg.JavaMemory)
log("Лимит памяти JVM выставлен: " + profile.JavaMemory)
return nil
}
+5
View File
@@ -95,6 +95,11 @@ func (s *Server) Handler() http.Handler {
"PUT /api/config/sandbox": s.handlePutSandbox,
"GET /api/config/manager": s.handleGetManagerConfig,
"PUT /api/config/manager": s.handlePutManagerConfig,
"GET /api/profiles": s.handleListProfiles,
"POST /api/profiles": s.handleCreateProfile,
"PUT /api/profiles/{id}": s.handleUpdateProfile,
"DELETE /api/profiles/{id}": s.handleDeleteProfile,
"POST /api/profiles/{id}/activate": s.handleActivateProfile,
"GET /api/mods": s.handleGetMods,
"PUT /api/mods": s.handlePutMods,
"POST /api/mods/lookup": s.handleLookupMods,
+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 {
+113 -30
View File
@@ -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 проверяет имя, а не
+238 -27
View File
@@ -5,12 +5,56 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// Config — всё, что менеджеру нужно знать о хосте и о сервере PZ.
// profileIDRe ограничивает идентификатор профиля: он становится именем файлов
// PZ (servertest.ini, servertest_SandboxVars.lua) и частью пути к миру.
var profileIDRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,32}$`)
// Profile — один игровой сервер: собственный мир, конфиг, набор модов и
// параметры запуска. ID профиля — это -servername для Project Zomboid.
type Profile struct {
// ID — имя набора конфигов PZ: servertest -> servertest.ini, мир лежит в
// Saves/Multiplayer/servertest.
ID string `yaml:"id"`
// Title — понятное человеку название для веб-панели.
Title string `yaml:"title"`
// AdminPassword — пароль админа игрового сервера, нужен при первом
// запуске PZ. Хранится здесь, чтобы не вводить его в консоли.
AdminPassword string `yaml:"admin_password"`
// JavaMemory — -Xmx для JVM, например "8g". Применяется при каждом
// запуске: файл запуска сервера общий для всех профилей.
JavaMemory string `yaml:"java_memory"`
// ExtraArgs — дополнительные аргументы start-server.sh.
ExtraArgs []string `yaml:"extra_args"`
}
// Name возвращает название для интерфейса: заголовок, а если его нет — ID.
func (p Profile) Name() string {
if p.Title != "" {
return p.Title
}
return p.ID
}
// ValidateProfileID проверяет идентификатор, приходящий из веб-панели.
func ValidateProfileID(id string) error {
if !profileIDRe.MatchString(id) {
return fmt.Errorf("идентификатор %q недопустим: разрешены латинские буквы, цифры, дефис и подчёркивание (до 32 символов)", id)
}
return nil
}
// Config — всё, что менеджеру нужно знать о хосте и о серверах PZ.
type Config struct {
// Listen — адрес веб-панели. Для доступа только через SSH-туннель
// используйте 127.0.0.1:8080.
@@ -19,7 +63,8 @@ type Config struct {
// DataDir — рабочая директория менеджера (пользователи, сессии, бэкапы).
DataDir string `yaml:"data_dir"`
// ServerDir — куда SteamCMD ставит серверные файлы PZ.
// ServerDir — куда SteamCMD ставит серверные файлы PZ. Общий для всех
// профилей: сама игра одна, различаются только конфиги и миры.
ServerDir string `yaml:"server_dir"`
// ZomboidDir — ~/Zomboid: сохранения, конфиги, логи.
@@ -28,18 +73,12 @@ type Config struct {
// SteamCMDPath — путь к бинарю steamcmd.sh (пустой = ищем в PATH).
SteamCMDPath string `yaml:"steamcmd_path"`
// ServerName — имя набора конфигов PZ (servertest -> servertest.ini).
ServerName string `yaml:"server_name"`
// Profiles — сконфигурированные серверы. Одновременно работает только
// один: они делят серверные файлы и игровые порты.
Profiles []Profile `yaml:"profiles"`
// AdminPassword — пароль админа игрового сервера, нужен при первом
// запуске PZ. Хранится здесь, чтобы не вводить его в консоли.
AdminPassword string `yaml:"admin_password"`
// JavaMemory — -Xmx для JVM сервера, например "8g".
JavaMemory string `yaml:"java_memory"`
// ExtraArgs — дополнительные аргументы запуска сервера.
ExtraArgs []string `yaml:"extra_args"`
// Active — ID профиля, который запускается и настраивается сейчас.
Active string `yaml:"active_profile"`
// AutoStart — поднимать игровой сервер сразу при старте менеджера.
AutoStart bool `yaml:"autostart"`
@@ -56,6 +95,13 @@ type Config struct {
// Backup — настройки резервного копирования.
Backup BackupConfig `yaml:"backup"`
// Поля ниже остались от версии с единственным сервером. При загрузке они
// превращаются в первый профиль и больше не записываются в файл.
LegacyServerName string `yaml:"server_name,omitempty"`
LegacyAdminPassword string `yaml:"admin_password,omitempty"`
LegacyJavaMemory string `yaml:"java_memory,omitempty"`
LegacyExtraArgs []string `yaml:"extra_args,omitempty"`
path string
}
@@ -65,13 +111,23 @@ type BackupConfig struct {
Dir string `yaml:"dir"`
// Schedule — интервал автобэкапа; 0 отключает автоматику.
Schedule Duration `yaml:"schedule"`
// Keep — сколько последних архивов хранить (0 = не удалять).
// Keep — сколько последних архивов хранить для каждого профиля
// (0 = не удалять).
Keep int `yaml:"keep"`
// StopServer — останавливать сервер на время бэкапа. Безопаснее, но
// игроков выкидывает; по умолчанию выключено.
StopServer bool `yaml:"stop_server"`
}
// DefaultProfile — профиль, который создаётся на чистой установке.
func DefaultProfile() Profile {
return Profile{
ID: "servertest",
Title: "Основной сервер",
JavaMemory: "4g",
}
}
// Default возвращает конфиг с разумными значениями для Ubuntu.
func Default() *Config {
home, _ := os.UserHomeDir()
@@ -80,8 +136,8 @@ func Default() *Config {
DataDir: filepath.Join(home, ".local", "share", "pzmanager"),
ServerDir: filepath.Join(home, "pzserver"),
ZomboidDir: filepath.Join(home, "Zomboid"),
ServerName: "servertest",
JavaMemory: "4g",
Profiles: []Profile{DefaultProfile()},
Active: DefaultProfile().ID,
AutoRestart: true,
StopTimeout: Duration(90 * time.Second),
LogBufferLines: 5000,
@@ -109,13 +165,58 @@ func Load(path string) (*Config, error) {
if err != nil {
return nil, fmt.Errorf("прочитать конфиг %s: %w", path, err)
}
// Профили из файла должны заменить умолчания, а не дополнить их.
cfg.Profiles = nil
if err := yaml.Unmarshal(raw, cfg); err != nil {
return nil, fmt.Errorf("разобрать конфиг %s: %w", path, err)
}
migrated := cfg.migrateLegacy()
cfg.applyFallbacks()
if migrated {
// Сразу переписываем файл, чтобы старые поля не остались висеть
// рядом с новым списком профилей и не путали при правке руками.
if err := cfg.Save(); err != nil {
return nil, fmt.Errorf("обновить конфиг %s: %w", path, err)
}
}
return cfg, nil
}
// migrateLegacy превращает настройки единственного сервера в первый профиль.
// Возвращает true, если конфиг пришлось поменять.
func (c *Config) migrateLegacy() bool {
hasLegacy := c.LegacyServerName != "" || c.LegacyAdminPassword != "" ||
c.LegacyJavaMemory != "" || len(c.LegacyExtraArgs) > 0
if !hasLegacy {
return false
}
profile := Profile{
ID: c.LegacyServerName,
AdminPassword: c.LegacyAdminPassword,
JavaMemory: c.LegacyJavaMemory,
ExtraArgs: c.LegacyExtraArgs,
}
if profile.ID == "" {
profile.ID = DefaultProfile().ID
}
profile.Title = "Основной сервер"
// Если профили уже описаны, старые поля просто отбрасываем: список
// профилей — источник истины.
if len(c.Profiles) == 0 {
c.Profiles = []Profile{profile}
c.Active = profile.ID
}
c.LegacyServerName = ""
c.LegacyAdminPassword = ""
c.LegacyJavaMemory = ""
c.LegacyExtraArgs = nil
return true
}
// applyFallbacks подставляет умолчания вместо пустых значений: конфиг,
// написанный руками, редко заполнен целиком.
func (c *Config) applyFallbacks() {
@@ -132,12 +233,6 @@ func (c *Config) applyFallbacks() {
if c.ZomboidDir == "" {
c.ZomboidDir = d.ZomboidDir
}
if c.ServerName == "" {
c.ServerName = d.ServerName
}
if c.JavaMemory == "" {
c.JavaMemory = d.JavaMemory
}
if c.StopTimeout <= 0 {
c.StopTimeout = d.StopTimeout
}
@@ -147,6 +242,102 @@ func (c *Config) applyFallbacks() {
if c.Backup.Dir == "" {
c.Backup.Dir = d.Backup.Dir
}
if len(c.Profiles) == 0 {
c.Profiles = []Profile{DefaultProfile()}
}
for i := range c.Profiles {
if c.Profiles[i].JavaMemory == "" {
c.Profiles[i].JavaMemory = DefaultProfile().JavaMemory
}
}
// Активный профиль мог быть удалён из файла руками.
if _, ok := c.Profile(c.Active); !ok {
c.Active = c.Profiles[0].ID
}
}
// Profile возвращает профиль по идентификатору.
func (c *Config) Profile(id string) (Profile, bool) {
for _, p := range c.Profiles {
if p.ID == id {
return p, true
}
}
return Profile{}, false
}
// ActiveProfile возвращает профиль, который сейчас запускается и правится.
func (c *Config) ActiveProfile() Profile {
if p, ok := c.Profile(c.Active); ok {
return p
}
// До сюда доходить не должно: applyFallbacks чинит ссылку на активный
// профиль, а список никогда не бывает пустым.
if len(c.Profiles) > 0 {
return c.Profiles[0]
}
return DefaultProfile()
}
// AddProfile добавляет новый профиль.
func (c *Config) AddProfile(p Profile) error {
if err := ValidateProfileID(p.ID); err != nil {
return err
}
if _, exists := c.Profile(p.ID); exists {
return fmt.Errorf("профиль %q уже существует", p.ID)
}
if p.JavaMemory == "" {
p.JavaMemory = DefaultProfile().JavaMemory
}
p.Title = strings.TrimSpace(p.Title)
c.Profiles = append(c.Profiles, p)
return nil
}
// UpdateProfile заменяет настройки существующего профиля. Идентификатор
// профиля неизменен: от него зависят имена файлов конфигов и путь к миру.
func (c *Config) UpdateProfile(id string, p Profile) error {
for i := range c.Profiles {
if c.Profiles[i].ID != id {
continue
}
p.ID = id
if p.JavaMemory == "" {
p.JavaMemory = c.Profiles[i].JavaMemory
}
p.Title = strings.TrimSpace(p.Title)
c.Profiles[i] = p
return nil
}
return fmt.Errorf("профиль %q не найден", id)
}
// DeleteProfile убирает профиль из конфига. Файлы мира при этом остаются на
// диске: удалять чужие сохранения по нажатию кнопки в панели слишком опасно.
func (c *Config) DeleteProfile(id string) error {
if len(c.Profiles) == 1 {
return fmt.Errorf("нельзя удалить единственный профиль")
}
if id == c.Active {
return fmt.Errorf("нельзя удалить активный профиль — сначала переключитесь на другой")
}
for i, p := range c.Profiles {
if p.ID == id {
c.Profiles = append(c.Profiles[:i], c.Profiles[i+1:]...)
return nil
}
}
return fmt.Errorf("профиль %q не найден", id)
}
// SetActive переключает активный профиль.
func (c *Config) SetActive(id string) error {
if _, ok := c.Profile(id); !ok {
return fmt.Errorf("профиль %q не найден", id)
}
c.Active = id
return nil
}
// Save записывает конфиг атомарно: сначала во временный файл рядом, затем
@@ -185,17 +376,37 @@ func (c *Config) EnsureDirs() error {
return nil
}
// ServerConfigPath — путь к servertest.ini (или другому имени сервера).
// ServerConfigPathFor — путь к <profile>.ini.
func (c *Config) ServerConfigPathFor(profileID string) string {
return filepath.Join(c.ZomboidDir, "Server", profileID+".ini")
}
// SandboxConfigPathFor — путь к <profile>_SandboxVars.lua.
func (c *Config) SandboxConfigPathFor(profileID string) string {
return filepath.Join(c.ZomboidDir, "Server", profileID+"_SandboxVars.lua")
}
// WorldDirFor — директория мира профиля.
func (c *Config) WorldDirFor(profileID string) string {
return filepath.Join(c.ZomboidDir, "Saves", "Multiplayer", profileID)
}
// ServerConfigPath — конфиг активного профиля.
func (c *Config) ServerConfigPath() string {
return filepath.Join(c.ZomboidDir, "Server", c.ServerName+".ini")
return c.ServerConfigPathFor(c.ActiveProfile().ID)
}
// SandboxConfigPath — путь к <name>_SandboxVars.lua.
// SandboxConfigPath — настройки песочницы активного профиля.
func (c *Config) SandboxConfigPath() string {
return filepath.Join(c.ZomboidDir, "Server", c.ServerName+"_SandboxVars.lua")
return c.SandboxConfigPathFor(c.ActiveProfile().ID)
}
// SavesDir — директория с мирами (то, что бэкапим).
// ServerDirPath — директория с серверными конфигами всех профилей.
func (c *Config) ServerDirPath() string {
return filepath.Join(c.ZomboidDir, "Server")
}
// SavesDir — директория со всеми мирами.
func (c *Config) SavesDir() string {
return filepath.Join(c.ZomboidDir, "Saves")
}
+161
View File
@@ -0,0 +1,161 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestLoadMigratesSingleServerConfig проверяет обновление с версии, где сервер
// был один: его настройки должны стать первым профилем, а старые поля —
// исчезнуть из файла.
func TestLoadMigratesSingleServerConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
legacy := strings.Join([]string{
"listen: 127.0.0.1:8080",
"server_name: myworld",
"admin_password: hunter2",
"java_memory: 8g",
"extra_args:",
" - -debug",
}, "\n")
if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(cfg.Profiles) != 1 {
t.Fatalf("получено %d профилей, ожидался 1: %+v", len(cfg.Profiles), cfg.Profiles)
}
p := cfg.Profiles[0]
if p.ID != "myworld" || p.AdminPassword != "hunter2" || p.JavaMemory != "8g" {
t.Errorf("настройки не перенесены в профиль: %+v", p)
}
if len(p.ExtraArgs) != 1 || p.ExtraArgs[0] != "-debug" {
t.Errorf("extra_args не перенесены: %v", p.ExtraArgs)
}
if cfg.Active != "myworld" {
t.Errorf("активный профиль = %q", cfg.Active)
}
// Файл должен быть переписан без устаревших полей.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
saved := string(raw)
for _, key := range []string{"server_name:", "admin_password:", "java_memory:", "extra_args:"} {
if strings.Contains(saved, "\n"+key) {
t.Errorf("в файле осталось устаревшее поле %s:\n%s", key, saved)
}
}
if !strings.Contains(saved, "active_profile: myworld") {
t.Errorf("активный профиль не записан:\n%s", saved)
}
}
func TestLoadCreatesDefaultProfile(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(cfg.Profiles) != 1 || cfg.Profiles[0].ID != "servertest" {
t.Fatalf("профиль по умолчанию не создан: %+v", cfg.Profiles)
}
if cfg.ActiveProfile().ID != "servertest" {
t.Errorf("активный профиль = %q", cfg.ActiveProfile().ID)
}
}
// TestLoadRepairsDanglingActive: активный профиль могли удалить руками из
// файла, панель не должна из-за этого падать.
func TestLoadRepairsDanglingActive(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
raw := strings.Join([]string{
"active_profile: удалённый",
"profiles:",
" - id: vanilla",
" title: Ванилла",
}, "\n")
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.Active != "vanilla" {
t.Errorf("активный профиль = %q, ожидался vanilla", cfg.Active)
}
if cfg.Profiles[0].JavaMemory == "" {
t.Error("профилю без java_memory не подставлено умолчание")
}
}
func TestProfileCRUD(t *testing.T) {
cfg := Default()
if err := cfg.AddProfile(Profile{ID: "modded", Title: "С модами"}); err != nil {
t.Fatal(err)
}
if err := cfg.AddProfile(Profile{ID: "modded"}); err == nil {
t.Error("дубликат профиля принят")
}
if err := cfg.AddProfile(Profile{ID: "плохой id"}); err == nil {
t.Error("недопустимый идентификатор принят")
}
if err := cfg.DeleteProfile("servertest"); err == nil {
t.Error("активный профиль удалён без переключения")
}
if err := cfg.SetActive("modded"); err != nil {
t.Fatal(err)
}
if err := cfg.DeleteProfile("servertest"); err != nil {
t.Fatal(err)
}
if err := cfg.DeleteProfile("modded"); err == nil {
t.Error("удалён единственный оставшийся профиль")
}
if err := cfg.SetActive("несуществующий"); err == nil {
t.Error("активирован несуществующий профиль")
}
}
func TestUpdateProfileKeepsID(t *testing.T) {
cfg := Default()
err := cfg.UpdateProfile("servertest", Profile{ID: "другой", Title: "Переименован", JavaMemory: "16g"})
if err != nil {
t.Fatal(err)
}
p := cfg.ActiveProfile()
if p.ID != "servertest" {
t.Errorf("идентификатор профиля изменился: %q", p.ID)
}
if p.Title != "Переименован" || p.JavaMemory != "16g" {
t.Errorf("настройки не сохранены: %+v", p)
}
}
func TestProfilePaths(t *testing.T) {
cfg := Default()
cfg.ZomboidDir = filepath.Join("/home", "pz", "Zomboid")
if got := cfg.ServerConfigPathFor("modded"); filepath.Base(got) != "modded.ini" {
t.Errorf("конфиг профиля: %s", got)
}
if got := cfg.SandboxConfigPathFor("modded"); filepath.Base(got) != "modded_SandboxVars.lua" {
t.Errorf("песочница профиля: %s", got)
}
if got := cfg.WorldDirFor("modded"); filepath.Base(got) != "modded" ||
!strings.Contains(filepath.ToSlash(got), "Saves/Multiplayer") {
t.Errorf("мир профиля: %s", got)
}
}
+43 -20
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/sysinfo"
)
@@ -43,16 +44,20 @@ const restartDelay = 10 * time.Second
// Status — снимок состояния для веб-панели.
type Status struct {
State State `json:"state"`
Installed bool `json:"installed"`
PID int `json:"pid"`
UptimeSec int64 `json:"uptime_sec"`
Players []string `json:"players"`
PlayersAt int64 `json:"players_at"`
LastExit string `json:"last_exit"`
AutoRestart bool `json:"auto_restart"`
Proc sysinfo.ProcStats `json:"proc"`
Host sysinfo.HostStats `json:"host"`
State State `json:"state"`
// Profile и ProfileTitle описывают профиль, который сейчас работает
// (а если сервер остановлен — который будет запущен).
Profile string `json:"profile"`
ProfileTitle string `json:"profile_title"`
Installed bool `json:"installed"`
PID int `json:"pid"`
UptimeSec int64 `json:"uptime_sec"`
Players []string `json:"players"`
PlayersAt int64 `json:"players_at"`
LastExit string `json:"last_exit"`
AutoRestart bool `json:"auto_restart"`
Proc sysinfo.ProcStats `json:"proc"`
Host sysinfo.HostStats `json:"host"`
}
// Supervisor владеет процессом сервера: запускает, кормит командами консоли,
@@ -70,6 +75,9 @@ type Supervisor struct {
lastExit string
players []string
playersAt time.Time
// profile — профиль, с которым сервер был запущен: активный профиль
// в конфиге может отличаться, если его успели переключить.
profile config.Profile
// intentionalStop отличает остановку по кнопке от падения: во втором
// случае включается автоперезапуск.
intentionalStop bool
@@ -136,12 +144,19 @@ func (s *Supervisor) State() State {
// Status собирает полный снимок состояния вместе с метриками процесса и хоста.
func (s *Supervisor) Status() Status {
profile := s.cfg.ActiveProfile()
s.mu.Lock()
if s.state != StateStopped {
profile = s.profile
}
st := Status{
State: s.state,
Players: append([]string{}, s.players...),
LastExit: s.lastExit,
AutoRestart: s.cfg.AutoRestart,
State: s.state,
Profile: profile.ID,
ProfileTitle: profile.Name(),
Players: append([]string{}, s.players...),
LastExit: s.lastExit,
AutoRestart: s.cfg.AutoRestart,
}
if !s.playersAt.IsZero() {
st.PlayersAt = s.playersAt.Unix()
@@ -186,11 +201,18 @@ func (s *Supervisor) startLocked() error {
return fmt.Errorf("chmod %s: %w", script, err)
}
args := []string{"-servername", s.cfg.ServerName}
if s.cfg.AdminPassword != "" {
args = append(args, "-adminpassword", s.cfg.AdminPassword)
profile := s.cfg.ActiveProfile()
// Лимит памяти живёт в файле запуска, общем для всех профилей, поэтому
// выставляем его на каждом старте — иначе профиль унаследует чужой -Xmx.
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory); err != nil {
s.AppendLog("manager", "Не удалось выставить лимит памяти JVM: "+err.Error())
}
args = append(args, s.cfg.ExtraArgs...)
args := []string{"-servername", profile.ID}
if profile.AdminPassword != "" {
args = append(args, "-adminpassword", profile.AdminPassword)
}
args = append(args, profile.ExtraArgs...)
cmd := exec.Command(script, args...)
cmd.Dir = s.cfg.ServerDir
@@ -220,6 +242,7 @@ func (s *Supervisor) startLocked() error {
}
s.cmd = cmd
s.profile = profile
s.stdin = stdin
s.pgid = processGroupID(cmd.Process.Pid)
s.state = StateStarting
@@ -230,8 +253,8 @@ func (s *Supervisor) startLocked() error {
s.exited = make(chan struct{})
s.sampler.Reset()
s.AppendLog("manager", fmt.Sprintf("Запуск сервера: %s %s (pid %d)",
script, strings.Join(args, " "), cmd.Process.Pid))
s.AppendLog("manager", fmt.Sprintf("Запуск профиля %q: %s %s (pid %d)",
profile.Name(), script, strings.Join(args, " "), cmd.Process.Pid))
go s.readPipe(stdout, "stdout")
go s.readPipe(stderr, "stderr")