Files

115 lines
6.1 KiB
Python

"""Development-only provisioner. Separate from the read-only analysis runtime.
Uses a portable MariaDB, binds loopback, restores only the named World section.
Never connects to any other database server. Runtime credentials have SELECT only.
"""
import argparse
import hashlib
import io
import json
from pathlib import Path
import secrets
import re
import subprocess
import time
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("command", choices=["start", "restore", "stop"])
p.add_argument("--binary-dir", type=Path, required=True)
p.add_argument("--directory", type=Path, required=True)
p.add_argument("--dump", type=Path)
p.add_argument("--database", default="cata_world")
p.add_argument("--port", type=int, default=33079)
args = p.parse_args()
import pymysql
import zstandard
root = args.directory.resolve()
root.mkdir(parents=True, exist_ok=True)
binary = args.binary_dir.resolve()
def check_local(connection):
with connection.cursor() as cursor:
cursor.execute("SELECT @@datadir")
actual = Path(cursor.fetchone()[0]).resolve()
if actual != (root/"data").resolve():
raise RuntimeError("Port belongs to another data directory; refusing administration")
state_file = root / "credentials.local.json"
if not state_file.exists():
state_file.write_text(json.dumps({"root":secrets.token_urlsafe(32), "reader":secrets.token_urlsafe(32)}), encoding="utf-8")
credentials = json.loads(state_file.read_text(encoding="utf-8"))
if args.command == "start":
config = root / "server.ini"
config.write_text("[mysqld]\n" + f"basedir={binary.parent.as_posix()}\ndatadir={(root/'data').as_posix()}\n"
+ f"port={args.port}\nbind-address=127.0.0.1\nmax_allowed_packet=256M\n"
+ "local_infile=0\nread_only=ON\nskip-log-bin\n" + f"log-error={(root/'server.log').as_posix()}\n", encoding="utf-8")
process = subprocess.Popen([str(binary/"mariadbd.exe"), "--defaults-file="+str(config)],
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
(root / "server.pid.json").write_text(json.dumps({"pid":process.pid, "port":args.port}), encoding="utf-8")
for _ in range(60):
try:
connection = pymysql.connect(host="127.0.0.1",port=args.port,user="root", password="",connect_timeout=1)
check_local(connection)
with connection.cursor() as cursor:
cursor.execute("SET PASSWORD FOR 'root'@'localhost' = PASSWORD(%s)", (credentials["root"],))
connection.close()
print(json.dumps({"status":"STARTED", "port":args.port}))
return
except pymysql.err.OperationalError as error:
if error.args[0] == 1045:
break
if process.poll() is not None:
raise RuntimeError("Local MariaDB failed to start; see server.log") from None
time.sleep(.5)
connection = pymysql.connect(host="127.0.0.1",port=args.port,user="root",password=credentials["root"],connect_timeout=3,autocommit=True)
try:
check_local(connection)
if args.command == "stop":
with connection.cursor() as cursor:
cursor.execute("SHUTDOWN")
print(json.dumps({"status":"STOPPED"}))
return
if args.command == "start":
print(json.dumps({"status":"STARTED", "port":args.port}))
return
if args.dump is None or not re.fullmatch("[A-Za-z_][A-Za-z0-9_]{0,63}",args.database):
raise ValueError("A dump and plain database identifier are required")
client_config = root / "restore.local.ini"
client_config.write_text(f"[client]\nhost=127.0.0.1\nport={args.port}\nuser=root\npassword={credentials['root']}\n",encoding="utf-8")
error_log = (root / "restore.log").open("wb")
process = subprocess.Popen([str(binary/"mariadb.exe"),"--defaults-file="+str(client_config),"--binary-mode","--max-allowed-packet=256M"],
stdin=subprocess.PIPE,stdout=subprocess.DEVNULL,stderr=error_log,
creationflags=getattr(subprocess,"CREATE_NO_WINDOW",0))
selected = False
with args.dump.open("rb") as raw:
stream = io.TextIOWrapper(zstandard.ZstdDecompressor().stream_reader(raw),encoding="utf-8")
for line in stream:
if line.startswith("-- Current Database:"):
selected = line.strip() == f"-- Current Database: `{args.database}`"
# Header sets the dump encoding and SQL modes; subsequent sections
# are discarded completely, including account/character content.
if selected or (not selected and line.startswith("/*!40101 SET NAMES")):
process.stdin.write(line.encode("utf-8"))
process.stdin.close()
code = process.wait()
error_log.close()
if code:
raise RuntimeError("World restore failed; see restore.log")
with connection.cursor() as cursor:
cursor.execute("CREATE USER IF NOT EXISTS 'viano_reader'@'127.0.0.1' IDENTIFIED BY %s", (credentials["reader"],))
cursor.execute("GRANT SELECT ON `"+args.database+"`.* TO 'viano_reader'@'127.0.0.1'")
cursor.execute("SET GLOBAL read_only=ON")
cursor.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=%s", (args.database,))
count = cursor.fetchone()[0]
with args.dump.open("rb") as dump_stream:
sha = hashlib.file_digest(dump_stream,"sha256").hexdigest()
(root/"snapshot.json").write_text(json.dumps({"database":args.database,"backup_sha256":sha,"tables":count}),encoding="utf-8")
print(json.dumps({"status":"RESTORED", "database":args.database,"tables":count,"backup_sha256":sha}))
finally:
connection.close()
if __name__ == "__main__":
main()