|
| 1 | +#!/usr/bin/python3 |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | + |
| 4 | +""" |
| 5 | +clone: https://github.com/FAForever/faf-coop-maps |
| 6 | +
|
| 7 | +FAF coop maps updater |
| 8 | +
|
| 9 | +All default settings are setup for FAF production! |
| 10 | +Override the directory settings for local testing. |
| 11 | +To get more help run |
| 12 | + $ pipenv run patch-coop-maps -h |
| 13 | +
|
| 14 | +Default usage: |
| 15 | + $ pipenv run patch-coop-maps -s <git tag> |
| 16 | +""" |
| 17 | +import argparse |
| 18 | +import hashlib |
| 19 | +import logging |
| 20 | +import os |
| 21 | +import shutil |
| 22 | +import subprocess |
| 23 | +import sys |
| 24 | +import zipfile |
| 25 | +from tempfile import TemporaryDirectory |
| 26 | +from typing import NamedTuple, List |
| 27 | + |
| 28 | +import mysql.connector |
| 29 | + |
| 30 | +logger: logging.Logger = logging.getLogger() |
| 31 | +logger.setLevel(logging.DEBUG) |
| 32 | + |
| 33 | +fixed_file_timestamp = 1078100502 # 2004-03-01T00:21:42Z |
| 34 | + |
| 35 | + |
| 36 | +db_config = { |
| 37 | + "host": os.getenv("DATABASE_HOST", "localhost"), |
| 38 | + "user": os.getenv("DATABASE_USERNAME", "root"), |
| 39 | + "password": os.getenv("DATABASE_PASSWORD", "banana"), |
| 40 | + "database": os.getenv("DATABASE_NAME", "faf_lobby"), |
| 41 | +} |
| 42 | + |
| 43 | + |
| 44 | +def get_db_connection(): |
| 45 | + """Create and return a MySQL connection.""" |
| 46 | + try: |
| 47 | + conn = mysql.connector.connect(**db_config) |
| 48 | + if conn.is_connected(): |
| 49 | + logger.debug(f"Connected to MySQL at {db_config['host']}") |
| 50 | + return conn |
| 51 | + except Error as e: |
| 52 | + logger.error(f"MySQL connection failed: {e}") |
| 53 | + sys.exit(1) |
| 54 | + |
| 55 | + |
| 56 | +def run_sql(conn, sql: str) -> str: |
| 57 | + """ |
| 58 | + Run an SQL query directly on the MySQL database instead of via Docker. |
| 59 | + Returns output in a string format similar to the old implementation. |
| 60 | + """ |
| 61 | + logger.debug(f"Executing SQL query:\n{sql}") |
| 62 | + try: |
| 63 | + with conn.cursor() as cursor: |
| 64 | + cursor.execute(sql) |
| 65 | + |
| 66 | + # If it's a SELECT query, fetch and format results |
| 67 | + if sql.strip().lower().startswith("select"): |
| 68 | + rows = cursor.fetchall() |
| 69 | + column_names = [desc[0] for desc in cursor.description] |
| 70 | + # Simulate the Docker mysql CLI tabular text output |
| 71 | + lines = ["\t".join(column_names)] |
| 72 | + for row in rows: |
| 73 | + lines.append("\t".join(str(x) for x in row)) |
| 74 | + result = "\n".join(lines) |
| 75 | + else: |
| 76 | + conn.commit() |
| 77 | + result = "Query OK" |
| 78 | + |
| 79 | + logger.debug(f"SQL result:\n{result}") |
| 80 | + return result |
| 81 | + |
| 82 | + except Error as e: |
| 83 | + logger.error(f"SQL execution failed: {e}") |
| 84 | + sys.exit(1) |
| 85 | + |
| 86 | + |
| 87 | +class CoopMap(NamedTuple): |
| 88 | + folder_name: str |
| 89 | + map_id: int |
| 90 | + map_type: int |
| 91 | + |
| 92 | + def build_zip_filename(self, version: int) -> str: |
| 93 | + return f"{self.folder_name.lower()}.v{version:04d}.zip" |
| 94 | + |
| 95 | + def build_folder_name(self, version: int) -> str: |
| 96 | + return f"{self.folder_name.lower()}.v{version:04d}" |
| 97 | + |
| 98 | + |
| 99 | +# Coop maps are in db table `coop_map` |
| 100 | +coop_maps: List[CoopMap] = [ |
| 101 | + # Forged Alliance missions |
| 102 | + CoopMap("X1CA_Coop_001", 1, 0), |
| 103 | + CoopMap("X1CA_Coop_002", 3, 0), |
| 104 | + CoopMap("X1CA_Coop_003", 4, 0), |
| 105 | + CoopMap("X1CA_Coop_004", 5, 0), |
| 106 | + CoopMap("X1CA_Coop_005", 6, 0), |
| 107 | + CoopMap("X1CA_Coop_006", 7, 0), |
| 108 | + |
| 109 | + # Vanilla Aeon missions |
| 110 | + CoopMap("SCCA_Coop_A01", 8, 1), |
| 111 | + CoopMap("SCCA_Coop_A02", 9, 1), |
| 112 | + CoopMap("SCCA_Coop_A03", 10, 1), |
| 113 | + CoopMap("SCCA_Coop_A04", 11, 1), |
| 114 | + CoopMap("SCCA_Coop_A05", 12, 1), |
| 115 | + CoopMap("SCCA_Coop_A06", 13, 1), |
| 116 | + |
| 117 | + # Vanilla Cybran missions |
| 118 | + CoopMap("SCCA_Coop_R01", 20, 2), |
| 119 | + CoopMap("SCCA_Coop_R02", 21, 2), |
| 120 | + CoopMap("SCCA_Coop_R03", 22, 2), |
| 121 | + CoopMap("SCCA_Coop_R04", 23, 2), |
| 122 | + CoopMap("SCCA_Coop_R05", 24, 2), |
| 123 | + CoopMap("SCCA_Coop_R06", 25, 2), |
| 124 | + |
| 125 | + # Vanilla UEF missions |
| 126 | + CoopMap("SCCA_Coop_E01", 14, 3), |
| 127 | + CoopMap("SCCA_Coop_E02", 15, 3), |
| 128 | + CoopMap("SCCA_Coop_E03", 16, 3), |
| 129 | + CoopMap("SCCA_Coop_E04", 17, 3), |
| 130 | + CoopMap("SCCA_Coop_E05", 18, 3), |
| 131 | + CoopMap("SCCA_Coop_E06", 19, 3), |
| 132 | + |
| 133 | + # Custom missions |
| 134 | + CoopMap("FAF_Coop_Prothyon_16", 26, 4), |
| 135 | + CoopMap("FAF_Coop_Fort_Clarke_Assault", 27, 4), |
| 136 | + CoopMap("FAF_Coop_Theta_Civilian_Rescue", 28, 4), |
| 137 | + CoopMap("FAF_Coop_Novax_Station_Assault", 31, 4), |
| 138 | + CoopMap("FAF_Coop_Operation_Tha_Atha_Aez", 32, 4), |
| 139 | + CoopMap("FAF_Coop_Havens_Invasion", 33, 4), |
| 140 | + CoopMap("FAF_Coop_Operation_Rescue", 35, 4), |
| 141 | + CoopMap("FAF_Coop_Operation_Uhthe_Thuum_QAI", 36, 4), |
| 142 | + CoopMap("FAF_Coop_Operation_Yath_Aez", 37, 4), |
| 143 | + CoopMap("FAF_Coop_Operation_Ioz_Shavoh_Kael", 38, 4), |
| 144 | + CoopMap("FAF_Coop_Operation_Trident", 39, 4), |
| 145 | + CoopMap("FAF_Coop_Operation_Blockade", 40, 4), |
| 146 | + CoopMap("FAF_Coop_Operation_Golden_Crystals", 41, 4), |
| 147 | + CoopMap("FAF_Coop_Operation_Holy_Raid", 42, 4), |
| 148 | + CoopMap("FAF_Coop_Operation_Tight_Spot", 45, 4), |
| 149 | + CoopMap("FAF_Coop_Operation_Overlord_Surth_Velsok", 47, 4), |
| 150 | + CoopMap("FAF_Coop_Operation_Rebel's_Rest", 48, 4), |
| 151 | + CoopMap("FAF_Coop_Operation_Red_Revenge", 49, 4), |
| 152 | +] |
| 153 | + |
| 154 | +def fix_file_timestamps(files: List[str]) -> None: |
| 155 | + for file in files: |
| 156 | + logger.debug(f"Fixing timestamp in {file}") |
| 157 | + os.utime(file, (fixed_file_timestamp, fixed_file_timestamp)) |
| 158 | + |
| 159 | + |
| 160 | +def fix_folder_paths(folder_name: str, files: List[str], new_version: int) -> None: |
| 161 | + old_maps_lua_path = f"/maps/{folder_name}/" |
| 162 | + new_maps_lua_path = f"/maps/{folder_name.lower()}.v{new_version:04d}/" |
| 163 | + |
| 164 | + for file in files: |
| 165 | + logger.debug(f"Fixing lua folder path in {file}: '{old_maps_lua_path}' -> '{new_maps_lua_path}'") |
| 166 | + |
| 167 | + with open(file, "rb") as file_handler: |
| 168 | + data = file_handler.read() |
| 169 | + data = data.replace(old_maps_lua_path.encode(), new_maps_lua_path.encode()) |
| 170 | + |
| 171 | + with open(file, "wb") as file_handler: |
| 172 | + file_handler.seek(0) |
| 173 | + file_handler.write(data) |
| 174 | + |
| 175 | + |
| 176 | +def get_latest_map_version(coop_map: CoopMap) -> int: |
| 177 | + logger.debug(f"Fetching latest map version for coop map {coop_map}") |
| 178 | + |
| 179 | + query = f""" |
| 180 | + SELECT version FROM coop_map WHERE id = {coop_map.map_id}; |
| 181 | + """ |
| 182 | + result = run_sql(query).split("\n") |
| 183 | + assert len(result) == 3, f"Mysql returned wrong result! Either map id {coop_map.map_id} is not in table coop_map" \ |
| 184 | + f" or the where clause is wrong. Result: " + "\n".join(result) |
| 185 | + return int(result[1]) |
| 186 | + |
| 187 | + |
| 188 | +def new_file_is_different(old_file_name: str, new_file_name: str) -> bool: |
| 189 | + old_file_md5 = calc_md5(old_file_name) |
| 190 | + new_file_md5 = calc_md5(new_file_name) |
| 191 | + |
| 192 | + logger.debug(f"MD5 hash of {old_file_name} is: {old_file_md5}") |
| 193 | + logger.debug(f"MD5 hash of {new_file_name} is: {new_file_md5}") |
| 194 | + |
| 195 | + return old_file_md5 != new_file_md5 |
| 196 | + |
| 197 | + |
| 198 | +def update_database(coop_map: CoopMap, new_version: int) -> None: |
| 199 | + logger.debug(f"Updating coop map {coop_map} in database to version {new_version}") |
| 200 | + |
| 201 | + query = f""" |
| 202 | + UPDATE coop_map |
| 203 | + SET version = {new_version}, filename = "maps/{coop_map.build_zip_filename(new_version)}" |
| 204 | + WHERE id = {coop_map.map_id} |
| 205 | + """ |
| 206 | + run_sql(query) |
| 207 | + |
| 208 | + |
| 209 | +def copytree(src, dst, symlinks=False, ignore=None): |
| 210 | + """ |
| 211 | + Reason for that method is because shutil.copytree will raise exception on existing |
| 212 | + temporary directory |
| 213 | + """ |
| 214 | + |
| 215 | + for item in os.listdir(src): |
| 216 | + s = os.path.join(src, item) |
| 217 | + d = os.path.join(dst, item) |
| 218 | + if os.path.isdir(s): |
| 219 | + shutil.copytree(s, d, symlinks, ignore) |
| 220 | + else: |
| 221 | + shutil.copy2(s, d) |
| 222 | + |
| 223 | + |
| 224 | +def create_zip_package(coop_map: CoopMap, version: int, files: List[str], tmp_folder_path: str, zip_file_path: str): |
| 225 | + fix_folder_paths(coop_map.folder_name, files, version) |
| 226 | + fix_file_timestamps(files) |
| 227 | + with zipfile.ZipFile(zip_file_path, 'w', zipfile.ZIP_BZIP2) as zip_file: |
| 228 | + for path in files: |
| 229 | + zip_file.write(path, arcname=f"/{coop_map.build_folder_name(version)}/{os.path.relpath(path, tmp_folder_path)}") |
| 230 | + |
| 231 | + |
| 232 | +def process_coop_map(coop_map: CoopMap, simulate: bool, git_directory:str, coop_maps_path: str): |
| 233 | + logger.info(f"Processing: {coop_map}") |
| 234 | + |
| 235 | + temp_dir = TemporaryDirectory() |
| 236 | + copytree(os.path.join(git_directory, coop_map.folder_name), temp_dir.name) |
| 237 | + processing_files = [] |
| 238 | + for root, dirs, files in os.walk(temp_dir.name): |
| 239 | + for f in files: |
| 240 | + processing_files.append(os.path.relpath(os.path.join(root, f), temp_dir.name)) |
| 241 | + |
| 242 | + logger.debug(f"Files to process in {coop_map}: {processing_files}") |
| 243 | + current_version = get_latest_map_version(coop_map) |
| 244 | + current_file_path = os.path.join(coop_maps_path, coop_map.build_zip_filename(current_version)) |
| 245 | + zip_file_path = os.path.join(temp_dir.name, coop_map.build_zip_filename(current_version)) |
| 246 | + create_zip_package(coop_map, current_version, processing_files, temp_dir.name, zip_file_path) |
| 247 | + if current_version == 0 or new_file_is_different(current_file_path, zip_file_path): |
| 248 | + new_version = current_version + 1 |
| 249 | + |
| 250 | + if current_version == 0: |
| 251 | + logger.info(f"{coop_map} first upload. New version: {new_version}") |
| 252 | + else: |
| 253 | + logger.info(f"{coop_map} has changed. New version: {new_version}") |
| 254 | + |
| 255 | + if not simulate: |
| 256 | + temp_dir.cleanup() |
| 257 | + temp_dir = TemporaryDirectory() |
| 258 | + copytree(os.path.join(git_directory, coop_map.folder_name), temp_dir.name) |
| 259 | + |
| 260 | + zip_file_path = os.path.join(coop_maps_path, coop_map.build_zip_filename(new_version)) |
| 261 | + create_zip_package(coop_map, new_version, processing_files, temp_dir.name, zip_file_path) |
| 262 | + |
| 263 | + update_database(coop_map, new_version) |
| 264 | + else: |
| 265 | + logger.info(f"Updating database skipped due to simulation") |
| 266 | + else: |
| 267 | + logger.info(f"{coop_map} remains unchanged") |
| 268 | + temp_dir.cleanup() |
| 269 | + |
| 270 | + |
| 271 | +def calc_md5(filename: str) -> str: |
| 272 | + """ |
| 273 | + Calculate the MD5 hash of a file |
| 274 | + """ |
| 275 | + hash_md5 = hashlib.md5() |
| 276 | + with open(filename, "rb") as f: |
| 277 | + for chunk in iter(lambda: f.read(4096), b""): |
| 278 | + hash_md5.update(chunk) |
| 279 | + return hash_md5.hexdigest() |
| 280 | + |
| 281 | + |
| 282 | +def run_checked_shell(cmd: List[str]) -> subprocess.CompletedProcess: |
| 283 | + """ |
| 284 | + Runs a command as a shell process and checks for success |
| 285 | + Output is captured in the result object |
| 286 | + :param cmd: command to run |
| 287 | + :return: CompletedProcess of the execution |
| 288 | + """ |
| 289 | + logger.debug("Run shell command: {cmd}".format(cmd=cmd)) |
| 290 | + return subprocess.run(cmd, check=True, stdout=subprocess.PIPE) |
| 291 | + |
| 292 | + |
| 293 | +def run_sql(sql: str, container: str = "faf-db", database: str = "faf_lobby") -> str: |
| 294 | + |
| 295 | + """ |
| 296 | + Run a sql-query against the faf-db in the docker container |
| 297 | + :param database: name of the database where to run the query |
| 298 | + :param container: name of the docker container where to run the query |
| 299 | + :param sql: the sql-query to run |
| 300 | + :return: the query output as string |
| 301 | + """ |
| 302 | + try: |
| 303 | + sql_text_result = run_checked_shell( |
| 304 | + ["docker", "exec", "-u", "root", container, "mysql", database, "-e", sql] |
| 305 | + ).stdout.decode() # type: str |
| 306 | + logger.debug(f"SQL output >>> \n{sql_text_result}<<<") |
| 307 | + return sql_text_result |
| 308 | + except subprocess.CalledProcessError as e: |
| 309 | + logger.error(f"""Executing sql query failed: {sql}\n\t\tError message: {str(e)}""") |
| 310 | + exit(1) |
| 311 | + |
| 312 | + |
| 313 | +def git_checkout(path: str, tag: str) -> None: |
| 314 | + """ |
| 315 | + Checkout a git tag of the git repository. This requires the repo to be checked out in the path folder! |
| 316 | +
|
| 317 | + :param path: the path of the git repository to checkout |
| 318 | + :param tag: version of the git tag (full name) |
| 319 | + :return: nothing |
| 320 | + """ |
| 321 | + cwd = os.getcwd() |
| 322 | + os.chdir(path) |
| 323 | + logger.debug(f"Git checkout from path {path}") |
| 324 | + |
| 325 | + try: |
| 326 | + run_checked_shell(["git", "fetch"]) |
| 327 | + run_checked_shell(["git", "checkout", tag]) |
| 328 | + except subprocess.CalledProcessError as e: |
| 329 | + logger.error(f"git checkout failed - please check the error message: {e.stderr}") |
| 330 | + exit(1) |
| 331 | + finally: |
| 332 | + os.chdir(cwd) |
| 333 | + |
| 334 | + |
| 335 | +def create_zip(content: List[str], relative_to: str, output_file: str) -> None: |
| 336 | + logger.debug(f"Zipping files to file `{output_file}`: {content}") |
| 337 | + |
| 338 | + with zipfile.ZipFile(output_file, 'w', zipfile.ZIP_DEFLATED) as zip_file: |
| 339 | + for path in content: |
| 340 | + if os.path.isdir(path): |
| 341 | + cwd = os.getcwd() |
| 342 | + os.chdir(path) |
| 343 | + |
| 344 | + for root, dirs, files in os.walk(path): |
| 345 | + for next_file in files: |
| 346 | + file_path = os.path.join(root, next_file) |
| 347 | + zip_file.write(file_path, os.path.relpath(file_path, relative_to)) |
| 348 | + |
| 349 | + os.chdir(cwd) |
| 350 | + else: |
| 351 | + zip_file.write(path, os.path.relpath(path, relative_to)) |
| 352 | + |
| 353 | + |
| 354 | +if __name__ == "__main__": |
| 355 | + # Setting up logger |
| 356 | + stream_handler = logging.StreamHandler(sys.stdout) |
| 357 | + stream_handler.setFormatter(logging.Formatter('%(levelname)-5s - %(message)s')) |
| 358 | + logger.addHandler(stream_handler) |
| 359 | + |
| 360 | + # Setting up CLI arguments |
| 361 | + parser = argparse.ArgumentParser(description=__doc__) |
| 362 | + |
| 363 | + parser.add_argument("version", help="the git tag name of the version") |
| 364 | + parser.add_argument("-s", "--simulate", dest="simulate", action="store_true", default=False, |
| 365 | + help="only runs a simulation without updating the database") |
| 366 | + parser.add_argument("--git-directory", dest="git_directory", action="store", |
| 367 | + default="/opt/featured-mods/faf-coop-maps", |
| 368 | + help="base directory of the faf-coop-maps repository") |
| 369 | + parser.add_argument("--maps-directory", dest="coop_maps_path", action="store", |
| 370 | + default="/opt/faf/data/maps", |
| 371 | + help="directory of the coop map files (content server)") |
| 372 | + |
| 373 | + args = parser.parse_args() |
| 374 | + |
| 375 | + git_checkout(args.git_directory, args.version) |
| 376 | + |
| 377 | + for coop_map in coop_maps: |
| 378 | + try: |
| 379 | + process_coop_map(coop_map, args.simulate, args.git_directory, args.coop_maps_path) |
| 380 | + except Exception as error: |
| 381 | + logger.warning(f"Unable to parse {coop_map}", exc_info=True) |
0 commit comments