Files
LihonT/scripts/deploy_site.py
cheb0chik ce1d870d54 LihonT 2 Luna beta: source release
Mod for TikTok 47.1.3 (arm64) in Material 3 style: bottom bar, profile menu, TikTok settings and
comments; downloads without the watermark; switches for ads, live streams and the Shop tab; badges of
other mods next to names and the niche mod badge (members service); app icons; mod settings screen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:32:13 +03:00

194 lines
8.3 KiB
Python

"""Deploy the redesigned site to the Helsinki server (ssh host "hels") and roll it back.
py scripts/deploy_site.py --dry-run show the plan, change nothing
py scripts/deploy_site.py back up the live files, upload, verify
py scripts/deploy_site.py --rollback restore the newest backup (or --stamp <timestamp> for another)
py scripts/deploy_site.py --portal swap the /lihont2 portal's server.py, restart it, roll back on failure
Only the files listed in stage_site.PAYLOAD are touched. Before overwriting, each live file is
copied to /var/backups/lihont-site/<timestamp>/ (outside the web root, so it is never served).
Files are uploaded under a temporary name and renamed into place, so a visitor never sees a
half-written page. Downloads, logos and the old md3-theme.css are left alone.
"""
from __future__ import annotations
import argparse
import hashlib
import shlex
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import stage_site # noqa: E402
HOST = "hels"
WEB_ROOT = "/var/www/lihont"
BACKUPS = "/var/backups/lihont-site"
SSH = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", HOST]
def remote(command: str, data: bytes | None = None) -> subprocess.CompletedProcess:
return subprocess.run(SSH + [command], input=data, capture_output=True, timeout=180)
def text(result: subprocess.CompletedProcess) -> str:
# ssh prints a post-quantum key exchange warning on stderr; it is not an error.
lines = [line for line in result.stderr.decode("utf-8", "replace").splitlines()
if line and not line.startswith("**")]
return "\n".join(lines)
def deploy(dry_run: bool) -> int:
stage_site.main()
files = [(stage_site.STAGE / target, target) for _source, target in stage_site.PAYLOAD]
stamp = time.strftime("%Y%m%d-%H%M%S")
backup = "%s/%s" % (BACKUPS, stamp)
print("Backup folder on the server: %s" % backup)
for local, target in files:
print(" %-26s %7d bytes" % (target, local.stat().st_size))
if dry_run:
print("Dry run: nothing was changed.")
return 0
# 1. Back up every live file that exists.
script = "set -e; mkdir -p %s; " % shlex.quote(backup)
for _local, target in files:
live = "%s/%s" % (WEB_ROOT, target)
script += "if [ -f %s ]; then mkdir -p %s; cp -p %s %s; fi; " % (
shlex.quote(live), shlex.quote("%s/%s" % (backup, Path(target).parent.as_posix())),
shlex.quote(live), shlex.quote("%s/%s" % (backup, target)))
result = remote(script)
if result.returncode:
print("Backup failed, nothing was uploaded:\n" + text(result))
return 1
# 2. Upload under a temporary name, then rename into place.
for local, target in files:
live = "%s/%s" % (WEB_ROOT, target)
command = ("set -e; mkdir -p %s; cat > %s.new && chown root:root %s.new && chmod 644 %s.new "
"&& mv -f %s.new %s") % tuple(
[shlex.quote(str(Path(live).parent.as_posix()))] + [shlex.quote(live)] * 5)
result = remote(command, local.read_bytes())
if result.returncode:
print("Upload of %s failed:\n%s\nRoll back with: py scripts/deploy_site.py --rollback" % (target, text(result)))
return 1
print("uploaded %s" % target)
# 3. Verify that the server holds exactly what was staged.
failed = 0
for local, target in files:
digest = hashlib.sha256(local.read_bytes()).hexdigest()
result = remote("sha256sum %s" % shlex.quote("%s/%s" % (WEB_ROOT, target)))
live = result.stdout.decode().split()[0] if result.returncode == 0 and result.stdout else "?"
status = "ok" if live == digest else "MISMATCH"
failed += status != "ok"
print(" %-26s %s" % (target, status))
if failed:
print("Some files differ. Roll back with: py scripts/deploy_site.py --rollback")
return 1
print("Deployed. Backup: %s" % backup)
return 0
PORTAL_SOURCE = Path(__file__).resolve().parents[1] / "src" / "web" / "server.py"
PORTAL_PATH = "/opt/lihont2/server.py"
PORTAL_BACKUPS = "/var/backups/lihont-portal"
PORTAL_SERVICE = "lihont2"
PORTAL_URL = "http://127.0.0.1:18750/lihont2/"
def portal_healthy() -> bool:
check = "sleep 2; systemctl is-active %s && curl -s -o /dev/null -w '%%{http_code}' %s" % (PORTAL_SERVICE, PORTAL_URL)
result = remote(check)
return result.returncode == 0 and result.stdout.decode().split()[-1:] == ["200"]
def restore_portal(stamp: str) -> int:
backup = "%s/%s/server.py" % (PORTAL_BACKUPS, stamp)
command = "set -e; cp -p %s %s.new && mv -f %s.new %s && systemctl restart %s" % (
shlex.quote(backup), PORTAL_PATH, PORTAL_PATH, PORTAL_PATH, PORTAL_SERVICE)
result = remote(command)
print(text(result) or "restored %s" % backup)
return result.returncode or (0 if portal_healthy() else 1)
def deploy_portal(dry_run: bool) -> int:
"""Swap /opt/lihont2/server.py and restart the portal; sessions live in sqlite, so logins survive."""
data = PORTAL_SOURCE.read_bytes()
stamp = time.strftime("%Y%m%d-%H%M%S")
print("server.py: %d bytes, backup %s/%s (root only)" % (len(data), PORTAL_BACKUPS, stamp))
if dry_run:
print("Dry run: nothing was changed.")
return 0
# Upload next to the target and parse it with the server's own Python before anything is replaced.
result = remote("cat > %s.new && python3 -c \"import ast; ast.parse(open('%s.new').read())\"" % (PORTAL_PATH, PORTAL_PATH), data)
if result.returncode:
print("The new server.py does not parse on the server; nothing was replaced:\n" + text(result))
remote("rm -f %s.new" % PORTAL_PATH)
return 1
backup = "%s/%s" % (PORTAL_BACKUPS, stamp)
command = ("set -e; umask 077; mkdir -p %s; cp -p %s %s/server.py; chown root:root %s.new; chmod 644 %s.new; "
"mv -f %s.new %s; systemctl restart %s") % (
shlex.quote(backup), PORTAL_PATH, shlex.quote(backup), PORTAL_PATH, PORTAL_PATH, PORTAL_PATH, PORTAL_PATH, PORTAL_SERVICE)
result = remote(command)
if result.returncode:
print("Swap failed:\n" + text(result))
return 1
if not portal_healthy():
print("The portal is not answering with the new server.py; restoring the old one.")
return restore_portal(stamp) or 1
print("Portal restarted and answering. Backup: %s" % backup)
return 0
def rollback_portal(stamp: str | None) -> int:
result = remote("ls -1 %s | sort" % shlex.quote(PORTAL_BACKUPS))
stamps = result.stdout.decode().split()
if result.returncode or not stamps:
print("No portal backup found on the server.")
return 1
print("Portal backups: %s" % ", ".join(stamps))
return restore_portal(stamp or stamps[-1])
def rollback(stamp: str | None) -> int:
result = remote("ls -1 %s | sort" % shlex.quote(BACKUPS))
stamps = result.stdout.decode().split()
if result.returncode or not stamps:
print("No backup found on the server.")
return 1
print("Backups: %s" % ", ".join(stamps))
stamp = stamp or stamps[-1]
if stamp not in stamps:
print("No such backup: %s" % stamp)
return 1
backup = "%s/%s" % (BACKUPS, stamp)
print("Restoring %s" % backup)
command = ("set -e; cd %s; find . -type f | while read f; do "
"mkdir -p \"$(dirname %s/$f)\"; cp -p \"$f\" \"%s/$f.new\" && mv -f \"%s/$f.new\" \"%s/$f\"; done; echo restored")
command = command % (shlex.quote(backup), WEB_ROOT, WEB_ROOT, WEB_ROOT, WEB_ROOT)
result = remote(command)
print(result.stdout.decode().strip() or text(result))
return result.returncode
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--rollback", action="store_true")
parser.add_argument("--stamp", help="backup to restore with --rollback (default: the newest)")
parser.add_argument("--portal", action="store_true", help="act on the /lihont2 portal (server.py) instead of the static site")
args = parser.parse_args()
if args.portal:
return rollback_portal(args.stamp) if args.rollback else deploy_portal(args.dry_run)
return rollback(args.stamp) if args.rollback else deploy(args.dry_run)
if __name__ == "__main__":
sys.exit(main())