Files
AzerothAI/deploy/classic/cutover.py
T
2026-09-25 18:19:50 -07:00

99 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""Switch the validated Classic staging realm into the existing local endpoints.
Preserves Wrath containers, user services, database and a final database dump.
On an error restores the former endpoints without overwriting either database.
"""
import argparse,gzip,json,os,pathlib,shutil,sqlite3,subprocess,time,urllib.request
def main(root):
home=pathlib.Path.home();units=home/'.config/systemd/user';app=root/'app-staging'
archive=root/'cutover-backup';archive.mkdir(mode=0o700,exist_ok=False)
def run(args,check=True):
p=subprocess.run(args,capture_output=True,text=True)
if check and p.returncode:raise RuntimeError('Command failed: '+' '.join(args[:4])+' '+p.stderr[-500:])
return p.stdout.strip()
def status(stage,**extra):
value={'at':time.time(),'stage':stage,**extra};(root/'cutover-status.json').write_text(json.dumps(value,indent=2));print(stage,flush=True)
def state():return json.load(urllib.request.urlopen('http://127.0.0.1:8791/api/state',timeout=10))
s=state();bots=s['bots']
assert (root/'final-build-passed').exists()
assert len(bots)==50 and all(b['status']=='online' and time.time()-b['world']['at']<20 for b in bots)
assert sum(b['world']['team']==0 for b in bots)==25 and sum(b['world']['team']==1 for b in bots)==25
assert not s['settings']['hardcore']['enabled']
(archive/'classic-state.json').write_text(json.dumps(s))
old=['azerothai-runtime','azerothai-autonomy','azerothai-ale-relay','azerothai-operations.timer','azerothai-backup.timer']
classic=['azerothai-classic-runtime','azerothai-classic-relay','azerothai-classic-autonomy']
for file in units.glob('azerothai*'):shutil.copy2(file,archive/file.name)
shutil.copy2(app/'.dev.vars',archive/'classic.dev.vars');shutil.copy2(root/'staging-worker.env',archive/'staging-worker.env')
changed=False
try:
status('Stopping services for the validated Classic cutover')
changed=True
run(['systemctl','--user','disable','--now',*old],False)
run(['systemctl','--user','stop','azerothai-operations.service','azerothai-backup.service','azerothai-spectator',*classic],False)
run(['docker','stop','-t','30','ac-worldserver','ac-authserver','azerothai-dashboard-proxy'])
run(['docker','rename','azerothai-dashboard-proxy','azerothai-dashboard-proxy-wrath-rollback'])
status('Saving final stopped Wrath character and account databases')
cfg=json.loads(run(['docker','inspect','ac-database']))[0]
values=dict(v.split('=',1) for v in cfg['Config']['Env'] if '=' in v)
password=values.get('MYSQL_ROOT_PASSWORD') or values.get('MARIADB_ROOT_PASSWORD')
env=dict(os.environ,MYSQL_PWD=password)
with gzip.open(archive/'wrath-auth-characters.sql.gz','wb') as out:
proc=subprocess.Popen(['docker','exec','-e','MYSQL_PWD','ac-database','mysqldump','-uroot','--single-transaction','--set-gtid-purged=OFF','--databases','acore_auth','acore_characters'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=env)
shutil.copyfileobj(proc.stdout,out)
if proc.wait():raise RuntimeError('Final Wrath database backup failed')
assert (archive/'wrath-auth-characters.sql.gz').stat().st_size>10000
status('Publishing Classic game and authentication ports')
run(['docker','stop','-t','30','azerothai-classic-world','azerothai-classic-auth'])
for name in ['azerothai-classic-world','azerothai-classic-auth']:run(['docker','rename',name,name+'-staging'])
common=['--network','azerothai-classic-access','--restart','unless-stopped','--user','1000:1000','-v',str(root)+':/work','-w','/work/run/bin']
run(['docker','create','--name','azerothai-classic-world',*common,'--cpus','2','--memory','3g','-p','8086:8086','azerothai-classic-builder:local','bash','-c','exec 3<>/work/run/console; exec ./mangosd -c /work/run/etc/mangosd.conf <&3'])
run(['docker','create','--name','azerothai-classic-auth',*common,'--cpus','0.5','--memory','128m','-p','3724:3724','azerothai-classic-builder:local','./realmd','-c','/work/run/etc/realmd.conf'])
for name in ['azerothai-classic-world','azerothai-classic-auth']:
run(['docker','network','connect','azerothai-classic',name]);run(['docker','start',name])
run(['docker','update','--restart','unless-stopped','azerothai-classic-db'])
ip=run(['docker','inspect','--format','{{with index .NetworkSettings.Networks "azerothai-classic-access"}}{{.IPAddress}}{{end}}','azerothai-classic-world'])
p=app/'.dev.vars';text=p.read_text();import re
text=re.sub(r'^SOAP_URL=.*$','SOAP_URL=http://'+ip+':7879',text,flags=re.M);p.write_text(text)
# Discard copied Wrath metrics; native Classic counters remain intact.
databases=list((app/'.wrangler/state/v3/d1').rglob('*.sqlite'))
assert len(databases)==1
db=sqlite3.connect(databases[0]);backup=sqlite3.connect(archive/'classic-before-cutover.sqlite');db.backup(backup);backup.close()
names=[x[0] for x in db.execute("SELECT name FROM sqlite_master WHERE type='table'")]
for name in names:
if name.startswith('insights_') or name=='operations_samples':db.execute('DELETE FROM "'+name+'"')
db.commit();db.close()
for name in classic:
p=units/(name+'.service');text=p.read_text().replace('8791','8787').replace('RUNTIME_HOST=127.0.0.1','RUNTIME_HOST=0.0.0.0');p.write_text(text)
p=root/'staging-worker.env';p.write_text(p.read_text().replace('8791','8787'))
p=units/'azerothai-spectator.service';p.write_text(p.read_text().replace('/home/endami/AzerothAI',str(app)).replace('After=network-online.target azerothai-runtime.service','After=network-online.target azerothai-classic-runtime.service'))
run(['systemctl','--user','daemon-reload']);run(['systemctl','--user','enable','--now',*classic,'azerothai-spectator'])
run(['docker','run','-d','--name','azerothai-dashboard-proxy','--restart','unless-stopped','--network','azerothai-mini','--add-host','host.docker.internal:host-gateway','--memory','128m','-p','8788:80','-v',str(home/'.config/azerothai/nginx.conf')+':/etc/nginx/conf.d/default.conf:ro','-v',str(app/'dist/client')+':/srv:ro','nginx:alpine'])
status('Classic endpoints active; waiting for all 50 fresh observations')
for _ in range(36):
try:
current=json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/state',timeout=5));bs=current['bots']
if len(bs)==50 and all(b['status']=='online' and time.time()-b['world']['at']<20 for b in bs):break
except Exception:pass
time.sleep(5)
else:raise RuntimeError('Classic did not restore all 50 fresh observations')
assert sum(b['world']['team']==0 for b in bs)==25 and sum(b['world']['team']==1 for b in bs)==25
run(['python3',str(root/'adapter/check-auth.py'),'--root',str(root),'--port','3724'])
status('Classic cutover complete; 50 fresh bots, 25 Alliance and 25 Horde; login verified',cutover=True,backup=str(archive))
except Exception as error:
if changed:
run(['systemctl','--user','stop',*classic,'azerothai-spectator'],False)
run(['docker','stop','azerothai-classic-world','azerothai-classic-auth'],False)
if run(['docker','ps','-a','--filter','name=^azerothai-dashboard-proxy-wrath-rollback$','--format','{{.Names}}'],False):
run(['docker','rm','-f','azerothai-dashboard-proxy'],False);run(['docker','rename','azerothai-dashboard-proxy-wrath-rollback','azerothai-dashboard-proxy'],False)
for file in archive.glob('azerothai*'):shutil.copy2(file,units/file.name)
run(['systemctl','--user','daemon-reload'],False)
run(['docker','start','ac-worldserver','ac-authserver','azerothai-dashboard-proxy'],False)
run(['systemctl','--user','enable','--now',*old,'azerothai-spectator'],False)
status('Classic cutover blocked; former Wrath endpoints restored',error=str(error),cutover=False)
raise
if __name__=='__main__':
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);main(p.parse_args().root.resolve())