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.
- {duckstring-0.3.0/src/duckstring.egg-info → duckstring-0.4.0}/PKG-INFO +1 -1
- {duckstring-0.3.0 → duckstring-0.4.0}/pyproject.toml +1 -1
- duckstring-0.4.0/src/duckstring/alerts/__init__.py +21 -0
- duckstring-0.4.0/src/duckstring/alerts/base.py +94 -0
- duckstring-0.4.0/src/duckstring/alerts/email.py +106 -0
- duckstring-0.4.0/src/duckstring/alerts/event.py +97 -0
- duckstring-0.4.0/src/duckstring/alerts/webhook.py +46 -0
- duckstring-0.4.0/src/duckstring/catchment/alert_worker.py +58 -0
- duckstring-0.4.0/src/duckstring/catchment/app.py +199 -0
- duckstring-0.4.0/src/duckstring/catchment/asgi.py +39 -0
- duckstring-0.4.0/src/duckstring/catchment/auth.py +203 -0
- duckstring-0.4.0/src/duckstring/catchment/data_lease.py +159 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/driver.py +1064 -24
- duckstring-0.4.0/src/duckstring/catchment/egress_worker.py +102 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/launcher.py +13 -4
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/poller.py +32 -21
- duckstring-0.4.0/src/duckstring/catchment/registry.py +40 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/__init__.py +4 -0
- duckstring-0.4.0/src/duckstring/catchment/routes/alerts.py +75 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/catchment.py +37 -5
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/data.py +99 -17
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/deploy.py +50 -16
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/draw.py +50 -16
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/duck.py +3 -2
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/duct.py +8 -6
- duckstring-0.4.0/src/duckstring/catchment/routes/metrics.py +99 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/orchestrate.py +222 -27
- duckstring-0.4.0/src/duckstring/catchment/routes/secrets.py +43 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/view.py +3 -1
- duckstring-0.4.0/src/duckstring/catchment/schema/007_catchment_key.sql +9 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/008_spout.sql +18 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/009_spout_state.sql +10 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/010_spout_wake.sql +6 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/011_spout_window.sql +17 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/012_spout_node.sql +20 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/013_changed_f.sql +14 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/014_alert.sql +38 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/016_alert_scope_major.sql +15 -0
- duckstring-0.4.0/src/duckstring/catchment/secrets.py +72 -0
- duckstring-0.4.0/src/duckstring/catchment/state_sync.py +182 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/404.html +1 -1
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next.__PAGE__.txt +2 -2
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._full.txt +3 -3
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._head.txt +1 -1
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._index.txt +2 -2
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._tree.txt +2 -2
- 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
- duckstring-0.4.0/src/duckstring/catchment/static/_next/static/chunks/0qdnkaffkg.2-.js +2 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._full.txt +2 -2
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._head.txt +1 -1
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._index.txt +2 -2
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._not-found.__PAGE__.txt +1 -1
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._not-found.txt +1 -1
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._tree.txt +2 -2
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found.html +1 -1
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found.txt +2 -2
- duckstring-0.4.0/src/duckstring/catchment/static/index.html +1 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/index.txt +3 -3
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/__init__.py +17 -2
- duckstring-0.4.0/src/duckstring/cli/alert.py +146 -0
- duckstring-0.4.0/src/duckstring/cli/bulk.py +131 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/catchment.py +251 -41
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/config.py +19 -1
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/control.py +23 -0
- duckstring-0.4.0/src/duckstring/cli/data.py +266 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/deploy.py +41 -2
- duckstring-0.4.0/src/duckstring/cli/secret.py +72 -0
- duckstring-0.4.0/src/duckstring/cli/spout.py +142 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/core.py +174 -21
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/dataplane.py +143 -98
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/duck/__main__.py +21 -6
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/duck/core.py +56 -7
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/duck/executor.py +31 -10
- duckstring-0.4.0/src/duckstring/egress/__init__.py +19 -0
- duckstring-0.4.0/src/duckstring/egress/base.py +96 -0
- duckstring-0.4.0/src/duckstring/egress/credentials.py +93 -0
- duckstring-0.4.0/src/duckstring/egress/destination.py +64 -0
- duckstring-0.4.0/src/duckstring/egress/object_store.py +167 -0
- duckstring-0.4.0/src/duckstring/egress/postgres.py +170 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/engine/__init__.py +6 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/engine/catchment.py +204 -26
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/engine/core.py +23 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/engine/worker.py +6 -3
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/iceberg_catalog.py +31 -8
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/iceberg_plane.py +40 -42
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/local/runner.py +12 -1
- duckstring-0.4.0/src/duckstring/objects.py +225 -0
- duckstring-0.4.0/src/duckstring/storage.py +543 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle/builder.py +40 -18
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle/io.py +134 -41
- {duckstring-0.3.0 → duckstring-0.4.0/src/duckstring.egg-info}/PKG-INFO +1 -1
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring.egg-info/SOURCES.txt +40 -5
- duckstring-0.3.0/src/duckstring/catchment/app.py +0 -123
- duckstring-0.3.0/src/duckstring/catchment/asgi.py +0 -30
- duckstring-0.3.0/src/duckstring/catchment/registry.py +0 -27
- duckstring-0.3.0/src/duckstring/catchment/static/_next/static/chunks/0l6vjq3ae5vov.js +0 -2
- duckstring-0.3.0/src/duckstring/catchment/static/index.html +0 -1
- duckstring-0.3.0/src/duckstring/cli/data.py +0 -133
- {duckstring-0.3.0 → duckstring-0.4.0}/LICENSE +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/MANIFEST.in +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/setup.cfg +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/__init__.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/__main__.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/acc.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/agg.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/__init__.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/dag.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/db.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/001_init.sql +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/002_ducts.sql +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/003_pull_m.sql +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/004_identity.sql +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/005_pond_version_schema.sql +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/006_refresh.sql +0 -0
- {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
- {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
- {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
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/03~yq9q893hmn.js +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/07lhk_q6pmm3r.js +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/0dbhjjzl8qfwv.js +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/0jvmviuftg5e2.css +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/0kczw6usu-y-f.js +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/11kjahy2ntf0n.js +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/turbopack-03~mbvk_uplk_.js +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/favicon.svg +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/logo-mark.svg +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/catchment/static/logo.svg +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/_http.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/duct.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/puddle.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/status.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/trigger.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/cli/window.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/catalog/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/catalog/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/catalog/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/catalog/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/orders/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/orders/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/orders/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/orders/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/priced/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/priced/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/priced/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/priced/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/priced/src/puddles.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/products/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/products/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/products/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/products/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/reports/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/reports/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/reports/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/reports/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/revenue/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/revenue/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/revenue/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/revenue/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/revenue/src/puddles.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/sales/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/sales/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/sales/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/sales/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/sales/src/puddles.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/duck/__init__.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/duck/client.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/engine/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/keys.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/local/__init__.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/local/hydrate.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/local/project.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/schema_contract.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle/__init__.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle/acc.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle/agg.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle/context.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle_builder.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/trickle_io.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring/utils.py +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring.egg-info/dependency_links.txt +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring.egg-info/entry_points.txt +0 -0
- {duckstring-0.3.0 → duckstring-0.4.0}/src/duckstring.egg-info/requires.txt +0 -0
- {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
|
+
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
|
+
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
|