Files
panel/agent/internal/dispatch/clusterplayer.go
T
2026-07-14 23:01:33 -07:00

144 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}