panel v0.9.1 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:37:25 -07:00
commit 582b5a6b08
2161 changed files with 300950 additions and 0 deletions
+331
View File
@@ -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"
}