feat(l4d2-web): seed example script overlays from examples/script-overlays/
Bundles four reference script overlays (cedapug_maps, l4d2center_maps, competitive_rework, tickrate) and adds a `flask seed-script-overlays` CLI that upserts each *.sh as a system-wide overlay. Test deploy invokes it after the orphan-cleanup migration so fresh test servers come up with the same overlays the user has been maintaining by hand. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6b4eef22c2
commit
196d2db33e
7 changed files with 336 additions and 1 deletions
|
|
@ -258,6 +258,16 @@ print(\" \".join(str(r[0]) for r in c.execute(\"SELECT id FROM overlays\")))
|
||||||
$sudo_cmd chown left4me:left4me "$overlay_orphan_sentinel"
|
$sudo_cmd chown left4me:left4me "$overlay_orphan_sentinel"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Seed example script overlays (cedapug, l4d2center, competitive_rework, ...)
|
||||||
|
# as system-wide rows from /opt/left4me/examples/script-overlays/. Idempotent:
|
||||||
|
# subsequent deploys refresh the script body in place, leaving the row id and
|
||||||
|
# overlay directory intact.
|
||||||
|
run_left4me_with_env env \
|
||||||
|
JOB_WORKER_ENABLED=false \
|
||||||
|
PYTHONPATH=/opt/left4me \
|
||||||
|
/opt/left4me/.venv/bin/flask --app l4d2web.app:create_app \
|
||||||
|
seed-script-overlays /opt/left4me/examples/script-overlays
|
||||||
|
|
||||||
$sudo_cmd systemctl daemon-reload
|
$sudo_cmd systemctl daemon-reload
|
||||||
$sudo_cmd systemctl enable --now left4me-web.service
|
$sudo_cmd systemctl enable --now left4me-web.service
|
||||||
$sudo_cmd systemctl restart left4me-web.service
|
$sudo_cmd systemctl restart left4me-web.service
|
||||||
|
|
|
||||||
109
examples/script-overlays/cedapug_maps.sh
Normal file
109
examples/script-overlays/cedapug_maps.sh
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Cedapug custom-maps overlay.
|
||||||
|
# Scrapes the JS map list at https://cedapug.com/custom and keeps
|
||||||
|
# $OVERLAY/left4dead2/addons/ in sync. Re-downloads only when the zip's
|
||||||
|
# Last-Modified header changed.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CEDAPUG_HOST="https://cedapug.com"
|
||||||
|
LIST_URL="$CEDAPUG_HOST/custom"
|
||||||
|
ADDONS_DIR="$OVERLAY/left4dead2/addons"
|
||||||
|
STATE_DIR="$OVERLAY/.cedapug"
|
||||||
|
WORK_DIR="$STATE_DIR/work"
|
||||||
|
MANIFEST="$STATE_DIR/manifest.tsv" # zip_basename \t last_modified \t vpk1,vpk2,...
|
||||||
|
|
||||||
|
mkdir -p "$ADDONS_DIR" "$STATE_DIR" "$WORK_DIR"
|
||||||
|
|
||||||
|
# --- 1. Discover the current zip list -----------------------------------
|
||||||
|
# The page embeds the map list inside renderCustomMapDownloads([...]) with
|
||||||
|
# JSON-escaped slashes (\/maps\/foo.zip). Unescape with sed before grepping.
|
||||||
|
echo ":: fetching $LIST_URL"
|
||||||
|
mapfile -t ZIP_PATHS < <(
|
||||||
|
curl -fsSL --retry 3 --retry-delay 2 "$LIST_URL" \
|
||||||
|
| sed 's@\\/@/@g' \
|
||||||
|
| grep -oE '"/maps/[A-Za-z0-9_.+-]+\.zip"' \
|
||||||
|
| tr -d '"' \
|
||||||
|
| sort -u
|
||||||
|
)
|
||||||
|
if [[ ${#ZIP_PATHS[@]} -eq 0 ]]; then
|
||||||
|
echo "!! no map zips found on $LIST_URL — page format may have changed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ":: ${#ZIP_PATHS[@]} custom map zip(s) listed"
|
||||||
|
|
||||||
|
# --- 2. Load previous manifest ------------------------------------------
|
||||||
|
declare -A PREV_LM PREV_VPKS
|
||||||
|
if [[ -f "$MANIFEST" ]]; then
|
||||||
|
while IFS=$'\t' read -r zname lm vpks; do
|
||||||
|
[[ -n "$zname" ]] || continue
|
||||||
|
PREV_LM[$zname]=$lm
|
||||||
|
PREV_VPKS[$zname]=$vpks
|
||||||
|
done < "$MANIFEST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3. Download / extract each desired zip -----------------------------
|
||||||
|
declare -A NEW_LM NEW_VPKS
|
||||||
|
for zpath in "${ZIP_PATHS[@]}"; do
|
||||||
|
zname="${zpath##*/}"
|
||||||
|
url="$CEDAPUG_HOST$zpath"
|
||||||
|
|
||||||
|
lm="$(curl -fsSI --retry 3 --retry-delay 2 "$url" \
|
||||||
|
| awk 'BEGIN{IGNORECASE=1} /^last-modified:/ {sub(/^[^:]*:[[:space:]]*/, ""); gsub(/\r/, ""); print; exit}')"
|
||||||
|
NEW_LM[$zname]=$lm
|
||||||
|
|
||||||
|
prev_lm="${PREV_LM[$zname]:-}"
|
||||||
|
prev_vpks="${PREV_VPKS[$zname]:-}"
|
||||||
|
|
||||||
|
# Skip if Last-Modified matches AND every recorded vpk is still on disk.
|
||||||
|
if [[ -n "$lm" && "$lm" == "$prev_lm" && -n "$prev_vpks" ]]; then
|
||||||
|
all_present=1
|
||||||
|
IFS=',' read -r -a v_arr <<<"$prev_vpks"
|
||||||
|
for v in "${v_arr[@]}"; do
|
||||||
|
[[ -f "$ADDONS_DIR/$v" ]] || { all_present=0; break; }
|
||||||
|
done
|
||||||
|
if (( all_present )); then
|
||||||
|
echo ":: $zname unchanged (Last-Modified: $lm) — skipped"
|
||||||
|
NEW_VPKS[$zname]=$prev_vpks
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ":: downloading $zname"
|
||||||
|
zfile="$WORK_DIR/$zname"
|
||||||
|
curl -fSL --retry 3 --retry-delay 2 -o "$zfile" "$url"
|
||||||
|
|
||||||
|
7z x -y -bd -o"$ADDONS_DIR" "$zfile" '*.vpk' >/dev/null
|
||||||
|
|
||||||
|
vpk_list="$(7z l -slt -ba "$zfile" \
|
||||||
|
| awk '/^Path = .*\.vpk$/ {sub(/^Path = /, ""); print}' \
|
||||||
|
| paste -sd, -)"
|
||||||
|
if [[ -z "$vpk_list" ]]; then
|
||||||
|
echo "!! warning: $zname contained no .vpk — leaving manifest entry empty" >&2
|
||||||
|
fi
|
||||||
|
NEW_VPKS[$zname]=$vpk_list
|
||||||
|
|
||||||
|
rm -f "$zfile"
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- 4. Prune vpks from zips that disappeared from the listing ----------
|
||||||
|
for old_zname in "${!PREV_VPKS[@]}"; do
|
||||||
|
if [[ -z "${NEW_LM[$old_zname]:-}" ]]; then
|
||||||
|
IFS=',' read -r -a old_v_arr <<<"${PREV_VPKS[$old_zname]}"
|
||||||
|
for v in "${old_v_arr[@]}"; do
|
||||||
|
if [[ -n "$v" && -f "$ADDONS_DIR/$v" ]]; then
|
||||||
|
echo ":: pruning $v (zip $old_zname no longer listed)"
|
||||||
|
rm -f "$ADDONS_DIR/$v"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- 5. Rewrite manifest -------------------------------------------------
|
||||||
|
tmp_manifest="$(mktemp -p "$STATE_DIR")"
|
||||||
|
for zname in "${!NEW_LM[@]}"; do
|
||||||
|
printf '%s\t%s\t%s\n' "$zname" "${NEW_LM[$zname]}" "${NEW_VPKS[$zname]:-}" >>"$tmp_manifest"
|
||||||
|
done
|
||||||
|
mv -f "$tmp_manifest" "$MANIFEST"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
echo ":: done — ${#NEW_LM[@]} zip(s) tracked, addons/ in sync"
|
||||||
15
examples/script-overlays/competitive_rework.sh
Normal file
15
examples/script-overlays/competitive_rework.sh
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# L4D2 Competitive Rework overlay.
|
||||||
|
# Pulls the master branch of SirPlease/L4D2-Competitive-Rework as a tarball
|
||||||
|
# and extracts it into $OVERLAY/left4dead2/. Skipped on subsequent rebuilds
|
||||||
|
# once cfg/cfgogl/ is present — wipe the overlay to force a refresh.
|
||||||
|
# https://github.com/SirPlease/L4D2-Competitive-Rework
|
||||||
|
set -xeuo pipefail
|
||||||
|
|
||||||
|
DEST="$OVERLAY/left4dead2"
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
|
||||||
|
if [[ ! -d "$DEST/cfg/cfgogl" ]]; then
|
||||||
|
curl -fsSL https://github.com/SirPlease/L4D2-Competitive-Rework/archive/refs/heads/master.tar.gz \
|
||||||
|
| tar -xz --strip-components=1 -C "$DEST"
|
||||||
|
fi
|
||||||
48
examples/script-overlays/l4d2center_maps.sh
Normal file
48
examples/script-overlays/l4d2center_maps.sh
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -xeuo pipefail
|
||||||
|
|
||||||
|
mkdir -p /overlay/left4dead2/addons
|
||||||
|
cd /overlay/left4dead2/addons
|
||||||
|
|
||||||
|
for cmd in curl md5sum 7z; do
|
||||||
|
command -v "$cmd" >/dev/null || { echo "Missing: $cmd" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
CSV_URL="https://l4d2center.com/maps/servers/index.csv"
|
||||||
|
TEMP_CSV=$(mktemp)
|
||||||
|
trap 'rm -f "$TEMP_CSV"' EXIT
|
||||||
|
curl -fsSL -o "$TEMP_CSV" "$CSV_URL"
|
||||||
|
|
||||||
|
declare -A map_md5 map_links
|
||||||
|
{
|
||||||
|
IFS= read -r _header
|
||||||
|
while IFS=';' read -r Name _Size MD5 DownloadLink || [[ $Name ]]; do
|
||||||
|
Name=$(echo "$Name" | xargs)
|
||||||
|
MD5=$(echo "$MD5" | xargs)
|
||||||
|
DownloadLink=$(echo "$DownloadLink" | xargs)
|
||||||
|
map_md5["$Name"]="$MD5"
|
||||||
|
map_links["$Name"]="$DownloadLink"
|
||||||
|
done
|
||||||
|
} < "$TEMP_CSV"
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
for file in *.vpk; do
|
||||||
|
if [[ -z "${map_md5[$file]:-}" ]]; then
|
||||||
|
rm -f "$file"
|
||||||
|
elif [[ "$(md5sum "$file" | awk '{print $1}')" != "${map_md5[$file]}" ]]; then
|
||||||
|
rm -f "$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
for vpk in "${!map_md5[@]}"; do
|
||||||
|
[[ -f "$vpk" ]] && continue
|
||||||
|
url="${map_links[$vpk]}"
|
||||||
|
[[ -n "$url" ]] || continue
|
||||||
|
encoded=$(printf '%s' "$url" | sed 's/ /%20/g')
|
||||||
|
TEMP_7Z=$(mktemp --suffix=.7z)
|
||||||
|
curl -fsSL -o "$TEMP_7Z" "$encoded"
|
||||||
|
7z x -y "$TEMP_7Z"
|
||||||
|
rm -f "$TEMP_7Z"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Synchronization complete."
|
||||||
25
examples/script-overlays/tickrate.sh
Normal file
25
examples/script-overlays/tickrate.sh
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Tickrate overlay: server.cfg with high-rate settings + the tickrate_enabler
|
||||||
|
# addon (DLL/SO/VDF) from SirPlease/L4D2-Competitive-Rework.
|
||||||
|
# https://github.com/SirPlease/L4D2-Competitive-Rework/blob/7ecc3a32a5e2180d6607a40119ff2f3c072502a9/cfg/server.cfg#L58-L69
|
||||||
|
# https://www.programmersought.com/article/513810199514/
|
||||||
|
set -xeuo pipefail
|
||||||
|
|
||||||
|
CFG_DIR="$OVERLAY/left4dead2/cfg"
|
||||||
|
ADDONS_DIR="$OVERLAY/left4dead2/addons"
|
||||||
|
mkdir -p "$CFG_DIR" "$ADDONS_DIR"
|
||||||
|
|
||||||
|
cat <<'EOF' > "$CFG_DIR/server.cfg"
|
||||||
|
# https://github.com/SirPlease/L4D2-Competitive-Rework/blob/7ecc3a32a5e2180d6607a40119ff2f3c072502a9/cfg/server.cfg#L58-L69
|
||||||
|
sv_minrate 100000
|
||||||
|
sv_maxrate 100000
|
||||||
|
nb_update_frequency 0.014
|
||||||
|
net_splitpacket_maxrate 50000
|
||||||
|
net_maxcleartime 0.0001
|
||||||
|
fps_max 0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
for file in tickrate_enabler.dll tickrate_enabler.so tickrate_enabler.vdf; do
|
||||||
|
curl -fsSL "https://github.com/SirPlease/L4D2-Competitive-Rework/raw/refs/heads/master/addons/${file}" \
|
||||||
|
-o "$ADDONS_DIR/${file}"
|
||||||
|
done
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
@ -6,7 +7,11 @@ from sqlalchemy import select
|
||||||
|
|
||||||
from l4d2web.auth import hash_password
|
from l4d2web.auth import hash_password
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
from l4d2web.models import User
|
from l4d2web.models import Overlay, User
|
||||||
|
from l4d2web.services.overlay_creation import (
|
||||||
|
create_overlay_directory,
|
||||||
|
generate_overlay_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@click.command("promote-admin")
|
@click.command("promote-admin")
|
||||||
|
|
@ -41,6 +46,50 @@ def create_user(username: str, admin: bool) -> None:
|
||||||
click.echo(f"created user {username}")
|
click.echo(f"created user {username}")
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("seed-script-overlays")
|
||||||
|
@click.argument(
|
||||||
|
"directory",
|
||||||
|
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||||
|
)
|
||||||
|
def seed_script_overlays(directory: Path) -> None:
|
||||||
|
"""Upsert one system-wide script overlay per *.sh file in DIRECTORY.
|
||||||
|
|
||||||
|
Overlay name = filename stem; user_id stays NULL. Existing rows by name
|
||||||
|
have their script refreshed in place. Hard-errors if a name collides
|
||||||
|
with a non-script overlay.
|
||||||
|
"""
|
||||||
|
sh_files = sorted(p for p in directory.glob("*.sh") if p.stem)
|
||||||
|
if not sh_files:
|
||||||
|
click.echo(f"no *.sh files in {directory}", err=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
for sh in sh_files:
|
||||||
|
name = sh.stem
|
||||||
|
content = sh.read_text()
|
||||||
|
existing = db.scalar(
|
||||||
|
select(Overlay).where(Overlay.name == name, Overlay.user_id.is_(None))
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.type != "script":
|
||||||
|
raise click.ClickException(
|
||||||
|
f"overlay {name!r} exists but is type={existing.type!r}, not script"
|
||||||
|
)
|
||||||
|
existing.script = content
|
||||||
|
click.echo(f"updated {name} (id={existing.id})")
|
||||||
|
else:
|
||||||
|
overlay = Overlay(
|
||||||
|
name=name, path="", type="script", user_id=None, script=content
|
||||||
|
)
|
||||||
|
db.add(overlay)
|
||||||
|
db.flush()
|
||||||
|
overlay.path = generate_overlay_path(overlay.id)
|
||||||
|
db.flush()
|
||||||
|
create_overlay_directory(overlay)
|
||||||
|
click.echo(f"created {name} (id={overlay.id})")
|
||||||
|
|
||||||
|
|
||||||
def register_cli(app) -> None:
|
def register_cli(app) -> None:
|
||||||
app.cli.add_command(promote_admin)
|
app.cli.add_command(promote_admin)
|
||||||
app.cli.add_command(create_user)
|
app.cli.add_command(create_user)
|
||||||
|
app.cli.add_command(seed_script_overlays)
|
||||||
|
|
|
||||||
79
l4d2web/tests/test_seed_script_overlays.py
Normal file
79
l4d2web/tests/test_seed_script_overlays.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from l4d2web.app import create_app
|
||||||
|
from l4d2web.db import init_db, session_scope
|
||||||
|
from l4d2web.models import Overlay
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app(tmp_path: Path, monkeypatch):
|
||||||
|
db_url = f"sqlite:///{tmp_path/'seed.db'}"
|
||||||
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||||
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||||
|
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||||
|
init_db()
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_creates_system_wide_script_overlays(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
app = _make_app(tmp_path, monkeypatch)
|
||||||
|
examples = tmp_path / "examples"
|
||||||
|
examples.mkdir()
|
||||||
|
(examples / "alpha.sh").write_text("#!/bin/bash\necho alpha\n")
|
||||||
|
(examples / "beta.sh").write_text("#!/bin/bash\necho beta\n")
|
||||||
|
|
||||||
|
result = app.test_cli_runner().invoke(args=["seed-script-overlays", str(examples)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = db.query(Overlay).order_by(Overlay.name).all()
|
||||||
|
assert [(o.name, o.type, o.user_id, o.script.strip()) for o in rows] == [
|
||||||
|
("alpha", "script", None, "#!/bin/bash\necho alpha"),
|
||||||
|
("beta", "script", None, "#!/bin/bash\necho beta"),
|
||||||
|
]
|
||||||
|
for o in rows:
|
||||||
|
assert (tmp_path / "overlays" / o.path).is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_refreshes_existing_script_overlay(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
app = _make_app(tmp_path, monkeypatch)
|
||||||
|
examples = tmp_path / "examples"
|
||||||
|
examples.mkdir()
|
||||||
|
(examples / "alpha.sh").write_text("v1\n")
|
||||||
|
|
||||||
|
runner = app.test_cli_runner()
|
||||||
|
runner.invoke(args=["seed-script-overlays", str(examples)])
|
||||||
|
(examples / "alpha.sh").write_text("v2\n")
|
||||||
|
result = runner.invoke(args=["seed-script-overlays", str(examples)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with session_scope() as db:
|
||||||
|
rows = db.query(Overlay).filter(Overlay.name == "alpha").all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].script.strip() == "v2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_errors_on_type_collision(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
app = _make_app(tmp_path, monkeypatch)
|
||||||
|
examples = tmp_path / "examples"
|
||||||
|
examples.mkdir()
|
||||||
|
(examples / "shared.sh").write_text("#!/bin/bash\n")
|
||||||
|
|
||||||
|
with session_scope() as db:
|
||||||
|
db.add(Overlay(name="shared", path="shared", type="workshop", user_id=None))
|
||||||
|
|
||||||
|
result = app.test_cli_runner().invoke(args=["seed-script-overlays", str(examples)])
|
||||||
|
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "not script" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_no_files_is_noop(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
app = _make_app(tmp_path, monkeypatch)
|
||||||
|
examples = tmp_path / "examples"
|
||||||
|
examples.mkdir()
|
||||||
|
|
||||||
|
result = app.test_cli_runner().invoke(args=["seed-script-overlays", str(examples)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
with session_scope() as db:
|
||||||
|
assert db.query(Overlay).count() == 0
|
||||||
Loading…
Reference in a new issue