Spaces:
Sleeping
Sleeping
| """Admin / safe-deploy CLI for the live Kink Discovery Space. | |
| Every operation a user can do (and several they can't) β exposed as subcommands. Used to: | |
| * back up every profile to a local file before a risky deploy | |
| * restore those profiles after a deploy if anything goes sideways | |
| * fix one user without redeploying (lost token, stuck partner state, bad rating) | |
| * inspect the live state at a glance (stats, health, user list) | |
| Backed by ``/admin/*`` endpoints on the live API. Auth: ``KINK_ADMIN_SECRET`` env var (or | |
| ``--admin-secret``). The Space must have the same secret in its environment β bootstrap with | |
| ``admin_deploy.py provision-secret`` once. | |
| Examples | |
| ~~~~~~~~ | |
| # one-time: install KINK_ADMIN_SECRET as a Space secret | |
| .venv/bin/python scripts/admin_deploy.py provision-secret | |
| # day-to-day | |
| .venv/bin/python scripts/admin_deploy.py list-users | |
| .venv/bin/python scripts/admin_deploy.py user-info real-bunny-72 | |
| .venv/bin/python scripts/admin_deploy.py reset-token real-bunny-72 | |
| .venv/bin/python scripts/admin_deploy.py stats | |
| .venv/bin/python scripts/admin_deploy.py snapshot-push | |
| # safe deploy (backup β publish β wait β snapshot push β snapshot verify) | |
| .venv/bin/python scripts/admin_deploy.py safe-deploy | |
| # restore a known-good backup over whatever's live | |
| .venv/bin/python scripts/admin_deploy.py restore data/backups/2026-04-30T1730.db | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import secrets | |
| import sys | |
| import time | |
| import urllib.error | |
| import urllib.parse | |
| import urllib.request | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| REPO = Path(__file__).resolve().parent.parent | |
| sys.path.insert(0, str(REPO)) | |
| from backend.repo_dotenv import load_repo_dotenv # noqa: E402 | |
| DEFAULT_BASE = os.environ.get("KINK_AUDIT_BASE_URL", "https://perplexed7675-kink-discovery.hf.space") | |
| DEFAULT_SPACE = os.environ.get("HF_SPACE_REPO", "Perplexed7675/kink-discovery") | |
| BACKUP_DIR = REPO / "data" / "backups" | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HTTP helpers (stdlib only β keeps the script self-contained on a fresh checkout) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _admin_headers(secret: str) -> dict[str, str]: | |
| return {"x-admin-secret": secret} | |
| def _request(method: str, url: str, *, headers: dict | None = None, data: bytes | None = None, | |
| content_type: str | None = None, timeout: int = 120) -> tuple[int, bytes]: | |
| headers = dict(headers or {}) | |
| if content_type: | |
| headers["Content-Type"] = content_type | |
| req = urllib.request.Request(url, data=data, method=method, headers=headers) | |
| try: | |
| with urllib.request.urlopen(req, timeout=timeout) as resp: | |
| return resp.status, resp.read() | |
| except urllib.error.HTTPError as e: | |
| return e.code, e.read() | |
| def _api(method: str, base: str, path: str, secret: str, **kw) -> tuple[int, bytes]: | |
| return _request(method, base.rstrip("/") + path, headers=_admin_headers(secret), **kw) | |
| def _json_or_raise(status: int, body: bytes, label: str) -> dict | list: | |
| if 200 <= status < 300: | |
| return json.loads(body) if body else {} | |
| raise SystemExit(f"{label}: HTTP {status} β {body.decode(errors='replace')[:400]}") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Subcommands | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def cmd_list_users(args) -> int: | |
| s, body = _api("GET", args.base_url, "/admin/users?limit=2000", args.admin_secret) | |
| data = _json_or_raise(s, body, "list-users") | |
| items = data["items"] | |
| print(f"{len(items)} users (showing all):") | |
| print(f" {'user_id':<28} {'plays':>6} {'scen':>5} {'roles':>5} {'links':>5} {'groups':>6}") | |
| for it in items: | |
| print(f" {it['user_id']:<28} {it['plays']:>6} {it['scenarios']:>5} {it['roles']:>5} " | |
| f"{it['partner_links']:>5} {it['group_memberships']:>6}") | |
| return 0 | |
| def cmd_user_info(args) -> int: | |
| s, body = _api("GET", args.base_url, f"/admin/users/{urllib.parse.quote(args.user_id)}", args.admin_secret) | |
| data = _json_or_raise(s, body, "user-info") | |
| print(json.dumps(data, indent=2, sort_keys=True)) | |
| return 0 | |
| def cmd_reset_token(args) -> int: | |
| s, body = _api("POST", args.base_url, | |
| f"/admin/users/{urllib.parse.quote(args.user_id)}/regenerate-token", | |
| args.admin_secret) | |
| data = _json_or_raise(s, body, "reset-token") | |
| print(json.dumps(data, indent=2)) | |
| return 0 | |
| def cmd_delete_user(args) -> int: | |
| if not args.yes: | |
| print(f"Refusing without --yes; will delete {args.user_id} permanently.", file=sys.stderr) | |
| return 2 | |
| s, body = _api("DELETE", args.base_url, f"/admin/users/{urllib.parse.quote(args.user_id)}", | |
| args.admin_secret) | |
| data = _json_or_raise(s, body, "delete-user") | |
| print(json.dumps(data, indent=2)) | |
| return 0 | |
| def cmd_scrub_plays(args) -> int: | |
| if not args.yes: | |
| print(f"Refusing without --yes; will wipe ALL plays for {args.user_id}.", file=sys.stderr) | |
| return 2 | |
| s, body = _api("POST", args.base_url, | |
| f"/admin/users/{urllib.parse.quote(args.user_id)}/scrub-plays", | |
| args.admin_secret) | |
| print(json.dumps(_json_or_raise(s, body, "scrub-plays"), indent=2)) | |
| return 0 | |
| def cmd_delete_play(args) -> int: | |
| s, body = _api("DELETE", args.base_url, | |
| f"/admin/users/{urllib.parse.quote(args.user_id)}/plays/{urllib.parse.quote(args.kink_id)}", | |
| args.admin_secret) | |
| print(json.dumps(_json_or_raise(s, body, "delete-play"), indent=2)) | |
| return 0 | |
| def cmd_force_link(args) -> int: | |
| s, body = _api("POST", args.base_url, | |
| f"/admin/users/{urllib.parse.quote(args.user_id)}/partners/{urllib.parse.quote(args.partner_id)}/force-link", | |
| args.admin_secret) | |
| print(json.dumps(_json_or_raise(s, body, "force-link"), indent=2)) | |
| return 0 | |
| def cmd_unlink(args) -> int: | |
| s, body = _api("DELETE", args.base_url, | |
| f"/admin/partners/{urllib.parse.quote(args.user_a)}/{urllib.parse.quote(args.user_b)}", | |
| args.admin_secret) | |
| print(json.dumps(_json_or_raise(s, body, "unlink"), indent=2)) | |
| return 0 | |
| def cmd_backup(args) -> int: | |
| BACKUP_DIR.mkdir(parents=True, exist_ok=True) | |
| # ``--out`` is only on the ``backup`` subparser; ``safe-deploy`` calls cmd_backup directly | |
| # with its own Namespace that has no ``out`` attribute. Use getattr to keep both flows working. | |
| out_arg = getattr(args, "out", None) | |
| if out_arg: | |
| out = Path(out_arg) | |
| else: | |
| ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%SZ") | |
| out = BACKUP_DIR / f"user_state_{ts}.db" | |
| s, body = _api("GET", args.base_url, "/admin/export", args.admin_secret, timeout=300) | |
| if not (200 <= s < 300): | |
| raise SystemExit(f"backup: HTTP {s} β {body[:400]!r}") | |
| out.write_bytes(body) | |
| print(f"wrote {out} ({len(body):,} bytes)") | |
| return 0 | |
| def cmd_restore(args) -> int: | |
| if not args.yes: | |
| print(f"Refusing without --yes; will overwrite live user-state from {args.file!r}.", file=sys.stderr) | |
| return 2 | |
| body = Path(args.file).read_bytes() | |
| s, resp = _api("POST", args.base_url, "/admin/import", args.admin_secret, | |
| data=body, content_type="application/octet-stream", timeout=300) | |
| print(json.dumps(_json_or_raise(s, resp, "restore"), indent=2)) | |
| return 0 | |
| def cmd_snapshot_push(args) -> int: | |
| s, body = _api("POST", args.base_url, "/admin/snapshot/push", args.admin_secret, timeout=180) | |
| print(json.dumps(_json_or_raise(s, body, "snapshot-push"), indent=2)) | |
| return 0 | |
| def cmd_snapshot_pull(args) -> int: | |
| if not args.yes: | |
| print("Refusing without --yes; will overwrite local store with Hub snapshot.", file=sys.stderr) | |
| return 2 | |
| s, body = _api("POST", args.base_url, "/admin/snapshot/pull", args.admin_secret, timeout=180) | |
| print(json.dumps(_json_or_raise(s, body, "snapshot-pull"), indent=2)) | |
| return 0 | |
| def cmd_stats(args) -> int: | |
| s, body = _api("GET", args.base_url, "/admin/stats", args.admin_secret) | |
| print(json.dumps(_json_or_raise(s, body, "stats"), indent=2)) | |
| return 0 | |
| def cmd_health(args) -> int: | |
| s, body = _api("GET", args.base_url, "/admin/health", args.admin_secret) | |
| print(json.dumps(_json_or_raise(s, body, "health"), indent=2)) | |
| return 0 | |
| def cmd_provision_secret(args) -> int: | |
| """Generate a fresh KINK_ADMIN_SECRET, write it to .env.local AND set it as a Space secret.""" | |
| load_repo_dotenv() | |
| hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") | |
| if not hf_token: | |
| raise SystemExit("provision-secret needs HF_TOKEN in env / .env.local") | |
| new_secret = args.admin_secret or os.environ.get("KINK_ADMIN_SECRET") or secrets.token_urlsafe(32) | |
| from huggingface_hub import HfApi | |
| api = HfApi(token=hf_token) | |
| api.add_space_secret(repo_id=DEFAULT_SPACE, key="KINK_ADMIN_SECRET", value=new_secret) | |
| print(f"set KINK_ADMIN_SECRET on {DEFAULT_SPACE} (Space will pick it up on next restart)") | |
| env_local = REPO / ".env.local" | |
| contents = env_local.read_text(encoding="utf-8") if env_local.is_file() else "" | |
| lines = [ln for ln in contents.splitlines() if not ln.startswith("KINK_ADMIN_SECRET=")] | |
| lines.append(f"KINK_ADMIN_SECRET={new_secret}") | |
| env_local.write_text("\n".join(lines) + "\n", encoding="utf-8") | |
| print(f"wrote KINK_ADMIN_SECRET to {env_local}") | |
| print("(restart the Space to pick up the new secret)") | |
| return 0 | |
| def cmd_safe_deploy(args) -> int: | |
| """backup β publish β wait for RUNNING β verify users restored β manual snapshot push. | |
| Idempotent on retries: the backup is timestamped so each call snapshots state at that | |
| moment, and the post-deploy verify just compares user IDs (no destructive ops). | |
| """ | |
| print("== safe-deploy ==", flush=True) | |
| print("step 1/5: backup", flush=True) | |
| if cmd_backup(args) != 0: | |
| return 1 | |
| print("\nstep 2/5: collect pre-deploy user count", flush=True) | |
| s, body = _api("GET", args.base_url, "/admin/users?limit=2000", args.admin_secret) | |
| pre_users = {u["user_id"] for u in _json_or_raise(s, body, "pre-count")["items"]} | |
| print(f" pre-deploy users: {len(pre_users)}", flush=True) | |
| print("\nstep 3/5: publish to HF Space", flush=True) | |
| import subprocess | |
| rc = subprocess.run( | |
| [sys.executable, str(REPO / "scripts" / "publish_hf_space.py"), "--no-wait"], | |
| check=False, | |
| ).returncode | |
| if rc != 0: | |
| raise SystemExit(f"publish exited {rc}") | |
| print("\nstep 4/5: wait for the new build to come up RUNNING + boot-restore to log", flush=True) | |
| load_repo_dotenv() | |
| hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") | |
| if not hf_token: | |
| raise SystemExit("HF_TOKEN missing β cannot poll Space stage") | |
| deadline = time.time() + 600 | |
| last = None | |
| while time.time() < deadline: | |
| req = urllib.request.Request( | |
| f"https://huggingface.co/api/spaces/{DEFAULT_SPACE}", | |
| headers={"Authorization": f"Bearer {hf_token}"}, | |
| ) | |
| runtime = json.loads(urllib.request.urlopen(req, timeout=30).read())["runtime"] | |
| stage = runtime["stage"] | |
| if stage != last: | |
| print(f" stage={stage}", flush=True) | |
| last = stage | |
| if stage == "RUNNING": | |
| try: | |
| r = urllib.request.urlopen(args.base_url.rstrip('/') + "/health", timeout=20).read() | |
| if json.loads(r).get("user_snapshot", {}).get("enabled"): | |
| break | |
| except Exception: | |
| pass | |
| time.sleep(8) | |
| else: | |
| raise SystemExit("timeout waiting for build") | |
| print("\nstep 5/5: verify users restored + push fresh snapshot", flush=True) | |
| s, body = _api("GET", args.base_url, "/admin/users?limit=2000", args.admin_secret) | |
| post_users = {u["user_id"] for u in _json_or_raise(s, body, "post-count")["items"]} | |
| print(f" post-deploy users: {len(post_users)}", flush=True) | |
| missing = pre_users - post_users | |
| extra = post_users - pre_users | |
| if missing: | |
| print(f" WARNING: {len(missing)} pre-deploy users are MISSING post-deploy: {sorted(missing)[:10]}", | |
| flush=True) | |
| print(" β run: admin_deploy.py restore <backup-file> --yes", flush=True) | |
| else: | |
| print(" β all pre-deploy users present after deploy", flush=True) | |
| if extra: | |
| print(f" (note: {len(extra)} new users showed up post-deploy; that's fine)", flush=True) | |
| print("\n pushing fresh snapshot to Hubβ¦", flush=True) | |
| s, body = _api("POST", args.base_url, "/admin/snapshot/push", args.admin_secret, timeout=180) | |
| print(" " + json.dumps(_json_or_raise(s, body, "snapshot-push"), indent=2).replace("\n", "\n ")) | |
| return 0 if not missing else 3 | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # argparse wiring | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main() -> int: | |
| load_repo_dotenv() | |
| p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| p.add_argument("--base-url", default=DEFAULT_BASE) | |
| p.add_argument("--admin-secret", default=os.environ.get("KINK_ADMIN_SECRET", ""), | |
| help="Defaults to $KINK_ADMIN_SECRET") | |
| sub = p.add_subparsers(dest="cmd", required=True) | |
| sub.add_parser("list-users", help="List all profiles with summary counts").set_defaults(fn=cmd_list_users) | |
| s_info = sub.add_parser("user-info", help="Full state for one profile (incl. private_token)") | |
| s_info.add_argument("user_id") | |
| s_info.set_defaults(fn=cmd_user_info) | |
| s_reset = sub.add_parser("reset-token", help="Issue a new private_token for a user who lost theirs") | |
| s_reset.add_argument("user_id") | |
| s_reset.set_defaults(fn=cmd_reset_token) | |
| s_del = sub.add_parser("delete-user", help="Hard-delete a profile + all its state") | |
| s_del.add_argument("user_id") | |
| s_del.add_argument("--yes", action="store_true") | |
| s_del.set_defaults(fn=cmd_delete_user) | |
| s_scrub = sub.add_parser("scrub-plays", help="Wipe all PlayPreference for one user") | |
| s_scrub.add_argument("user_id") | |
| s_scrub.add_argument("--yes", action="store_true") | |
| s_scrub.set_defaults(fn=cmd_scrub_plays) | |
| s_dp = sub.add_parser("delete-play", help="Remove ONE play preference") | |
| s_dp.add_argument("user_id") | |
| s_dp.add_argument("kink_id") | |
| s_dp.set_defaults(fn=cmd_delete_play) | |
| s_link = sub.add_parser("force-link", help="Force-create a partner link without the request flow") | |
| s_link.add_argument("user_id") | |
| s_link.add_argument("partner_id") | |
| s_link.set_defaults(fn=cmd_force_link) | |
| s_unlink = sub.add_parser("unlink", help="Drop a partner link between two users") | |
| s_unlink.add_argument("user_a") | |
| s_unlink.add_argument("user_b") | |
| s_unlink.set_defaults(fn=cmd_unlink) | |
| s_b = sub.add_parser("backup", help="Download a user_state.db snapshot from live") | |
| s_b.add_argument("--out", help="Path; default data/backups/user_state_<ts>.db") | |
| s_b.set_defaults(fn=cmd_backup) | |
| s_r = sub.add_parser("restore", help="Upload a user_state.db and INSERT OR REPLACE") | |
| s_r.add_argument("file") | |
| s_r.add_argument("--yes", action="store_true") | |
| s_r.set_defaults(fn=cmd_restore) | |
| sub.add_parser("snapshot-push", help="Force flush user-state to the Hub dataset").set_defaults(fn=cmd_snapshot_push) | |
| s_pull = sub.add_parser("snapshot-pull", help="Force pull Hub snapshot + INSERT OR REPLACE into live store") | |
| s_pull.add_argument("--yes", action="store_true") | |
| s_pull.set_defaults(fn=cmd_snapshot_pull) | |
| sub.add_parser("stats", help="Aggregate row counts + top kinks").set_defaults(fn=cmd_stats) | |
| sub.add_parser("health", help="Detailed admin health (incl. last_restore log)").set_defaults(fn=cmd_health) | |
| sub.add_parser( | |
| "provision-secret", | |
| help="Generate KINK_ADMIN_SECRET, write to .env.local, set as Space secret", | |
| ).set_defaults(fn=cmd_provision_secret) | |
| sub.add_parser( | |
| "safe-deploy", | |
| help="Backup β publish β wait β verify β push fresh snapshot", | |
| ).set_defaults(fn=cmd_safe_deploy) | |
| args = p.parse_args() | |
| if args.cmd != "provision-secret" and not args.admin_secret: | |
| raise SystemExit("KINK_ADMIN_SECRET missing β set it via env or --admin-secret, or run `provision-secret`.") | |
| return args.fn(args) | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |