system.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. import json
  2. import os
  3. import platform
  4. import subprocess
  5. import sys
  6. from importlib.metadata import version as get_version
  7. import asyncclick as click
  8. from asyncclick import pass_context
  9. from dotenv import load_dotenv
  10. from cli.command_group import cli
  11. from cli.utils.docker_utils import (
  12. bring_down_docker_compose,
  13. remove_r2r_network,
  14. run_docker_serve,
  15. run_local_serve,
  16. wait_for_container_health,
  17. )
  18. from cli.utils.timer import timer
  19. from r2r import R2RAsyncClient
  20. @click.group()
  21. def system():
  22. """System commands."""
  23. pass
  24. @cli.command()
  25. @pass_context
  26. async def health(ctx):
  27. """Check the health of the server."""
  28. client: R2RAsyncClient = ctx.obj
  29. with timer():
  30. response = await client.system.health()
  31. click.echo(json.dumps(response, indent=2))
  32. @system.command()
  33. @click.option("--run-type-filter", help="Filter for log types")
  34. @click.option(
  35. "--offset", default=None, help="Pagination offset. Default is None."
  36. )
  37. @click.option(
  38. "--limit", default=None, help="Pagination limit. Defaults to 100."
  39. )
  40. @pass_context
  41. async def logs(ctx, run_type_filter, offset, limit):
  42. """Retrieve logs with optional type filter."""
  43. client: R2RAsyncClient = ctx.obj
  44. with timer():
  45. response = await client.system.logs(
  46. run_type_filter=run_type_filter,
  47. offset=offset,
  48. limit=limit,
  49. )
  50. for log in response["results"]:
  51. click.echo(f"Run ID: {log['run_id']}")
  52. click.echo(f"Run Type: {log['run_type']}")
  53. click.echo(f"Timestamp: {log['timestamp']}")
  54. click.echo(f"User ID: {log['user_id']}")
  55. click.echo("Entries:")
  56. for entry in log["entries"]:
  57. click.echo(f" - {entry['key']}: {entry['value'][:100]}")
  58. click.echo("---")
  59. click.echo(f"Total runs: {len(response['results'])}")
  60. @system.command()
  61. @pass_context
  62. async def settings(ctx):
  63. """Retrieve application settings."""
  64. client: R2RAsyncClient = ctx.obj
  65. with timer():
  66. response = await client.system.settings()
  67. click.echo(json.dumps(response, indent=2))
  68. @system.command()
  69. @pass_context
  70. async def status(ctx):
  71. """Get statistics about the server, including the start time, uptime, CPU usage, and memory usage."""
  72. client: R2RAsyncClient = ctx.obj
  73. with timer():
  74. response = await client.system.status()
  75. click.echo(json.dumps(response, indent=2))
  76. @cli.command()
  77. @click.option("--host", default=None, help="Host to run the server on")
  78. @click.option(
  79. "--port", default=None, type=int, help="Port to run the server on"
  80. )
  81. @click.option("--docker", is_flag=True, help="Run using Docker")
  82. @click.option(
  83. "--full",
  84. is_flag=True,
  85. help="Run the full R2R compose? This includes Hatchet and Unstructured.",
  86. )
  87. @click.option(
  88. "--project-name", default=None, help="Project name for Docker deployment"
  89. )
  90. @click.option(
  91. "--config-name", default=None, help="Name of the R2R configuration to use"
  92. )
  93. @click.option(
  94. "--config-path",
  95. default=None,
  96. help="Path to a custom R2R configuration file",
  97. )
  98. @click.option(
  99. "--build",
  100. is_flag=True,
  101. default=False,
  102. help="Run in debug mode. Only for development.",
  103. )
  104. @click.option("--image", help="Docker image to use")
  105. @click.option(
  106. "--image-env",
  107. default="prod",
  108. help="Which dev environment to pull the image from?",
  109. )
  110. @click.option(
  111. "--scale",
  112. default=None,
  113. help="How many instances of the R2R service to run",
  114. )
  115. @click.option(
  116. "--exclude-postgres",
  117. is_flag=True,
  118. default=False,
  119. help="Excludes creating a Postgres container in the Docker setup.",
  120. )
  121. async def serve(
  122. host,
  123. port,
  124. docker,
  125. full,
  126. project_name,
  127. config_name,
  128. config_path,
  129. build,
  130. image,
  131. image_env,
  132. scale,
  133. exclude_postgres,
  134. ):
  135. """Start the R2R server."""
  136. load_dotenv()
  137. click.echo("Spinning up an R2R deployment...")
  138. if host is None:
  139. host = os.getenv("R2R_HOST", "0.0.0.0")
  140. if port is None:
  141. port = int(os.getenv("R2R_PORT", (os.getenv("PORT", "7272"))))
  142. click.echo(f"Running on {host}:{port}, with docker={docker}")
  143. if full:
  144. click.echo(
  145. "Running the full R2R setup which includes `Hatchet` and `Unstructured.io`."
  146. )
  147. if config_path and config_name:
  148. raise click.UsageError(
  149. "Both `config-path` and `config-name` were provided. Please provide only one."
  150. )
  151. if config_name and os.path.isfile(config_name):
  152. click.echo(
  153. "Warning: `config-name` corresponds to an existing file. If you intended a custom config, use `config-path`."
  154. )
  155. if build:
  156. click.echo(
  157. "`build` flag detected. Building Docker image from local repository..."
  158. )
  159. if image and image_env:
  160. click.echo(
  161. "WARNING: Both `image` and `image_env` were provided. Using `image`."
  162. )
  163. if not image and docker:
  164. r2r_version = get_version("r2r")
  165. version_specific_image = f"ragtoriches/{image_env}:{r2r_version}"
  166. latest_image = f"ragtoriches/{image_env}:latest"
  167. def image_exists(img):
  168. try:
  169. subprocess.run(
  170. ["docker", "manifest", "inspect", img],
  171. check=True,
  172. capture_output=True,
  173. text=True,
  174. )
  175. return True
  176. except subprocess.CalledProcessError:
  177. return False
  178. if image_exists(version_specific_image):
  179. click.echo(f"Using image: {version_specific_image}")
  180. image = version_specific_image
  181. elif image_exists(latest_image):
  182. click.echo(
  183. f"Version-specific image not found. Using latest: {latest_image}"
  184. )
  185. image = latest_image
  186. else:
  187. click.echo(
  188. f"Neither {version_specific_image} nor {latest_image} found in remote registry. Confirm the sanity of your output for `docker manifest inspect ragtoriches/{version_specific_image}` and `docker manifest inspect ragtoriches/{latest_image}`."
  189. )
  190. click.echo(
  191. "Please pull the required image or build it using the --build flag."
  192. )
  193. raise click.Abort()
  194. if docker:
  195. os.environ["R2R_IMAGE"] = image
  196. if build:
  197. subprocess.run(
  198. ["docker", "build", "-t", image, "-f", "Dockerfile", "."],
  199. check=True,
  200. )
  201. if config_path:
  202. config_path = os.path.abspath(config_path)
  203. # For Windows, convert backslashes to forward slashes and prepend /host_mnt/
  204. if platform.system() == "Windows":
  205. drive, path = os.path.splitdrive(config_path)
  206. config_path = f"/host_mnt/{drive[0].lower()}" + path.replace(
  207. "\\", "/"
  208. )
  209. if docker:
  210. run_docker_serve(
  211. host,
  212. port,
  213. full,
  214. project_name,
  215. image,
  216. config_name,
  217. config_path,
  218. exclude_postgres,
  219. scale,
  220. )
  221. if (
  222. "pytest" in sys.modules
  223. or "unittest" in sys.modules
  224. or os.environ.get("PYTEST_CURRENT_TEST")
  225. ):
  226. click.echo("Test environment detected. Skipping browser open.")
  227. else:
  228. # Open browser after Docker setup is complete
  229. import webbrowser
  230. click.echo("Waiting for all services to become healthy...")
  231. if not wait_for_container_health(
  232. project_name or ("r2r-full" if full else "r2r"), "r2r"
  233. ):
  234. click.secho(
  235. "r2r container failed to become healthy.", fg="red"
  236. )
  237. return
  238. traefik_port = os.environ.get("R2R_DASHBOARD_PORT", "80")
  239. url = f"http://localhost:{traefik_port}"
  240. click.secho(f"Navigating to R2R application at {url}.", fg="blue")
  241. webbrowser.open(url)
  242. else:
  243. await run_local_serve(host, port, config_name, config_path, full)
  244. @cli.command()
  245. @click.option(
  246. "--volumes",
  247. is_flag=True,
  248. help="Remove named volumes declared in the `volumes` section of the Compose file",
  249. )
  250. @click.option(
  251. "--remove-orphans",
  252. is_flag=True,
  253. help="Remove containers for services not defined in the Compose file",
  254. )
  255. @click.option(
  256. "--project-name",
  257. default=None,
  258. help="Which Docker Compose project to bring down",
  259. )
  260. def docker_down(volumes, remove_orphans, project_name):
  261. """Bring down the Docker Compose setup and attempt to remove the network if necessary."""
  262. if not project_name:
  263. print("Bringing down the default R2R Docker setup(s)...")
  264. try:
  265. result = bring_down_docker_compose(
  266. project_name or "r2r", volumes, remove_orphans
  267. )
  268. except:
  269. pass
  270. try:
  271. result = bring_down_docker_compose(
  272. project_name or "r2r-full", volumes, remove_orphans
  273. )
  274. except:
  275. pass
  276. else:
  277. print(f"Bringing down the `{project_name}` R2R Docker setup...")
  278. result = bring_down_docker_compose(
  279. project_name, volumes, remove_orphans
  280. )
  281. if result != 0:
  282. click.echo(
  283. f"An error occurred while bringing down the {project_name} Docker Compose setup. Attempting to remove the network..."
  284. )
  285. else:
  286. click.echo(
  287. f"{project_name} Docker Compose setup has been successfully brought down."
  288. )
  289. remove_r2r_network()
  290. @cli.command()
  291. def generate_report():
  292. """Generate a system report including R2R version, Docker info, and OS details."""
  293. # Get R2R version
  294. from importlib.metadata import version
  295. report = {"r2r_version": version("r2r")}
  296. # Get Docker info
  297. try:
  298. subprocess.run(
  299. ["docker", "version"], check=True, capture_output=True, timeout=5
  300. )
  301. docker_ps_output = subprocess.check_output(
  302. ["docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Status}}"],
  303. text=True,
  304. timeout=5,
  305. ).strip()
  306. report["docker_ps"] = [
  307. dict(zip(["id", "name", "status"], line.split("\t")))
  308. for line in docker_ps_output.split("\n")
  309. if line
  310. ]
  311. docker_network_output = subprocess.check_output(
  312. ["docker", "network", "ls", "--format", "{{.ID}}\t{{.Name}}"],
  313. text=True,
  314. timeout=5,
  315. ).strip()
  316. networks = [
  317. dict(zip(["id", "name"], line.split("\t")))
  318. for line in docker_network_output.split("\n")
  319. if line
  320. ]
  321. report["docker_subnets"] = []
  322. for network in networks:
  323. inspect_output = subprocess.check_output(
  324. [
  325. "docker",
  326. "network",
  327. "inspect",
  328. network["id"],
  329. "--format",
  330. "{{range .IPAM.Config}}{{.Subnet}}{{end}}",
  331. ],
  332. text=True,
  333. timeout=5,
  334. ).strip()
  335. if subnet := inspect_output:
  336. network["subnet"] = subnet
  337. report["docker_subnets"].append(network)
  338. except subprocess.CalledProcessError as e:
  339. report["docker_error"] = f"Error running Docker command: {e}"
  340. except FileNotFoundError:
  341. report["docker_error"] = (
  342. "Docker command not found. Is Docker installed and in PATH?"
  343. )
  344. except subprocess.TimeoutExpired:
  345. report["docker_error"] = (
  346. "Docker command timed out. Docker might be unresponsive."
  347. )
  348. # Get OS information
  349. report["os_info"] = {
  350. "system": platform.system(),
  351. "release": platform.release(),
  352. "version": platform.version(),
  353. "machine": platform.machine(),
  354. "processor": platform.processor(),
  355. }
  356. click.echo("System Report:")
  357. click.echo(json.dumps(report, indent=2))
  358. @cli.command()
  359. def update():
  360. """Update the R2R package to the latest version."""
  361. try:
  362. cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "r2r"]
  363. click.echo("Updating R2R...")
  364. result = subprocess.run(
  365. cmd, check=True, capture_output=True, text=True
  366. )
  367. click.echo(result.stdout)
  368. click.echo("R2R has been successfully updated.")
  369. except subprocess.CalledProcessError as e:
  370. click.echo(f"An error occurred while updating R2R: {e}")
  371. click.echo(e.stderr)
  372. except Exception as e:
  373. click.echo(f"An unexpected error occurred: {e}")
  374. @cli.command()
  375. def version():
  376. """Reports the SDK version."""
  377. from importlib.metadata import version
  378. click.echo(json.dumps(version("r2r"), indent=2))