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 // /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 }