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"
|
||||
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 enable --now 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
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
|
@ -6,7 +7,11 @@ from sqlalchemy import select
|
|||
|
||||
from l4d2web.auth import hash_password
|
||||
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")
|
||||
|
|
@ -41,6 +46,50 @@ def create_user(username: str, admin: bool) -> None:
|
|||
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:
|
||||
app.cli.add_command(promote_admin)
|
||||
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