panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
agentmodule "github.com/dbledeez/panel/agent/internal/module"
|
||||
"github.com/dbledeez/panel/agent/internal/runtime"
|
||||
modulepkg "github.com/dbledeez/panel/pkg/module"
|
||||
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
||||
)
|
||||
|
||||
// tryWarmSeedFromSibling looks for another instance of the same module
|
||||
// already on this agent with a populated install volume; if found, copies
|
||||
// that volume verbatim into the new instance's install volume via a
|
||||
// throwaway debian sidecar. Saves operators 30+ minutes of redundant
|
||||
// SteamCMD downloads when standing up a second instance of a game.
|
||||
//
|
||||
// Returns (true, nil) on a successful seed — caller should skip the
|
||||
// updater and treat the install as already-complete.
|
||||
// Returns (false, nil) when there's no candidate sibling — caller falls
|
||||
// through to the normal updater.
|
||||
// Returns (false, err) on operational failure of the seed itself —
|
||||
// caller should also fall through (we'd rather over-download than leave
|
||||
// the user without a working install).
|
||||
//
|
||||
// Symlink handling: install volumes typically contain symlinks back into
|
||||
// the per-instance saves volume (e.g. /game/serverconfig.xml ->
|
||||
// /game-saves/serverconfig.xml on 7DTD, set up by the entrypoint at first
|
||||
// boot). Those symlinks would be DANGLING in the new instance because
|
||||
// the new saves volume is empty. We strip broken symlinks after copy
|
||||
// (`find -xtype l -delete`) so the entrypoint sees a clean state and
|
||||
// recreates them on its first boot.
|
||||
//
|
||||
// Saves bootstrap: if the sibling's saves volume contains a
|
||||
// serverconfig.xml-style file at a known location, we copy it to the
|
||||
// new saves volume so the entrypoint has a starting config (operator
|
||||
// can edit afterwards). Otherwise the entrypoint falls back to the
|
||||
// game's shipped default. Done per-module by the seedSavesPaths list.
|
||||
func (d *Dispatcher) tryWarmSeedFromSibling(ctx context.Context, newInstanceID string, manifest *modulepkg.Manifest, newDataPath string) (bool, error) {
|
||||
if manifest == nil || len(manifest.UpdateProviders) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
provider := manifest.UpdateProviders[0]
|
||||
installPath := provider.InstallPath
|
||||
if installPath == "" {
|
||||
installPath = manifest.Runtime.Docker.BrowseableRoot
|
||||
}
|
||||
if installPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Find a sibling: same agent, same module, NOT this instance, with a
|
||||
// container that exists (so we know the volume was at least populated
|
||||
// once). Prefer a stopped or running instance over a half-baked one.
|
||||
//
|
||||
// VERSION SAFETY: only reuse an install from a sibling on the SAME Steam
|
||||
// branch as this new instance. Copying e.g. a 3.0 (latest_experimental)
|
||||
// install into a server the operator created as 2.6 — or vice versa —
|
||||
// produces a binary/world mismatch that corrupts the save. The new
|
||||
// instance's record was tagged with its branch at Create (resolveCreateBranch),
|
||||
// before this runs. Siblings predating the feature have an empty branch and
|
||||
// are treated as the module's DEFAULT branch (branchOrDefault) — for 7DTD
|
||||
// that's v2.6, so the existing 2.6 fleet is still a valid seed source for a
|
||||
// new 2.6 server.
|
||||
d.mu.Lock()
|
||||
wantBranch := branchOrDefault("", manifest)
|
||||
if nr, ok := d.instances[newInstanceID]; ok {
|
||||
wantBranch = branchOrDefault(nr.Branch, manifest)
|
||||
}
|
||||
var srcID, srcDataPath string
|
||||
for id, rec := range d.instances {
|
||||
if id == newInstanceID {
|
||||
continue
|
||||
}
|
||||
if rec.ModuleID != manifest.ID {
|
||||
continue
|
||||
}
|
||||
if branchOrDefault(rec.Branch, manifest) != wantBranch {
|
||||
continue // different game version — not a safe seed source
|
||||
}
|
||||
// Best-effort: if we have multiple on this branch, the first one wins.
|
||||
// Could be improved later by picking the most-recently-updated.
|
||||
srcID = id
|
||||
srcDataPath = rec.DataPath
|
||||
break
|
||||
}
|
||||
d.mu.Unlock()
|
||||
if srcID == "" {
|
||||
d.log.Debug("warm-seed: no same-branch sibling instance found", "module", manifest.ID, "instance", newInstanceID, "want_branch", wantBranch)
|
||||
return false, nil
|
||||
}
|
||||
d.log.Info("warm-seed: matched sibling on branch", "module", manifest.ID, "instance", newInstanceID, "src", srcID, "branch", wantBranch)
|
||||
|
||||
srcVolumes := agentmodule.ResolveVolumes(manifest, srcID, srcDataPath)
|
||||
dstVolumes := agentmodule.ResolveVolumes(manifest, newInstanceID, newDataPath)
|
||||
|
||||
srcGameVol := volumeNameForPath(srcVolumes, installPath)
|
||||
dstGameVol := volumeNameForPath(dstVolumes, installPath)
|
||||
if srcGameVol == "" || dstGameVol == "" {
|
||||
return false, fmt.Errorf("warm-seed: install_path %q not in module volumes", installPath)
|
||||
}
|
||||
|
||||
// Find a "saves-style" sibling volume to bootstrap from. Heuristic:
|
||||
// any non-install named volume whose container path contains "save"
|
||||
// or "data". Most modules have one. If none, we still seed the install
|
||||
// volume; the entrypoint may still need to bootstrap saves itself.
|
||||
srcSavesVol, srcSavesPath := findSavesVolume(srcVolumes, installPath)
|
||||
dstSavesVol, _ := findSavesVolume(dstVolumes, installPath)
|
||||
|
||||
// Build the seed sidecar. debian:12-slim is small, has bash + find +
|
||||
// cp + mkdir, and is already cached on every panel-agent host (the
|
||||
// fs-helper sidecar uses it).
|
||||
// Why a tar pipe instead of cp -a: we want fine-grained exclusions for
|
||||
// per-instance state that shouldn't be inherited from the sibling.
|
||||
//
|
||||
// Mods/ split:
|
||||
// BASE mods (TFP_*, 0_TFP_*, Allocs_*, PrismaCore, xMarkers) are
|
||||
// load-bearing — TFP_Harmony is the runtime patcher, the server
|
||||
// won't even boot without it; Allocs is the live map renderer the
|
||||
// panel maps tab depends on; PrismaCore is the staff command surface
|
||||
// refugebotserver expects. We KEEP these.
|
||||
//
|
||||
// CUSTOM mods (AGF-VP-*, RefugeBot, AsylumRoboticInbox, anything
|
||||
// else) are server-specific — operators usually want a clean slate
|
||||
// on a new server so the panel's mod manager / their own upload
|
||||
// decides what's installed. We DROP these.
|
||||
//
|
||||
// steamapps/userdata, steamapps/downloading: per-Steam-account state
|
||||
// + half-fetched chunks. Always excluded.
|
||||
//
|
||||
// Approach: tar everything EXCEPT Mods + downloading + userdata, then
|
||||
// selectively copy back the base-mod entries from the sibling.
|
||||
// The Mods/ split + base-mod completeness gate below are 7DTD-SPECIFIC:
|
||||
// TFP_Harmony/Allocs/PrismaCore are load-bearing 7DTD server mods, and the
|
||||
// "missing base mods → exit 42" check re-triggers a full SteamCMD validate
|
||||
// to refetch them. Every OTHER module (palworld, soulmask, valheim, …) has
|
||||
// no Mods/ dir at all, so running this block against them ALWAYS hit the
|
||||
// completeness gate and exit-42'd — turning a valid sibling seed into a
|
||||
// spurious "incomplete sibling" failure. For non-7DTD modules we copy the
|
||||
// whole install verbatim (minus per-instance Steam state) and skip the gate.
|
||||
//
|
||||
// Root cause of the 2026-07-10 gamehost outage: Palworld + Soulmask checkouts
|
||||
// warm-seeded from a sibling, hit this 7DTD gate, exit-42'd, and fell through
|
||||
// to a fresh download — which was then canceled by a racing env-config
|
||||
// recreate (fixed separately in the controller). See changelog 2026-07-10.
|
||||
seedScript := `set -e
|
||||
echo "warm-seed: copying install volume contents (excl. Mods/, steamapps/userdata, steamapps/downloading)"
|
||||
find /dst-game -mindepth 1 -delete
|
||||
( cd /src-game && tar --exclude='./Mods' --exclude='./steamapps/userdata' --exclude='./steamapps/downloading' -cf - . ) | ( cd /dst-game && tar -xf - )
|
||||
`
|
||||
if manifest.ID == "7dtd" {
|
||||
seedScript += `mkdir -p /dst-game/Mods
|
||||
echo "warm-seed: copying BASE mods (TFP_*, Allocs_*, PrismaCore, Xample_MarkersMod) — load-bearing for the server"
|
||||
for mod in /src-game/Mods/TFP_* /src-game/Mods/0_TFP_* /src-game/Mods/Allocs_* /src-game/Mods/PrismaCore /src-game/Mods/Xample_* /src-game/Mods/*Markers* /src-game/Mods/xMarkers /src-game/Mods/xmarkers; do
|
||||
case "$mod" in *"*"*) continue ;; esac
|
||||
if [ -e "$mod" ]; then
|
||||
cp -a "$mod" /dst-game/Mods/
|
||||
echo " + $(basename $mod)"
|
||||
fi
|
||||
done
|
||||
`
|
||||
}
|
||||
seedScript += `echo "warm-seed: stripping dangling symlinks (saves-volume references)"
|
||||
find /dst-game -xtype l -delete
|
||||
`
|
||||
if manifest.ID == "7dtd" {
|
||||
seedScript += `# Completeness check (7DTD only) — fail the warmseed if any LOAD-BEARING base
|
||||
# mod is missing on dst. The caller falls back to a full steamcmd validate which
|
||||
# will re-fetch the TFP/xMarkers mods from the dedicated-server depot.
|
||||
missing=""
|
||||
for required in 0_TFP_Harmony TFP_WebServer; do
|
||||
[ -d "/dst-game/Mods/$required" ] || missing="$missing $required"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "warm-seed: incomplete sibling — missing base mods:$missing"
|
||||
exit 42
|
||||
fi
|
||||
`
|
||||
}
|
||||
if srcSavesVol != "" && dstSavesVol != "" {
|
||||
// Copy ONLY the rendered config file (and the .local/share parent
|
||||
// for HOME-based games). Don't copy the world data — the user wants
|
||||
// a fresh save on this new instance, not the sibling's.
|
||||
seedScript += `echo "warm-seed: bootstrapping saves volume from sibling"
|
||||
[ -s /src-saves/serverconfig.xml ] && cp /src-saves/serverconfig.xml /dst-saves/serverconfig.xml || true
|
||||
mkdir -p /dst-saves/.local/share 2>/dev/null || true
|
||||
# 7DTD HOME tree — entrypoint mkdir's the deeper path on boot, but creating
|
||||
# the parent here lets the install symlink resolve to a valid empty dir
|
||||
# even before the first start.
|
||||
for d in /src-saves/.local/share/7DaysToDie /src-saves/.local/share/Empyrion; do
|
||||
if [ -d "$d" ]; then
|
||||
mkdir -p "/dst-saves/$(echo $d | sed 's|/src-saves/||')"
|
||||
fi
|
||||
done
|
||||
`
|
||||
}
|
||||
seedScript += `echo SEED_OK`
|
||||
|
||||
sidecarID := fmt.Sprintf("%s-warmseed", newInstanceID)
|
||||
volumes := []runtime.VolumeSpec{
|
||||
{Type: "volume", VolumeName: srcGameVol, ContainerPath: "/src-game", ReadOnly: true},
|
||||
{Type: "volume", VolumeName: dstGameVol, ContainerPath: "/dst-game"},
|
||||
}
|
||||
if srcSavesVol != "" && dstSavesVol != "" {
|
||||
volumes = append(volumes,
|
||||
runtime.VolumeSpec{Type: "volume", VolumeName: srcSavesVol, ContainerPath: "/src-saves", ReadOnly: true},
|
||||
runtime.VolumeSpec{Type: "volume", VolumeName: dstSavesVol, ContainerPath: "/dst-saves"},
|
||||
)
|
||||
}
|
||||
spec := runtime.InstanceSpec{
|
||||
InstanceID: sidecarID,
|
||||
IsSidecar: true,
|
||||
Image: "debian:12-slim",
|
||||
Command: []string{"bash", "-c", seedScript},
|
||||
Volumes: volumes,
|
||||
RestartPolicy: "no",
|
||||
}
|
||||
|
||||
// Surface what's happening — without this the card just shows a static
|
||||
// "installing" pulse with no console output during a multi-GB sibling copy,
|
||||
// which looks hung.
|
||||
d.sendInstanceState(newInstanceID, panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING, 0, fmt.Sprintf("pulling game files from existing install (%s) — no SteamCMD download needed", srcID))
|
||||
d.log.Info("warm-seed: starting sidecar", "src", srcID, "dst", newInstanceID, "src_vol", srcGameVol, "dst_vol", dstGameVol, "saves_seed", srcSavesPath != "")
|
||||
contID, err := d.runtime.Create(ctx, spec)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("warm-seed: create sidecar: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
_ = d.runtime.Remove(rmCtx, contID)
|
||||
}()
|
||||
if err := d.runtime.Start(ctx, contID); err != nil {
|
||||
return false, fmt.Errorf("warm-seed: start sidecar: %w", err)
|
||||
}
|
||||
logCtx, cancelLogs := context.WithCancel(ctx)
|
||||
defer cancelLogs()
|
||||
logDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(logDone)
|
||||
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
|
||||
d.log.Info("warm-seed log", "instance_id", newInstanceID, "line", line)
|
||||
// Mirror the sidecar's friendly progress echoes ("warm-seed: copying
|
||||
// install volume…", "+ <mod>") onto the instance console so the
|
||||
// operator can watch the "pulling from existing install" progress.
|
||||
d.sendEnv(&panelv1.AgentEnvelope{
|
||||
SentAt: timestamppb.Now(),
|
||||
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
|
||||
InstanceId: newInstanceID, Stream: "install", At: timestamppb.Now(), Line: line,
|
||||
}},
|
||||
})
|
||||
})
|
||||
}()
|
||||
exitCode, err := d.runtime.Wait(ctx, contID)
|
||||
cancelLogs()
|
||||
<-logDone
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("warm-seed: wait sidecar: %w", err)
|
||||
}
|
||||
if exitCode != 0 {
|
||||
return false, fmt.Errorf("warm-seed: sidecar exited %d", exitCode)
|
||||
}
|
||||
d.log.Info("warm-seed: success", "src", srcID, "dst", newInstanceID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// volumeNameForPath returns the named-volume name whose container path
|
||||
// matches `path` exactly, or "" if no match. Bind mounts are skipped —
|
||||
// we only seed named volumes (host path is operator-managed).
|
||||
func volumeNameForPath(volumes []runtime.VolumeSpec, path string) string {
|
||||
for _, v := range volumes {
|
||||
if v.Type == "volume" && v.VolumeName != "" && v.ContainerPath == path {
|
||||
return v.VolumeName
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findSavesVolume returns the (volumeName, containerPath) of the first
|
||||
// named-volume whose container path looks like a saves/data dir, ignoring
|
||||
// the install volume. Used to bootstrap the new instance's saves with a
|
||||
// rendered config from the sibling.
|
||||
func findSavesVolume(volumes []runtime.VolumeSpec, installPath string) (string, string) {
|
||||
for _, v := range volumes {
|
||||
if v.Type != "volume" || v.VolumeName == "" {
|
||||
continue
|
||||
}
|
||||
if v.ContainerPath == installPath {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(v.ContainerPath)
|
||||
if strings.Contains(lower, "save") || strings.Contains(lower, "data") {
|
||||
return v.VolumeName, v.ContainerPath
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// normalizeBranch collapses a provider `beta` value into a stable warm-seed
|
||||
// matching key: the empty beta (the moving public/stable branch) becomes
|
||||
// "public"; any explicit beta (e.g. "v2.6", "latest_experimental") is returned
|
||||
// trimmed as-is. Two installs are warm-seed compatible iff their normalized
|
||||
// branches are equal.
|
||||
func normalizeBranch(beta string) string {
|
||||
b := strings.TrimSpace(beta)
|
||||
if b == "" {
|
||||
return "public"
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// branchOrDefault returns the normalized branch for a record's Branch value,
|
||||
// treating an empty value (instances created before branch-tagging existed) as
|
||||
// the module's DEFAULT branch — UpdateProviders[0].Beta normalized. For 7DTD
|
||||
// the default is v2.6, so the pre-existing 2.6 fleet remains a valid warm-seed
|
||||
// source for a new 2.6 server. Returns "public" when the manifest has no
|
||||
// providers.
|
||||
func branchOrDefault(branch string, m *modulepkg.Manifest) string {
|
||||
if strings.TrimSpace(branch) != "" {
|
||||
return normalizeBranch(branch)
|
||||
}
|
||||
if m != nil && len(m.UpdateProviders) > 0 {
|
||||
return normalizeBranch(m.UpdateProviders[0].Beta)
|
||||
}
|
||||
return "public"
|
||||
}
|
||||
Reference in New Issue
Block a user