Add one-command server mod updates: build_mods.py --push via update.sh (stop/sync/start, prunes stale jars)
This commit is contained in:
@@ -35,6 +35,21 @@ cd ~/reaper-sky-server && ./run.sh
|
|||||||
The world generates on first boot — wait for `Done (...)` and join at
|
The world generates on first boot — wait for `Done (...)` and join at
|
||||||
`reaperofveriod.nl`. Full details in [`server/README.md`](server/README.md).
|
`reaperofveriod.nl`. Full details in [`server/README.md`](server/README.md).
|
||||||
|
|
||||||
|
## Updating the server's mods (maintainers)
|
||||||
|
|
||||||
|
After adding/removing mods in the Prism instance `Reaper's Sky - dev`, push
|
||||||
|
them to the live server with one command from the repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 server/build_mods.py --push
|
||||||
|
```
|
||||||
|
|
||||||
|
It regenerates `server/mods.json`, uploads it, then on the server:
|
||||||
|
**stop → sync mods (prune stale + download new, sha1-verified) → start**, and
|
||||||
|
waits until the log shows `Done (...)`. No git commit needed first. See
|
||||||
|
[`server/README.md`](server/README.md#updating-the-servers-mods) for details,
|
||||||
|
manual options and troubleshooting.
|
||||||
|
|
||||||
## What's inside
|
## What's inside
|
||||||
|
|
||||||
<details open>
|
<details open>
|
||||||
|
|||||||
+53
-9
@@ -22,7 +22,7 @@ and verifies every mod by sha1 before installing.
|
|||||||
|------|--------|
|
|------|--------|
|
||||||
| System packages | `openjdk-21-jre-headless`, `curl`, `unzip` (needs root/`sudo`) |
|
| System packages | `openjdk-21-jre-headless`, `curl`, `unzip` (needs root/`sudo`) |
|
||||||
| NeoForge server | Official installer from Maven → server files + `libraries/` |
|
| NeoForge server | Official installer from Maven → server files + `libraries/` |
|
||||||
| Mods | Downloads the 90 server-safe mods pinned in `mods.json`, sha1-verified |
|
| Mods | Downloads the 90 server-safe mods pinned in `mods.json`, sha1-verified; prunes jars no longer in the pack |
|
||||||
| Config | `eula.txt` (EULA accepted), `server.properties` (IP, motd, 8 G) |
|
| Config | `eula.txt` (EULA accepted), `server.properties` (IP, motd, 8 G) |
|
||||||
| Run script | `run.sh` with 8 GB + G1GC (`nogui`) |
|
| Run script | `run.sh` with 8 GB + G1GC (`nogui`) |
|
||||||
|
|
||||||
@@ -54,13 +54,57 @@ Stop the server cleanly with the console command `stop` (Ctrl-c also works).
|
|||||||
the server will crash with `Cannot assign requested address`.
|
the server will crash with `Cannot assign requested address`.
|
||||||
- `difficulty=hard`, `spawn-protection=0`, `view-distance=10` are set for the
|
- `difficulty=hard`, `spawn-protection=0`, `view-distance=10` are set for the
|
||||||
airship-focused, land-claim-friendly experience.
|
airship-focused, land-claim-friendly experience.
|
||||||
- The 19 client-only mods (Iris/shaders, Sodium, Minimap, Jade, JEI, etc.) are
|
- The 17 client-only mods (Iris/shaders, Sodium, Minimap, MouseTweaks, etc.)
|
||||||
intentionally excluded here — the server is pure gameplay logic.
|
are intentionally excluded here — everything gameplay-relevant runs on the
|
||||||
|
server too (including JEI and Jade).
|
||||||
|
|
||||||
## Updating the pack
|
## Updating the server's mods
|
||||||
|
|
||||||
1. Add/remove mods in the Prism instance `Reaper's Sky - dev`.
|
### The easy way — one command from your PC
|
||||||
2. Rebuild + push the mrpack (see repo root `README.md`).
|
|
||||||
3. Re-run `server/build_mods.py` or regenerate `mods.json`, commit, push.
|
If your PC has SSH key access to the server (`ssh root@ReapersSky` works
|
||||||
4. On the server: `git pull && sudo ./deploy.sh && ./run.sh`
|
without a password), you can push mod changes to the live server with a single
|
||||||
(also remove any stale jars from `~/reaper-sky-server/mods/` first).
|
command run **from the repo root on your PC**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 server/build_mods.py --push
|
||||||
|
```
|
||||||
|
|
||||||
|
This does everything:
|
||||||
|
|
||||||
|
1. Scans the Prism instance `Reaper's Sky - dev` and regenerates
|
||||||
|
`server/mods.json` (skipping client-only mods).
|
||||||
|
2. Uploads that manifest to the server.
|
||||||
|
3. On the server: **stops** it cleanly, **syncs** `mods/` against the manifest
|
||||||
|
(deletes jars that are no longer in the pack, downloads + sha1-verifies new
|
||||||
|
or changed ones), then **starts** it again.
|
||||||
|
4. Waits for boot and prints `Server is UP` once the log shows `Done (...)`.
|
||||||
|
|
||||||
|
No git commit/push is needed first — it works straight from your working tree,
|
||||||
|
so you can test changes on the server before committing them.
|
||||||
|
|
||||||
|
Useful variations:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 server/build_mods.py --push # full cycle: stop -> sync -> start
|
||||||
|
RS_SERVER_HOST=user@otherhost python3 server/build_mods.py --push # different box
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual / on-server way
|
||||||
|
|
||||||
|
If you already have a `mods.json` on the server (e.g. uploaded by hand), run
|
||||||
|
this **on the server box**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bash ~/update.sh /path/to/mods.json # stop -> sync -> start
|
||||||
|
bash ~/update.sh /path/to/mods.json --no-start # sync only, leave stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
`update.sh` is safe to re-run at any time: if the server isn't running it just
|
||||||
|
syncs; every jar is verified by sha1 before it counts as installed.
|
||||||
|
|
||||||
|
### Full reinstall
|
||||||
|
|
||||||
|
For a brand-new box (or to rebuild everything from scratch), use the one-shot
|
||||||
|
installer described at the top — it also prunes stale jars since it shares the
|
||||||
|
same sync logic.
|
||||||
|
|||||||
+26
-2
@@ -8,13 +8,17 @@ For every .jar in the pack instance it:
|
|||||||
- drops the 19 client-only mods
|
- drops the 19 client-only mods
|
||||||
and writes the server manifest to mods.json.
|
and writes the server manifest to mods.json.
|
||||||
|
|
||||||
Usage: python3 build_mods.py
|
Usage: python3 build_mods.py regenerate server/mods.json
|
||||||
|
python3 build_mods.py --push regenerate, then update the live server
|
||||||
|
(stop -> sync mods -> start, via SSH)
|
||||||
"""
|
"""
|
||||||
import hashlib, json, os, subprocess, sys, time
|
import argparse, hashlib, json, os, subprocess, sys, time
|
||||||
|
|
||||||
INSTANCE = os.path.expanduser(
|
INSTANCE = os.path.expanduser(
|
||||||
"~/.local/share/PrismLauncher/instances/Reaper's Sky - dev/.minecraft/mods")
|
"~/.local/share/PrismLauncher/instances/Reaper's Sky - dev/.minecraft/mods")
|
||||||
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mods.json")
|
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mods.json")
|
||||||
|
UPDATE_SH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "update.sh")
|
||||||
|
SERVER_HOST = os.environ.get("RS_SERVER_HOST", "root@ReapersSky")
|
||||||
MC_VERSION = "1.21.1"
|
MC_VERSION = "1.21.1"
|
||||||
NEOFORGE_VERSION = "21.1.248"
|
NEOFORGE_VERSION = "21.1.248"
|
||||||
|
|
||||||
@@ -67,7 +71,25 @@ def resolve(jar, digest):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def push():
|
||||||
|
"""Upload mods.json to the server and run update.sh there (stop/sync/start)."""
|
||||||
|
print(f"Uploading manifest to {SERVER_HOST}:/tmp/reaper-sky-mods.json ...")
|
||||||
|
with open(OUT, "rb") as f:
|
||||||
|
subprocess.run(["ssh", SERVER_HOST, "cat > /tmp/reaper-sky-mods.json"],
|
||||||
|
stdin=f, check=True)
|
||||||
|
print("Running update.sh on the server (stop -> sync -> start) ...")
|
||||||
|
with open(UPDATE_SH, "rb") as f:
|
||||||
|
subprocess.run(["ssh", SERVER_HOST, "bash -s -- /tmp/reaper-sky-mods.json"],
|
||||||
|
stdin=f, check=True)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--push", action="store_true",
|
||||||
|
help="also update the live server: stop it, sync mods "
|
||||||
|
"(prune + download + sha1-verify), start it again")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
mods = []
|
mods = []
|
||||||
for jar in sorted(os.listdir(INSTANCE)):
|
for jar in sorted(os.listdir(INSTANCE)):
|
||||||
if not jar.endswith(".jar"):
|
if not jar.endswith(".jar"):
|
||||||
@@ -85,6 +107,8 @@ def main():
|
|||||||
with open(OUT, "w") as f:
|
with open(OUT, "w") as f:
|
||||||
json.dump(manifest, f, indent=2)
|
json.dump(manifest, f, indent=2)
|
||||||
print(f"Wrote {len(mods)} server mods to {OUT}")
|
print(f"Wrote {len(mods)} server mods to {OUT}")
|
||||||
|
if args.push:
|
||||||
|
push()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+11
-2
@@ -5,7 +5,7 @@
|
|||||||
# Installs everything needed to run the Reaper's Sky NeoForge 21.1.248 server:
|
# Installs everything needed to run the Reaper's Sky NeoForge 21.1.248 server:
|
||||||
# - OpenJDK 21 (JRE, headless) + curl/unzip
|
# - OpenJDK 21 (JRE, headless) + curl/unzip
|
||||||
# - NeoForge 21.1.248 server via its official installer
|
# - NeoForge 21.1.248 server via its official installer
|
||||||
# - the 88 server-safe mods (pinned in mods.json, verified by sha1)
|
# - the server-safe mods (pinned in mods.json, verified by sha1)
|
||||||
# - eula.txt + server.properties (IP reaperofveriod.nl)
|
# - eula.txt + server.properties (IP reaperofveriod.nl)
|
||||||
# - run.sh (8 GB, G1GC)
|
# - run.sh (8 GB, G1GC)
|
||||||
#
|
#
|
||||||
@@ -74,8 +74,10 @@ install_mods() {
|
|||||||
python3 - "$SCRIPT_DIR/mods.json" <<'PY'
|
python3 - "$SCRIPT_DIR/mods.json" <<'PY'
|
||||||
import json, sys, hashlib, subprocess, os
|
import json, sys, hashlib, subprocess, os
|
||||||
manifest = json.load(open(sys.argv[1]))
|
manifest = json.load(open(sys.argv[1]))
|
||||||
|
keep = set()
|
||||||
for m in manifest["mods"]:
|
for m in manifest["mods"]:
|
||||||
fname, url, want = m["file"], m["url"], m["sha1"]
|
fname, url, want = m["file"], m["url"], m["sha1"]
|
||||||
|
keep.add(fname)
|
||||||
tmp = fname + ".part"
|
tmp = fname + ".part"
|
||||||
if os.path.exists(fname):
|
if os.path.exists(fname):
|
||||||
h = hashlib.sha1()
|
h = hashlib.sha1()
|
||||||
@@ -84,7 +86,9 @@ for m in manifest["mods"]:
|
|||||||
h.update(b)
|
h.update(b)
|
||||||
if h.hexdigest() == want:
|
if h.hexdigest() == want:
|
||||||
continue # already present & correct
|
continue # already present & correct
|
||||||
subprocess.run(["curl", "-fL", "--retry", "3", "-o", tmp, url], check=True)
|
if not url:
|
||||||
|
raise SystemExit(f"no download URL for {fname}")
|
||||||
|
subprocess.run(["curl", "-fsSL", "--retry", "3", "-o", tmp, url], check=True)
|
||||||
h = hashlib.sha1()
|
h = hashlib.sha1()
|
||||||
with open(tmp, "rb") as fh:
|
with open(tmp, "rb") as fh:
|
||||||
for b in iter(lambda: fh.read(65536), b""):
|
for b in iter(lambda: fh.read(65536), b""):
|
||||||
@@ -94,6 +98,11 @@ for m in manifest["mods"]:
|
|||||||
raise SystemExit(f"sha1 mismatch for {fname}")
|
raise SystemExit(f"sha1 mismatch for {fname}")
|
||||||
os.rename(tmp, fname)
|
os.rename(tmp, fname)
|
||||||
print(f" + {fname}")
|
print(f" + {fname}")
|
||||||
|
# remove jars that are no longer part of the pack
|
||||||
|
for f in sorted(os.listdir(".")):
|
||||||
|
if f.endswith(".jar") and f not in keep:
|
||||||
|
os.unlink(f)
|
||||||
|
print(f" - {f} (stale)")
|
||||||
PY
|
PY
|
||||||
log "Mods ready: $(ls -1 "$SERVER_DIR/mods"/*.jar | wc -l) jars installed."
|
log "Mods ready: $(ls -1 "$SERVER_DIR/mods"/*.jar | wc -l) jars installed."
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Reaper's Sky - server mod updater.
|
||||||
|
#
|
||||||
|
# Brings the server's mods/ folder in line with a mods.json manifest:
|
||||||
|
# 1. gracefully stops the running server (tmux session "mc")
|
||||||
|
# 2. prunes jars that are not in the manifest, downloads + sha1-verifies
|
||||||
|
# the ones that are missing
|
||||||
|
# 3. boots the server again and waits until it reports "Done"
|
||||||
|
#
|
||||||
|
# Usage (on the server):
|
||||||
|
# bash update.sh [MANIFEST] [--no-start]
|
||||||
|
#
|
||||||
|
# MANIFEST path to mods.json (default: /tmp/reaper-sky-mods.json)
|
||||||
|
# --no-start sync only, leave the server stopped
|
||||||
|
#
|
||||||
|
# You normally do NOT run this by hand. From your PC run:
|
||||||
|
# python3 server/build_mods.py --push
|
||||||
|
# which regenerates mods.json from the client instance, uploads it to
|
||||||
|
# /tmp/reaper-sky-mods.json on the server and pipes this script over SSH.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVER_DIR="${DIR:-$HOME/reaper-sky-server}"
|
||||||
|
SESSION="mc"
|
||||||
|
MANIFEST="/tmp/reaper-sky-mods.json"
|
||||||
|
START=true
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--no-start) START=false ;;
|
||||||
|
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||||
|
*) MANIFEST="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -f "$MANIFEST" ] || { echo "[update] ERROR: manifest not found: $MANIFEST" >&2; exit 1; }
|
||||||
|
[ -d "$SERVER_DIR" ] || { echo "[update] ERROR: server dir not found: $SERVER_DIR" >&2; exit 1; }
|
||||||
|
|
||||||
|
log() { printf '\033[1;34m[update]\033[0m %s\n' "$*"; }
|
||||||
|
die() { printf '\033[1;31m[update] ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# "running" = the tmux session's pane is executing java right now.
|
||||||
|
running() {
|
||||||
|
tmux has-session -t "$SESSION" 2>/dev/null || return 1
|
||||||
|
[ "$(tmux list-panes -t "$SESSION" -F '#{pane_current_command}' 2>/dev/null)" = "java" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 1. stop -------------------------------------------------------------------
|
||||||
|
if running; then
|
||||||
|
log "Stopping server (session '$SESSION')..."
|
||||||
|
tmux send-keys -t "$SESSION" "stop" C-m
|
||||||
|
for _ in $(seq 1 90); do
|
||||||
|
running || break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
running && die "Server still running after 90s -- refusing to continue."
|
||||||
|
log "Server stopped."
|
||||||
|
else
|
||||||
|
log "Server not running -- skipping stop."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 2. sync mods ----------------------------------------------------------------
|
||||||
|
log "Syncing mods against $(basename "$MANIFEST")..."
|
||||||
|
mkdir -p "$SERVER_DIR/mods"
|
||||||
|
cd "$SERVER_DIR/mods"
|
||||||
|
python3 - "$MANIFEST" <<'PY'
|
||||||
|
import hashlib, json, os, subprocess, sys
|
||||||
|
|
||||||
|
manifest = json.load(open(sys.argv[1]))
|
||||||
|
keep = set()
|
||||||
|
for m in manifest["mods"]:
|
||||||
|
fname, url, want = m["file"], m["url"], m["sha1"]
|
||||||
|
keep.add(fname)
|
||||||
|
if os.path.exists(fname):
|
||||||
|
h = hashlib.sha1()
|
||||||
|
with open(fname, "rb") as fh:
|
||||||
|
for b in iter(lambda: fh.read(65536), b""):
|
||||||
|
h.update(b)
|
||||||
|
if h.hexdigest() == want:
|
||||||
|
continue # already present & correct
|
||||||
|
print(f" ~ {fname} (hash mismatch -- re-downloading)")
|
||||||
|
else:
|
||||||
|
print(f" + {fname}")
|
||||||
|
if not url:
|
||||||
|
raise SystemExit(f"no download URL for {fname}")
|
||||||
|
tmp = fname + ".part"
|
||||||
|
subprocess.run(["curl", "-fsSL", "--retry", "3", "-o", tmp, url], check=True)
|
||||||
|
h = hashlib.sha1()
|
||||||
|
with open(tmp, "rb") as fh:
|
||||||
|
for b in iter(lambda: fh.read(65536), b""):
|
||||||
|
h.update(b)
|
||||||
|
if h.hexdigest() != want:
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise SystemExit(f"sha1 mismatch for {fname}")
|
||||||
|
os.rename(tmp, fname)
|
||||||
|
|
||||||
|
for f in sorted(os.listdir(".")):
|
||||||
|
if f.endswith(".jar") and f not in keep:
|
||||||
|
os.unlink(f)
|
||||||
|
print(f" - {f} (stale)")
|
||||||
|
|
||||||
|
on_disk = len([f for f in os.listdir(".") if f.endswith(".jar")])
|
||||||
|
print(f"[update] {len(keep)} mods in manifest, {on_disk} jars on disk")
|
||||||
|
PY
|
||||||
|
log "Mods synced."
|
||||||
|
|
||||||
|
# --- 3. start + wait for boot ------------------------------------------------------
|
||||||
|
start_server() {
|
||||||
|
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
|
||||||
|
tmux new-session -d -s "$SESSION" -c "$SERVER_DIR"
|
||||||
|
fi
|
||||||
|
tmux send-keys -t "$SESSION" "cd '$SERVER_DIR' && ./run.sh" C-m
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$START" = false ]; then
|
||||||
|
log "--no-start given: leaving the server stopped."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
LOG="$SERVER_DIR/logs/latest.log"
|
||||||
|
OFFSET=$(wc -l < "$LOG" 2>/dev/null || echo 0) # ignore pre-boot log content
|
||||||
|
log "Starting server (tmux session '$SESSION')..."
|
||||||
|
start_server
|
||||||
|
|
||||||
|
log "Waiting for boot (up to 4 min)..."
|
||||||
|
for i in $(seq 1 240); do
|
||||||
|
cur=$(wc -l < "$LOG" 2>/dev/null || echo 0)
|
||||||
|
[ "$cur" -lt "$OFFSET" ] && OFFSET=0 # log was rotated/truncated
|
||||||
|
if [ "$cur" -gt "$OFFSET" ] && tail -n +"$((OFFSET + 1))" "$LOG" | grep -q "Done ("; then
|
||||||
|
log "Server is UP after ~${i}s."
|
||||||
|
log "Console: tmux attach -t $SESSION (detach: Ctrl-b d)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# pane exited or left java => boot failed / crashed
|
||||||
|
if ! tmux has-session -t "$SESSION" 2>/dev/null || ! running; then
|
||||||
|
sleep 5 # give the shell a moment to print any error
|
||||||
|
die "Server process exited during boot -- check: tail -50 $LOG"
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
die "Timed out waiting for 'Done' after 4 min -- check: tail -50 $LOG"
|
||||||
Reference in New Issue
Block a user