panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user