295eb22826
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
144 lines
4.9 KiB
Go
144 lines
4.9 KiB
Go
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
|
||
}
|