panel v0.9.2 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 01:06:16 -07:00
commit 0f6aea796c
2164 changed files with 301480 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
package dispatch
import (
"context"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// Cluster player-save snapshots.
//
// A 7DTD cluster shares ONE Player/ folder across every member server (each
// member's active-world Player dir is a symlink to the shared /cluster/Player
// bind). That shared dir holds every character's .ttp (inventory, skills,
// position, quests) — the single most irreplaceable per-player state. But it
// is NOT captured by the panel tar backup: the backup tars /game-saves, where
// the active Player dir is a symlink pointing OUT to /cluster, so tar stores
// the dangling symlink, not the contents. Region Medic only heals world
// regions, not player files. Net result before this: one corrupt write to a
// .ttp and that character was gone with no restore path.
//
// Fix (mirrors the region-medic rolling-snapshot pattern): on every 7DTD
// start, if the instance is clustered, copy the shared Player dir to a fresh
// rolling slot keyed by cluster id and prune to keep N. All members write to
// the same per-cluster rolling history, so a member's 4×/day restart cadence
// gives a dense set of restore points. Best-effort: never blocks the start.
func clusterPlayerRollingRoot() string {
return medicEnvOr("PANEL_CLUSTER_PLAYER_ROLLING_DIR", "/home/refuge/cluster-player-rolling")
}
func clusterPlayerKeep() int {
if v := os.Getenv("PANEL_CLUSTER_PLAYER_KEEP"); v != "" {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
return n
}
}
return 12
}
// snapshotClusterPlayerOnStart resolves the instance's /cluster bind to its
// host path, and if it's a real shared bind holding player data, snapshots
// <bind>/Player into a rolling slot keyed by the cluster id (= the bind dir's
// basename, e.g. cl_37e3a7f72cdc). No-op for standalone servers (whose
// /cluster is an empty per-instance named volume, not a bind).
func (d *Dispatcher) snapshotClusterPlayerOnStart(ctx context.Context, rec *instanceRecord) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
log := d.log.With("instance_id", rec.InstanceID, "cluster_player_snapshot", true)
mounts, err := d.runtime.ContainerMounts(ctx, "panel-"+rec.InstanceID)
if err != nil {
log.Warn("cluster-player snapshot: inspect mounts failed (skipping)", "err", err)
return
}
var bind string
for _, m := range mounts {
if m.Destination == "/cluster" && m.Type == "bind" && m.Source != "" {
bind = m.Source
break
}
}
if bind == "" {
return // standalone server (named-volume /cluster) — nothing shared to snapshot
}
playerDir := filepath.Join(bind, "Player")
if st, err := os.Stat(playerDir); err != nil || !st.IsDir() {
return // cluster joined but no Player dir yet (first boot)
}
if empty, _ := dirIsEmpty(playerDir); empty {
return
}
clusterID := filepath.Base(bind)
destRoot := filepath.Join(clusterPlayerRollingRoot(), clusterID)
if err := os.MkdirAll(destRoot, 0o755); err != nil {
log.Warn("cluster-player snapshot: mkdir rolling root failed", "err", err)
return
}
slot := filepath.Join(destRoot, "slot-"+strconv.FormatInt(time.Now().Unix(), 10))
dst := filepath.Join(slot, "Player")
if err := os.MkdirAll(slot, 0o755); err != nil {
log.Warn("cluster-player snapshot: mkdir slot failed", "err", err)
return
}
// cp -a preserves perms/acls/timestamps and the dir tree (player subdirs
// of .7rm map files). figaro is Linux; cp is always present. Best-effort.
cmd := exec.CommandContext(ctx, "cp", "-a", playerDir+"/.", dst)
if out, err := cmd.CombinedOutput(); err != nil {
log.Warn("cluster-player snapshot: copy failed", "err", err, "out", strings.TrimSpace(string(out)))
_ = os.RemoveAll(slot) // don't leave a half-copied slot that prune would keep
return
}
keep := clusterPlayerKeep()
pruned := pruneRollingSlots(destRoot, keep)
log.Info("cluster-player snapshot taken", "cluster", clusterID, "slot", filepath.Base(slot), "keep", keep, "pruned", pruned)
}
// dirIsEmpty reports whether dir has no entries.
func dirIsEmpty(dir string) (bool, error) {
f, err := os.Open(dir)
if err != nil {
return false, err
}
defer f.Close()
names, err := f.Readdirnames(1)
if err != nil {
// io.EOF → empty.
return len(names) == 0, nil
}
return len(names) == 0, nil
}
// pruneRollingSlots keeps the newest `keep` slot-* dirs under root (by name,
// which embeds a unix timestamp so lexical == chronological) and removes the
// rest. Returns how many it deleted.
func pruneRollingSlots(root string, keep int) int {
entries, err := os.ReadDir(root)
if err != nil {
return 0
}
var slots []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "slot-") {
slots = append(slots, e.Name())
}
}
if len(slots) <= keep {
return 0
}
sort.Strings(slots) // oldest first
removed := 0
for _, name := range slots[:len(slots)-keep] {
if err := os.RemoveAll(filepath.Join(root, name)); err == nil {
removed++
}
}
return removed
}