WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit 67653dd

Browse files
committed
Put coop deployment into cronjob
1 parent 6a8b697 commit 67653dd

File tree

6 files changed

+462
-0
lines changed

6 files changed

+462
-0
lines changed

apps/faf-backup/Chart.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
apiVersion: v2
2+
name: faf-backup
3+
version: 1.0.0
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
kind: CronJob
2+
apiVersion: batch/v1
3+
metadata:
4+
name: faf-backup
5+
namespace: faf-apps
6+
labels:
7+
app: faf-backup
8+
spec:
9+
# Disabled because triggered manually
10+
schedule: "0 0 31 2 *"
11+
suspend: true
12+
concurrencyPolicy: Forbid
13+
jobTemplate:
14+
metadata:
15+
labels:
16+
app: faf-backup
17+
annotations:
18+
prometheus.io/scrape: 'false'
19+
spec:
20+
template:
21+
spec:
22+
containers:
23+
- image: faforever/faf-db-migrations:v138
24+
imagePullPolicy: Always
25+
name: faf-backup
26+
env:
27+
- name: FLYWAY_URL
28+
value: "jdbc:mariadb://mariadb:3306/faf_lobby?ssl=false"
29+
envFrom:
30+
- secretRef:
31+
name: faf-db-migrations
32+
restartPolicy: Never
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
apiVersion: v2
2+
name: faf-legacy-deployment
3+
version: 1.0.0
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
#!/usr/bin/env python3
2+
3+
"""
4+
Mod updater script
5+
6+
This script packs up the coop mod files, writes them to /opt/faf/data/content/legacy-featured-mod-files/.../, and updates the database.
7+
8+
Code is mostly self-explanatory - haha, fat chance! Read it from bottom to top and don't blink. Blink and you're dead, no wait where were we?
9+
To adapt this duct-tape based blob of shit for new mission voice overs, just change files array at the very bottom.
10+
11+
Environment variables required:
12+
PATCH_VERSION
13+
DATABASE_HOST
14+
DATABASE_NAME
15+
DATABASE_USERNAME
16+
DATABASE_PASSWORD
17+
"""
18+
import glob
19+
import hashlib
20+
import json
21+
import os
22+
import re
23+
import shutil
24+
import subprocess
25+
import sys
26+
import tempfile
27+
import urllib.request
28+
import urllib.error
29+
import zipfile
30+
31+
import mysql.connector
32+
33+
34+
def get_db_connection():
35+
"""Establish and return a MySQL connection using environment variables."""
36+
host = os.getenv("DATABASE_HOST", "localhost")
37+
db = os.getenv("DATABASE_NAME", "faf")
38+
user = os.getenv("DATABASE_USERNAME", "root")
39+
password = os.getenv("DATABASE_PASSWORD", "banana")
40+
41+
return mysql.connector.connect(
42+
host=host,
43+
user=user,
44+
password=password,
45+
database=db,
46+
)
47+
48+
49+
def read_db(conn, mod):
50+
"""
51+
Read latest versions and md5's from db
52+
Returns dict {fileId: {version, name, md5}}
53+
"""
54+
query = f"""
55+
SELECT uf.fileId, uf.version, uf.name, uf.md5
56+
FROM (
57+
SELECT fileId, MAX(version) AS version
58+
FROM updates_{mod}_files
59+
GROUP BY fileId
60+
) AS maxthings
61+
INNER JOIN updates_{mod}_files AS uf
62+
ON maxthings.fileId = uf.fileId AND maxthings.version = uf.version;
63+
"""
64+
65+
with conn.cursor() as cursor:
66+
cursor.execute(query)
67+
68+
oldfiles = {}
69+
for (fileId, version, name, md5) in cursor.fetchall():
70+
oldfiles[int(fileId)] = {
71+
"version": version,
72+
"name": name,
73+
"md5": md5,
74+
}
75+
76+
return oldfiles
77+
78+
79+
def update_db(conn, mod, fileId, version, name, md5, dryrun):
80+
"""
81+
Delete and reinsert a file record in updates_{mod}_files
82+
"""
83+
delete_query = f"DELETE FROM updates_{mod}_files WHERE fileId=%s AND version=%s"
84+
insert_query = f"""
85+
INSERT INTO updates_{mod}_files (fileId, version, name, md5, obselete)
86+
VALUES (%s, %s, %s, %s, 0)
87+
"""
88+
89+
print(f"Updating DB for {name} (fileId={fileId}, version={version})")
90+
91+
if not dryrun:
92+
try:
93+
with conn.cursor() as cursor:
94+
cursor.execute(delete_query, (fileId, version))
95+
cursor.execute(insert_query, (fileId, version, name, md5))
96+
conn.commit()
97+
except mysql.connector.Error as err:
98+
print(f"MySQL error while updating {name}: {err}")
99+
conn.rollback()
100+
exit(1)
101+
else:
102+
print(f"Dryrun: would run for {name}")
103+
104+
def calc_md5(fname):
105+
hash_md5 = hashlib.md5()
106+
with open(fname, "rb") as f:
107+
for chunk in iter(lambda: f.read(4096), b""):
108+
hash_md5.update(chunk)
109+
return hash_md5.hexdigest()
110+
111+
def zipdir(path, ziph):
112+
# ziph is zipfile handle
113+
114+
if not os.path.exists(path):
115+
print(f"Warning: {path} does not exist, skipping")
116+
return
117+
118+
if os.path.isdir(path):
119+
for root, dirs, files in os.walk(path):
120+
for file in files:
121+
arcname = os.path.relpath(os.path.join(root, file), start=path)
122+
ziph.write(os.path.join(root, file), arcname=arcname)
123+
124+
else:
125+
ziph.write(path)
126+
127+
128+
def create_file(conn, mod, fileId, version, name, source, target_dir, old_md5, dryrun):
129+
"""Pack or copy files, compare MD5, update DB if changed."""
130+
target_dir = os.path.join(target_dir, f"updates_{mod}_files")
131+
os.makedirs(target_dir, exist_ok=True)
132+
133+
name = name.format(version)
134+
target_name = os.path.join(target_dir, name)
135+
136+
print(f"Processing {name} (fileId {fileId})")
137+
138+
if isinstance(source, list):
139+
print(f"Zipping {source} -> {target_name}")
140+
fd, fname = tempfile.mkstemp("_" + name, "patcher_")
141+
os.close(fd)
142+
with zipfile.ZipFile(fname, "w", zipfile.ZIP_DEFLATED) as zf:
143+
for sm in source:
144+
zipdir(sm, zf)
145+
rename = True
146+
checksum = calc_md5(fname)
147+
else:
148+
rename = False
149+
fname = source
150+
if source is None:
151+
checksum = calc_md5(target_name) if os.path.exists(target_name) else None
152+
else:
153+
checksum = calc_md5(fname)
154+
155+
if checksum is None:
156+
print(f"Skipping {name} (no source file and no existing file to checksum)")
157+
return
158+
159+
print(f"Compared checksums: Old {old_md5} New {checksum}")
160+
161+
if checksum != old_md5:
162+
if fname is not None:
163+
print(f"Copying {fname} -> {target_name}")
164+
if not dryrun:
165+
shutil.copy(fname, target_name)
166+
elif rename:
167+
print(f"Dry run, not moving tempfile. Please delete {fname}.")
168+
else:
169+
print("No source file, not moving")
170+
171+
if os.path.exists(target_name):
172+
update_db(conn, mod, fileId, version, name, checksum, dryrun)
173+
if not dryrun:
174+
try:
175+
os.chmod(target_name, 0o664)
176+
except PermissionError:
177+
print(f"Warning: Could not chmod {target_name}")
178+
else:
179+
print(f"Target file {target_name} does not exist, not updating db")
180+
181+
else:
182+
print("New {} file is identical to current version - skipping update".format(name))
183+
if rename:
184+
if not dryrun:
185+
os.unlink(fname)
186+
else:
187+
print('Dry run, not moving tempfile. Please delete {}.'.format(fname))
188+
189+
190+
def do_files(conn, mod, version, files, target_dir, dryrun):
191+
"""Process all files for given mod/version."""
192+
current_files = read_db(conn, mod)
193+
for name, fileId, source in files:
194+
old_md5 = current_files.get(fileId, {}).get("md5")
195+
create_file(conn, mod, fileId, version, name, source, target_dir, old_md5, dryrun)
196+
197+
198+
def prepare_repo():
199+
"""Clone or update the fa-coop repository and checkout the specified ref."""
200+
repo_url = os.getenv("GIT_REPO_URL", "https://github.com/FAForever/fa-coop.git")
201+
git_ref = os.getenv("GIT_REF", "v" + os.getenv("PATCH_VERSION"))
202+
workdir = os.getenv("GIT_WORKDIR", "/tmp/fa-coop")
203+
204+
if not git_ref:
205+
print("Error: GIT_REF or PATCH_VERSION must be specified.")
206+
sys.exit(1)
207+
208+
print(f"=== Preparing repository {repo_url} at ref {git_ref} in {workdir} ===")
209+
210+
# Clone if not exists
211+
if not os.path.isdir(os.path.join(workdir, ".git")):
212+
print(f"Cloning repository into {workdir} ...")
213+
subprocess.check_call(["git", "clone", repo_url, workdir])
214+
else:
215+
print(f"Repository already exists in {workdir}, fetching latest changes...")
216+
subprocess.check_call(["git", "-C", workdir, "fetch", "--all", "--tags"])
217+
218+
# Checkout the desired ref
219+
print(f"Checking out {git_ref} ...")
220+
subprocess.check_call(["git", "-C", workdir, "fetch", "--tags"])
221+
subprocess.check_call(["git", "-C", workdir, "checkout", git_ref])
222+
223+
print(f"=== Repository ready at {workdir} ===")
224+
return workdir
225+
226+
227+
def download_vo_assets(version, target_dir):
228+
"""
229+
Download VO .nx2 files from latest GitHub release of fa-coop,
230+
rename them for the given patch version, and copy to target directory.
231+
"""
232+
os.makedirs(target_dir, exist_ok=True)
233+
print(f"Fetching VO assets for patch version {version}...")
234+
235+
# 1. Get latest release JSON from GitHub
236+
api_url = "https://api.github.com/repos/FAForever/fa-coop/releases/latest"
237+
with urllib.request.urlopen(api_url) as response:
238+
release_info = json.load(response)
239+
240+
# 2. Filter assets ending with .nx2
241+
nx2_urls = [
242+
asset["browser_download_url"]
243+
for asset in release_info.get("assets", [])
244+
if asset["browser_download_url"].endswith(".nx2")
245+
]
246+
247+
if not nx2_urls:
248+
print("No VO .nx2 assets found in the latest release.")
249+
return
250+
251+
temp_dir = os.path.join("/tmp", f"vo_download_{version}")
252+
os.makedirs(temp_dir, exist_ok=True)
253+
254+
# 3. Download each .nx2 file
255+
for url in nx2_urls:
256+
filename = os.path.basename(url)
257+
dest_path = os.path.join(temp_dir, filename)
258+
print(f"Downloading {url} -> {dest_path}")
259+
urllib.request.urlretrieve(url, dest_path)
260+
261+
# 4. Rename files to include patch version (e.g., A01_VO.v49.nx2)
262+
for filepath in glob.glob(os.path.join(temp_dir, "*.nx2")):
263+
base = os.path.basename(filepath)
264+
# Insert .vXX. before the extension
265+
new_name = re.sub(r"\.nx2$", f".v{version}.nx2", base)
266+
new_path = os.path.join(temp_dir, new_name)
267+
os.rename(filepath, new_path)
268+
269+
# 5. Copy to target directory
270+
for filepath in glob.glob(os.path.join(temp_dir, "*.nx2")):
271+
target_path = os.path.join(target_dir, os.path.basename(filepath))
272+
print(f"Copying {filepath} -> {target_path}")
273+
shutil.copy(filepath, target_path)
274+
# Set permissions like in your script
275+
os.chmod(target_path, 0o664)
276+
try:
277+
shutil.chown(target_path, group="www-data")
278+
except Exception:
279+
print(f"Warning: could not chown {target_path}, continue...")
280+
281+
print("VO assets processed successfully.")
282+
283+
284+
def main():
285+
mod = "coop"
286+
dryrun = os.getenv("DRY_RUN", "false").lower() in ("1", "true", "yes")
287+
version = os.getenv("PATCH_VERSION")
288+
289+
if version is None:
290+
print('Please pass patch version in environment variable PATCH_VERSION')
291+
sys.exit(1)
292+
293+
print(f"=== Starting mod updater for version {version}, dryrun={dryrun} ===")
294+
295+
# /updater_{mod}_files will be appended by create_file
296+
target_dir = './legacy-featured-mod-files'
297+
298+
# Prepare git repo
299+
repo_dir = prepare_repo()
300+
301+
# Download VO assets
302+
vo_dir = os.path.join(target_dir, f"updates_{mod}_files")
303+
download_vo_assets(version, vo_dir)
304+
305+
# target filename / fileId in updates_{mod}_files table / source files with version placeholder
306+
# if source files is single string, file is copied directly
307+
# if source files is a list, files are zipped
308+
files = [
309+
('init_coop.v{}.lua', 1, os.path.join(repo_dir, 'init_coop.lua')),
310+
('lobby_coop_v{}.cop', 2, [
311+
os.path.join(repo_dir, 'lua'),
312+
os.path.join(repo_dir, 'mods'),
313+
os.path.join(repo_dir, 'units'),
314+
os.path.join(repo_dir, 'mod_info.lua'),
315+
os.path.join(repo_dir, 'readme.md'),
316+
os.path.join(repo_dir, 'changelog.md'),
317+
]),
318+
('A01_VO.v{}.nx2', 3, None),
319+
('A02_VO.v{}.nx2', 4, None),
320+
('A03_VO.v{}.nx2', 5, None),
321+
('A04_VO.v{}.nx2', 6, None),
322+
('A05_VO.v{}.nx2', 7, None),
323+
('A06_VO.v{}.nx2', 8, None),
324+
('C01_VO.v{}.nx2', 9, None),
325+
('C02_VO.v{}.nx2', 10, None),
326+
('C03_VO.v{}.nx2', 11, None),
327+
('C04_VO.v{}.nx2', 12, None),
328+
('C05_VO.v{}.nx2', 13, None),
329+
('C06_VO.v{}.nx2', 14, None),
330+
('E01_VO.v{}.nx2', 15, None),
331+
('E02_VO.v{}.nx2', 16, None),
332+
('E03_VO.v{}.nx2', 17, None),
333+
('E04_VO.v{}.nx2', 18, None),
334+
('E05_VO.v{}.nx2', 19, None),
335+
('E06_VO.v{}.nx2', 20, None),
336+
('Prothyon16_VO.v{}.nx2', 21, None),
337+
('TCR_VO.v{}.nx2', 22, None),
338+
('SCCA_Briefings.v{}.nx2', 23, None),
339+
('SCCA_FMV.nx2.v{}.nx2', 24, None),
340+
('FAF_Coop_Operation_Tight_Spot_VO.v{}.nx2', 25, None),
341+
]
342+
343+
conn = get_db_connection()
344+
try:
345+
do_files(conn, mod, version, files, target_dir, dryrun)
346+
finally:
347+
conn.close()
348+
349+
350+
if __name__ == "__main__":
351+
main()

0 commit comments

Comments
 (0)