"""Deploy the Climperor web site static bundle to Cloudflare Pages. Bundles export + integrity check + wrangler direct upload + custom domain binding into one local command. Designed to run with credentials injected by keyzoo asset_exec, but also works with manually exported env vars. Usage: python deploy_relations.py [--no-export] [--project-name NAME] \\ [--domain DOMAIN] [--dist PATH] Env vars (credentials, never printed): CLOUDFLARE_EMAIL / CLOUDFLARE_API_KEY -- preferred KEYZOO_ASSET_META_USERNAME -- fallback email (keyzoo inject) KEYZOO_ASSET_SECRET_GLOBAL_API_KEY -- fallback key (keyzoo inject) Flow: 1. resolve credentials + account_id (GET /accounts) 2. ensure Pages project exists (create if missing, production_branch=main) 3. run export_relations_site.py unless --no-export 4. asset-integrity check: abort if key dirs are empty 5. wrangler pages deploy dist/relations (CI=true, non-interactive) 6. bind custom domain if not already 7. print deployment + custom domain URLs Security: the API key is only ever placed into HTTP headers and the wrangler subprocess env. It is never printed, logged, or written to disk. """ from __future__ import annotations import argparse import json import os import shutil import subprocess import sys import time import urllib.error import urllib.parse import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent DEFAULT_PROJECT = "climperor-relations" DEFAULT_DOMAIN = "dota2.refining.dev" DEFAULT_DIST = ROOT / "dist" / "relations" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" # Directories export_relations_site.py writes under dist/relations. Keys are # the sub-directory names; values flag whether an empty dir is a hard failure # (True) or just a warning (False). Portrait/ui-icon must be non-empty or the # site is visibly broken; item/ability degrade with onerror but should exist. INTEGRITY_DIRS: dict[str, bool] = { "portrait": True, "ui-icon": True, "item": True, "ability": False, "attr": True, "item-cat": True, } def resolve_credentials() -> tuple[str, str]: """Return (email, api_key) from env, preferring CLOUDFLARE_* vars.""" email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get( "KEYZOO_ASSET_META_USERNAME" ) api_key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get( "KEYZOO_ASSET_SECRET_GLOBAL_API_KEY" ) if not email or not api_key: raise SystemExit( "missing credentials: set CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY " "(or run via keyzoo asset_exec on the refining/cloudflare asset)" ) return email, api_key def cf_api( method: str, path: str, *, email: str, api_key: str, body: dict | None = None, ) -> dict: """Call the Cloudflare REST API with Global API Key auth. Uses X-Auth-Email + X-Auth-Key headers. Returns the parsed JSON envelope. Never raises on HTTP error; returns the error envelope so callers can inspect `success` / `errors` without a traceback. """ url = "https://api.cloudflare.com/client/v4" + path headers = { "X-Auth-Email": email, "X-Auth-Key": api_key, "Content-Type": "application/json", } data = json.dumps(body).encode("utf-8") if body is not None else None req = urllib.request.Request(url, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: # HTTPError still carries the CF JSON envelope; surface it verbatim # (it contains no secrets, only error codes/messages). try: return json.loads(exc.read().decode("utf-8")) except Exception: return {"success": False, "errors": [{"code": exc.code, "message": str(exc)}]} except urllib.error.URLError as exc: return {"success": False, "errors": [{"code": -1, "message": str(exc)}]} def get_account_id(email: str, api_key: str) -> str: """Resolve the Cloudflare account id via GET /accounts.""" d = cf_api("GET", "/accounts", email=email, api_key=api_key) if not d.get("success"): raise SystemExit(f"GET /accounts failed: {d.get('errors')}") accounts = d.get("result") or [] if not accounts: raise SystemExit("no Cloudflare accounts found for these credentials") if len(accounts) > 1: names = [a.get("name", a.get("id")) for a in accounts] print(f"multiple accounts found, using the first: {names}", file=sys.stderr) return str(accounts[0]["id"]) def ensure_project(email: str, api_key: str, account_id: str, project: str) -> None: """Create the Pages project if it does not already exist.""" d = cf_api( "GET", f"/accounts/{account_id}/pages/projects/{project}", email=email, api_key=api_key, ) if d.get("success"): print(f"project exists: {project}") return # 8000xxx = project not found; anything else is a real error. print(f"project not found, creating: {project}") d = cf_api( "POST", f"/accounts/{account_id}/pages/projects", email=email, api_key=api_key, body={"name": project, "production_branch": "main"}, ) if not d.get("success"): raise SystemExit(f"create project failed: {d.get('errors')}") def run_export( ability_video_base: str | None = None, static_asset_base: str | None = None, ) -> None: """Re-run export_relations_site.py to refresh dist/relations.""" print("exporting static site ...") cmd = [sys.executable, str(ROOT / "export_relations_site.py")] oss = ability_video_base if oss is None: oss = os.environ.get("ABILITY_VIDEO_BASE", DEFAULT_OSS_BASE) static = static_asset_base if static is None: static = os.environ.get("STATIC_ASSET_BASE", oss or DEFAULT_OSS_BASE) if oss: cmd.extend(["--ability-video-base", oss]) print(f"ABILITY_VIDEO_BASE={oss}") if static: cmd.extend(["--static-asset-base", static]) print(f"STATIC_ASSET_BASE={static}") subprocess.run(cmd, cwd=str(ROOT), check=True) def read_config_js_var(dist: Path, name: str) -> str: """Parse a string literal from generated config.js.""" cfg = dist / "config.js" if not cfg.is_file(): return "" prefix = f"var {name} = " for line in cfg.read_text(encoding="utf-8").splitlines(): if line.startswith(prefix): return json.loads(line[len(prefix) :].rstrip(";")) return "" def check_integrity(dist: Path) -> None: """Abort if a hard-required asset directory is empty; warn otherwise.""" static_base = read_config_js_var(dist, "STATIC_ASSET_BASE") if static_base: print( f"asset integrity check: skipped local dirs " f"(STATIC_ASSET_BASE={static_base})" ) return problems: list[str] = [] for sub, required in INTEGRITY_DIRS.items(): d = dist / sub n = sum(1 for _ in d.glob("*") if _.is_file()) if d.is_dir() else 0 if n == 0: tag = "MISSING (abort)" if required else "empty (warn)" problems.append(f" {sub}/: {tag}") hint = { "portrait": "run: python fetch_hero_portraits.py", "ability": "run: python fetch_hero_abilities.py --icons-only", "item": "run: python fetch_hero_items.py", "attr": "assets/attr_icons is committed; check git checkout", "item-cat": "assets/item_cat_icons is committed; check git checkout", "ui-icon": "assets/ui_icons is committed; check git checkout", }.get(sub, "see AGENTS.md fetch commands") problems.append(f" -> {hint}") if not problems: return hard = [s for s, r in INTEGRITY_DIRS.items() if r and not any((dist / s).glob("*"))] print("asset integrity check:") print("\n".join(problems)) if hard: raise SystemExit( f"aborting deploy: required asset dirs empty: {hard}. " "Run the fetch scripts first, or set STATIC_ASSET_BASE for OSS hosting." ) def resolve_npx() -> str: """Locate the npx executable (Windows ships npx.cmd).""" for name in ("npx", "npx.cmd"): path = shutil.which(name) if path: return path raise SystemExit("npx not found on PATH; install Node.js first") def wrangler_deploy( dist: Path, project: str, *, email: str, api_key: str, account_id: str, ) -> None: """Upload dist via `wrangler pages deploy` (direct upload, non-interactive). Wrangler looks for ``functions/`` relative to the process cwd (not inside an absolute asset-directory argument). Run from ``dist`` and deploy ``.`` so ``dist/functions/`` (e.g. /api/live-status) is compiled and uploaded. """ functions_dir = dist / "functions" if not functions_dir.is_dir(): print( f"warn: {functions_dir} missing — Pages Functions will not deploy " "(re-run export_relations_site.py)" ) env = os.environ.copy() # wrangler reads Global API Key auth from these env vars. They are passed # to the subprocess only; wrangler does not echo them. env["CLOUDFLARE_EMAIL"] = email env["CLOUDFLARE_API_KEY"] = api_key env["CLOUDFLARE_ACCOUNT_ID"] = account_id env["CI"] = "true" cmd = [ resolve_npx(), "--yes", "wrangler@3", "pages", "deploy", ".", "--project-name", project, "--commit-dirty", "--branch", "main", ] print(f"uploading {dist} via wrangler (cwd=dist, functions={functions_dir.is_dir()}) ...") subprocess.run(cmd, cwd=str(dist), env=env, check=True) def smoke_test_production( base_url: str, *, expected_run_id: str = "", attempts: int = 6, delay_s: float = 5.0, ) -> None: """Verify the deployed payload and Pages Function after propagation.""" base = base_url.rstrip("/") marker = expected_run_id or str(int(time.time())) error = "unknown failure" for attempt in range(1, attempts + 1): try: data_url = f"{base}/data.json?refresh={urllib.parse.quote(marker)}" req = urllib.request.Request(data_url, headers={"User-Agent": "climperor-smoke"}) with urllib.request.urlopen(req, timeout=30) as resp: payload = json.loads(resp.read().decode("utf-8")) if not isinstance(payload, dict) or not payload.get("heroes"): raise RuntimeError("data.json has no heroes") actual_run_id = str((payload.get("meta") or {}).get("refresh_run_id") or "") if expected_run_id and actual_run_id != expected_run_id: raise RuntimeError( f"stale deployment run_id={actual_run_id!r}, expected={expected_run_id!r}" ) live_url = f"{base}/api/live-status?refresh={urllib.parse.quote(marker)}" live_req = urllib.request.Request( live_url, headers={"User-Agent": "climperor-smoke"} ) with urllib.request.urlopen(live_req, timeout=30) as resp: content_type = resp.headers.get_content_type() live_cache = (resp.headers.get("X-Live-Cache") or "").lower() live_payload = json.loads(resp.read().decode("utf-8")) if content_type != "application/json" or not isinstance(live_payload, dict): raise RuntimeError( f"/api/live-status is not JSON (content-type={content_type})" ) if live_cache in ("error", "stale-override"): print( f"warn: live-status Function is routed but probe state is {live_cache}" ) print( f"production smoke passed: {base} " f"(heroes={len(payload['heroes'])}, run_id={actual_run_id or '-'}, " f"live_cache={live_cache or '?'})" ) return except ( OSError, ValueError, RuntimeError, json.JSONDecodeError, urllib.error.URLError, ) as exc: error = str(exc) print(f"smoke attempt {attempt}/{attempts} failed: {error}") if attempt < attempts: time.sleep(delay_s) raise SystemExit(f"deployment smoke failed after {attempts} attempts: {error}") def ensure_cname(email: str, api_key: str, domain: str, project: str) -> None: """Ensure the zone has a proxied CNAME: -> .pages.dev. Pages binds the custom domain but does not always auto-create the zone CNAME; without it SSL validation stalls at 'CNAME record not set'. Idempotent: skips if a CNAME already exists for the domain. """ zone = domain.split(".", 1)[1] if "." in domain else domain target = f"{project}.pages.dev" zd = cf_api("GET", f"/zones?name={zone}", email=email, api_key=api_key) zr = zd.get("result") or [] if not zr: print(f"warn: zone {zone} not found on this account; add CNAME manually") return zid = zr[0]["id"] recs = cf_api( "GET", f"/zones/{zid}/dns_records?name={domain}", email=email, api_key=api_key ) existing = [r for r in (recs.get("result") or []) if r.get("type") == "CNAME"] if existing: r = existing[0] print(f"CNAME exists: {r.get('name')} -> {r.get('content')} (proxied={r.get('proxied')})") return print(f"adding CNAME: {domain} -> {target} (proxied=true)") d = cf_api( "POST", f"/zones/{zid}/dns_records", email=email, api_key=api_key, body={"type": "CNAME", "name": domain, "content": target, "proxied": True}, ) if d.get("success"): print("CNAME added; SSL will activate within a few minutes") else: print(f"warn: add CNAME failed: {d.get('errors')} (add manually in dashboard)") def bind_domain( email: str, api_key: str, account_id: str, project: str, domain: str, ) -> None: """Attach the custom domain to the Pages project and ensure its CNAME.""" d = cf_api( "GET", f"/accounts/{account_id}/pages/projects/{project}/domains", email=email, api_key=api_key, ) if not d.get("success"): raise SystemExit(f"list domains failed: {d.get('errors')}") existing = [x.get("name") for x in (d.get("result") or [])] if domain in existing: print(f"custom domain already bound: {domain}") else: print(f"binding custom domain: {domain}") d = cf_api( "POST", f"/accounts/{account_id}/pages/projects/{project}/domains", email=email, api_key=api_key, body={"name": domain}, ) if not d.get("success"): raise SystemExit( f"bind domain failed: {d.get('errors')}. " f"Ensure {domain} zone is hosted on this Cloudflare account." ) ensure_cname(email, api_key, domain, project) def check_player_bindings(email: str, api_key: str, account_id: str, project: str) -> None: """Warn (do not abort) if D1/Queue bindings for player pages are missing.""" data = cf_api( "GET", f"/accounts/{account_id}/pages/projects/{project}", email=email, api_key=api_key, ) if not data.get("success"): print("warning: could not verify Pages player bindings") return prod = ((data.get("result") or {}).get("deployment_configs") or {}).get( "production" ) or {} d1 = prod.get("d1_databases") or {} queues = prod.get("queue_producers") or {} r2 = prod.get("r2_buckets") or {} missing = [] if "DB" not in d1: missing.append("D1:DB") if "SYNC_QUEUE" not in queues: missing.append("Queue:SYNC_QUEUE") if missing: print( "warning: Pages missing player bindings " f"{', '.join(missing)} — run python web/cloudflare/provision.py" ) else: print("pages player bindings: DB + SYNC_QUEUE ok") if "MATCHES" not in r2: print( "note: R2 MATCHES not bound yet " "(enable R2 in Dashboard, then re-run provision.py)" ) def main() -> None: ap = argparse.ArgumentParser(description="Deploy Climperor web site to Cloudflare Pages") ap.add_argument("--no-export", action="store_true", help="skip re-export, deploy existing dist") ap.add_argument("--project-name", default=DEFAULT_PROJECT) ap.add_argument("--domain", default=DEFAULT_DOMAIN) ap.add_argument("--dist", default=str(DEFAULT_DIST)) ap.add_argument( "--ability-video-base", default=None, help="passed to export as --ability-video-base (default: OSS climperor endpoint " "or ABILITY_VIDEO_BASE env)", ) ap.add_argument( "--static-asset-base", default=None, help="passed to export as --static-asset-base (default: same as ability-video-base " "or STATIC_ASSET_BASE env)", ) ap.add_argument( "--skip-smoke", action="store_true", help="skip post-deploy data.json and /api/live-status verification", ) args = ap.parse_args() dist = Path(args.dist).resolve() email, api_key = resolve_credentials() account_id = get_account_id(email, api_key) print(f"account_id: {account_id}") ensure_project(email, api_key, account_id, args.project_name) check_player_bindings(email, api_key, account_id, args.project_name) if not args.no_export: run_export(args.ability_video_base, args.static_asset_base) else: print("--no-export: using existing dist") if not dist.is_dir(): raise SystemExit(f"dist not found: {dist}. Run without --no-export first.") check_integrity(dist) wrangler_deploy(dist, args.project_name, email=email, api_key=api_key, account_id=account_id) bind_domain(email, api_key, account_id, args.project_name, args.domain) if not args.skip_smoke: smoke_test_production( f"https://{args.project_name}.pages.dev", expected_run_id=os.environ.get("REFRESH_RUN_ID") or "", ) print() print(f"deployment url : https://{args.project_name}.pages.dev") print(f"custom domain : https://{args.domain}") print("note: a freshly bound custom domain takes ~1-2 min to issue SSL.") if __name__ == "__main__": main()