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:
mwiegand 2026-05-08 18:41:08 +02:00
parent 6b4eef22c2
commit 196d2db33e
No known key found for this signature in database
7 changed files with 336 additions and 1 deletions

View file

@ -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

View 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"

View 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

View 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."

View 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

View file

@ -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)

View 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