panel v0.9.1 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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, "")
|
||||
}
|
||||
Reference in New Issue
Block a user