115 lines
6.1 KiB
Python
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()
|