"""HTTP entrypoint for the Cloudflare Container runner. The Worker creates a queued job in D1, then calls this container's /run-once endpoint. The container reuses cloudflare_runner.run_once(), so the actual registration flow stays in Python and runs inside Cloudflare Containers rather than on the operator's local machine. """ from __future__ import annotations import json import hmac import os import tempfile import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from types import SimpleNamespace from typing import Any from chatgpt_register_sub2api.runner.cloudflare_runner import run_once _RUN_LOCK = threading.Lock() class ContainerRunnerHandler(BaseHTTPRequestHandler): server_version = "ChatGPTRegisterCloudRunner/0.1" def do_GET(self) -> None: # noqa: N802 - http.server hook if self.path in {"/", "/health", "/ping"}: self._json({"ok": True, "service": "registration-runner"}) return self._json({"error": "not_found"}, status=404) def do_POST(self) -> None: # noqa: N802 - http.server hook if self.path != "/run-once": self._json({"error": "not_found"}, status=404) return if not self._authorized(): self._json({"ok": False, "error": "unauthorized"}, status=401) return if not _RUN_LOCK.acquire(blocking=False): self._json({"ok": False, "busy": True, "error": "runner_busy"}, status=409) return try: body = self._read_json() result = self._run_once(body) self._json(result) except Exception as error: # keep HTTP service alive after one failed job self._json({"ok": False, "error": str(error)}, status=500) finally: _RUN_LOCK.release() def log_message(self, fmt: str, *args: Any) -> None: print("[container-runner] " + (fmt % args), flush=True) def _run_once(self, body: dict[str, Any]) -> dict[str, Any]: base_url = str(body.get("base_url") or os.environ.get("WORKER_BASE_URL") or "").strip() runner_token = str(body.get("runner_token") or os.environ.get("RUNNER_TOKEN") or "").strip() config_yaml = str(body.get("config_yaml") or os.environ.get("RUNNER_CONFIG_YAML") or "").strip() timeout = float(body.get("timeout") or os.environ.get("RUNNER_TIMEOUT") or 30) if not base_url: raise ValueError("base_url is required") if not runner_token: raise ValueError("runner_token is required") with tempfile.TemporaryDirectory(prefix="cf-container-runner-") as temp_dir: temp = Path(temp_dir) config_path = temp / "config.yaml" if config_yaml: config_path.write_text(config_yaml, encoding="utf-8") else: config_path.write_text("mail:\n providers: []\n", encoding="utf-8") args = SimpleNamespace( base_url=base_url, token=runner_token, config=str(config_path), timeout=timeout, prune_local_config=False, no_prune_local_backup=True, ) did_work = run_once(args) return {"ok": True, "did_work": did_work} def _read_json(self) -> dict[str, Any]: size = int(self.headers.get("content-length") or "0") if size <= 0: return {} raw = self.rfile.read(min(size, 2_000_000)) data = json.loads(raw.decode("utf-8")) if not isinstance(data, dict): raise ValueError("JSON body must be an object") return data def _authorized(self) -> bool: expected = str(os.environ.get("RUNNER_INVOKE_TOKEN") or "").strip() if not expected: return True actual = str(self.headers.get("X-Runner-Invoke-Token") or "").strip() return hmac.compare_digest(actual, expected) def _json(self, payload: dict[str, Any], status: int = 200) -> None: data = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def main() -> int: port = int(os.environ.get("PORT") or "8080") server = ThreadingHTTPServer(("0.0.0.0", port), ContainerRunnerHandler) print(f"[container-runner] listening on 0.0.0.0:{port}", flush=True) server.serve_forever() return 0 if __name__ == "__main__": raise SystemExit(main())