bootstrap.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. """Environment parsing and dependency wiring for the standalone Runner."""
  2. from __future__ import annotations
  3. import os
  4. from dataclasses import dataclass, field
  5. import click
  6. from minio import Minio
  7. from app.core.data_source.runtime import (
  8. DataSourceRuntimeConfig,
  9. build_standalone_data_source_runtime,
  10. )
  11. from app.runner.api import create_runner_app
  12. from app.runner.artifacts import ArtifactStore, PostgresArtifactResolver
  13. from app.runner.auth import TaskTokenVerifier
  14. from app.runner.ledger import PostgresTaskLedger
  15. from app.runner.nodes import (
  16. GovernedHttpExecutor,
  17. NodeRegistry,
  18. RestrictedPythonExecutor,
  19. SqlExecuteExecutor,
  20. SqlQueryExecutor,
  21. )
  22. from app.runner.rule_polars import PolarsRulePlanAdapter
  23. from app.runner.rule_sql import (
  24. SqlGlotQualityPlanAdapter,
  25. SqlGlotRulePlanAdapter,
  26. )
  27. from app.runner.rules import (
  28. PostgresRulePlanRepository,
  29. RulePlanExecutor,
  30. )
  31. def _required(name):
  32. value = str(os.environ.get(name, "")).strip()
  33. if not value:
  34. raise ValueError(f"{name} is required")
  35. return value
  36. def _integer(name, default, minimum, maximum):
  37. try:
  38. value = int(os.environ.get(name, str(default)))
  39. except (TypeError, ValueError) as exc:
  40. raise ValueError(f"{name} must be an integer") from exc
  41. if value < minimum or value > maximum:
  42. raise ValueError(f"{name} must be between {minimum} and {maximum}")
  43. return value
  44. def _boolean(name, default=False):
  45. value = str(os.environ.get(name, str(default))).strip().lower()
  46. if value in {"1", "true", "yes", "on"}:
  47. return True
  48. if value in {"0", "false", "no", "off"}:
  49. return False
  50. raise ValueError(f"{name} must be a boolean")
  51. @dataclass(frozen=True)
  52. class RunnerSettings:
  53. runtime: DataSourceRuntimeConfig = field(repr=False)
  54. task_token_secret: str = field(repr=False)
  55. artifact_host: str = field(repr=False)
  56. artifact_user: str = field(repr=False)
  57. artifact_password: str = field(repr=False)
  58. artifact_bucket: str = "dataops-rules"
  59. artifact_secure: bool = False
  60. artifact_max_bytes: int = 32 * 1024 * 1024
  61. artifact_max_rows: int = 100_000
  62. artifact_memory_limit_bytes: int = 256 * 1024 * 1024
  63. artifact_ttl_seconds: int = 3600
  64. allowed_http_hosts: frozenset = field(default_factory=frozenset)
  65. task_token_ttl_seconds: int = 60
  66. max_query_rows: int = 1000
  67. def register_artifact_cleanup_cli(app, artifact_resolver):
  68. """Register the bounded production maintenance entrypoint."""
  69. @app.cli.command("reconcile-rule-artifacts")
  70. @click.option("--limit", type=click.IntRange(1, 1_000), default=100)
  71. @click.option(
  72. "--grace-seconds",
  73. type=click.IntRange(30, 86_400),
  74. default=300,
  75. )
  76. def reconcile_rule_artifacts(limit, grace_seconds):
  77. result = artifact_resolver.reconcile(
  78. limit=limit,
  79. grace_seconds=grace_seconds,
  80. )
  81. click.echo(
  82. " ".join(
  83. f"{key}={result[key]}"
  84. for key in (
  85. "pending_finalized",
  86. "pending_deleted",
  87. "ready_failed",
  88. "orphans_deleted",
  89. )
  90. )
  91. )
  92. @app.cli.command("cleanup-rule-artifacts")
  93. @click.option("--limit", type=click.IntRange(1, 1_000), default=100)
  94. def cleanup_rule_artifacts(limit):
  95. removed = artifact_resolver.cleanup_expired(limit=limit)
  96. click.echo(f"removed {removed} expired rule artifacts")
  97. def runner_settings_from_env():
  98. task_token_secret = _required("RUNNER_TASK_TOKEN_SECRET")
  99. worker_count = _integer("RUNNER_WORKERS", 2, 1, 8)
  100. runtime = DataSourceRuntimeConfig.from_mapping(
  101. {
  102. "platform_database_url": _required("DATABASE_URL"),
  103. "neo4j_uri": _required("NEO4J_URI"),
  104. "neo4j_user": _required("NEO4J_USER"),
  105. "neo4j_password": _required("NEO4J_PASSWORD"),
  106. "credential_master_key": _required(
  107. "DATASOURCE_CREDENTIAL_MASTER_KEY"
  108. ),
  109. "credential_key_version": _required(
  110. "DATASOURCE_CREDENTIAL_KEY_VERSION"
  111. ),
  112. "certificate_dir": os.environ.get(
  113. "DATASOURCE_CERT_DIR",
  114. "/etc/dataops-platform/datasource-certs",
  115. ),
  116. "pool_size": _integer("RUNNER_DATASOURCE_POOL_SIZE", 1, 1, 3),
  117. "max_overflow": _integer(
  118. "RUNNER_DATASOURCE_MAX_OVERFLOW", 1, 0, 3
  119. ),
  120. "pool_timeout": _integer(
  121. "RUNNER_DATASOURCE_POOL_TIMEOUT", 10, 1, 60
  122. ),
  123. "pool_recycle": _integer(
  124. "RUNNER_DATASOURCE_POOL_RECYCLE", 1800, 60, 86400
  125. ),
  126. "idle_ttl": _integer(
  127. "RUNNER_DATASOURCE_POOL_IDLE_TTL", 900, 60, 86400
  128. ),
  129. "max_idle_pools": _integer(
  130. "RUNNER_DATASOURCE_MAX_IDLE_POOLS", 4, 1, 20
  131. ),
  132. "query_timeout": _integer(
  133. "RUNNER_DATASOURCE_QUERY_TIMEOUT", 30, 1, 300
  134. ),
  135. "worker_count": worker_count,
  136. "connection_budget": _integer(
  137. "RUNNER_DATASOURCE_CONNECTION_BUDGET", 32, 1, 200
  138. ),
  139. }
  140. )
  141. allowed_hosts = frozenset(
  142. host.strip().lower()
  143. for host in os.environ.get("RUNNER_HTTP_ALLOWED_HOSTS", "").split(",")
  144. if host.strip()
  145. )
  146. return RunnerSettings(
  147. runtime=runtime,
  148. task_token_secret=task_token_secret,
  149. artifact_host=_required("RUNNER_MINIO_HOST"),
  150. artifact_user=_required("RUNNER_MINIO_USER"),
  151. artifact_password=_required("RUNNER_MINIO_PASSWORD"),
  152. artifact_bucket=_required("RUNNER_MINIO_BUCKET"),
  153. artifact_secure=_boolean("RUNNER_MINIO_SECURE", False),
  154. artifact_max_bytes=_integer(
  155. "RUNNER_ARTIFACT_MAX_BYTES",
  156. 32 * 1024 * 1024,
  157. 1024,
  158. 2 * 1024 * 1024 * 1024,
  159. ),
  160. artifact_max_rows=_integer(
  161. "RUNNER_ARTIFACT_MAX_ROWS", 100_000, 1, 10_000_000
  162. ),
  163. artifact_memory_limit_bytes=_integer(
  164. "RUNNER_ARTIFACT_MEMORY_LIMIT_BYTES",
  165. 256 * 1024 * 1024,
  166. 16 * 1024 * 1024,
  167. 16 * 1024 * 1024 * 1024,
  168. ),
  169. artifact_ttl_seconds=_integer(
  170. "RUNNER_ARTIFACT_TTL_SECONDS", 3600, 1, 86400
  171. ),
  172. allowed_http_hosts=allowed_hosts,
  173. task_token_ttl_seconds=_integer(
  174. "RUNNER_TASK_TOKEN_TTL_SECONDS", 60, 1, 300
  175. ),
  176. max_query_rows=_integer("RUNNER_MAX_QUERY_ROWS", 1000, 1, 10000),
  177. )
  178. def build_runner_application(settings=None):
  179. settings = settings or runner_settings_from_env()
  180. runtime = build_standalone_data_source_runtime(settings.runtime)
  181. query_executor = SqlQueryExecutor(
  182. runtime.manager,
  183. max_rows=settings.max_query_rows,
  184. )
  185. write_executor = SqlExecuteExecutor(runtime.manager)
  186. sql_rule_adapter = SqlGlotRulePlanAdapter(runtime.manager)
  187. artifact_store = ArtifactStore(
  188. Minio(
  189. settings.artifact_host,
  190. access_key=settings.artifact_user,
  191. secret_key=settings.artifact_password,
  192. secure=settings.artifact_secure,
  193. ),
  194. bucket=settings.artifact_bucket,
  195. max_artifact_bytes=settings.artifact_max_bytes,
  196. max_rows=settings.artifact_max_rows,
  197. memory_limit_bytes=settings.artifact_memory_limit_bytes,
  198. max_ttl_seconds=settings.artifact_ttl_seconds,
  199. )
  200. artifact_resolver = PostgresArtifactResolver(
  201. runtime.platform_engine, artifact_store
  202. )
  203. polars_rule_adapter = PolarsRulePlanAdapter(
  204. artifact_store=artifact_store,
  205. artifact_resolver=artifact_resolver,
  206. masking_policies={
  207. "customer_mobile_last4": "preserve_last_4",
  208. "redact": "redact",
  209. },
  210. artifact_ttl_seconds=settings.artifact_ttl_seconds,
  211. )
  212. rule_executor = RulePlanExecutor(
  213. PostgresRulePlanRepository(runtime.platform_engine),
  214. adapters={
  215. "sql_pushdown": sql_rule_adapter,
  216. "polars_batch": polars_rule_adapter,
  217. "quality_check": SqlGlotQualityPlanAdapter(),
  218. },
  219. )
  220. registry = NodeRegistry(
  221. {
  222. "sql.query": query_executor,
  223. "sql.execute": write_executor,
  224. "python": RestrictedPythonExecutor({}),
  225. "http": GovernedHttpExecutor(
  226. allowed_hosts=settings.allowed_http_hosts
  227. ),
  228. "rule.apply": rule_executor,
  229. "quality.check": rule_executor,
  230. }
  231. )
  232. application = create_runner_app(
  233. verifier=TaskTokenVerifier(settings.task_token_secret),
  234. ledger=PostgresTaskLedger(runtime.platform_engine),
  235. registry=registry,
  236. )
  237. register_artifact_cleanup_cli(application, artifact_resolver)
  238. application.extensions["dataops_runner_runtime"] = runtime
  239. application.extensions["dataops_runner_settings"] = settings
  240. return application