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, "") }