Skip to content

Commit b32c9d6

Browse files
committed
Deploy coop maps
1 parent 47aa61b commit b32c9d6

File tree

2 files changed

+437
-0
lines changed

2 files changed

+437
-0
lines changed
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
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

Comments
 (0)