5232609719
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
196 lines
7.6 KiB
Go
196 lines
7.6 KiB
Go
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, "")
|
|
}
|