bootstrap.py 9.4 KB

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