landsandboat/tools/dbtool.py

1436 lines
46 KiB
Python

# Internal Deps
import os
import subprocess
import sys
import re
import time
import fileinput
import shutil
import importlib
import pathlib
import platform
# Pre-flight sanity checks
def preflight_exit():
# If double clicked on Windows: pause with an input so the user can read the error...
if os.name == "nt" and "PROMPT" not in os.environ:
input("Press ENTER to continue...")
exit(-1)
# - git should installed and available
try:
subprocess.call(["git"], stdout=subprocess.PIPE)
except Exception:
print(
"ERROR: Make sure git is installed and available on your system's PATH environment variable."
)
preflight_exit()
# If launching from somewhere that isnt <root>/tools/, figure out where dbtool.py is located, and the server root.
dbtool_dir_path = os.path.normpath(os.path.realpath(os.path.dirname(__file__)))
server_dir_path = os.path.normpath(os.path.realpath(os.path.dirname(dbtool_dir_path)))
def from_dbtool_path(path):
return os.path.normpath(os.path.join(dbtool_dir_path, path))
def from_server_path(path):
return os.path.normpath(os.path.join(server_dir_path, path))
# - Repo should be checked out as a git repo, not as plain files
if (
not subprocess.call(
["git", "-C", server_dir_path, "status"],
stderr=subprocess.STDOUT,
stdout=open(os.devnull, "w"),
)
== 0
):
print(
"ERROR: The project must be checked out as a git repo (using git clone, or similar)."
)
preflight_exit()
# External Deps (requirements.txt)
try:
import mariadb
from mariadb.constants import *
from git import Repo
import yaml
import colorama
except Exception as e:
print("ERROR: Exception occured while importing external dependencies:")
print(e)
print(
"Ensure you've run/re-run the command you use to install python dependencies (ex: pip install --upgrade -r requirements.txt)"
)
preflight_exit()
GREEN = colorama.Fore.GREEN
RED = colorama.Fore.RED
RESET = colorama.Style.RESET_ALL
NOOP = lambda *args, **kwargs: None
def print_red(str):
print(colorama.Fore.RED + str)
def print_green(str):
print(colorama.Fore.GREEN + str)
def populate_migrations():
migration_list = []
for file in sorted(
os.scandir(from_dbtool_path("migrations")), key=lambda e: e.name
):
if file.name.endswith(".py"):
name = file.name.replace(".py", "")
module = importlib.import_module("migrations." + name)
migration_list.append(module)
return migration_list
# Migrations are automatically scraped from the migrations folder
migrations = populate_migrations()
# NOTE: Everything is returned as a dict of dicts of strings. If you are looking for bools or values
# : you'll need to convert them yourself.
def populate_settings():
settings = {}
default_settings = {}
def load_into_dict(filename, settings):
if os.path.exists(filename) and os.path.isfile(filename):
try:
with open(filename) as f:
filename_key = filename[:-4].split(os.sep)[-1]
# Get or default, so we update any existing dict
# instead of wiping it out
current_settings = settings.get(filename_key, {})
for line in f.readlines():
if not line:
break
if "=" in line:
# remove newline
line = line.replace("\n", "")
# NOTE: Do not use split or rsplit in herewithout a counter,
# : to make sure you leave the contents of val alone!
parts = line.split("=", 1)
key = parts[0].strip()
val = parts[1].strip()
# ignore commented out entries
if key.startswith("--"):
continue
# strip off comments
val = val.rsplit("--")[0].strip()
# pop off leading quote
if val.startswith('"'):
val = val[1:]
elif val.startswith("'"):
val = val[1:]
# pop off trailing comma
if val.endswith(","):
val = val[:-1]
# pop off trailing quote
if val.endswith('"'):
val = val[:-1]
elif val.endswith("'"):
val = val[:-1]
current_settings[key] = val
settings[filename_key] = current_settings
except Exception as e:
print_red("Error fetching settings.")
print(e)
return False
for filename in os.listdir(from_server_path("settings/default")):
full_path = from_server_path("settings/default")
load_into_dict(os.path.join(full_path, filename), default_settings)
load_into_dict(os.path.join(full_path, filename), settings)
for filename in os.listdir(from_server_path("settings")):
full_path = from_server_path("settings")
load_into_dict(os.path.join(full_path, filename), settings)
# DEBUG:
# for k, v in settings.items():
# for ik, iv in v.items():
# print(f"| {k} | {ik} | {iv} |")
# exit()
return settings, default_settings
# Settings are automatically scraped from the settings folder(s)
settings, default_settings = populate_settings()
# These are the 'protected' files
player_data = [
"accounts.sql",
"accounts_banned.sql",
"accounts_totp.sql",
"auction_house_items.sql",
"auction_house.sql",
"audit_bazaar.sql",
"audit_dbox.sql",
"audit_trade.sql",
"audit_vendor.sql",
"char_blacklist.sql",
"char_chocobos.sql",
"char_effects.sql",
"char_equip.sql",
"char_equip_saved.sql",
"char_exp.sql",
"char_fishing_contest_history.sql",
"char_flags.sql",
"char_history.sql",
"char_inventory.sql",
"char_jobs.sql",
"char_job_points.sql",
"char_look.sql",
"char_merit.sql",
"char_monstrosity.sql",
"char_pet.sql",
"char_points.sql",
"char_profile.sql",
"char_skills.sql",
"char_spells.sql",
"char_stats.sql",
"char_storage.sql",
"char_style.sql",
"char_unlocks.sql",
"char_vars.sql",
"chars.sql",
"conquest_system.sql",
"delivery_box.sql",
"fishing_contest.sql",
"fishing_contest_entries.sql",
"help_desk.sql",
"ip_exceptions.sql",
"linkshell_concierge.sql",
"linkshells.sql",
"server_variables.sql",
"unity_system.sql",
]
import_files = []
import_protected = []
backups = []
database = None
host = None
port = None
login = None
password = None
db = None
cur = None
repo = Repo(server_dir_path)
db_ver = ""
current_client = None
release_version = None
release_client = None
express_enabled = False
auto_backup = 0
auto_update_client = True
mysql_bin = ""
mysql_env = shutil.which("mysql")
if mysql_env:
mysql_bin = os.path.dirname(mysql_env).replace("\\", "/")
if mysql_bin[-1] != "/":
mysql_bin = mysql_bin + "/"
if os.name == "nt":
exe = ".exe"
else:
exe = ""
colorama.init(autoreset=True)
# Redirect errors through this to hide annoying password warning
def fetch_errors(query, result):
for line in result.stderr.splitlines():
# Safe to ignore this warning
if "Using a password on the command line interface can be insecure" in line:
continue
# If the output line begins with ERROR, print it in red and exit
if line.startswith("ERROR"):
print_red("Encountered error while executing SQL query:")
print_red(query)
print_red("Error:")
print_red(line)
print_red("Exiting...")
exit(-1)
def db_query(query):
result = subprocess.run(
[
f"{mysql_bin}mysql{exe}",
f"-h{host}",
f"-P{str(port)}",
f"-u{login}",
f"-p{password}",
database,
f"-e {query}",
],
capture_output=True,
text=True,
)
fetch_errors(query, result)
return result
def fetch_credentials():
global settings, database, host, port, login, password
database = (
os.getenv("XI_NETWORK_SQL_DATABASE") or settings["network"]["SQL_DATABASE"]
)
host = os.getenv("XI_NETWORK_SQL_HOST") or settings["network"]["SQL_HOST"]
port = int(os.getenv("XI_NETWORK_SQL_PORT") or int(settings["network"]["SQL_PORT"]))
login = os.getenv("XI_NETWORK_SQL_LOGIN") or settings["network"]["SQL_LOGIN"]
password = (
os.getenv("XI_NETWORK_SQL_PASSWORD") or settings["network"]["SQL_PASSWORD"]
)
def fetch_versions():
global current_client, release_version, release_client
current_client = None
release_version = None
release_client = None
try:
release_version = repo.git.rev_parse(repo.head.object.hexsha, short=4)
except Exception as e:
print_red("Unable to read current version hash.")
print(e)
release_client = default_settings["login"]["CLIENT_VER"]
current_client = settings["login"]["CLIENT_VER"]
if db_ver and release_version:
fetch_files(True)
else:
fetch_files()
def fetch_configs():
global mysql_bin, auto_backup, auto_update_client, db_ver
try:
if os.path.exists(from_dbtool_path("config.yaml")):
with open(from_dbtool_path("config.yaml")) as file:
configs = yaml.full_load(file)
for config in configs:
for key, value in config.items():
if key == "mysql_bin":
if value != "":
mysql_bin = value
if key == "auto_backup":
auto_backup = int(value)
if key == "auto_update_client":
auto_update_client = bool(value)
if key == "db_ver":
db_ver = value
else:
write_configs()
except Exception as e:
print(e)
def write_configs():
with open(from_dbtool_path("config.yaml"), "w") as file:
dump = [
{"mysql_bin": mysql_bin},
{"auto_backup": auto_backup},
{"auto_update_client": auto_update_client},
{"db_ver": db_ver},
]
yaml.dump(dump, file)
def fetch_module_files():
with open(from_server_path("modules/init.txt"), "r", encoding="utf-8") as file:
for line in file.readlines():
if not line.startswith("#") and line.strip() and not line in ["\n", "\r\n"]:
line = from_server_path("modules/" + line.strip())
if pathlib.Path(line).is_dir():
for filename in sorted(pathlib.Path(line).glob("**/*.sql")):
import_files.append(str(filename).replace("\\", "/"))
else:
if line.endswith(".sql"):
import_files.append(str(line).replace("\\", "/"))
def check_protected():
global import_protected, express_enabled
if not cur:
connect()
import_protected.clear()
for table in player_data:
import_protected.append("'" + table[:-4] + "'")
cur.execute(
f"SELECT TABLE_NAME FROM `information_schema`.`tables` WHERE `TABLE_SCHEMA` = '{database}' AND `TABLE_NAME` IN ({', '.join(import_protected)})"
)
tables = cur.fetchall()
import_protected.clear()
for value in tables:
import_protected.append("".join(value) + ".sql")
import_protected = [
from_server_path("sql/" + value)
for value in player_data
if value not in import_protected
]
if import_protected:
express_enabled = True
def fetch_files(express=False):
import_files.clear()
if express:
try:
global express_enabled
sql_diffs = repo.commit(db_ver).diff(
release_version, paths=from_server_path("sql/")
)
if len(sql_diffs) > 0:
for diff in sql_diffs:
if os.path.exists(from_server_path(diff.b_path)):
import_files.append(from_server_path(diff.b_path))
if len(import_files) > 0:
express_enabled = True
else:
express_enabled = False
if (
len(
repo.commit(db_ver).diff(
release_version, paths=from_server_path("tools/migrations")
)
)
> 0
):
express_enabled = True
except Exception as e:
print_red("Error checking diffs.\nCheck that hash is valid in config.yaml.")
print(e)
else:
for _, _, filenames in os.walk(from_server_path("sql/")):
for filename in sorted(filenames):
if filename.endswith(".sql"):
import_files.append(from_server_path("sql/" + filename))
break
check_protected()
backups.clear()
for _, _, filenames in os.walk(from_server_path("sql/backups/")):
for file in sorted(filenames):
if not ".gitignore" in file:
if file.endswith(".sql"):
backups.append(from_server_path("sql/backups/" + file))
break
backups.sort()
import_files.sort()
try:
import_files.append(
import_files.pop(import_files.index(from_server_path("sql/triggers.sql")))
)
except Exception:
pass
fetch_module_files()
def write_version(silent=False):
global db_ver
update_client = auto_update_client
if not silent and current_client != release_client:
update_client = input("Update client version? [y/N] ").lower() == "y"
update_client = update_client and release_client
db_ver = release_version
write_configs()
if os.path.exists(from_server_path("settings/login.lua")):
try:
if current_client != release_client:
for line in fileinput.input(
from_server_path("settings/login.lua"), inplace=True
):
match = re.match(r"\s+?CLIENT_VER =\s+(\S+)", line)
if match:
line = ' CLIENT_VER = "' + release_client + '",\n'
print(line, end="")
else:
update_client = False
if update_client:
print_green("Updated client version!")
fetch_versions()
except Exception as e:
fileinput.close()
print_red("Error writing version.")
print(e)
def import_file(file):
file = os.path.normpath(file).replace("\\", "/")
if not os.path.exists(file):
print_red(
f"Trying to import file that does not exist ({file}), or is an incomplete path."
)
return
print("Importing " + file)
query = f"""
SET autocommit=0;
SET unique_checks=0;
SET foreign_key_checks=0;
SOURCE {file};
SET unique_checks=1;
SET foreign_key_checks=1;
COMMIT;
"""
_ = db_query(query)
def connect():
global db, cur
try:
db = mariadb.connect(
host=host, user=login, passwd=password, db=database, port=port
)
cur = db.cursor()
except mariadb.Error as err:
if err.errno == mariadb.constants.ERR.ER_ACCESS_DENIED_ERROR:
print_red(
"Incorrect mysql_login or mysql_password, update settings/network.lua."
)
quit()
elif err.errno == mariadb.constants.ERR.ER_BAD_DB_ERROR:
print_red("Database " + database + " does not exist.")
if (
input(
"Would you like to create new database: " + database + "? [y/N] "
).lower()
== "y"
):
query = f"CREATE DATABASE {database} CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
result = subprocess.run(
[
f"{mysql_bin}mysql{exe}",
f"-h{host}",
f"-P{str(port)}",
f"-u{login}",
f"-p{password}",
f"-e {query}",
],
capture_output=True,
text=True,
)
fetch_errors(query, result)
setup_db()
connect()
else:
quit()
else:
print_red(str(err))
quit()
return False
return True
def close():
if db:
print("Closing connection...")
cur.close()
db.close()
time.sleep(0.5)
quit()
def setup_db():
fetch_files()
for sql_file in import_files:
import_file(sql_file)
print_green("Finished importing!")
write_version()
def backup_db(silent=False, lite=False):
if silent or input("Would you like to backup your database? [y/N] ").lower() == "y":
if not silent:
lite = (
input("Would you like to only backup protected tables? [y/N] ").lower()
== "y"
)
dumpcmd = [
f"{mysql_bin}mysqldump{exe}",
"--hex-blob",
"--add-drop-trigger",
f"-h{host}",
f"-P{str(port)}",
f"-u{login}",
f"-p{password}",
database,
]
if lite:
tables = []
for table in player_data:
tables.append(table[:-4])
dumpcmd.extend(tables)
outfile_path = f"{server_dir_path}/sql/backups/{database}-{time.strftime('%Y%m%d-%H%M%S')}-lite.sql"
else:
if db_ver:
outfile_path = f"{server_dir_path}/sql/backups/{database}-{time.strftime('%Y%m%d-%H%M%S')}-{db_ver}.sql"
else:
outfile_path = f"{server_dir_path}/sql/backups/{database}-{time.strftime('%Y%m%d-%H%M%S')}-full.sql"
with open(outfile_path, "w") as outfile:
result = subprocess.run(
dumpcmd,
stdout=outfile,
stderr=subprocess.PIPE,
text=True,
)
fetch_errors("Dumping database", result)
print_green("Database saved!")
time.sleep(0.5)
def express_update(silent=False):
update_db(silent, True)
def update_db(silent=False, express=False):
global express_enabled
if not silent or auto_backup:
backup_db(silent, auto_backup == 2)
if not express:
fetch_files()
if not silent:
if import_protected:
print_green(
"The following PROTECTED tables could not be found and will be imported:"
)
for sql_file in import_protected:
print(sql_file)
if import_files:
print_green("The following files will be imported:")
for sql_file in import_files:
if pathlib.Path(sql_file).name not in player_data:
print(os.path.normpath(sql_file).replace("\\", "/"))
if silent or input("Proceed with update? [y/N] ").lower() == "y":
for sql_file in import_protected:
import_file(sql_file)
for sql_file in import_files:
if pathlib.Path(sql_file).name not in player_data:
import_file(sql_file)
print_green("Finished importing!")
express_enabled = False
run_all_migrations(silent or express)
write_version(silent)
def adjust_mysql_bin():
global mysql_bin
while True:
choice = input(
"Please enter the path to your MySQL bin directory or press enter to check PATH.\ne.g. C:\\Program Files\\MariaDB 10.6\\bin\\\n> "
).replace("\\", "/")
if choice == "":
mysql_file = shutil.which("mysql")
if not mysql_file:
continue
choice = os.path.dirname(mysql_file).replace("\\", "/")
if choice and choice[-1] != "/":
choice = choice + "/"
if os.path.exists(choice + "mysql" + exe):
mysql_bin = choice
break
def adjust_auto_backup():
global auto_backup
while True:
choice = input(
"Would you like a backup to automatically be created when running an update from the command line? [y/n] "
)
if choice == "y":
choice = input(
"Would you like to only automatically backup protected tables? [y/N] "
)
if choice == "y":
auto_backup = 2
break
else:
auto_backup = 1
break
elif choice == "n":
auto_backup = 0
break
bad_selection()
def adjust_auto_update_client():
global auto_update_client
while True:
choice = input(
"Would you like to automatically update the client version when running an update from the command line? [y/n] "
)
if choice == "y":
auto_update_client = True
break
elif choice == "n":
auto_update_client = False
break
bad_selection()
def run_all_migrations(silent=False):
migrations_needed = []
print_green("Checking migrations...")
for migration in migrations:
check_migration(migration, migrations_needed, silent)
if len(migrations_needed) > 0:
if not silent:
if (
input("Migrations required!\nRun missing migrations? [y/N] ").lower()
!= "y"
):
return
else:
backup_db()
if os.path.exists(from_dbtool_path("migration_errors.log")):
os.remove(from_dbtool_path("migration_errors.log"))
for migration in migrations_needed:
print("Running migrations for " + migration.migration_name() + "...")
migration.migrate(cur, db)
print_green("Finished migrations!")
if os.path.exists(from_dbtool_path("migration_errors.log")):
print_red(
"There were errors with some migrations, this likely means one or more characters \n"
"have corrupt data in some field. See migration_errors.log for more details."
)
time.sleep(0.5)
else:
print_green("No migrations required.")
time.sleep(0.5)
def check_migration(migration, migrations_needed, silent=False):
migration.check_preconditions(cur)
if not migration.needs_to_run(cur):
if not silent:
print_red(
"["
+ colorama.Fore.GREEN
+ "*"
+ colorama.Fore.RED
+ "] "
+ colorama.Style.RESET_ALL
+ migration.migration_name()
)
return
migrations_needed.append(migration)
if not silent:
print_red("[ ] " + colorama.Style.RESET_ALL + migration.migration_name())
def restore_backup():
backup_db()
fetch_files()
while len(backups):
for i, backup in enumerate(backups):
print_green(
str(i + 1) + colorama.Style.RESET_ALL + ". " + backup.replace("\\", "/")
)
choice = input(
'Choose a number to import, or type "delete #" to delete a file.\n> '
)
if choice.isnumeric():
choice = int(choice)
if 0 < choice < len(backups) + 1:
backup_file = backups[choice - 1].replace("\\", "/")
print(colorama.ansi.clear_screen())
print_red(
"If this is a full backup created by this tool, it is recommended to manually change \n"
"the DB_VER in config.yaml to the hash sequence in the filename, after \n"
"the database name and the timestamp, so that express update functions properly."
)
if input("Import " + backup_file + "? [y/N] ").lower() == "y":
import_file(backup_file)
print_green("Finished importing!")
break
else:
bad_selection()
else:
choice = re.match(r"^delete (\d+)$", choice)
if choice:
choice = int(choice.group(1))
if 0 < choice < len(backups) + 1:
backup_file = backups[choice - 1]
print(colorama.ansi.clear_screen())
if input("Delete " + backup_file + "? [y/N] ").lower() == "y":
os.remove(backup_file)
print_green("Deleted " + backup_file + "!")
fetch_files()
else:
bad_selection()
else:
return
def reset_db():
backup_db()
print_red("Are you sure you want to reset your database to default?")
choice = input('Type "reset ' + database + '" to confirm.\n> ')
choice = re.match(r"^reset (\w+)$", choice)
if choice and choice.group(1) == database:
setup_db()
def bad_selection():
print_red("Invalid selection.")
time.sleep(0.5)
def settings_menu():
fetch_configs()
print_green("Current MySQL bin location: " + colorama.Style.RESET_ALL + mysql_bin)
if input("Change this location? [y/N] ").lower() == "y":
adjust_mysql_bin()
print_green(
"Automatic backup for command line updates: "
+ colorama.Style.RESET_ALL
+ str(bool(auto_backup))
)
if input("Change this? [y/N] ").lower() == "y":
adjust_auto_backup()
print_green(
"Automatic client version update for command line updates: "
+ colorama.Style.RESET_ALL
+ str(auto_update_client)
)
if input("Change this? [y/N] ").lower() == "y":
adjust_auto_update_client()
write_configs()
def set_external_ip(ip_str):
global mysql_bin, exe
query = f"""
UPDATE zone_settings SET zoneip = '{ip_str}';
"""
print("Executing query:")
print(query)
_ = db_query(query)
def set_external_ip_dialog():
import urllib.request
if input("Make server public-facing? [y/N] ").lower() == "y":
external_ip = urllib.request.urlopen("https://ident.me").read().decode("utf8")
print_green(f"Detected your public-facing IP as: {external_ip}")
if input("Is this correct? [y/N] ").lower() == "y":
set_external_ip(external_ip)
else:
external_ip = input("Please enter your public-facing IP: ")
if len(external_ip.strip()) > 0:
set_external_ip(external_ip)
def print_db_tables_by_size():
global mysql_bin, exe
query = """
SELECT table_name AS 'Table',
ROUND(((data_length + index_length) / 1024 / 1024), 2) AS 'Size (MB)'
FROM information_schema.TABLES
WHERE ROUND(((data_length + index_length) / 1024 / 1024), 2) > 1.0
ORDER BY (data_length + index_length) DESC;
"""
print("Executing query:")
print(query)
result = db_query(query)
print(result.stdout)
def offload_to_auction_house_history():
global mysql_bin, exe
if (
input(
"This feature is very slow, and runs the risk of wiping out some or all of your auction_house history.\n"
+ RED
+ "USE AT YOUR OWN RISK!\n"
+ RESET
+ "Do you wish to continue? [y/N] "
).lower()
!= "y"
):
return
if not cur:
connect()
ah_before = db_query("SELECT COUNT(*) FROM auction_house;").stdout
ah_history_before = db_query("SELECT COUNT(*) FROM auction_house_history;").stdout
cur.execute("SELECT DISTINCT itemid FROM auction_house;")
items = cur.fetchall()
index = 0
for itemid in items:
if index % 500 == 0:
percent = (index / len(items)) * 100
print(
f"Offloading historical data auction_house data: {percent:.1f}% ({index}/{len(items)})"
)
index = index + 1
query = f"""
START TRANSACTION;
SET @itemid = {itemid[0]};
-- Insert into history table
INSERT INTO auction_house_history
SELECT * FROM auction_house
WHERE
itemid = @itemid AND
buyer_name IS NOT NULL AND -- Sold
id NOT IN (
SELECT id
FROM (
-- Most recent 20 unsold listings for item @itemid
SELECT id, itemid, buyer_name
FROM auction_house
WHERE
itemid = @itemid AND
buyer_name IS NOT NULL
ORDER BY sell_date DESC
LIMIT 20
) listed
);
-- Delete from non-history (same query as above, just for deleting)
DELETE auction_house.*
FROM auction_house
WHERE
itemid = @itemid AND
buyer_name IS NOT NULL AND -- Sold
id NOT IN (
SELECT id
FROM (
-- Most recent 20 unsold listings for item @itemid
SELECT id, itemid, buyer_name
FROM auction_house
WHERE
itemid = @itemid AND
buyer_name IS NOT NULL
ORDER BY sell_date DESC
LIMIT 20
) listed
);
COMMIT;
"""
db_query(query)
print("Done!")
print("Number of rows in auction_house (before):")
print(ah_before)
print("Number of rows in auction_house_history (before):")
print(ah_history_before)
print("Number of rows in auction_house (after):")
print(db_query("SELECT COUNT(*) FROM auction_house;").stdout)
print("Number of rows in auction_house_history (after):")
print(db_query("SELECT COUNT(*) FROM auction_house_history;").stdout)
def announce_menu():
from tools.announce import send_server_message
message = input("What message would you like to send to the whole server?\n> ")
if (
input(
"Are you sure you want to send the this to the whole server? [y/N]"
).lower()
== "y"
):
send_server_message(message)
# fmt: off
def present_menu(title, contents):
# Determine width of entire menu, including multiline titles
# and menu items
length = 0
title_parts = title.split("\n")
for part in title_parts:
length = max(len(part), length)
for value in contents.values():
length = max(len(value[0]), length)
# Add some more padding to account for the manual styling of
# menu options and spacings
option_pad = 3
length = length + option_pad
LEFT = "| "
RIGHT = " |"
# Present title
print(
GREEN + "o" + RED + "-" + "-" * length + "-" + GREEN + "o"
)
for part in title_parts:
print(
RED + LEFT + RESET + part.center(length) + RED + RIGHT
)
print(
GREEN + "o" + RED + "-" + "-" * length + "-" + GREEN + "o"
)
# Menu options
for key, value in contents.items():
description = value[0]
print(
RED + LEFT + GREEN + key + RESET + ". " + description + " " * (length - len(description) - option_pad) + RED + RIGHT
)
# Footer
print(colorama.Fore.GREEN + "o" + colorama.Fore.RED + "-" + "-" * length + "-" + colorama.Fore.GREEN + "o\n")
# Handle inputs
selection = input("> ").lower()
print(colorama.ansi.clear_screen())
contents.get(selection, ["", bad_selection])[1]()
# fmt: on
def configure_single_process():
query = f"UPDATE xidb.zone_settings SET zoneport = 54230;"
print(query)
db_query(query)
def configure_multi_process_by_modulus(mod):
for idx in range(0, mod):
query = f"UPDATE xidb.zone_settings SET zoneport = 54230 + {idx} WHERE zoneid % {mod} = {idx};"
print(query)
db_query(query)
def configure_multi_process_by_modulus_3():
configure_multi_process_by_modulus(3)
def configure_multi_process_by_modulus_7():
configure_multi_process_by_modulus(7)
def launch_process_in_background(process_params):
# fmt: off
if platform.system() == "Windows":
subprocess.Popen(
process_params,
shell=True,
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
cwd=server_dir_path,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
subprocess.Popen(
process_params,
start_new_session=True, # POSIX specific flag, can't use the same flags as windows
cwd=server_dir_path,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# fmt: on
def launch_using_zone_settings():
result = db_query("SELECT DISTINCT zoneip FROM xidb.zone_settings;")
zoneip = result.stdout.split("\n")[1]
result = db_query(
f"""
SELECT DISTINCT zoneport from zone_settings ORDER BY zoneport ASC;
"""
)
ports = result.stdout.split("\n")[1:-1]
# Strip out any '0' entries from ports
ports = [port for port in ports if port.strip() != "0"]
print(f"ZoneIP: {zoneip}, Ports: {ports}\n")
xi_connect_executable = from_server_path(f"xi_connect{exe}")
xi_map_executable = from_server_path(f"xi_map{exe}")
xi_search_executable = from_server_path(f"xi_search{exe}")
xi_world_executable = from_server_path(f"xi_world{exe}")
print(f"Launching {xi_connect_executable} --log log/connect-server.log")
launch_process_in_background(
[xi_connect_executable, "--log", "log/connect-server.log"]
)
print(f"Launching {xi_search_executable} --log log/search-server.log")
launch_process_in_background(
[xi_search_executable, "--log", f"log/search-server.log"]
)
print(f"Launching {xi_world_executable} --log log/world-server.log")
launch_process_in_background(
[xi_world_executable, "--log", f"log/world-server.log"]
)
time.sleep(1)
for port in ports:
print(
f"Launching {xi_map_executable} --log log/map-server-{port}.log --ip {zoneip} --port {port}"
)
launch_process_in_background(
[
xi_map_executable,
"--log",
f"log/map-server-{port}.log",
"--ip",
zoneip,
"--port",
port,
]
)
def update_submodules():
# fmt: off
result = subprocess.run(
["git", "submodule", "update", "--init", "--recursive", "--progress"], capture_output=True, text=True).stdout
print(result)
# fmt: on
def update_sql_from_db(table_name):
try:
# Fetch all rows from the specified table
cur.execute(f"SELECT * FROM {table_name};")
rows = cur.fetchall()
# Read the SQL file
with open(
from_server_path(f"sql/{table_name}.sql"), "r", encoding="utf-8"
) as file:
sql_lines = file.readlines()
sql_variables = {}
updated_lines = []
row_index = 0
# Iterate over the lines in the file
for line in sql_lines:
# Scan for variables
if line.strip().startswith("SET @"):
parts = line.strip().split("=")
var_name = parts[0].split()[1]
var_value = parts[1].replace(";", "").split("--")[0].strip()
sql_variables[var_value] = var_name
# Scan for INSERT
lowercase_line = line.strip().lower()
insert_start = re.match(
rf"insert into `{table_name}` values \(", lowercase_line
)
if insert_start:
# Build a string using the values pulled from the database
values = rows[row_index]
updated_values = []
for i, value in enumerate(values):
# NULL
if value is None:
updated_values.append("NULL")
# Binary
elif isinstance(value, bytes):
if len(value) == 0:
updated_values.append(f"''")
# npc_list name field is binary but should be decoded for the sql files
elif table_name == "npc_list" and i == 1:
text = True
for j in value:
if j < 32 or j > 126: # ascii printable characters
text = False
break
if text:
updated_values.append(f"'{value.decode('latin_1')}'")
# If the value contains non-printable characters, use hex instead
else:
hex_value = value.hex().upper()
updated_values.append(f"0x{hex_value}")
# Otherwise print binary in 0x hex form
else:
hex_value = value.hex().upper()
updated_values.append(f"0x{hex_value}")
# String
elif isinstance(value, str):
escaped_value = value.replace("'", "\\'")
updated_values.append(f"'{escaped_value}'")
# Number
else:
# mob_droplist and pet_skills use variables for certain fields
if (
table_name == "mob_droplist"
and i == 5
and str(value) in sql_variables
):
updated_values.append(sql_variables[str(value)])
elif table_name == "pet_skills" and i == 9:
var_list = []
for var in sql_variables.keys():
if value & int(var):
var_list.append(sql_variables[var])
updated_values.append(" | ".join(var_list))
else:
# Get float formatting from the cursor description.
# https://github.com/mariadb-corporation/mariadb-connector-python/blob/67d3062ad597cca8d5419b2af2ad8b62528204e5/mariadb/mariadb_cursor.c#L777-L787
if (
cur.description[i][1]
== mariadb.constants.FIELD_TYPE.FLOAT
and cur.description[i][5] > 0
):
updated_values.append(
f"{value:.{cur.description[i][5]}f}"
)
else:
updated_values.append(str(value))
values = ",".join(updated_values)
# Replace the values in the current line with the values pulled from the database
updated_line = line[: insert_start.end()] + f"{values});"
# Append any comments, preserving whitespace
if "--" in line:
insert_end = line.index(");") + 2
before_comment = line[insert_end:].split("--")[0]
updated_line = f"{updated_line}{before_comment}{line[insert_end + len(before_comment):]}"
else:
updated_line = f"{updated_line}\n"
updated_lines.append(updated_line)
row_index += 1
# Otherwise just save the line as-is
else:
updated_lines.append(line)
# Write the updated content back to the file
with open(
from_server_path(f"sql/{table_name}.sql"), "w", encoding="utf-8"
) as file:
file.writelines(updated_lines)
except Exception as e:
print_red(f"Error: {e}")
def dump_table(table_name=None, silent=False):
if not silent:
table_name = input("Which table would you like to dump?\n> ")
update_sql_from_db(table_name)
print_green(f"Replaced values in {table_name}.sql with data from the database.")
def dump_all_tables(silent=False):
if silent or input("Dump all database tables to .sql files? [y/N] ").lower() == "y":
dump_tables = []
for _, _, filenames in os.walk(from_server_path("sql/")):
for filename in sorted(filenames):
if filename.endswith(".sql"):
dump_tables.append(filename)
break
dump_tables.remove("triggers.sql")
for table in dump_tables:
if table not in player_data:
update_sql_from_db(table[:-4])
print_green(f"Replaced values in all .sql files with data from the database.")
def validate_yaml_data():
import tempfile
with tempfile.TemporaryDirectory() as tmp:
rc = subprocess.call(
[sys.executable, "-m", "tools.codegen", tmp, "--validate"],
cwd=server_dir_path,
)
if rc == 0:
print_green("YAML data validates clean.")
else:
print_red("YAML validation failed.")
def tasks_menu():
present_menu(
"Maintenance Tasks",
{
"1": ["Update git submodules", update_submodules],
"2": ["Set zone IP addresses", set_external_ip_dialog],
"3": ["Server-wide announcement", announce_menu],
"4": ["Show table sizes (min 2MB)", print_db_tables_by_size],
# "5": [
# "Offload historical auction data to auction_house_history",
# offload_to_auction_house_history,
# ],
"v": ["Validate YAML data", validate_yaml_data],
"l": [
"Configure single-process server",
configure_single_process,
],
"b": [
"Configure multi-process server (3 processes)",
configure_multi_process_by_modulus_3,
],
"c": [
"Configure multi-process server (7 processes)",
configure_multi_process_by_modulus_7,
],
"d": ["Dump Table", dump_table],
"a": ["Dump All Tables", dump_all_tables],
"q": ["Quit to main menu", NOOP],
},
)
def main():
try:
global mysql_bin, exe
fetch_credentials()
fetch_configs()
# Check MySQL path/availability
if not os.path.exists(mysql_bin + "mysql" + exe):
adjust_mysql_bin()
write_configs()
# CLI args
if len(sys.argv) > 1:
arg1 = str(sys.argv[1])
if "backup" == arg1:
if len(sys.argv) > 2 and str(sys.argv[2]) == "lite":
backup_db(True, True)
else:
backup_db(True)
return
elif "migrate" == arg1:
if connect() != False:
fetch_versions()
run_all_migrations(True)
close()
return
elif "update" == arg1:
fetch_versions()
full_update = False
if len(sys.argv) > 2 and str(sys.argv[2]) == "full":
full_update = True
if (
db_ver
and release_version
and not express_enabled
and not full_update
):
print_green("Database is up to date.")
return
if connect() != False:
if express_enabled and not full_update:
express_update(True)
else:
update_db(True)
close()
return
elif "setup" == arg1:
if len(sys.argv) > 2 and str(sys.argv[2]) == database:
query = f"CREATE DATABASE {database} CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
result = subprocess.run(
[
f"{mysql_bin}mysql{exe}",
f"-h{host}",
f"-P{str(port)}",
f"-u{login}",
f"-p{password}",
f"-e {query}",
],
capture_output=True,
text=True,
)
fetch_errors(query, result)
fetch_versions()
setup_db()
return
elif "dump" == arg1:
if len(sys.argv) > 2:
dump_table(str(sys.argv[2]), True)
else:
dump_all_tables(True)
return
# Main loop
print(colorama.ansi.clear_screen())
fetch_versions()
connect()
while cur:
colorama.init(autoreset=True)
title = (
"LandSandBoat Database Management Tool\n" + "Connected to " + database
)
if db_ver:
title = title + "\n" + str("#" + db_ver)
options = {
"1": ["Update DB", update_db],
"2": ["Check migrations", run_all_migrations],
"3": ["Backup", backup_db],
"4": ["Restore/Import", restore_backup],
"r": ["Reset DB", reset_db],
"t": ["Maintenance Tasks", tasks_menu],
"l": ["Launch Server", launch_using_zone_settings],
"s": ["Settings", settings_menu],
"q": ["Quit", close],
}
if express_enabled:
append = {
"e": [
"Express Update " + "(#" + release_version + ")",
express_update,
],
}
options = {**append, **options}
present_menu(title, options)
except KeyboardInterrupt:
try:
sys.exit(0)
except SystemExit:
os._exit(0)
if __name__ == "__main__":
main()