panel — open-source game server manager (public release)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:19:43 -07:00
commit 658bda1d24
2160 changed files with 300413 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
package dispatch
import (
"context"
"fmt"
"strconv"
"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"
)
// seedModsFromInstance clones the Mods/ folder of srcInstanceID (the cluster
// master) onto dstInstanceID's install volume, REPLACING the destination's mod
// set with the master's. Runs a throwaway debian sidecar that mounts both
// instances' install volumes and tar-copies /src-game/Mods → /dst-game/Mods.
//
// This is the inverse of tryWarmSeedFromSibling (which copies the install but
// EXCLUDES Mods): here we copy ONLY Mods, so a freshly-joined cluster member
// ends up running exactly the master's mod set without an operator re-uploading
// every mod by hand.
//
// Per-server STATE is preserved/excluded so a clone never leaks one server's
// player data onto another:
// - The master's RefugeBot homes.json (teleport homes, keyed by SteamID) is
// EXCLUDED from the copy — it's per-server state, not mod config. This is
// the exact file that leaked when an operator rsync'd Mods by hand.
// - The destination's OWN homes.json is snapshotted before the wipe and
// restored after, so the joining server keeps its own teleports.
//
// Both instances must live on THIS agent (same Docker host) — the sidecar
// mounts local named volumes. If the master isn't found here the seed returns
// an error and the caller treats it as best-effort (the server still boots with
// whatever mods it already had).
func (d *Dispatcher) seedModsFromInstance(ctx context.Context, srcInstanceID, dstInstanceID, dstDataPath string, manifest *modulepkg.Manifest) (int, error) {
if manifest == nil {
return 0, fmt.Errorf("mod-seed: nil manifest")
}
if srcInstanceID == dstInstanceID {
return 0, fmt.Errorf("mod-seed: refusing to seed instance %q from itself", dstInstanceID)
}
installPath := ""
if len(manifest.UpdateProviders) > 0 {
installPath = manifest.UpdateProviders[0].InstallPath
}
if installPath == "" {
installPath = manifest.Runtime.Docker.BrowseableRoot
}
if installPath == "" {
return 0, fmt.Errorf("mod-seed: no install path for module %q", manifest.ID)
}
// Resolve the master's data path from our local instance table.
d.mu.Lock()
srcRec, ok := d.instances[srcInstanceID]
d.mu.Unlock()
if !ok {
return 0, fmt.Errorf("mod-seed: master instance %q is not on this agent", srcInstanceID)
}
if srcRec.ModuleID != manifest.ID {
return 0, fmt.Errorf("mod-seed: master %q is module %q, not %q", srcInstanceID, srcRec.ModuleID, manifest.ID)
}
srcVolumes := agentmodule.ResolveVolumes(manifest, srcInstanceID, srcRec.DataPath)
dstVolumes := agentmodule.ResolveVolumes(manifest, dstInstanceID, dstDataPath)
srcGameVol := volumeNameForPath(srcVolumes, installPath)
dstGameVol := volumeNameForPath(dstVolumes, installPath)
if srcGameVol == "" || dstGameVol == "" {
return 0, fmt.Errorf("mod-seed: install_path %q not a named volume on both instances", installPath)
}
// tar, not rsync — debian:12-slim ships no rsync. Replace /dst-game/Mods
// from /src-game/Mods, excluding the master's per-server homes.json, and
// preserving the destination's own homes.json across the wipe.
const seedScript = `set -e
echo "mod-seed: cloning master Mods set (excl per-server homes.json)"
HOMES=/dst-game/Mods/zzzzzzzRefugeBot/homes.json
if [ -f "$HOMES" ]; then cp -a "$HOMES" /tmp/dst-homes.json; echo "mod-seed: preserved destination homes.json"; fi
mkdir -p /dst-game/Mods
find /dst-game/Mods -mindepth 1 -delete
( cd /src-game/Mods && tar --exclude='./zzzzzzzRefugeBot/homes.json' -cf - . ) | ( cd /dst-game/Mods && tar -xf - )
if [ -f /tmp/dst-homes.json ]; then mkdir -p /dst-game/Mods/zzzzzzzRefugeBot; cp -a /tmp/dst-homes.json "$HOMES"; echo "mod-seed: restored destination homes.json"; fi
echo "mod-seed: $(ls /dst-game/Mods 2>/dev/null | wc -l) mods now present"
echo SEED_OK
`
sidecarID := fmt.Sprintf("%s-modseed", dstInstanceID)
spec := runtime.InstanceSpec{
InstanceID: sidecarID,
IsSidecar: true,
Image: "debian:12-slim",
Command: []string{"bash", "-c", seedScript},
RestartPolicy: "no",
Volumes: []runtime.VolumeSpec{
{Type: "volume", VolumeName: srcGameVol, ContainerPath: "/src-game", ReadOnly: true},
{Type: "volume", VolumeName: dstGameVol, ContainerPath: "/dst-game"},
},
}
d.log.Info("mod-seed: starting sidecar", "src", srcInstanceID, "dst", dstInstanceID, "src_vol", srcGameVol, "dst_vol", dstGameVol)
contID, err := d.runtime.Create(ctx, spec)
if err != nil {
return 0, fmt.Errorf("mod-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 0, fmt.Errorf("mod-seed: start sidecar: %w", err)
}
logCtx, cancelLogs := context.WithCancel(ctx)
defer cancelLogs()
logDone := make(chan struct{})
var modCount int
go func() {
defer close(logDone)
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
d.log.Info("mod-seed log", "instance_id", dstInstanceID, "line", line)
// Parse the sidecar's "mod-seed: N mods now present" echo so the
// caller (and the cluster-sync UI) can report how many mods landed.
if strings.Contains(line, "mods now present") {
fields := strings.Fields(line)
for i, f := range fields {
if f == "mods" && i > 0 {
if n, err := strconv.Atoi(fields[i-1]); err == nil {
modCount = n
}
}
}
}
})
}()
exitCode, err := d.runtime.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
return 0, fmt.Errorf("mod-seed: wait sidecar: %w", err)
}
if exitCode != 0 {
return 0, fmt.Errorf("mod-seed: sidecar exited %d", exitCode)
}
d.log.Info("mod-seed: success", "src", srcInstanceID, "dst", dstInstanceID, "mods", modCount)
return modCount, nil
}
// handleSeedMods runs the mod-seed sidecar on demand — no container recreate,
// no restart. Clones src_instance_id's Mods/ onto instance_id (excluding the
// master's per-server homes.json, preserving the member's own). The member
// keeps running on its currently-loaded mods; the freshly-copied files take
// effect on its NEXT restart. Backs the cluster "Sync mods to members" action,
// where the master pushes its current mod set to every other member at once.
func (d *Dispatcher) handleSeedMods(ctx context.Context, corrID string, req *panelv1.SeedModsRequest) {
log := d.log.With("instance_id", req.InstanceId, "src", req.SrcInstanceId, "correlation_id", corrID)
reply := func(ok bool, modCount int, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_SeedModsResult{
SeedModsResult: &panelv1.SeedModsResult{
InstanceId: req.InstanceId,
Ok: ok,
ModCount: int32(modCount),
Error: errMsg,
},
},
})
if !ok {
log.Warn("mod-seed RPC failed", "err", errMsg)
}
}
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
reply(false, 0, err.Error())
return
}
manifest, ok := d.modules.Get(rec.ModuleID)
if !ok {
reply(false, 0, "module "+rec.ModuleID+" not in registry")
return
}
modCount, err := d.seedModsFromInstance(ctx, req.SrcInstanceId, req.InstanceId, rec.DataPath, manifest)
if err != nil {
reply(false, 0, err.Error())
return
}
log.Info("mod-seed RPC complete", "mods", modCount)
reply(true, modCount, "")
}