Script overlays commonly need 7z and md5sum (e.g. the l4d2center map sync recipe). Add p7zip-full to the apt install line, p7zip + p7zip-plugins to dnf, and coreutils explicitly so md5sum is guaranteed even on slim base images. Lock both in with a regression test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
274 lines
11 KiB
Bash
Executable file
274 lines
11 KiB
Bash
Executable file
#!/bin/sh
|
|
set -eu
|
|
|
|
usage() {
|
|
printf 'Usage: %s <ssh-user@host>\n' "$0" >&2
|
|
exit 2
|
|
}
|
|
|
|
if [ "$#" -ne 1 ]; then
|
|
usage
|
|
fi
|
|
|
|
target=$1
|
|
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd)
|
|
tmp_dir=$(mktemp -d)
|
|
archive="$tmp_dir/left4me.tar.gz"
|
|
|
|
cleanup() {
|
|
rm -rf "$tmp_dir"
|
|
}
|
|
trap cleanup EXIT INT HUP TERM
|
|
|
|
COPYFILE_DISABLE=1 tar -czf "$archive" \
|
|
--exclude .git \
|
|
--exclude .claude \
|
|
--exclude .venv \
|
|
--exclude __pycache__ \
|
|
--exclude .pytest_cache \
|
|
--exclude '*.egg-info' \
|
|
--exclude 'l4d2web.db*' \
|
|
--exclude '._*' \
|
|
-C "$repo_root" .
|
|
|
|
remote_tmp=$(ssh "$target" 'mktemp -d')
|
|
scp "$archive" "$target:$remote_tmp/left4me.tar.gz"
|
|
|
|
admin_username_file=
|
|
admin_password_file=
|
|
if [ "${LEFT4ME_ADMIN_USERNAME+x}" = x ] && [ "${LEFT4ME_ADMIN_PASSWORD+x}" = x ]; then
|
|
admin_username_file="$tmp_dir/admin_username"
|
|
admin_password_file="$tmp_dir/admin_password"
|
|
umask 077
|
|
printf '%s' "$LEFT4ME_ADMIN_USERNAME" > "$admin_username_file"
|
|
printf '%s' "$LEFT4ME_ADMIN_PASSWORD" > "$admin_password_file"
|
|
scp "$admin_username_file" "$target:$remote_tmp/admin_username"
|
|
scp "$admin_password_file" "$target:$remote_tmp/admin_password"
|
|
fi
|
|
|
|
ssh "$target" sh -s -- "$remote_tmp" <<'REMOTE'
|
|
set -eu
|
|
|
|
remote_tmp=$1
|
|
archive="$remote_tmp/left4me.tar.gz"
|
|
repo_tmp="$remote_tmp/repo"
|
|
|
|
if [ "$(id -u)" -eq 0 ]; then
|
|
sudo_cmd=
|
|
else
|
|
sudo_cmd=sudo
|
|
fi
|
|
|
|
run_as_left4me() {
|
|
sudo -u left4me "$@"
|
|
}
|
|
|
|
run_left4me_with_env() {
|
|
run_as_left4me sh -c 'set -a; . /etc/left4me/host.env; . /etc/left4me/web.env; set +a; exec "$@"' sh "$@"
|
|
}
|
|
|
|
cleanup_remote() {
|
|
rm -rf "$remote_tmp"
|
|
}
|
|
trap cleanup_remote EXIT INT HUP TERM
|
|
|
|
if ! id left4me >/dev/null 2>&1; then
|
|
$sudo_cmd useradd --system --home-dir /var/lib/left4me --create-home --shell /usr/sbin/nologin left4me
|
|
fi
|
|
|
|
# Sandbox uid for script-overlay builds. No home, no login shell — the bwrap
|
|
# invocation uses --uid/--gid to drop to it.
|
|
if ! id l4d2-sandbox >/dev/null 2>&1; then
|
|
$sudo_cmd useradd --system --no-create-home --shell /usr/sbin/nologin l4d2-sandbox
|
|
fi
|
|
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
$sudo_cmd apt-get update
|
|
$sudo_cmd apt-get install -y python3 python3-venv python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip-full
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
$sudo_cmd dnf install -y python3 python3-pip curl ca-certificates tar gzip util-linux sudo coreutils p7zip p7zip-plugins
|
|
else
|
|
printf 'Unsupported package manager: expected apt-get or dnf\n' >&2
|
|
exit 1
|
|
fi
|
|
|
|
$sudo_cmd mkdir -p \
|
|
/etc/left4me \
|
|
/opt/left4me \
|
|
/usr/local/lib/systemd/system \
|
|
/usr/local/libexec/left4me \
|
|
/var/lib/left4me/installation \
|
|
/var/lib/left4me/overlays \
|
|
/var/lib/left4me/instances \
|
|
/var/lib/left4me/runtime \
|
|
/var/lib/left4me/workshop_cache \
|
|
/var/lib/left4me/tmp
|
|
|
|
$sudo_cmd chown left4me:left4me \
|
|
/var/lib/left4me \
|
|
/var/lib/left4me/installation \
|
|
/var/lib/left4me/overlays \
|
|
/var/lib/left4me/instances \
|
|
/var/lib/left4me/runtime \
|
|
/var/lib/left4me/workshop_cache \
|
|
/var/lib/left4me/tmp
|
|
|
|
# /var/lib/left4me is left4me's home dir (mode 0700 from useradd --create-home).
|
|
# Allow other uids (notably l4d2-sandbox, used by script overlay builds) to
|
|
# traverse — but not list — so the bwrap bind-mount can resolve the overlay
|
|
# path under the dropped privilege.
|
|
$sudo_cmd chmod 0711 /var/lib/left4me
|
|
$sudo_cmd chown -R left4me:left4me /opt/left4me
|
|
|
|
mkdir -p "$repo_tmp"
|
|
tar -xzf "$archive" -C "$repo_tmp"
|
|
|
|
if [ -d /opt/left4me/.venv ]; then
|
|
$sudo_cmd mv /opt/left4me/.venv "$remote_tmp/venv"
|
|
fi
|
|
$sudo_cmd find /opt/left4me -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
|
$sudo_cmd cp -R "$repo_tmp"/. /opt/left4me/
|
|
if [ -d "$remote_tmp/venv" ]; then
|
|
$sudo_cmd mv "$remote_tmp/venv" /opt/left4me/.venv
|
|
fi
|
|
$sudo_cmd chown -R left4me:left4me /opt/left4me
|
|
|
|
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service /usr/local/lib/systemd/system/left4me-web.service
|
|
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service /usr/local/lib/systemd/system/left4me-server@.service
|
|
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-systemctl
|
|
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-journalctl
|
|
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-overlay /usr/local/libexec/left4me/left4me-overlay
|
|
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox /usr/local/libexec/left4me/left4me-script-sandbox
|
|
$sudo_cmd chmod 0755 /usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-overlay /usr/local/libexec/left4me/left4me-script-sandbox
|
|
$sudo_cmd cp /opt/left4me/deploy/files/etc/sudoers.d/left4me /etc/sudoers.d/left4me
|
|
$sudo_cmd chmod 0440 /etc/sudoers.d/left4me
|
|
$sudo_cmd visudo -cf /etc/sudoers.d/left4me
|
|
|
|
$sudo_cmd cp /opt/left4me/deploy/templates/etc/left4me/host.env /etc/left4me/host.env
|
|
$sudo_cmd chmod 0644 /etc/left4me/host.env
|
|
|
|
# Sandbox-only resolver config; bind-mounted into the script sandbox's /etc/resolv.conf
|
|
# so DNS still works when IPAddressDeny= blocks the host's (typically private-IP) resolver.
|
|
$sudo_cmd install -m 0644 -o root -g root \
|
|
/opt/left4me/deploy/files/etc/left4me/sandbox-resolv.conf \
|
|
/etc/left4me/sandbox-resolv.conf
|
|
|
|
if [ ! -f /etc/left4me/web.env ]; then
|
|
secret_key=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
|
|
tmp_web_env="$remote_tmp/web.env"
|
|
{
|
|
printf 'DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n'
|
|
printf 'SECRET_KEY=%s\n' "$secret_key"
|
|
printf 'JOB_WORKER_THREADS=4\n'
|
|
printf 'SESSION_COOKIE_SECURE=false\n'
|
|
} > "$tmp_web_env"
|
|
$sudo_cmd install -m 0640 -o root -g left4me "$tmp_web_env" /etc/left4me/web.env
|
|
fi
|
|
|
|
if [ ! -x /opt/left4me/.venv/bin/python ]; then
|
|
run_as_left4me python3 -m venv /opt/left4me/.venv
|
|
fi
|
|
run_as_left4me /opt/left4me/.venv/bin/python -m pip install --upgrade pip
|
|
run_as_left4me /opt/left4me/.venv/bin/pip install -e /opt/left4me/l4d2host -e /opt/left4me/l4d2web
|
|
|
|
run_as_left4me sh -c "cd /opt/left4me/l4d2web && set -a; . /etc/left4me/host.env; . /etc/left4me/web.env; set +a; env \
|
|
JOB_WORKER_ENABLED=false \
|
|
PYTHONPATH=/opt/left4me \
|
|
/opt/left4me/.venv/bin/alembic -c /opt/left4me/l4d2web/alembic.ini upgrade head"
|
|
|
|
# Tighten the application database to left4me:left4me 0640. The DB is
|
|
# created by the web app at first start with the default 0644 umask, which
|
|
# makes it world-readable on the host. The script-overlay sandbox runs as a
|
|
# separate system uid (l4d2-sandbox) which is NOT in the left4me group —
|
|
# 0640 blocks it via "other". The owner (left4me) keeps read+write so the
|
|
# web service can update the DB.
|
|
#
|
|
# SQLite in WAL mode (the default in this app) maintains -wal and -shm
|
|
# sidecar files; both must also be writable by the web service. If a previous
|
|
# operator opened the DB as root (e.g. for ad-hoc inspection), the sidecars
|
|
# may have ended up root-owned, which makes SQLite report "readonly database"
|
|
# on the next write. Re-chown them defensively. Idempotent on rerun.
|
|
for db_file in /var/lib/left4me/left4me.db /var/lib/left4me/left4me.db-wal /var/lib/left4me/left4me.db-shm; do
|
|
if [ -f "$db_file" ]; then
|
|
$sudo_cmd chown left4me:left4me "$db_file"
|
|
$sudo_cmd chmod 0640 "$db_file"
|
|
fi
|
|
done
|
|
|
|
if [ -f "$remote_tmp/admin_username" ] && [ -f "$remote_tmp/admin_password" ]; then
|
|
LEFT4ME_ADMIN_USERNAME=$(cat "$remote_tmp/admin_username")
|
|
LEFT4ME_ADMIN_PASSWORD=$(cat "$remote_tmp/admin_password")
|
|
if ! create_user_output=$(run_left4me_with_env env \
|
|
JOB_WORKER_ENABLED=false \
|
|
LEFT4ME_ADMIN_PASSWORD="$LEFT4ME_ADMIN_PASSWORD" \
|
|
/opt/left4me/.venv/bin/flask --app l4d2web.app:create_app create-user "$LEFT4ME_ADMIN_USERNAME" --admin 2>&1); then
|
|
case "$create_user_output" in
|
|
*'user already exists'*) printf '%s\n' "$create_user_output" ;;
|
|
*) printf '%s\n' "$create_user_output" >&2; exit 1 ;;
|
|
esac
|
|
else
|
|
printf '%s\n' "$create_user_output"
|
|
fi
|
|
fi
|
|
|
|
# One-shot migration: fuse-overlayfs running as the left4me user used
|
|
# user.fuseoverlayfs.* xattrs for whiteouts and opaque-dir markers; kernel
|
|
# overlayfs ignores those entirely, so a pre-existing upper/ from the fuse
|
|
# era would resurrect "deleted" files. Wipe upper/ and work/ for every
|
|
# instance once, gated by a sentinel file so reruns are no-ops.
|
|
overlay_sentinel=/var/lib/left4me/.kernel-overlay-migrated
|
|
if [ ! -e "$overlay_sentinel" ]; then
|
|
$sudo_cmd sh -c "systemctl stop 'left4me-server@*.service' 2>/dev/null || true"
|
|
$sudo_cmd systemctl stop left4me-web.service 2>/dev/null || true
|
|
$sudo_cmd sh -c "findmnt -t fuse.fuse-overlayfs -o TARGET --noheadings 2>/dev/null | xargs -r -n1 umount -l 2>/dev/null || true"
|
|
$sudo_cmd sh -c "findmnt -t overlay -o TARGET --noheadings 2>/dev/null | grep '/var/lib/left4me/runtime/' | xargs -r -n1 umount -l 2>/dev/null || true"
|
|
$sudo_cmd sh -c 'for d in /var/lib/left4me/runtime/*/; do [ -d "$d" ] || continue; rm -rf "$d/upper" "$d/work"; mkdir -p "$d/upper" "$d/work"; chown left4me:left4me "$d/upper" "$d/work"; done'
|
|
$sudo_cmd touch "$overlay_sentinel"
|
|
$sudo_cmd chown left4me:left4me "$overlay_sentinel"
|
|
fi
|
|
|
|
# One-shot migration: 0005_script_overlays drops the legacy
|
|
# l4d2center_maps / cedapug_maps overlay rows but doesn't touch their
|
|
# directories under /var/lib/left4me/overlays/{id}. Without cleanup, when
|
|
# AUTOINCREMENT (or its absence after the 0002 batch_alter_table recreate)
|
|
# re-issues an id matching one of those orphan dirs, the web app's
|
|
# create_overlay_directory(exist_ok=False) fails with FileExistsError.
|
|
# Sweep any overlay dir whose id has no matching DB row, plus the
|
|
# now-unused global_overlay_cache.
|
|
overlay_orphan_sentinel=/var/lib/left4me/.script-overlays-orphans-cleaned
|
|
if [ ! -e "$overlay_orphan_sentinel" ]; then
|
|
$sudo_cmd rm -rf /var/lib/left4me/global_overlay_cache
|
|
$sudo_cmd sh -c '
|
|
cd /var/lib/left4me/overlays || exit 0
|
|
ids_in_db=$(/opt/left4me/.venv/bin/python -c "
|
|
import sqlite3
|
|
c = sqlite3.connect(\"/var/lib/left4me/left4me.db\")
|
|
print(\" \".join(str(r[0]) for r in c.execute(\"SELECT id FROM overlays\")))
|
|
")
|
|
for d in */; do
|
|
id=${d%/}
|
|
case " $ids_in_db " in
|
|
*" $id "*) ;;
|
|
*) echo "removing orphan overlay dir: $id"; rm -rf "$id" ;;
|
|
esac
|
|
done
|
|
'
|
|
$sudo_cmd touch "$overlay_orphan_sentinel"
|
|
$sudo_cmd chown left4me:left4me "$overlay_orphan_sentinel"
|
|
fi
|
|
|
|
$sudo_cmd systemctl daemon-reload
|
|
$sudo_cmd systemctl enable --now left4me-web.service
|
|
$sudo_cmd systemctl restart left4me-web.service
|
|
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
|
if curl -fsS http://127.0.0.1:8000/health; then
|
|
exit 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
$sudo_cmd systemctl status left4me-web.service --no-pager >&2 || true
|
|
$sudo_cmd journalctl -u left4me-web.service -n 80 --no-pager >&2 || true
|
|
exit 1
|
|
REMOTE
|