5232609719
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
207 lines
8.8 KiB
Go
207 lines
8.8 KiB
Go
package dispatch
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
agentmodule "github.com/dbledeez/panel/agent/internal/module"
|
|
"github.com/dbledeez/panel/agent/internal/runtime"
|
|
"github.com/dbledeez/panel/agent/internal/updater"
|
|
modulepkg "github.com/dbledeez/panel/pkg/module"
|
|
)
|
|
|
|
// installBaseMods runs a SteamCMD validate (if the module has a steamcmd
|
|
// update provider) followed by an alpine sidecar that downloads + installs
|
|
// the third-party 7DTD base-mod set:
|
|
//
|
|
// - Allocs Server Fixes — tarball from illy.bz (canonical upstream).
|
|
// Contains Allocs_CommandExtensions + Allocs_WebAndMapRendering +
|
|
// Alloc_XmlPatch, all under a Mods/ prefix when extracted to /game.
|
|
// - PrismaCore — latest GitHub release .zip from Prisma501/PrismaCore.
|
|
//
|
|
// SteamCMD validate step (new):
|
|
// - If the module declares at least one update provider with kind="steamcmd",
|
|
// the first such provider is used to run `app_update <app_id> validate`
|
|
// with anonymous login BEFORE the alpine sidecar runs. This re-downloads
|
|
// or restores stock TFP mods (TFP_*, 0_TFP_*, xMarkers) that ship with
|
|
// the dedicated server install.
|
|
// - If the SteamCMD validate fails, the function returns an error and does
|
|
// NOT proceed to the alpine step.
|
|
// - If there is no steamcmd provider (e.g., Conan Exiles), the validate is
|
|
// skipped and the function goes straight to the alpine path.
|
|
//
|
|
// What the alpine step does NOT touch (and does not need to):
|
|
// - TFP_* / 0_TFP_* — shipped by SteamCMD as part of the dedicated
|
|
// server install. TFP_Harmony is the runtime patcher; wiping it
|
|
// would brick the engine. Always present in /game/Mods/ after a
|
|
// fresh `app_update 294420`.
|
|
// - xMarkers — also ships with the SteamCMD 7DTD install per the
|
|
// operator (2026-05-04). Already in /game/Mods/ after install.
|
|
// - Custom mods (RefugeBot, AGF-VP-*, AsylumRoboticInbox, anything not
|
|
// in the recognized base-mod name set). Those are operator territory.
|
|
//
|
|
// Behavior on existing installs: any directory with a name that matches
|
|
// the incoming mod gets renamed to `<name>.bak-<UTCstamp>` first so a
|
|
// re-run is reversible. Direct port of the safety pattern in
|
|
// refugebotserver's DependencyInstaller.cs.
|
|
//
|
|
// Caller responsibility: the instance container MUST be stopped before
|
|
// calling this — the game holds DLLs in /game/Mods open while running,
|
|
// and rename-on-extract will fail or corrupt under that.
|
|
func (d *Dispatcher) installBaseMods(ctx context.Context, instanceID string, manifest *modulepkg.Manifest, dataPath string) error {
|
|
if manifest == nil || len(manifest.UpdateProviders) == 0 {
|
|
return errors.New("install-base-mods: module has no update provider")
|
|
}
|
|
installPath := manifest.UpdateProviders[0].InstallPath
|
|
if installPath == "" {
|
|
installPath = manifest.Runtime.Docker.BrowseableRoot
|
|
}
|
|
if installPath == "" {
|
|
return errors.New("install-base-mods: install_path missing on module")
|
|
}
|
|
volumes := agentmodule.ResolveVolumes(manifest, instanceID, dataPath)
|
|
gameVol := volumeNameForPath(volumes, installPath)
|
|
if gameVol == "" {
|
|
return fmt.Errorf("install-base-mods: install_path %q not in module volumes", installPath)
|
|
}
|
|
|
|
// ── SteamCMD validate step ──────────────────────────────────────────
|
|
// If the module has a steamcmd update provider, run app_update validate
|
|
// with anonymous login FIRST to re-download/restore stock TFP mods.
|
|
// This ensures TFP_*, 0_TFP_*, and xMarkers are present before the
|
|
// alpine sidecar installs third-party base mods.
|
|
var steamcmdSpec *modulepkg.UpdateProvider
|
|
for i := range manifest.UpdateProviders {
|
|
if manifest.UpdateProviders[i].Kind == "steamcmd" {
|
|
steamcmdSpec = &manifest.UpdateProviders[i]
|
|
break
|
|
}
|
|
}
|
|
if steamcmdSpec != nil {
|
|
d.log.Info("install-base-mods: steamcmd validate starting", "instance_id", instanceID, "app_id", steamcmdSpec.AppID)
|
|
prov, err := updater.Get("steamcmd")
|
|
if err != nil {
|
|
return fmt.Errorf("install-base-mods: get steamcmd provider: %w", err)
|
|
}
|
|
// Build a log sink that streams through the same "install-base-mods log" logger.
|
|
sink := func(line string) {
|
|
d.log.Info("install-base-mods log", "instance_id", instanceID, "line", line)
|
|
}
|
|
uc := &updater.Context{
|
|
InstanceID: instanceID,
|
|
Manifest: manifest,
|
|
BrowseableRoot: installPath,
|
|
DataPath: dataPath,
|
|
Runtime: d.runtime,
|
|
Log: sink,
|
|
SteamUsername: "", // anonymous login
|
|
SteamPassword: "", // anonymous login
|
|
}
|
|
if err := prov.Update(ctx, uc, steamcmdSpec); err != nil {
|
|
return fmt.Errorf("basemods: steamcmd validate failed: %w", err)
|
|
}
|
|
d.log.Info("install-base-mods: steamcmd validate succeeded", "instance_id", instanceID)
|
|
}
|
|
|
|
// alpine:3.19 is a 7 MB image with apk available. apk add fetches
|
|
// curl + unzip + tar + jq in ~5s on a warm cache. Cheaper than
|
|
// shipping our own base image and good enough for occasional runs.
|
|
script := `set -e
|
|
apk add --no-cache --quiet curl tar unzip jq ca-certificates >/dev/null
|
|
STAMP=$(date -u +%Y%m%d%H%M%S)
|
|
TMP=$(mktemp -d)
|
|
trap 'rm -rf "$TMP"' EXIT
|
|
|
|
backup_if_exists() {
|
|
for d in "$@"; do
|
|
if [ -d "$d" ] && [ ! -L "$d" ]; then
|
|
mv "$d" "$d.bak-$STAMP" 2>/dev/null || true
|
|
fi
|
|
done
|
|
}
|
|
|
|
# ── Allocs Server Fixes ───────────────────────────────────────────────
|
|
echo "[basemods] downloading Allocs from illy.bz"
|
|
curl -fsSL -o "$TMP/allocs.tgz" "https://illy.bz/fi/7dtd/server_fixes.tar.gz"
|
|
# Magic-byte check: gzip starts with 0x1f 0x8b — protects against
|
|
# CDN-served HTML 404 pages landing as a "valid" archive.
|
|
head -c 2 "$TMP/allocs.tgz" | od -An -tx1 | tr -d ' \n' | grep -q '^1f8b' \
|
|
|| { echo "[basemods] Allocs download wasn't gzip — aborting"; exit 1; }
|
|
backup_if_exists /game/Mods/Allocs_CommandExtensions /game/Mods/Allocs_WebAndMapRendering /game/Mods/Alloc_XmlPatch
|
|
tar -xzf "$TMP/allocs.tgz" -C /game
|
|
echo "[basemods] Allocs installed"
|
|
|
|
# ── PrismaCore (latest GitHub release zip) ─────────────────────────────
|
|
echo "[basemods] resolving latest PrismaCore release"
|
|
PRISMA_JSON="$TMP/prisma.json"
|
|
curl -fsSL -A "panel-basemods/1.0" -H "Accept: application/vnd.github+json" \
|
|
-o "$PRISMA_JSON" "https://api.github.com/repos/Prisma501/PrismaCore/releases/latest"
|
|
PRISMA_TAG=$(jq -r '.tag_name // empty' "$PRISMA_JSON")
|
|
PRISMA_URL=$(jq -r '.assets[]? | select(.name | test("\\.zip$"; "i")) | .browser_download_url' "$PRISMA_JSON" | head -1)
|
|
if [ -z "$PRISMA_URL" ]; then
|
|
echo "[basemods] no PrismaCore .zip asset — skipping"
|
|
else
|
|
echo "[basemods] downloading PrismaCore $PRISMA_TAG from $PRISMA_URL"
|
|
curl -fsSL -A "panel-basemods/1.0" -o "$TMP/prisma.zip" "$PRISMA_URL"
|
|
head -c 2 "$TMP/prisma.zip" | od -An -tx1 | tr -d ' \n' | grep -q '^504b' \
|
|
|| { echo "[basemods] PrismaCore download wasn't a zip — aborting"; exit 1; }
|
|
for d in /game/Mods/Prisma*; do backup_if_exists "$d"; done
|
|
unzip -o -q "$TMP/prisma.zip" -d /game/Mods/
|
|
echo "[basemods] PrismaCore installed"
|
|
fi
|
|
|
|
# Note: TFP_* and xMarkers are shipped as part of the SteamCMD dedicated
|
|
# server install (app 294420). They're already in /game/Mods/ after a
|
|
# fresh install and we don't touch them here — wiping TFP_Harmony would
|
|
# brick the engine.
|
|
echo BASEMODS_OK
|
|
`
|
|
|
|
sidecarID := fmt.Sprintf("%s-basemods", instanceID)
|
|
spec := runtime.InstanceSpec{
|
|
InstanceID: sidecarID,
|
|
IsSidecar: true,
|
|
Image: "alpine:3.19",
|
|
Command: []string{"sh", "-c", script},
|
|
Volumes: []runtime.VolumeSpec{
|
|
{Type: "volume", VolumeName: gameVol, ContainerPath: installPath},
|
|
},
|
|
RestartPolicy: "no",
|
|
}
|
|
d.log.Info("install-base-mods: starting sidecar", "instance_id", instanceID, "vol", gameVol)
|
|
contID, err := d.runtime.Create(ctx, spec)
|
|
if err != nil {
|
|
return fmt.Errorf("install-base-mods: create: %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 fmt.Errorf("install-base-mods: start: %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("install-base-mods log", "instance_id", instanceID, "line", line)
|
|
})
|
|
}()
|
|
exitCode, err := d.runtime.Wait(ctx, contID)
|
|
cancelLogs()
|
|
<-logDone
|
|
if err != nil {
|
|
return fmt.Errorf("install-base-mods: wait: %w", err)
|
|
}
|
|
if exitCode != 0 {
|
|
return fmt.Errorf("install-base-mods: sidecar exited %d", exitCode)
|
|
}
|
|
d.log.Info("install-base-mods: success", "instance_id", instanceID)
|
|
return nil
|
|
}
|