diff --git a/deploy/classic/README.md b/deploy/classic/README.md index 538d6fe..8d95268 100644 --- a/deploy/classic/README.md +++ b/deploy/classic/README.md @@ -61,11 +61,10 @@ registration and enables activity even when no human player is present. Automatic population creation remains disabled; the native identity allowlist still limits managed login to the planned 50. -Remaining cutover gates include live quest/training/survival observations, -event telemetry and human-player conversation parity, dashboard/operator actions, -normal resurrection, actual party acceptance and speech, authentication/client -configuration, and a verified rollback route. Human-player chat delivery is -explicitly disabled in the staged Classic adapter until implemented and tested. +Before cutover, verify fresh 50-character observations, quest and spell outcomes, +normal resurrection, actual party receipts, native GM commands, authentication, +and rollback. Human player replies require an actual in-game session for an +end-to-end acceptance check. Lua/ALE reflection remains a Wrath-only capability. Migration safeguards: @@ -94,3 +93,8 @@ copies only ENDAMI's existing SRP credentials into the isolated authentication database, and disables upstream sample logins. `check-auth.py --root ROOT` verifies a complete SRP handshake and realm listing without entering the game. `health.py --root ROOT --url URL` reports host health without Wrath SQL. + +`cutover.py --root ROOT` requires a passing build and all 50 fresh staging bots, +backs up stopped Wrath accounts/characters, switches the existing endpoints, and +automatically restores Wrath endpoints if login or population verification fails. +Miniflare metadata databases are excluded from game-state backup selection. diff --git a/deploy/classic/backup.py b/deploy/classic/backup.py new file mode 100644 index 0000000..983aacd --- /dev/null +++ b/deploy/classic/backup.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Bounded daily backup of Classic persistent accounts, characters and dashboard.""" +import argparse,gzip,json,os,pathlib,shutil,sqlite3,subprocess,time +p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);a=p.parse_args();root=a.root +base=root/'backups';base.mkdir(mode=0o700,exist_ok=True) +target=base/time.strftime('%Y%m%dT%H%M%SZ',time.gmtime());target.mkdir(mode=0o700) +c=json.loads((root/'database-credentials.json').read_text()) +try: + with gzip.open(target/'classic-auth-characters.sql.gz','wb') as out: + p=subprocess.Popen(['docker','exec','-e','MYSQL_PWD','azerothai-classic-db','mysqldump','-uroot','--single-transaction','--set-gtid-purged=OFF','--databases','classicrealmd','classiccharacters'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=dict(os.environ,MYSQL_PWD=c['root'])) + shutil.copyfileobj(p.stdout,out) + if p.wait():raise RuntimeError('Classic backup failed') + dbs=[p for p in (root/'app-staging/.wrangler/state/v3/d1').rglob('*.sqlite') if p.name!='metadata.sqlite'];assert len(dbs)==1 + source=sqlite3.connect(dbs[0]);dest=sqlite3.connect(target/'dashboard.sqlite');source.backup(dest);dest.close();source.close() + shutil.copytree(root/'run/etc',target/'config') + (target/'complete.json').write_text(json.dumps({'at':time.time(),'backend':'cmangos-classic'})) + for old in sorted(base.iterdir(),reverse=True)[3:]: + if old.is_dir() and (old/'complete.json').exists():shutil.rmtree(old) + print('Classic backup complete: '+str(target)) +except Exception: + (target/'failed').touch();raise diff --git a/deploy/classic/cutover.py b/deploy/classic/cutover.py index 3212e1b..674fc26 100644 --- a/deploy/classic/cutover.py +++ b/deploy/classic/cutover.py @@ -8,7 +8,7 @@ import argparse,gzip,json,os,pathlib,shutil,sqlite3,subprocess,time,urllib.reque 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) + archive=root/('cutover-backup-'+str(int(time.time())));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:]) @@ -46,7 +46,9 @@ def main(root): 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']) + for name in ['azerothai-classic-world','azerothai-classic-auth']: + run(['docker','rm','-f',name+'-staging'],False) + 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']) @@ -57,7 +59,7 @@ def main(root): 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')) + databases=[p for p in (app/'.wrangler/state/v3/d1').rglob('*.sqlite') if p.name!='metadata.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'")]