duckstring 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. {duckstring-0.3.0/src/duckstring.egg-info → duckstring-0.4.0}/PKG-INFO +1 -1
  2. {duckstring-0.3.0 → duckstring-0.4.0}/pyproject.toml +1 -1
  3. duckstring-0.4.0/src/duckstring/alerts/__init__.py +21 -0
  4. duckstring-0.4.0/src/duckstring/alerts/base.py +94 -0
  5. duckstring-0.4.0/src/duckstring/alerts/email.py +106 -0
  6. duckstring-0.4.0/src/duckstring/alerts/event.py +97 -0
  7. duckstring-0.4.0/src/duckstring/alerts/webhook.py +46 -0
  8. duckstring-0.4.0/src/duckstring/catchment/alert_worker.py +58 -0
  9. duckstring-0.4.0/src/duckstring/catchment/app.py +199 -0
  10. duckstring-0.4.0/src/duckstring/catchment/asgi.py +39 -0
  11. duckstring-0.4.0/src/duckstring/catchment/auth.py +203 -0
  12. duckstring-0.4.0/src/duckstring/catchment/data_lease.py +159 -0
  13. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/driver.py +1064 -24
  14. duckstring-0.4.0/src/duckstring/catchment/egress_worker.py +102 -0
  15. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/launcher.py +13 -4
  16. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/poller.py +32 -21
  17. duckstring-0.4.0/src/duckstring/catchment/registry.py +40 -0
  18. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/__init__.py +4 -0
  19. duckstring-0.4.0/src/duckstring/catchment/routes/alerts.py +75 -0
  20. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/catchment.py +37 -5
  21. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/data.py +99 -17
  22. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/deploy.py +50 -16
  23. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/draw.py +50 -16
  24. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/duck.py +3 -2
  25. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/duct.py +8 -6
  26. duckstring-0.4.0/src/duckstring/catchment/routes/metrics.py +99 -0
  27. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/orchestrate.py +222 -27
  28. duckstring-0.4.0/src/duckstring/catchment/routes/secrets.py +43 -0
  29. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/view.py +3 -1
  30. duckstring-0.4.0/src/duckstring/catchment/schema/007_catchment_key.sql +9 -0
  31. duckstring-0.4.0/src/duckstring/catchment/schema/008_spout.sql +18 -0
  32. duckstring-0.4.0/src/duckstring/catchment/schema/009_spout_state.sql +10 -0
  33. duckstring-0.4.0/src/duckstring/catchment/schema/010_spout_wake.sql +6 -0
  34. duckstring-0.4.0/src/duckstring/catchment/schema/011_spout_window.sql +17 -0
  35. duckstring-0.4.0/src/duckstring/catchment/schema/012_spout_node.sql +20 -0
  36. duckstring-0.4.0/src/duckstring/catchment/schema/013_changed_f.sql +14 -0
  37. duckstring-0.4.0/src/duckstring/catchment/schema/014_alert.sql +38 -0
  38. duckstring-0.4.0/src/duckstring/catchment/schema/016_alert_scope_major.sql +15 -0
  39. duckstring-0.4.0/src/duckstring/catchment/secrets.py +72 -0
  40. duckstring-0.4.0/src/duckstring/catchment/state_sync.py +182 -0
  41. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/404.html +1 -1
  42. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next.__PAGE__.txt +2 -2
  43. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._full.txt +3 -3
  44. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._head.txt +1 -1
  45. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._index.txt +2 -2
  46. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._tree.txt +2 -2
  47. duckstring-0.3.0/src/duckstring/catchment/static/_next/static/chunks/0d-69yy-ja-ef.css → duckstring-0.4.0/src/duckstring/catchment/static/_next/static/chunks/0p30fzde51na5.css +1 -1
  48. duckstring-0.4.0/src/duckstring/catchment/static/_next/static/chunks/0qdnkaffkg.2-.js +2 -0
  49. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._full.txt +2 -2
  50. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._head.txt +1 -1
  51. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._index.txt +2 -2
  52. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._not-found.__PAGE__.txt +1 -1
  53. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._not-found.txt +1 -1
  54. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._tree.txt +2 -2
  55. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found.html +1 -1
  56. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found.txt +2 -2
  57. duckstring-0.4.0/src/duckstring/catchment/static/index.html +1 -0
  58. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/index.txt +3 -3
  59. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/__init__.py +17 -2
  60. duckstring-0.4.0/src/duckstring/cli/alert.py +146 -0
  61. duckstring-0.4.0/src/duckstring/cli/bulk.py +131 -0
  62. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/catchment.py +251 -41
  63. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/config.py +19 -1
  64. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/control.py +23 -0
  65. duckstring-0.4.0/src/duckstring/cli/data.py +266 -0
  66. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/deploy.py +41 -2
  67. duckstring-0.4.0/src/duckstring/cli/secret.py +72 -0
  68. duckstring-0.4.0/src/duckstring/cli/spout.py +142 -0
  69. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/core.py +174 -21
  70. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/dataplane.py +143 -98
  71. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/duck/__main__.py +21 -6
  72. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/duck/core.py +56 -7
  73. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/duck/executor.py +31 -10
  74. duckstring-0.4.0/src/duckstring/egress/__init__.py +19 -0
  75. duckstring-0.4.0/src/duckstring/egress/base.py +96 -0
  76. duckstring-0.4.0/src/duckstring/egress/credentials.py +93 -0
  77. duckstring-0.4.0/src/duckstring/egress/destination.py +64 -0
  78. duckstring-0.4.0/src/duckstring/egress/object_store.py +167 -0
  79. duckstring-0.4.0/src/duckstring/egress/postgres.py +170 -0
  80. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/engine/__init__.py +6 -0
  81. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/engine/catchment.py +204 -26
  82. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/engine/core.py +23 -0
  83. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/engine/worker.py +6 -3
  84. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/iceberg_catalog.py +31 -8
  85. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/iceberg_plane.py +40 -42
  86. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/local/runner.py +12 -1
  87. duckstring-0.4.0/src/duckstring/objects.py +225 -0
  88. duckstring-0.4.0/src/duckstring/storage.py +543 -0
  89. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle/builder.py +40 -18
  90. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle/io.py +134 -41
  91. {duckstring-0.3.0 → duckstring-0.4.0/src/duckstring.egg-info}/PKG-INFO +1 -1
  92. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring.egg-info/SOURCES.txt +40 -5
  93. duckstring-0.3.0/src/duckstring/catchment/app.py +0 -123
  94. duckstring-0.3.0/src/duckstring/catchment/asgi.py +0 -30
  95. duckstring-0.3.0/src/duckstring/catchment/registry.py +0 -27
  96. duckstring-0.3.0/src/duckstring/catchment/static/_next/static/chunks/0l6vjq3ae5vov.js +0 -2
  97. duckstring-0.3.0/src/duckstring/catchment/static/index.html +0 -1
  98. duckstring-0.3.0/src/duckstring/cli/data.py +0 -133
  99. {duckstring-0.3.0 → duckstring-0.4.0}/LICENSE +0 -0
  100. {duckstring-0.3.0 → duckstring-0.4.0}/MANIFEST.in +0 -0
  101. {duckstring-0.3.0 → duckstring-0.4.0}/README.md +0 -0
  102. {duckstring-0.3.0 → duckstring-0.4.0}/setup.cfg +0 -0
  103. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/__init__.py +0 -0
  104. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/__main__.py +0 -0
  105. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/acc.py +0 -0
  106. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/agg.py +0 -0
  107. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/__init__.py +0 -0
  108. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/dag.py +0 -0
  109. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/db.py +0 -0
  110. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/001_init.sql +0 -0
  111. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/002_ducts.sql +0 -0
  112. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/003_pull_m.sql +0 -0
  113. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/004_identity.sql +0 -0
  114. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/005_pond_version_schema.sql +0 -0
  115. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/006_refresh.sql +0 -0
  116. {duckstring-0.3.0/src/duckstring/catchment/static/_next/static/Z066xVKlXhWUYRNRq4sUP → duckstring-0.4.0/src/duckstring/catchment/static/_next/static/K01gZbd5BTz315upsZeJJ}/_buildManifest.js +0 -0
  117. {duckstring-0.3.0/src/duckstring/catchment/static/_next/static/Z066xVKlXhWUYRNRq4sUP → duckstring-0.4.0/src/duckstring/catchment/static/_next/static/K01gZbd5BTz315upsZeJJ}/_clientMiddlewareManifest.js +0 -0
  118. {duckstring-0.3.0/src/duckstring/catchment/static/_next/static/Z066xVKlXhWUYRNRq4sUP → duckstring-0.4.0/src/duckstring/catchment/static/_next/static/K01gZbd5BTz315upsZeJJ}/_ssgManifest.js +0 -0
  119. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/03~yq9q893hmn.js +0 -0
  120. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/07lhk_q6pmm3r.js +0 -0
  121. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/0dbhjjzl8qfwv.js +0 -0
  122. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/0jvmviuftg5e2.css +0 -0
  123. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/0kczw6usu-y-f.js +0 -0
  124. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/11kjahy2ntf0n.js +0 -0
  125. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/turbopack-03~mbvk_uplk_.js +0 -0
  126. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/favicon.svg +0 -0
  127. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/logo-mark.svg +0 -0
  128. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/logo.svg +0 -0
  129. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/_http.py +0 -0
  130. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/duct.py +0 -0
  131. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/pond.py +0 -0
  132. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/puddle.py +0 -0
  133. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/status.py +0 -0
  134. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/trigger.py +0 -0
  135. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/window.py +0 -0
  136. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/catalog/.gitignore +0 -0
  137. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/catalog/README.md +0 -0
  138. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/catalog/pond.toml +0 -0
  139. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/catalog/src/pond.py +0 -0
  140. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/orders/.gitignore +0 -0
  141. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/orders/README.md +0 -0
  142. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/orders/pond.toml +0 -0
  143. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/orders/src/pond.py +0 -0
  144. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/priced/.gitignore +0 -0
  145. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/priced/README.md +0 -0
  146. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/priced/pond.toml +0 -0
  147. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/priced/src/pond.py +0 -0
  148. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/priced/src/puddles.py +0 -0
  149. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/products/.gitignore +0 -0
  150. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/products/README.md +0 -0
  151. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/products/pond.toml +0 -0
  152. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/products/src/pond.py +0 -0
  153. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/reports/.gitignore +0 -0
  154. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/reports/README.md +0 -0
  155. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/reports/pond.toml +0 -0
  156. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/reports/src/pond.py +0 -0
  157. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/revenue/.gitignore +0 -0
  158. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/revenue/README.md +0 -0
  159. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/revenue/pond.toml +0 -0
  160. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/revenue/src/pond.py +0 -0
  161. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/revenue/src/puddles.py +0 -0
  162. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/sales/.gitignore +0 -0
  163. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/sales/README.md +0 -0
  164. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/sales/pond.toml +0 -0
  165. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/sales/src/pond.py +0 -0
  166. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/sales/src/puddles.py +0 -0
  167. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/.gitignore +0 -0
  168. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/README.md +0 -0
  169. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/pond.toml +0 -0
  170. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/src/pond.py +0 -0
  171. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/duck/__init__.py +0 -0
  172. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/duck/client.py +0 -0
  173. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/engine/pond.py +0 -0
  174. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/keys.py +0 -0
  175. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/local/__init__.py +0 -0
  176. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/local/hydrate.py +0 -0
  177. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/local/project.py +0 -0
  178. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/schema_contract.py +0 -0
  179. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle/__init__.py +0 -0
  180. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle/acc.py +0 -0
  181. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle/agg.py +0 -0
  182. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle/context.py +0 -0
  183. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle_builder.py +0 -0
  184. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle_io.py +0 -0
  185. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/utils.py +0 -0
  186. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring.egg-info/dependency_links.txt +0 -0
  187. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring.egg-info/entry_points.txt +0 -0
  188. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring.egg-info/requires.txt +0 -0
  189. {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duckstring
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Build data pipelines the way you build software: version each transform, declare its dependencies, and Duckstring resolves the execution DAG automatically.
5
5
  Author-email: Duckstring <dev@duckstring.com>
6
6
  License: Apache License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "duckstring"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Build data pipelines the way you build software: version each transform, declare its dependencies, and Duckstring resolves the execution DAG automatically."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,21 @@
1
+ """Alerts — failure & freshness notifications to external channels. See plans/alerts.md.
2
+
3
+ The observability sibling of a Spout: operational config + a scheme-selected notifier seam +
4
+ ``${env:}``/``${secret:}`` credentials + an async delivery worker that never cascades a failure back into
5
+ the engine. Alerting *observes* the state the engine already computes; it adds no orchestration state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .base import Notifier, NotifierError, get_notifier, parse_notifier_destination
11
+ from .event import KNOWN_EVENTS, AlertEvent, normalise_events
12
+
13
+ __all__ = [
14
+ "AlertEvent",
15
+ "KNOWN_EVENTS",
16
+ "Notifier",
17
+ "NotifierError",
18
+ "get_notifier",
19
+ "normalise_events",
20
+ "parse_notifier_destination",
21
+ ]
@@ -0,0 +1,94 @@
1
+ """The notifier seam (see plans/alerts.md) — mirrors :mod:`duckstring.egress.base`.
2
+
3
+ A small, scheme-selected interface the alert worker delivers an :class:`AlertEvent` through. The channel
4
+ destination is a URI whose scheme picks the notifier; ``get_notifier(destination)`` resolves it, exactly
5
+ like ``get_egress``. Credentials travel inside the URI as ``${env:NAME}``/``${secret:NAME}`` references and
6
+ are resolved only at send time (:mod:`duckstring.egress.credentials`) — never persisted or logged resolved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Callable, Protocol, runtime_checkable
13
+ from urllib.parse import urlparse
14
+
15
+ from ..egress import credentials
16
+ from .event import AlertEvent
17
+
18
+ # Schemes a channel may target. http/https → a webhook (Slack-incoming-webhook compatible); mailto → SMTP.
19
+ WEBHOOK_SCHEMES = {"http", "https"}
20
+ EMAIL_SCHEMES = {"mailto"}
21
+ KNOWN_SCHEMES = WEBHOOK_SCHEMES | EMAIL_SCHEMES
22
+
23
+
24
+ class NotifierError(ValueError):
25
+ """An invalid channel destination, or a delivery/connectivity failure (sanitised — never a credential)."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Destination:
30
+ scheme: str
31
+ raw: str # the original URI, with ${...} references intact (resolved only at send time)
32
+
33
+
34
+ def parse_notifier_destination(uri: str) -> Destination:
35
+ """Validate a channel destination URI: a known scheme + well-formed ``${...}`` references. Does **not**
36
+ resolve credentials (so a channel can be created before its secrets are present). Raises NotifierError."""
37
+ if not uri or not uri.strip():
38
+ raise NotifierError("destination must not be empty")
39
+ try:
40
+ credentials.references(uri) # validates ${env:}/${secret:} syntax
41
+ except credentials.CredentialError as exc:
42
+ raise NotifierError(str(exc)) from exc
43
+ scheme = urlparse(uri).scheme.lower()
44
+ if not scheme:
45
+ raise NotifierError(f"destination {uri!r} has no scheme — expected e.g. https://…, mailto:…")
46
+ if scheme not in KNOWN_SCHEMES:
47
+ raise NotifierError(
48
+ f"unsupported alert destination scheme {scheme!r} — supported: {', '.join(sorted(KNOWN_SCHEMES))}"
49
+ )
50
+ return Destination(scheme=scheme, raw=uri)
51
+
52
+
53
+ @runtime_checkable
54
+ class Notifier(Protocol):
55
+ def send(self, event: AlertEvent) -> None:
56
+ """Deliver ``event`` to the destination. Raises :class:`NotifierError` (sanitised) on failure."""
57
+ ...
58
+
59
+ def test(self) -> None:
60
+ """Probe connectivity/credentials without delivering a real alert (the ``alert test`` command).
61
+ Returns on success; raises :class:`NotifierError` (sanitised) on failure."""
62
+ ...
63
+
64
+
65
+ _REGISTRY: dict[str, Callable[[Destination], Notifier]] = {}
66
+
67
+
68
+ def register(scheme: str, factory: Callable[[Destination], Notifier]) -> None:
69
+ _REGISTRY[scheme] = factory
70
+
71
+
72
+ def get_notifier(destination: str) -> Notifier:
73
+ """Resolve the notifier for a channel destination by its scheme. Raises :class:`NotifierError` for an
74
+ unknown scheme, or a known scheme whose notifier is not built."""
75
+ dest = parse_notifier_destination(destination)
76
+ factory = _REGISTRY.get(dest.scheme)
77
+ if factory is None:
78
+ raise NotifierError(
79
+ f"notifier for scheme {dest.scheme!r} is not implemented yet (built: "
80
+ f"{', '.join(sorted(_REGISTRY)) or 'none'})"
81
+ )
82
+ return factory(dest)
83
+
84
+
85
+ def _register_builtins() -> None:
86
+ from .email import EmailNotifier
87
+ from .webhook import WebhookNotifier
88
+
89
+ for driver in (WebhookNotifier, EmailNotifier):
90
+ for scheme in driver.SCHEMES:
91
+ register(scheme, driver)
92
+
93
+
94
+ _register_builtins()
@@ -0,0 +1,106 @@
1
+ """The email notifier (``mailto:``) — the simplest floor.
2
+
3
+ ``mailto:ops@x.com,dev@x.com?smtp=host:587&from=alerts@x.com&user=${env:SMTP_USER}&password=${secret:SMTP_PASS}&tls=1``
4
+
5
+ SMTP host/port/user/password/from come from the URI query, or the ``DUCKSTRING_SMTP_*`` environment as a
6
+ fallback (so a Catchment can carry one SMTP config for every mailto channel). Credentials are resolved from
7
+ ``${env:}``/``${secret:}`` only at send time. Uses the stdlib ``smtplib`` — no new dependency.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from email.message import EmailMessage
14
+ from urllib.parse import parse_qs, unquote, urlparse
15
+
16
+ from .base import Destination, NotifierError
17
+ from .event import AlertEvent
18
+
19
+ _TIMEOUT = 20.0
20
+
21
+
22
+ class EmailNotifier:
23
+ SCHEMES = ("mailto",)
24
+
25
+ def __init__(self, dest: Destination):
26
+ parsed = urlparse(dest.raw)
27
+ self.recipients = [r.strip() for r in unquote(parsed.path).split(",") if r.strip()]
28
+ if not self.recipients:
29
+ raise NotifierError("mailto: destination has no recipient — use mailto:you@example.com")
30
+ q = {k: v[0] for k, v in parse_qs(parsed.query).items()}
31
+ self._raw = q # references intact; resolved at send time
32
+ self.smtp = q.get("smtp") or os.environ.get("DUCKSTRING_SMTP_HOST", "")
33
+ self.sender = q.get("from") or os.environ.get("DUCKSTRING_SMTP_FROM", "duckstring@localhost")
34
+ tls = q.get("tls")
35
+ self.tls = (tls not in ("0", "false", "no")) if tls is not None else \
36
+ (os.environ.get("DUCKSTRING_SMTP_TLS", "1") not in ("0", "false", "no"))
37
+ if not self.smtp:
38
+ raise NotifierError(
39
+ "mailto: destination needs an SMTP server — add ?smtp=host:port or set DUCKSTRING_SMTP_HOST"
40
+ )
41
+
42
+ def _host_port(self) -> tuple[str, int]:
43
+ host, _, port = self.smtp.partition(":")
44
+ return host, int(port) if port else 587
45
+
46
+ def _credentials(self) -> tuple[str | None, str | None]:
47
+ from ..egress import credentials
48
+
49
+ user = self._raw.get("user") or os.environ.get("DUCKSTRING_SMTP_USER")
50
+ password = self._raw.get("password") or os.environ.get("DUCKSTRING_SMTP_PASSWORD")
51
+ # Resolve ${env:}/${secret:} refs at call time — never persisted/logged resolved.
52
+ user = credentials.resolve(user) if user else None
53
+ password = credentials.resolve(password) if password else None
54
+ return user, password
55
+
56
+ def _connect(self):
57
+ import smtplib
58
+
59
+ host, port = self._host_port()
60
+ server = smtplib.SMTP(host, port, timeout=_TIMEOUT)
61
+ try:
62
+ server.ehlo()
63
+ if self.tls:
64
+ server.starttls()
65
+ server.ehlo()
66
+ user, password = self._credentials()
67
+ if user and password:
68
+ server.login(user, password)
69
+ except Exception:
70
+ server.close()
71
+ raise
72
+ return server
73
+
74
+ def send(self, event: AlertEvent) -> None:
75
+ msg = EmailMessage()
76
+ msg["Subject"] = event.summary()
77
+ msg["From"] = self.sender
78
+ msg["To"] = ", ".join(self.recipients)
79
+ lines = [event.message]
80
+ if event.pond:
81
+ lines.append(f"\nPond: {event.pond}")
82
+ if event.f:
83
+ lines.append(f"Freshness: {event.f}")
84
+ if event.detail:
85
+ for k, v in event.detail.items():
86
+ lines.append(f"{k}: {v}")
87
+ msg.set_content("\n".join(lines))
88
+ try:
89
+ server = self._connect()
90
+ try:
91
+ server.send_message(msg)
92
+ finally:
93
+ server.quit()
94
+ except NotifierError:
95
+ raise
96
+ except Exception as exc: # noqa: BLE001 — sanitise: type only, never the credential/host detail
97
+ raise NotifierError(f"email delivery failed: {type(exc).__name__}") from None
98
+
99
+ def test(self) -> None:
100
+ try:
101
+ server = self._connect() # connect + STARTTLS + login, deliver nothing
102
+ server.quit()
103
+ except NotifierError:
104
+ raise
105
+ except Exception as exc: # noqa: BLE001
106
+ raise NotifierError(f"SMTP connection failed: {type(exc).__name__}") from None
@@ -0,0 +1,97 @@
1
+ """The alert event model + the event-kind vocabulary (see plans/alerts.md).
2
+
3
+ An :class:`AlertEvent` is the rendered, **sanitised** payload a notifier delivers. It reuses what
4
+ ``/api/runs`` surfaces (error message, freshness, pond) but never a raw traceback — a channel destination
5
+ can be third-party, and a traceback can leak paths/connection strings (the same concern behind the API's
6
+ ``_redact_tracebacks``). Keep it JSON-serialisable: it is stored verbatim in the ``alert_delivery`` outbox.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+
14
+ # The event vocabulary. `freshness` is tick-driven (the SLA sweep); the rest are transition-driven.
15
+ KNOWN_EVENTS = ("failure", "contract", "spout", "recovery", "freshness")
16
+
17
+ # Default severity per kind (a channel filters by kind, not severity — this is only for the payload/label).
18
+ SEVERITY = {
19
+ "failure": "error",
20
+ "contract": "error",
21
+ "spout": "error",
22
+ "freshness": "warning",
23
+ "recovery": "info",
24
+ }
25
+
26
+
27
+ def normalise_events(events: str | None) -> tuple[str, ...]:
28
+ """Parse a channel's ``events`` CSV (or ``all``/empty) into a validated tuple of known kinds.
29
+
30
+ ``all`` / empty → every kind. Raises :class:`ValueError` naming an unknown kind."""
31
+ if not events or events.strip().lower() == "all":
32
+ return KNOWN_EVENTS
33
+ out = []
34
+ for raw in events.split(","):
35
+ kind = raw.strip().lower()
36
+ if not kind:
37
+ continue
38
+ if kind not in KNOWN_EVENTS:
39
+ raise ValueError(f"unknown alert event {kind!r} — choose from {', '.join(KNOWN_EVENTS)} (or 'all')")
40
+ out.append(kind)
41
+ if not out:
42
+ return KNOWN_EVENTS
43
+ return tuple(dict.fromkeys(out)) # de-duplicated, order preserved
44
+
45
+
46
+ @dataclass
47
+ class AlertEvent:
48
+ """A rendered notification. ``detail`` carries kind-specific extras (e.g. the blocked-downstream blast
49
+ radius for a failure); never put a traceback in it."""
50
+
51
+ kind: str
52
+ pond: str | None
53
+ title: str
54
+ message: str
55
+ severity: str = ""
56
+ f: str | None = None
57
+ catchment: str | None = None
58
+ ts: str = ""
59
+ detail: dict = field(default_factory=dict)
60
+
61
+ def __post_init__(self) -> None:
62
+ if not self.severity:
63
+ self.severity = SEVERITY.get(self.kind, "info")
64
+ if not self.ts:
65
+ self.ts = datetime.now(timezone.utc).isoformat()
66
+
67
+ def summary(self) -> str:
68
+ """A one-line human summary (the webhook ``text`` / the email subject line)."""
69
+ where = f" [{self.catchment}]" if self.catchment else ""
70
+ return f"{self.severity.upper()}{where}: {self.title}"
71
+
72
+ def to_payload(self) -> dict:
73
+ return {
74
+ "kind": self.kind,
75
+ "severity": self.severity,
76
+ "pond": self.pond,
77
+ "f": self.f,
78
+ "title": self.title,
79
+ "message": self.message,
80
+ "catchment": self.catchment,
81
+ "ts": self.ts,
82
+ "detail": self.detail,
83
+ }
84
+
85
+ @classmethod
86
+ def from_payload(cls, payload: dict) -> "AlertEvent":
87
+ return cls(
88
+ kind=payload.get("kind", ""),
89
+ pond=payload.get("pond"),
90
+ title=payload.get("title", ""),
91
+ message=payload.get("message", ""),
92
+ severity=payload.get("severity", ""),
93
+ f=payload.get("f"),
94
+ catchment=payload.get("catchment"),
95
+ ts=payload.get("ts", ""),
96
+ detail=payload.get("detail") or {},
97
+ )
@@ -0,0 +1,46 @@
1
+ """The webhook notifier (``http``/``https``) — the highest-leverage channel.
2
+
3
+ POSTs a JSON body that is both a plain structured event **and** Slack-incoming-webhook compatible: a
4
+ top-level ``text`` summary (which Slack renders, and a generic receiver can read) plus the full structured
5
+ event. So one driver covers Slack, a generic webhook receiver, and (via a proxy) PagerDuty's Events API.
6
+ Any credential/token in the URL is a ``${env:}``/``${secret:}`` reference, resolved only at send time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .base import Destination, NotifierError
12
+ from .event import AlertEvent
13
+
14
+ _TIMEOUT = 15.0
15
+
16
+
17
+ class WebhookNotifier:
18
+ SCHEMES = ("http", "https")
19
+
20
+ def __init__(self, dest: Destination):
21
+ self.dest = dest
22
+
23
+ def _post(self, event: AlertEvent) -> None:
24
+ import httpx
25
+
26
+ from ..egress import credentials
27
+
28
+ url = credentials.resolve(self.dest.raw) # resolve any ${env:}/${secret:} token — never logged
29
+ body = {"text": event.summary(), **event.to_payload()} # `text` for Slack; the rest for generic receivers
30
+ try:
31
+ resp = httpx.post(url, json=body, timeout=_TIMEOUT)
32
+ resp.raise_for_status()
33
+ except httpx.HTTPStatusError as exc:
34
+ # Sanitise: report status + reason, never the URL (it may carry a token in the path/query).
35
+ raise NotifierError(f"webhook returned {exc.response.status_code}") from None
36
+ except httpx.HTTPError as exc:
37
+ raise NotifierError(f"webhook delivery failed: {type(exc).__name__}") from None
38
+
39
+ def send(self, event: AlertEvent) -> None:
40
+ self._post(event)
41
+
42
+ def test(self) -> None:
43
+ self._post(AlertEvent(
44
+ kind="recovery", pond=None, title="Duckstring alert channel test",
45
+ message="This is a test notification — your alert channel is configured correctly.",
46
+ ))
@@ -0,0 +1,58 @@
1
+ """The alert worker — delivers queued notifications to their channels. See plans/alerts.md.
2
+
3
+ One async task in the Catchment process (outbound I/O, the exact shape of the egress worker): each pass it
4
+ drains the pending ``alert_delivery`` rows the engine enqueued (:meth:`Driver.take_alert_deliveries`),
5
+ resolves each channel's notifier by scheme, ``send``s it in a threadpool with a per-send timeout, and marks
6
+ the row sent (:meth:`Driver.mark_delivery_sent`) or bumps attempts / parks it failed at the cap
7
+ (:meth:`Driver.mark_delivery_failed`).
8
+
9
+ **A send failure never propagates into the engine** — it is recorded on the delivery row (auditable via
10
+ ``alert log``) and retried on the next tick until it succeeds or hits ``MAX_ATTEMPTS``. A permanently-broken
11
+ channel therefore stops retrying but leaves a visible failed row, never a Pond failure.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+
18
+ from fastapi.concurrency import run_in_threadpool
19
+
20
+ _RECONCILE_INTERVAL = 5.0 # self-healing tick — retries pending deliveries a previous send left behind
21
+ _PER_SEND_TIMEOUT = 30.0 # ceiling on one delivery, so a slow channel can't starve the others
22
+ MAX_ATTEMPTS = 6 # after this many failed sends, park the delivery 'failed' (stop retrying)
23
+
24
+
25
+ def _deliver(destination: str, payload: dict) -> None:
26
+ """Send one notification (blocking — runs in the thread pool). Raises on any failure."""
27
+ from ..alerts import AlertEvent, get_notifier
28
+
29
+ notifier = get_notifier(destination) # resolves the scheme's driver (+ validates the URI)
30
+ notifier.send(AlertEvent.from_payload(payload))
31
+
32
+
33
+ async def _drain(driver) -> None:
34
+ for row in driver.take_alert_deliveries():
35
+ try:
36
+ await asyncio.wait_for(
37
+ run_in_threadpool(_deliver, row["destination"], row["payload"]),
38
+ timeout=_PER_SEND_TIMEOUT,
39
+ )
40
+ except Exception as exc: # noqa: BLE001 — any delivery error is recorded, never raised into the engine
41
+ driver.mark_delivery_failed(row["id"], f"{type(exc).__name__}: {exc}", MAX_ATTEMPTS)
42
+ else:
43
+ driver.mark_delivery_sent(row["id"])
44
+
45
+
46
+ async def run_alert_worker(driver, wake: asyncio.Event) -> None:
47
+ """Drain queued alert deliveries on each wake (a delivery was enqueued) or the reconcile tick.
48
+ Cancelled on shutdown."""
49
+ while True:
50
+ try:
51
+ await asyncio.wait_for(wake.wait(), timeout=_RECONCILE_INTERVAL)
52
+ except asyncio.TimeoutError:
53
+ pass # periodic reconcile — retry anything still pending
54
+ wake.clear()
55
+ try:
56
+ await _drain(driver)
57
+ except Exception as exc: # keep the loop alive
58
+ print(f"[catchment] alert worker error: {exc}", flush=True)
@@ -0,0 +1,199 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from contextlib import asynccontextmanager
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from fastapi import FastAPI
10
+ from fastapi.staticfiles import StaticFiles
11
+
12
+ from . import auth
13
+ from .db import connect, ensure_identity, migrate
14
+ from .driver import Driver
15
+ from .launcher import NoopLauncher, SubprocessLauncher
16
+ from .routes import router
17
+
18
+ _STATIC_DIR = Path(__file__).parent / "static"
19
+
20
+
21
+ async def _scheduler(driver: Driver) -> None:
22
+ """Drive clock processes (Tide deadlines, window boundaries, Wave-on-idle) at next_wake."""
23
+ while True:
24
+ nw = driver.next_wake()
25
+ now = datetime.now(timezone.utc)
26
+ delay = (nw - now).total_seconds() if nw else 1.0
27
+ await asyncio.sleep(max(0.05, min(delay, 5.0)))
28
+ try:
29
+ driver.scheduler_tick()
30
+ except Exception as exc: # keep the loop alive
31
+ print(f"[catchment] scheduler error: {exc}", flush=True)
32
+
33
+
34
+ @asynccontextmanager
35
+ async def _lifespan(app: FastAPI):
36
+ base_url = app.state.base_url
37
+ if os.environ.get("DUCKSTRING_DISABLE_DUCKS"):
38
+ launcher = NoopLauncher()
39
+ else:
40
+ # Ducks dial back over the duck channel with the internal worker token (decoupled from the user
41
+ # keys, so rotating those never disrupts running Ducks).
42
+ # base_url None = unknown (the platform picked the bind address): the launcher defers spawns
43
+ # until the dial-back middleware learns the address from the first request.
44
+ launcher = SubprocessLauncher(
45
+ app.state.root, base_url, token=app.state.duck_token, data_root=app.state.data_root
46
+ )
47
+ driver = Driver(app.state.db, app.state.root, base_url, launcher, data_root=app.state.data_root)
48
+ app.state.driver = driver
49
+ app.state.launcher = launcher
50
+
51
+ # Restore: resume any Pond Runs that were in flight when the Catchment last stopped.
52
+ driver.resume_incomplete()
53
+
54
+ from .alert_worker import run_alert_worker
55
+ from .egress_worker import run_egress_worker
56
+ from .poller import run_poller
57
+
58
+ # The poller wakes immediately when a Draw acquires demand (so it solicits its upstream at once),
59
+ # instead of waiting for its next cycle. The driver signals across threads via the running loop.
60
+ wake = asyncio.Event()
61
+ loop = asyncio.get_running_loop()
62
+ driver.set_notify(lambda: loop.call_soon_threadsafe(wake.set))
63
+
64
+ # The egress worker wakes when a Pond publishes (its Spouts may have work) or a Spout is resynced.
65
+ egress_wake = asyncio.Event()
66
+ driver.set_egress_notify(lambda: loop.call_soon_threadsafe(egress_wake.set))
67
+
68
+ # The alert worker wakes when a failure/freshness event enqueues a notification delivery.
69
+ alert_wake = asyncio.Event()
70
+ driver.set_alert_notify(lambda: loop.call_soon_threadsafe(alert_wake.set))
71
+
72
+ from .state_sync import checkpoint_full, run_checkpoint_worker
73
+
74
+ scheduler = asyncio.create_task(_scheduler(driver))
75
+ poller = asyncio.create_task(run_poller(driver, app.state.root, wake))
76
+ egress = asyncio.create_task(run_egress_worker(driver, app.state.root, egress_wake))
77
+ alerts = asyncio.create_task(run_alert_worker(driver, alert_wake))
78
+ # Tier-1 state backup: push a duck.db snapshot to DUCKSTRING_STATE_BACKUP_URI on an interval (no-op
79
+ # when unset). The Tier-2 warm bundle is flushed once below, after the Ducks are stopped (quiescent).
80
+ checkpointer = asyncio.create_task(
81
+ run_checkpoint_worker(app.state.root, app.state.state_backup, app.state.checkpoint_every)
82
+ )
83
+ # Renew the data-root writer lease so a live Catchment's ownership never lapses (external data root only).
84
+ tasks = [scheduler, poller, egress, alerts, checkpointer]
85
+ if app.state.data_lease is not None:
86
+ from .data_lease import run_lease_renewer
87
+
88
+ lease_store, owner_id = app.state.data_lease
89
+ tasks.append(asyncio.create_task(run_lease_renewer(lease_store, owner_id)))
90
+ try:
91
+ yield
92
+ finally:
93
+ for task in tasks:
94
+ task.cancel()
95
+ try:
96
+ await task
97
+ except asyncio.CancelledError:
98
+ pass
99
+ launcher.shutdown_all()
100
+ if app.state.data_lease is not None:
101
+ from .data_lease import release_lease
102
+
103
+ lease_store, owner_id = app.state.data_lease
104
+ try:
105
+ release_lease(lease_store, owner_id)
106
+ except Exception: # pragma: no cover - releasing the lease must not break shutdown
107
+ pass
108
+ # Ducks are now stopped → the registries/ledgers are quiescent: flush the warm Tier-1+2 bundle so a
109
+ # scaled-to-zero restart comes back warm (and engine state survives even a hard next crash).
110
+ if app.state.state_backup:
111
+ from fastapi.concurrency import run_in_threadpool
112
+
113
+ await run_in_threadpool(checkpoint_full, app.state.root, app.state.state_backup)
114
+
115
+
116
+ def create_app(
117
+ root: Path, api_key: str | None = None, base_url: str | None = None, name: str | None = None,
118
+ *, data_root: str | None = None, state_backup: str | None = None, checkpoint_every: str | None = None,
119
+ ) -> FastAPI:
120
+ # Data plane location (object store / Volume / path) and the Tier-1 state-backup target. Explicit
121
+ # arguments (the CLI passes the registration's values) win, else the platform-hosting env vars.
122
+ data_root = data_root or os.environ.get("DUCKSTRING_DATA_ROOT") or None
123
+ state_backup = state_backup or os.environ.get("DUCKSTRING_STATE_BACKUP_URI") or None
124
+ checkpoint_every = checkpoint_every or os.environ.get("DUCKSTRING_CHECKPOINT_INTERVAL") or "60s"
125
+
126
+ # Restore Tier-1 state (duck.db, ledgers) from the backup before opening the DB, if the state root is
127
+ # empty (a fresh/scaled-to-zero node) and a backup exists.
128
+ from .state_sync import restore_state_if_empty
129
+
130
+ restore_state_if_empty(root, state_backup)
131
+
132
+ root.mkdir(parents=True, exist_ok=True)
133
+ con = connect(root / "duck.db")
134
+ migrate(con)
135
+ ensure_identity(con, name or os.environ.get("DUCKSTRING_CATCHMENT_NAME"))
136
+
137
+ # Writer lease on an external data root — refuse to start if a *different* live Catchment owns it (two
138
+ # Catchments racing one lake's Iceberg catalog would dangle its pointer). A same-id restart reclaims
139
+ # instantly; only engaged for an external DUCKSTRING_DATA_ROOT, so the local default is untouched.
140
+ data_lease = None
141
+ if data_root:
142
+ from ..storage import get_storage
143
+ from .data_lease import acquire_lease
144
+
145
+ cid = con.execute("SELECT value FROM catchment_meta WHERE key = 'id'").fetchone()
146
+ owner_id = cid[0] if cid else "unknown"
147
+ lease_store = get_storage(data_root)
148
+ acquire_lease(lease_store, owner_id) # raises LeaseConflict → the server does not start
149
+ data_lease = (lease_store, owner_id)
150
+
151
+ app = FastAPI(title="Duckstring Catchment", lifespan=_lifespan)
152
+ app.state.root = root
153
+ app.state.data_lease = data_lease
154
+ app.state.data_root = data_root
155
+ app.state.state_backup = state_backup
156
+ app.state.checkpoint_every = checkpoint_every
157
+ app.state.db = con
158
+ # Built-in single API key (legacy / bare self-hosting): explicit argument or the environment. It
159
+ # means full access. The tiered read/demand/full keys live in `catchment_key` (see auth.py); either
160
+ # may gate the API. With neither configured, the Catchment is fully open.
161
+ app.state.api_key = api_key or os.environ.get("DUCKSTRING_API_KEY") or None
162
+ # The internal token Ducks present on the duck channel (persisted, decoupled from the user keys).
163
+ app.state.duck_token = auth.ensure_duck_token(con)
164
+ # The write-only secret store (at the root, archive-excluded). Injected into the egress credential
165
+ # resolver so a ${secret:NAME} reference resolves at egress time.
166
+ from ..egress import credentials
167
+ from .secrets import SecretStore
168
+ app.state.secret_store = SecretStore(root)
169
+ credentials.set_secret_provider(app.state.secret_store.get)
170
+ # The address Ducks dial back to: explicit argument (the CLI passes its bind address), or the
171
+ # environment, or None — unknown, because the host platform picks the bind address (e.g. Posit
172
+ # Connect). When None it is learned from the first request's ASGI scope below.
173
+ app.state.base_url = base_url or os.environ.get("DUCKSTRING_CATCHMENT_URL") or None
174
+
175
+ @app.middleware("http")
176
+ async def _learn_dialback_address(request, call_next):
177
+ launcher = getattr(app.state, "launcher", None)
178
+ if launcher is not None and getattr(launcher, "base_url", "") is None:
179
+ server = request.scope.get("server") # the server's bound (host, port) per the ASGI spec
180
+ if server and server[1]: # a unix socket has port None — nothing TCP to dial
181
+ host = "127.0.0.1" if server[0] in ("0.0.0.0", "::") else server[0]
182
+ url = f"http://{host}:{server[1]}"
183
+ app.state.base_url = url
184
+ app.state.driver.base_url = url
185
+ launcher.set_base_url(url) # spawns any Ducks that were waiting on the address
186
+ return await call_next(request)
187
+
188
+ app.include_router(router, prefix="/api")
189
+ auth.audit_routes(app) # fail-closed: every /api route must declare an access level
190
+
191
+ # Prometheus scrape endpoint at the ROOT (unauthenticated, the exporter convention) — mounted before
192
+ # the static "/" catch-all so it resolves, and outside "/api" so the access-level audit doesn't cover it.
193
+ from .routes.metrics import router as metrics_router
194
+ app.include_router(metrics_router)
195
+
196
+ if _STATIC_DIR.exists():
197
+ app.mount("/", StaticFiles(directory=_STATIC_DIR, html=True), name="frontend")
198
+
199
+ return app