Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh. Co-authored-by: Cursor <cursoragent@cursor.com>
279 lines
8.4 KiB
Python
279 lines
8.4 KiB
Python
"""Create Cloudflare D1 / R2 / Queues for Climperor player data and bind Pages.
|
|
|
|
Credentials: CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY (or keyzoo refining/cloudflare).
|
|
Writes web/cloudflare/.resources.json with ids (no secrets).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
OUT = Path(__file__).resolve().parent / ".resources.json"
|
|
MIGRATION = Path(__file__).resolve().parent / "migrations" / "0001_init.sql"
|
|
PAGES_PROJECT = "climperor-relations"
|
|
D1_NAME = "climperor-users"
|
|
R2_NAME = "climperor-player-data"
|
|
QUEUE_NAME = "climperor-player-sync"
|
|
DLQ_NAME = "climperor-player-sync-dlq"
|
|
WORKER_NAME = "climperor-player-sync"
|
|
API = "https://api.cloudflare.com/client/v4"
|
|
|
|
|
|
def creds() -> tuple[str, str]:
|
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
|
"KEYZOO_ASSET_META_USERNAME"
|
|
)
|
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
|
)
|
|
if not email or not key:
|
|
raise SystemExit("missing CLOUDFLARE_EMAIL / CLOUDFLARE_API_KEY")
|
|
return email, key
|
|
|
|
|
|
def api(method: str, path: str, body: dict | None = None) -> dict:
|
|
email, key = creds()
|
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
API + path,
|
|
data=data,
|
|
method=method,
|
|
headers={
|
|
"X-Auth-Email": email,
|
|
"X-Auth-Key": key,
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "climperor-provision",
|
|
},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
payload = json.loads(resp.read().decode())
|
|
except urllib.error.HTTPError as e:
|
|
raw = e.read().decode("utf-8", errors="replace")
|
|
raise SystemExit(f"CF API {method} {path} -> {e.code}: {raw[:400]}") from e
|
|
if not payload.get("success"):
|
|
raise SystemExit(f"CF API failed: {payload.get('errors')}")
|
|
return payload
|
|
|
|
|
|
def account_id() -> str:
|
|
rows = api("GET", "/accounts")["result"]
|
|
if not rows:
|
|
raise SystemExit("no Cloudflare accounts")
|
|
return rows[0]["id"]
|
|
|
|
|
|
def ensure_d1(acct: str) -> str:
|
|
listed = api("GET", f"/accounts/{acct}/d1/database")["result"] or []
|
|
for row in listed:
|
|
if row.get("name") == D1_NAME:
|
|
print(f"d1 exists: {row['uuid']}")
|
|
return row["uuid"]
|
|
created = api(
|
|
"POST",
|
|
f"/accounts/{acct}/d1/database",
|
|
{"name": D1_NAME},
|
|
)["result"]
|
|
print(f"d1 created: {created['uuid']}")
|
|
return created["uuid"]
|
|
|
|
|
|
def run_migration(acct: str, db_id: str) -> None:
|
|
sql = MIGRATION.read_text(encoding="utf-8")
|
|
api(
|
|
"POST",
|
|
f"/accounts/{acct}/d1/database/{db_id}/query",
|
|
{"sql": sql},
|
|
)
|
|
print("d1 migration applied")
|
|
|
|
|
|
def ensure_r2(acct: str) -> bool:
|
|
"""Return True if bucket exists/created. False if R2 not enabled on account."""
|
|
try:
|
|
api("GET", f"/accounts/{acct}/r2/buckets/{R2_NAME}")
|
|
print(f"r2 exists: {R2_NAME}")
|
|
return True
|
|
except SystemExit:
|
|
pass
|
|
try:
|
|
api("POST", f"/accounts/{acct}/r2/buckets", {"name": R2_NAME})
|
|
print(f"r2 created: {R2_NAME}")
|
|
return True
|
|
except SystemExit as e:
|
|
msg = str(e)
|
|
if "already exists" in msg.lower() or "10004" in msg:
|
|
print(f"r2 exists: {R2_NAME}")
|
|
return True
|
|
if "10042" in msg or "enable R2" in msg:
|
|
print(
|
|
"r2 skipped: enable R2 in Cloudflare Dashboard "
|
|
"(https://dash.cloudflare.com/?to=/:account/r2), then re-run"
|
|
)
|
|
return False
|
|
raise
|
|
|
|
|
|
def ensure_queue(acct: str, name: str) -> str:
|
|
listed = api("GET", f"/accounts/{acct}/queues")["result"] or []
|
|
# API shape may be {result: [...]} or {result: {queues: [...]}}
|
|
rows = listed if isinstance(listed, list) else (listed.get("queues") or [])
|
|
for row in rows:
|
|
if row.get("queue_name") == name or row.get("name") == name:
|
|
qid = row.get("queue_id") or row.get("id")
|
|
print(f"queue exists: {name} ({qid})")
|
|
return qid
|
|
created = api("POST", f"/accounts/{acct}/queues", {"queue_name": name})["result"]
|
|
qid = created.get("queue_id") or created.get("id")
|
|
print(f"queue created: {name} ({qid})")
|
|
return qid
|
|
|
|
|
|
def patch_wrangler(d1_id: str) -> None:
|
|
path = Path(__file__).resolve().parent / "player-sync" / "wrangler.toml"
|
|
text = path.read_text(encoding="utf-8")
|
|
text = text.replace("REPLACE_D1_ID", d1_id)
|
|
path.write_text(text, encoding="utf-8")
|
|
print(f"updated {path}")
|
|
|
|
|
|
def write_resources(
|
|
acct: str,
|
|
d1_id: str,
|
|
queue_id: str | None,
|
|
dlq_id: str | None,
|
|
*,
|
|
r2_ok: bool,
|
|
) -> None:
|
|
payload = {
|
|
"account_id": acct,
|
|
"d1": {"name": D1_NAME, "id": d1_id},
|
|
"r2": {"name": R2_NAME, "ready": r2_ok},
|
|
"queue": {"name": QUEUE_NAME, "id": queue_id},
|
|
"dlq": {"name": DLQ_NAME, "id": dlq_id},
|
|
"worker": WORKER_NAME,
|
|
"pages_project": PAGES_PROJECT,
|
|
}
|
|
OUT.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
print(f"wrote {OUT}")
|
|
|
|
|
|
def ensure_queue_soft(acct: str, name: str) -> str | None:
|
|
try:
|
|
return ensure_queue(acct, name)
|
|
except SystemExit as e:
|
|
print(f"queue skipped ({name}): {e}")
|
|
return None
|
|
|
|
|
|
def _env_bindings(
|
|
base: dict,
|
|
*,
|
|
d1_id: str,
|
|
queue_id: str | None,
|
|
r2_ok: bool,
|
|
fail_open: bool | None,
|
|
) -> dict:
|
|
"""Build one environment's deployment_config with required bindings."""
|
|
out = {
|
|
k: v
|
|
for k, v in base.items()
|
|
if k
|
|
not in (
|
|
"d1_databases",
|
|
"queue_producers",
|
|
"r2_buckets",
|
|
"fail_open",
|
|
)
|
|
}
|
|
d1_bindings = dict(base.get("d1_databases") or {})
|
|
d1_bindings["DB"] = {"id": d1_id}
|
|
out["d1_databases"] = d1_bindings
|
|
if queue_id:
|
|
producers = dict(base.get("queue_producers") or {})
|
|
producers["SYNC_QUEUE"] = {"name": QUEUE_NAME}
|
|
out["queue_producers"] = producers
|
|
if r2_ok:
|
|
buckets = dict(base.get("r2_buckets") or {})
|
|
buckets["MATCHES"] = {"name": R2_NAME}
|
|
out["r2_buckets"] = buckets
|
|
# Cloudflare requires fail_open equal on production and preview.
|
|
if fail_open is not None:
|
|
out["fail_open"] = bool(fail_open)
|
|
return out
|
|
|
|
|
|
def bind_pages(
|
|
acct: str,
|
|
d1_id: str,
|
|
queue_id: str | None,
|
|
*,
|
|
r2_ok: bool = False,
|
|
) -> None:
|
|
"""Attach D1 (+ Queue / R2) to Pages production and preview bindings."""
|
|
path = f"/accounts/{acct}/pages/projects/{PAGES_PROJECT}"
|
|
try:
|
|
project = api("GET", path)["result"]
|
|
except SystemExit as e:
|
|
print(f"pages bind skipped (project missing?): {e}")
|
|
return
|
|
dc = project.get("deployment_configs") or {}
|
|
prod = dict(dc.get("production") or {})
|
|
preview = dict(dc.get("preview") or {})
|
|
fail_open = prod.get("fail_open")
|
|
if fail_open is None:
|
|
fail_open = preview.get("fail_open")
|
|
if fail_open is None:
|
|
fail_open = False
|
|
body = {
|
|
"deployment_configs": {
|
|
"production": _env_bindings(
|
|
prod,
|
|
d1_id=d1_id,
|
|
queue_id=queue_id,
|
|
r2_ok=r2_ok,
|
|
fail_open=fail_open,
|
|
),
|
|
"preview": _env_bindings(
|
|
preview,
|
|
d1_id=d1_id,
|
|
queue_id=queue_id,
|
|
r2_ok=r2_ok,
|
|
fail_open=fail_open,
|
|
),
|
|
}
|
|
}
|
|
try:
|
|
api("PATCH", path, body)
|
|
print(f"pages bindings updated on {PAGES_PROJECT} (prod+preview)")
|
|
except SystemExit as e:
|
|
print(f"pages bind soft-fail (set manually): {e}")
|
|
|
|
|
|
def main() -> int:
|
|
acct = account_id()
|
|
print(f"account_id: {acct}")
|
|
d1_id = ensure_d1(acct)
|
|
run_migration(acct, d1_id)
|
|
r2_ok = ensure_r2(acct)
|
|
queue_id = ensure_queue_soft(acct, QUEUE_NAME)
|
|
dlq_id = ensure_queue_soft(acct, DLQ_NAME)
|
|
patch_wrangler(d1_id)
|
|
bind_pages(acct, d1_id, queue_id, r2_ok=r2_ok)
|
|
write_resources(acct, d1_id, queue_id, dlq_id, r2_ok=r2_ok)
|
|
print(
|
|
"next: deploy worker with wrangler + bind D1/R2/Queue to Pages project "
|
|
f"{PAGES_PROJECT} (see web/cloudflare/README.md)"
|
|
)
|
|
return 0 if d1_id else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|