cdt-kit 0.1.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.
- cdt_kit-0.1.0/.gitignore +25 -0
- cdt_kit-0.1.0/CHANGELOG.md +15 -0
- cdt_kit-0.1.0/PKG-INFO +180 -0
- cdt_kit-0.1.0/README.md +158 -0
- cdt_kit-0.1.0/VERSION +1 -0
- cdt_kit-0.1.0/pyproject.toml +45 -0
- cdt_kit-0.1.0/src/cdtkit/__init__.py +7 -0
- cdt_kit-0.1.0/src/cdtkit/config.py +118 -0
- cdt_kit-0.1.0/src/cdtkit/fastapi.py +35 -0
- cdt_kit-0.1.0/src/cdtkit/http/__init__.py +39 -0
- cdt_kit-0.1.0/src/cdtkit/http/logging.py +62 -0
- cdt_kit-0.1.0/src/cdtkit/jobs/__init__.py +122 -0
- cdt_kit-0.1.0/src/cdtkit/jobs/prune.py +59 -0
- cdt_kit-0.1.0/src/cdtkit/jobs/recovery.py +108 -0
- cdt_kit-0.1.0/src/cdtkit/jobs/worker.py +96 -0
- cdt_kit-0.1.0/src/cdtkit/migrations.py +188 -0
- cdt_kit-0.1.0/src/cdtkit/testing/__init__.py +7 -0
- cdt_kit-0.1.0/src/cdtkit/testing/plugin.py +28 -0
- cdt_kit-0.1.0/src/cdtkit/testing/postgres.py +93 -0
- cdt_kit-0.1.0/src/cdtkit/testing/queue.py +35 -0
- cdt_kit-0.1.0/tests/test_config.py +73 -0
- cdt_kit-0.1.0/tests/test_http.py +76 -0
- cdt_kit-0.1.0/tests/test_jobs.py +141 -0
- cdt_kit-0.1.0/tests/test_lint.py +73 -0
- cdt_kit-0.1.0/tests/test_migrations.py +77 -0
- cdt_kit-0.1.0/tests/test_recovery_prune.py +125 -0
- cdt_kit-0.1.0/tests/test_testing.py +12 -0
cdt_kit-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# secrets
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
*.pem
|
|
6
|
+
*.key
|
|
7
|
+
# python
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.pyc
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
# runtime
|
|
13
|
+
data/
|
|
14
|
+
logs/
|
|
15
|
+
*.log
|
|
16
|
+
*.tmp
|
|
17
|
+
*.bak
|
|
18
|
+
node_modules/
|
|
19
|
+
.DS_Store
|
|
20
|
+
.toolkit/
|
|
21
|
+
# build and test output
|
|
22
|
+
dist/
|
|
23
|
+
build/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.pytest_cache/
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-28
|
|
4
|
+
- First release of the kit (claude-deploy-toolkit #422, sequenced in claude-deploy-toolkit #432 section 4): distribution
|
|
5
|
+
`cdt-kit`, import name `cdtkit`; framework-agnostic core plus the FastAPI adapter.
|
|
6
|
+
- `cdtkit.config`: `*_FILE` secrets (an empty file counts as unset), the database from `DATABASE_*` and
|
|
7
|
+
`DATABASE_PASSWORD_FILE`, `ENV_CLASS`, `CDT_LANGUAGE`, `TOOLKIT_JOB_TYPES`.
|
|
8
|
+
- `cdtkit.migrations`: forward-only SQL files under an advisory lock with `schema_migrations` (compatible with
|
|
9
|
+
magic-minutes' table), the queue schema when missing, and the expand-only lint (`python -m cdtkit.migrations lint`).
|
|
10
|
+
- `cdtkit.jobs`: procrastinate with one queue per job type, `Backoff` (total attempts, exponential with jitter, capped),
|
|
11
|
+
`NoRow` ending a job as `skipped=no-row`, the final-failure hook; the worker process with heartbeat health; dead-worker
|
|
12
|
+
and stuck-job recovery; `prune_finished` after `failed_retention`.
|
|
13
|
+
- `cdtkit.http`: logfmt logging and health checks; `cdtkit.fastapi.health_router` for `/healthz`.
|
|
14
|
+
- `cdtkit.testing`: the Postgres fixtures (`cdt_postgres`, `cdt_database`) and the in-memory queue.
|
|
15
|
+
- The self-test stack (selftest and its testdb sidecar) and publishing to PyPI with Trusted Publishing (RUNBOOK.md).
|
cdt_kit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cdt-kit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Server-side app kit of the Controlled Delivery Toolkit: config, migrations, jobs and HTTP basics
|
|
5
|
+
Project-URL: Source, https://github.com/fromerobr-ai/cdt-kit
|
|
6
|
+
Project-URL: Changelog, https://github.com/fromerobr-ai/cdt-kit/blob/main/CHANGELOG.md
|
|
7
|
+
Author: fromerobr-ai
|
|
8
|
+
Classifier: Framework :: FastAPI
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Database
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Requires-Dist: psycopg<4,>=3.2
|
|
14
|
+
Provides-Extra: fastapi
|
|
15
|
+
Requires-Dist: fastapi>=0.115; extra == 'fastapi'
|
|
16
|
+
Provides-Extra: jobs
|
|
17
|
+
Requires-Dist: procrastinate<4,>=3.10; extra == 'jobs'
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# cdt-kit
|
|
24
|
+
|
|
25
|
+
The server-side app kit of the Controlled Delivery Toolkit (CDT): a Python library every app pins for what the toolkit's
|
|
26
|
+
standard asks of its request path, so projects stop rebuilding it (claude-deploy-toolkit #422). Distribution `cdt-kit`
|
|
27
|
+
on PyPI, import name `cdtkit` (PyPI's `cdt` is another project's module).
|
|
28
|
+
|
|
29
|
+
0.1.0 holds what the pilot, magic-minutes, needs first:
|
|
30
|
+
|
|
31
|
+
| Module | What it does |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `cdtkit.config` | `*_FILE` secrets, the database from `DATABASE_*`, `ENV_CLASS`, `CDT_LANGUAGE`, `TOOLKIT_JOB_TYPES` |
|
|
34
|
+
| `cdtkit.migrations` | forward-only SQL files at start (advisory lock, `schema_migrations`), the expand-only lint |
|
|
35
|
+
| `cdtkit.jobs` | procrastinate on the app's Postgres: one queue per job type, backoff with jitter, `NoRow` (`skipped=no-row`), the final-failure hook |
|
|
36
|
+
| `cdtkit.jobs.worker` | the worker process: one worker per queue, heartbeat health check, graceful stop |
|
|
37
|
+
| `cdtkit.jobs.recovery` | dead-worker and stuck-job recovery |
|
|
38
|
+
| `cdtkit.jobs.prune` | finished jobs removed after `failed_retention` (run from a declared schedule) |
|
|
39
|
+
| `cdtkit.http` | logfmt logging on stdout, the `/healthz` checks |
|
|
40
|
+
| `cdtkit.fastapi` | the FastAPI adapter: `health_router` |
|
|
41
|
+
| `cdtkit.testing` | test stand-ins: a Postgres (`cdt_postgres`, `cdt_database` fixtures) and the in-memory queue |
|
|
42
|
+
|
|
43
|
+
Identity, the account contract, legal, i18n and consent come in 0.2.0; `/metrics`, RFC 9457 problems, rate classes and
|
|
44
|
+
Idempotency-Key later (see #422).
|
|
45
|
+
|
|
46
|
+
## Install and pin
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
cdt-kit[jobs,fastapi]==0.1.0 # requirements.txt of an app; [test] for its test environment
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The core depends only on `psycopg` 3 (choose its C build in the app: `psycopg[binary]`); `jobs` adds procrastinate
|
|
53
|
+
3.x, `fastapi` the adapter, `test` pytest and the fixtures. Python 3.11 or newer.
|
|
54
|
+
|
|
55
|
+
**Production pins only stable kit versions.** Every deployed kit candidate is published to PyPI at once, so its pilot
|
|
56
|
+
can test it before promotion; a version is stable once promoted (`RELEASES.md` of this repository, the GitHub Release
|
|
57
|
+
marked latest). An app may pin a candidate on staging; its production deployments pin a stable version. The toolkit's
|
|
58
|
+
rule `kit-version` (planned, claude-deploy-toolkit #423) will check it; until then it is a rule each app keeps.
|
|
59
|
+
|
|
60
|
+
Versions are semantic: a minor adds, a major breaks; anything deprecated keeps working for two minors with a warning.
|
|
61
|
+
At most one kit release per toolkit minor.
|
|
62
|
+
|
|
63
|
+
## Use
|
|
64
|
+
|
|
65
|
+
Configuration, read once at start:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from cdtkit import config
|
|
69
|
+
|
|
70
|
+
db = config.database() # DATABASE_HOST/_PORT/_NAME/_USER + DATABASE_PASSWORD_FILE
|
|
71
|
+
conninfo = db.conninfo("web") # libpq string; repr(db) never shows the password
|
|
72
|
+
key = config.read_secret("KEYS_ENCRYPTION_KEY") # KEYS_ENCRYPTION_KEY_FILE, else the variable (local runs)
|
|
73
|
+
optional = config.read_secret("SMTP_PASSWORD", required=False) # "" when unset or an empty file
|
|
74
|
+
env_class, language = config.env_class(), config.language() # "staging"/"production", CDT_LANGUAGE
|
|
75
|
+
job_types = config.job_types(service="worker") # this worker's TOOLKIT_JOB_TYPES entry: {id: params}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Missing or invalid configuration raises `config.ConfigError` (a `SystemExit`: the process stops with one line).
|
|
79
|
+
|
|
80
|
+
Migrations at start, in every process (web and workers may start together; one migrates, the others wait):
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from cdtkit import migrations
|
|
84
|
+
migrations.migrate(conninfo, "app/migrations", queue_app=queue_app) # *.sql in name order, each once
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Each file runs in its own transaction and is recorded in `schema_migrations (name, applied_at)`; with a procrastinate
|
|
88
|
+
app, its schema is applied when missing. The lint reports contracting statements (drops, renames, type changes, new
|
|
89
|
+
`NOT NULL`, `UNIQUE` or `CHECK` on existing data):
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
python -m cdtkit.migrations lint app/migrations [--after 0007_accounts_ui.sql] # exit 1 on findings
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A contract belongs in a cleanup release that declares it in `deploy/package.json` `"contracts"`; mark its line
|
|
96
|
+
`-- cdt: contract <reason>` and the lint accepts it.
|
|
97
|
+
|
|
98
|
+
Jobs (one queue per job type; parameters from `deploy/workers.json` through `TOOLKIT_JOB_TYPES`):
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from cdtkit import jobs
|
|
102
|
+
from cdtkit.jobs import worker
|
|
103
|
+
|
|
104
|
+
DEFAULTS = {"convert": {"concurrency": 1, "max_attempts": 3, "backoff": "60s", "timeout_item": "3h"}}
|
|
105
|
+
|
|
106
|
+
async def convert(meeting_id):
|
|
107
|
+
meeting = load(meeting_id)
|
|
108
|
+
if meeting is None:
|
|
109
|
+
raise jobs.NoRow("meeting gone") # deleted while queued: the job ends, logged skipped=no-row, no retry
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
app = jobs.make_app(conninfo, job_types, {"convert": convert}, DEFAULTS, pool_max=2, on_final_failure=mark_failed)
|
|
113
|
+
await jobs.defer_async(app, "convert", meeting_id=7) # web processes defer; payloads carry IDs only
|
|
114
|
+
await worker.run(app, ["convert"], job_types, DEFAULTS, ping=db_ping, on_given_up=mark_failed,
|
|
115
|
+
never_give_up=("account-delete",))
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- `max_attempts` counts runs in total (at most 25); the wait after the n-th failure is `backoff * 2^(n-1)`, capped at 6
|
|
119
|
+
hours, shortened by up to half at random (jitter).
|
|
120
|
+
- `on_final_failure(job_id, payload)` runs when the last attempt raises; `on_given_up` when the recovery gives a job up
|
|
121
|
+
(its worker died with no attempt left, or it ran past `timeout_item` plus 5 minutes). Both mark what the user sees.
|
|
122
|
+
- The worker's container health check: `python -m cdtkit.jobs.worker --health` (exit 0 while the heartbeat in
|
|
123
|
+
`/tmp/worker-heartbeat` is younger than 80 seconds; the heartbeat runs `ping` first).
|
|
124
|
+
- Finished jobs: successful ones are deleted at once; failed, cancelled and aborted ones after the entry's
|
|
125
|
+
`failed_retention` by `python -m cdtkit.jobs.prune --service worker`, which the app declares as a schedule
|
|
126
|
+
(`deploy/schedules.json`), never a loop in its code.
|
|
127
|
+
|
|
128
|
+
HTTP basics:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from cdtkit.http import Health
|
|
132
|
+
from cdtkit.http.logging import setup
|
|
133
|
+
from cdtkit.fastapi import health_router
|
|
134
|
+
|
|
135
|
+
setup() # logfmt on stdout, one line per event
|
|
136
|
+
app.include_router(health_router(Health(database=db.ping))) # GET /healthz: 200 "ok" or 503 "unavailable: database"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Tests: with `cdt-kit[test]` installed the fixtures are there without imports:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
def test_store(cdt_database): # a fresh empty database (a cdtkit.config.Database)
|
|
143
|
+
migrations.migrate(cdt_database.conninfo(), "app/migrations")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
They use the server named by `CDT_TEST_DATABASE_HOST` (`_PORT`, `_USER`, `_PASSWORD`, `_NAME`), else start a
|
|
147
|
+
throwaway cluster with the local `initdb`/`pg_ctl` (not as root), else skip. `cdtkit.testing.queue.memory_app`,
|
|
148
|
+
`drain` and `statuses` run job handlers on procrastinate's in-memory connector.
|
|
149
|
+
|
|
150
|
+
## Layout
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
src/cdtkit/ the library (docs/PROJECT_NOTES.md has the module map)
|
|
154
|
+
tests/ the test suite (pytest; Postgres tests use the fixtures above)
|
|
155
|
+
selftest/ the self-test stack's run script and the testdb sidecar's script
|
|
156
|
+
compose.yaml the self-test stack (below); Dockerfile its image
|
|
157
|
+
requirements-selftest.txt pinned dependencies of the self-test image (arm64 wheels checked)
|
|
158
|
+
.github/workflows/publish.yml publishing to PyPI
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## The self-test stack
|
|
162
|
+
|
|
163
|
+
The toolkit deploys Compose stacks, and a library has no service, so cdt-kit's stack proves the kit on each server that
|
|
164
|
+
deploys it: `selftest` builds the wheel from the tree, installs it with the pinned dependencies and runs the test suite
|
|
165
|
+
once per build against `testdb`, a throwaway Postgres 18 on an internal network that stops (exit 0) after the run.
|
|
166
|
+
`selftest` then idles, healthy only when the tests passed, so a failing suite fails the deploy. The result is kept in
|
|
167
|
+
`data/selftest/` per build: a restart does not rerun the tests. It is a workaround until the toolkit offers a
|
|
168
|
+
check-only kind (claude-project-common#176); its two conformance exceptions point there.
|
|
169
|
+
|
|
170
|
+
Run it by hand on the primary: `cd /opt/stacks/cdt-kit && docker compose logs selftest`.
|
|
171
|
+
|
|
172
|
+
## Publishing
|
|
173
|
+
|
|
174
|
+
`.github/workflows/publish.yml` publishes the wheel and the sdist to PyPI when the servers create the version's GitHub
|
|
175
|
+
Release, that is when the candidate is deployed on the primary; PyPI Trusted Publishing, no stored token. The one-time
|
|
176
|
+
setup on pypi.org is in `RUNBOOK.md`. The workflow's result shows on the release issue. A version that failed to
|
|
177
|
+
publish is published again with the workflow's "Run workflow" button and its tag.
|
|
178
|
+
|
|
179
|
+
Running the tests locally: `pip install -e .[jobs,fastapi,test]` then `pytest` (Postgres binaries on PATH or
|
|
180
|
+
`CDT_TEST_DATABASE_HOST`).
|
cdt_kit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# cdt-kit
|
|
2
|
+
|
|
3
|
+
The server-side app kit of the Controlled Delivery Toolkit (CDT): a Python library every app pins for what the toolkit's
|
|
4
|
+
standard asks of its request path, so projects stop rebuilding it (claude-deploy-toolkit #422). Distribution `cdt-kit`
|
|
5
|
+
on PyPI, import name `cdtkit` (PyPI's `cdt` is another project's module).
|
|
6
|
+
|
|
7
|
+
0.1.0 holds what the pilot, magic-minutes, needs first:
|
|
8
|
+
|
|
9
|
+
| Module | What it does |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `cdtkit.config` | `*_FILE` secrets, the database from `DATABASE_*`, `ENV_CLASS`, `CDT_LANGUAGE`, `TOOLKIT_JOB_TYPES` |
|
|
12
|
+
| `cdtkit.migrations` | forward-only SQL files at start (advisory lock, `schema_migrations`), the expand-only lint |
|
|
13
|
+
| `cdtkit.jobs` | procrastinate on the app's Postgres: one queue per job type, backoff with jitter, `NoRow` (`skipped=no-row`), the final-failure hook |
|
|
14
|
+
| `cdtkit.jobs.worker` | the worker process: one worker per queue, heartbeat health check, graceful stop |
|
|
15
|
+
| `cdtkit.jobs.recovery` | dead-worker and stuck-job recovery |
|
|
16
|
+
| `cdtkit.jobs.prune` | finished jobs removed after `failed_retention` (run from a declared schedule) |
|
|
17
|
+
| `cdtkit.http` | logfmt logging on stdout, the `/healthz` checks |
|
|
18
|
+
| `cdtkit.fastapi` | the FastAPI adapter: `health_router` |
|
|
19
|
+
| `cdtkit.testing` | test stand-ins: a Postgres (`cdt_postgres`, `cdt_database` fixtures) and the in-memory queue |
|
|
20
|
+
|
|
21
|
+
Identity, the account contract, legal, i18n and consent come in 0.2.0; `/metrics`, RFC 9457 problems, rate classes and
|
|
22
|
+
Idempotency-Key later (see #422).
|
|
23
|
+
|
|
24
|
+
## Install and pin
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
cdt-kit[jobs,fastapi]==0.1.0 # requirements.txt of an app; [test] for its test environment
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The core depends only on `psycopg` 3 (choose its C build in the app: `psycopg[binary]`); `jobs` adds procrastinate
|
|
31
|
+
3.x, `fastapi` the adapter, `test` pytest and the fixtures. Python 3.11 or newer.
|
|
32
|
+
|
|
33
|
+
**Production pins only stable kit versions.** Every deployed kit candidate is published to PyPI at once, so its pilot
|
|
34
|
+
can test it before promotion; a version is stable once promoted (`RELEASES.md` of this repository, the GitHub Release
|
|
35
|
+
marked latest). An app may pin a candidate on staging; its production deployments pin a stable version. The toolkit's
|
|
36
|
+
rule `kit-version` (planned, claude-deploy-toolkit #423) will check it; until then it is a rule each app keeps.
|
|
37
|
+
|
|
38
|
+
Versions are semantic: a minor adds, a major breaks; anything deprecated keeps working for two minors with a warning.
|
|
39
|
+
At most one kit release per toolkit minor.
|
|
40
|
+
|
|
41
|
+
## Use
|
|
42
|
+
|
|
43
|
+
Configuration, read once at start:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from cdtkit import config
|
|
47
|
+
|
|
48
|
+
db = config.database() # DATABASE_HOST/_PORT/_NAME/_USER + DATABASE_PASSWORD_FILE
|
|
49
|
+
conninfo = db.conninfo("web") # libpq string; repr(db) never shows the password
|
|
50
|
+
key = config.read_secret("KEYS_ENCRYPTION_KEY") # KEYS_ENCRYPTION_KEY_FILE, else the variable (local runs)
|
|
51
|
+
optional = config.read_secret("SMTP_PASSWORD", required=False) # "" when unset or an empty file
|
|
52
|
+
env_class, language = config.env_class(), config.language() # "staging"/"production", CDT_LANGUAGE
|
|
53
|
+
job_types = config.job_types(service="worker") # this worker's TOOLKIT_JOB_TYPES entry: {id: params}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Missing or invalid configuration raises `config.ConfigError` (a `SystemExit`: the process stops with one line).
|
|
57
|
+
|
|
58
|
+
Migrations at start, in every process (web and workers may start together; one migrates, the others wait):
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from cdtkit import migrations
|
|
62
|
+
migrations.migrate(conninfo, "app/migrations", queue_app=queue_app) # *.sql in name order, each once
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Each file runs in its own transaction and is recorded in `schema_migrations (name, applied_at)`; with a procrastinate
|
|
66
|
+
app, its schema is applied when missing. The lint reports contracting statements (drops, renames, type changes, new
|
|
67
|
+
`NOT NULL`, `UNIQUE` or `CHECK` on existing data):
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
python -m cdtkit.migrations lint app/migrations [--after 0007_accounts_ui.sql] # exit 1 on findings
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A contract belongs in a cleanup release that declares it in `deploy/package.json` `"contracts"`; mark its line
|
|
74
|
+
`-- cdt: contract <reason>` and the lint accepts it.
|
|
75
|
+
|
|
76
|
+
Jobs (one queue per job type; parameters from `deploy/workers.json` through `TOOLKIT_JOB_TYPES`):
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from cdtkit import jobs
|
|
80
|
+
from cdtkit.jobs import worker
|
|
81
|
+
|
|
82
|
+
DEFAULTS = {"convert": {"concurrency": 1, "max_attempts": 3, "backoff": "60s", "timeout_item": "3h"}}
|
|
83
|
+
|
|
84
|
+
async def convert(meeting_id):
|
|
85
|
+
meeting = load(meeting_id)
|
|
86
|
+
if meeting is None:
|
|
87
|
+
raise jobs.NoRow("meeting gone") # deleted while queued: the job ends, logged skipped=no-row, no retry
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
app = jobs.make_app(conninfo, job_types, {"convert": convert}, DEFAULTS, pool_max=2, on_final_failure=mark_failed)
|
|
91
|
+
await jobs.defer_async(app, "convert", meeting_id=7) # web processes defer; payloads carry IDs only
|
|
92
|
+
await worker.run(app, ["convert"], job_types, DEFAULTS, ping=db_ping, on_given_up=mark_failed,
|
|
93
|
+
never_give_up=("account-delete",))
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- `max_attempts` counts runs in total (at most 25); the wait after the n-th failure is `backoff * 2^(n-1)`, capped at 6
|
|
97
|
+
hours, shortened by up to half at random (jitter).
|
|
98
|
+
- `on_final_failure(job_id, payload)` runs when the last attempt raises; `on_given_up` when the recovery gives a job up
|
|
99
|
+
(its worker died with no attempt left, or it ran past `timeout_item` plus 5 minutes). Both mark what the user sees.
|
|
100
|
+
- The worker's container health check: `python -m cdtkit.jobs.worker --health` (exit 0 while the heartbeat in
|
|
101
|
+
`/tmp/worker-heartbeat` is younger than 80 seconds; the heartbeat runs `ping` first).
|
|
102
|
+
- Finished jobs: successful ones are deleted at once; failed, cancelled and aborted ones after the entry's
|
|
103
|
+
`failed_retention` by `python -m cdtkit.jobs.prune --service worker`, which the app declares as a schedule
|
|
104
|
+
(`deploy/schedules.json`), never a loop in its code.
|
|
105
|
+
|
|
106
|
+
HTTP basics:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from cdtkit.http import Health
|
|
110
|
+
from cdtkit.http.logging import setup
|
|
111
|
+
from cdtkit.fastapi import health_router
|
|
112
|
+
|
|
113
|
+
setup() # logfmt on stdout, one line per event
|
|
114
|
+
app.include_router(health_router(Health(database=db.ping))) # GET /healthz: 200 "ok" or 503 "unavailable: database"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Tests: with `cdt-kit[test]` installed the fixtures are there without imports:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
def test_store(cdt_database): # a fresh empty database (a cdtkit.config.Database)
|
|
121
|
+
migrations.migrate(cdt_database.conninfo(), "app/migrations")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
They use the server named by `CDT_TEST_DATABASE_HOST` (`_PORT`, `_USER`, `_PASSWORD`, `_NAME`), else start a
|
|
125
|
+
throwaway cluster with the local `initdb`/`pg_ctl` (not as root), else skip. `cdtkit.testing.queue.memory_app`,
|
|
126
|
+
`drain` and `statuses` run job handlers on procrastinate's in-memory connector.
|
|
127
|
+
|
|
128
|
+
## Layout
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
src/cdtkit/ the library (docs/PROJECT_NOTES.md has the module map)
|
|
132
|
+
tests/ the test suite (pytest; Postgres tests use the fixtures above)
|
|
133
|
+
selftest/ the self-test stack's run script and the testdb sidecar's script
|
|
134
|
+
compose.yaml the self-test stack (below); Dockerfile its image
|
|
135
|
+
requirements-selftest.txt pinned dependencies of the self-test image (arm64 wheels checked)
|
|
136
|
+
.github/workflows/publish.yml publishing to PyPI
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## The self-test stack
|
|
140
|
+
|
|
141
|
+
The toolkit deploys Compose stacks, and a library has no service, so cdt-kit's stack proves the kit on each server that
|
|
142
|
+
deploys it: `selftest` builds the wheel from the tree, installs it with the pinned dependencies and runs the test suite
|
|
143
|
+
once per build against `testdb`, a throwaway Postgres 18 on an internal network that stops (exit 0) after the run.
|
|
144
|
+
`selftest` then idles, healthy only when the tests passed, so a failing suite fails the deploy. The result is kept in
|
|
145
|
+
`data/selftest/` per build: a restart does not rerun the tests. It is a workaround until the toolkit offers a
|
|
146
|
+
check-only kind (claude-project-common#176); its two conformance exceptions point there.
|
|
147
|
+
|
|
148
|
+
Run it by hand on the primary: `cd /opt/stacks/cdt-kit && docker compose logs selftest`.
|
|
149
|
+
|
|
150
|
+
## Publishing
|
|
151
|
+
|
|
152
|
+
`.github/workflows/publish.yml` publishes the wheel and the sdist to PyPI when the servers create the version's GitHub
|
|
153
|
+
Release, that is when the candidate is deployed on the primary; PyPI Trusted Publishing, no stored token. The one-time
|
|
154
|
+
setup on pypi.org is in `RUNBOOK.md`. The workflow's result shows on the release issue. A version that failed to
|
|
155
|
+
publish is published again with the workflow's "Run workflow" button and its tag.
|
|
156
|
+
|
|
157
|
+
Running the tests locally: `pip install -e .[jobs,fastapi,test]` then `pytest` (Postgres binaries on PATH or
|
|
158
|
+
`CDT_TEST_DATABASE_HOST`).
|
cdt_kit-0.1.0/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.0
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.33.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cdt-kit"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Server-side app kit of the Controlled Delivery Toolkit: config, migrations, jobs and HTTP basics"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [{ name = "fromerobr-ai" }]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Framework :: FastAPI",
|
|
15
|
+
"Topic :: Database",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
]
|
|
18
|
+
# The core needs only psycopg 3; the driver's C build is the app's choice (psycopg[binary] or [c]).
|
|
19
|
+
dependencies = ["psycopg>=3.2,<4"]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
jobs = ["procrastinate>=3.10,<4"]
|
|
23
|
+
fastapi = ["fastapi>=0.115"]
|
|
24
|
+
test = ["pytest>=8", "pytest-asyncio>=0.24"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Source = "https://github.com/fromerobr-ai/cdt-kit"
|
|
28
|
+
Changelog = "https://github.com/fromerobr-ai/cdt-kit/blob/main/CHANGELOG.md"
|
|
29
|
+
|
|
30
|
+
[project.entry-points.pytest11]
|
|
31
|
+
cdtkit = "cdtkit.testing.plugin"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.version]
|
|
34
|
+
path = "src/cdtkit/__init__.py"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/cdtkit"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["src/cdtkit", "tests", "README.md", "CHANGELOG.md", "VERSION"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""cdt-kit: the server-side app kit of the Controlled Delivery Toolkit (CDT).
|
|
2
|
+
|
|
3
|
+
Modules (0.1.0): `cdtkit.config` (environment and `*_FILE` secrets), `cdtkit.migrations` (forward-only SQL files),
|
|
4
|
+
`cdtkit.jobs` (procrastinate on the app's Postgres), `cdtkit.http` (logfmt logging and the health check) and the
|
|
5
|
+
FastAPI adapter `cdtkit.fastapi`; `cdtkit.testing` holds the test stand-ins. The core imports no web framework.
|
|
6
|
+
"""
|
|
7
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Configuration the toolkit gives every app: read once at start from the environment and the secret files.
|
|
2
|
+
|
|
3
|
+
Values come from `config/defaults.env`, `.toolkit/vars.env` (rendered by the toolkit) and `.env`, through Compose's
|
|
4
|
+
`env_file`; every secret from the file named by `<NAME>_FILE` (the toolkit mounts `.toolkit/secrets/<NAME>`). The
|
|
5
|
+
database is given in parts (`DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USER`, the password in
|
|
6
|
+
`DATABASE_PASSWORD_FILE`), never as a URL (standard, chapter "database").
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
ENV_CLASSES = ("staging", "production")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(SystemExit):
|
|
16
|
+
"""A configuration the app cannot start with: the process stops with this message (no traceback)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _env(env):
|
|
20
|
+
return os.environ if env is None else env
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_secret(name, env=None, required=True):
|
|
24
|
+
"""The value of secret NAME: the file named by NAME_FILE (toolkit secrets), else NAME itself (local runs, tests).
|
|
25
|
+
|
|
26
|
+
Surrounding whitespace is removed. An empty value counts as unset: an optional secret with no value on a server
|
|
27
|
+
arrives as an empty file (toolkit 2.111.0). Returns "" for an unset optional secret; raises ConfigError for an
|
|
28
|
+
unset required one or an unreadable file.
|
|
29
|
+
"""
|
|
30
|
+
env = _env(env)
|
|
31
|
+
path = env.get(name + "_FILE")
|
|
32
|
+
if path:
|
|
33
|
+
try:
|
|
34
|
+
with open(path, encoding="utf-8") as f:
|
|
35
|
+
value = f.read().strip()
|
|
36
|
+
except OSError as e:
|
|
37
|
+
raise ConfigError("%s: cannot read %s (%s)" % (name, path, e.strerror))
|
|
38
|
+
else:
|
|
39
|
+
value = (env.get(name) or "").strip()
|
|
40
|
+
if not value and required:
|
|
41
|
+
raise ConfigError("%s: missing or empty (written by the toolkit into .toolkit/secrets/)" % name)
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Database:
|
|
47
|
+
host: str
|
|
48
|
+
port: int
|
|
49
|
+
name: str
|
|
50
|
+
user: str
|
|
51
|
+
password: str
|
|
52
|
+
|
|
53
|
+
def conninfo(self, application_name=""):
|
|
54
|
+
"""A libpq connection string; values are quoted, so a password with spaces or quotes stays one value."""
|
|
55
|
+
parts = [("host", self.host), ("port", str(self.port)), ("dbname", self.name), ("user", self.user),
|
|
56
|
+
("password", self.password)]
|
|
57
|
+
if application_name:
|
|
58
|
+
parts.append(("application_name", application_name))
|
|
59
|
+
return " ".join("%s=%s" % (k, _quote(v)) for k, v in parts)
|
|
60
|
+
|
|
61
|
+
def __repr__(self): # never the password in a log line or a traceback
|
|
62
|
+
return "Database(host=%r, port=%d, name=%r, user=%r)" % (self.host, self.port, self.name, self.user)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _quote(value):
|
|
66
|
+
if value and not any(c in value for c in " '\\"):
|
|
67
|
+
return value
|
|
68
|
+
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def database(env=None):
|
|
72
|
+
"""The app's database from DATABASE_* and the password file; ConfigError names every missing variable."""
|
|
73
|
+
env = _env(env)
|
|
74
|
+
missing = [n for n in ("DATABASE_HOST", "DATABASE_NAME", "DATABASE_USER") if not (env.get(n) or "").strip()]
|
|
75
|
+
if missing:
|
|
76
|
+
raise ConfigError("missing configuration: %s" % ", ".join(missing))
|
|
77
|
+
raw_port = (env.get("DATABASE_PORT") or "").strip() or "5432"
|
|
78
|
+
if not raw_port.isdigit():
|
|
79
|
+
raise ConfigError("DATABASE_PORT must be a port number")
|
|
80
|
+
return Database(env["DATABASE_HOST"].strip(), int(raw_port), env["DATABASE_NAME"].strip(),
|
|
81
|
+
env["DATABASE_USER"].strip(), read_secret("DATABASE_PASSWORD", env))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def env_class(env=None, default="staging"):
|
|
85
|
+
"""ENV_CLASS: "staging" or "production" (the toolkit writes it); `default` when unset (local runs, tests)."""
|
|
86
|
+
value = (_env(env).get("ENV_CLASS") or "").strip() or default
|
|
87
|
+
if value not in ENV_CLASSES and value != default:
|
|
88
|
+
raise ConfigError("ENV_CLASS must be one of %s" % ", ".join(ENV_CLASSES))
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_production(env=None):
|
|
93
|
+
return env_class(env) == "production"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def language(env=None):
|
|
97
|
+
"""CDT_LANGUAGE, the deployment's default language (a BCP 47 tag); "" means the app's own default."""
|
|
98
|
+
return (_env(env).get("CDT_LANGUAGE") or "").strip()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def worker_entry(env=None, service="worker"):
|
|
102
|
+
"""This worker service's entry of TOOLKIT_JOB_TYPES (deploy/workers.json, rendered by the toolkit):
|
|
103
|
+
{"queue", "failed_retention", "job_types": {id: params}}; {} when the variable is unset or names no such service."""
|
|
104
|
+
raw = (_env(env).get("TOOLKIT_JOB_TYPES") or "").strip()
|
|
105
|
+
if not raw:
|
|
106
|
+
return {}
|
|
107
|
+
try:
|
|
108
|
+
entry = json.loads(raw).get(service) or {}
|
|
109
|
+
except (ValueError, AttributeError):
|
|
110
|
+
raise ConfigError("TOOLKIT_JOB_TYPES is not valid JSON")
|
|
111
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("job_types", {}), (dict, type(None))):
|
|
112
|
+
raise ConfigError("TOOLKIT_JOB_TYPES: the entry of %s is not an object" % service)
|
|
113
|
+
return entry
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def job_types(env=None, service="worker"):
|
|
117
|
+
"""The worker service's job types from TOOLKIT_JOB_TYPES: {id: params}; {} when unset."""
|
|
118
|
+
return worker_entry(env, service).get("job_types", {}) or {}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The FastAPI adapter (the starter's framework): the health route of `cdtkit.http`.
|
|
2
|
+
|
|
3
|
+
from cdtkit.fastapi import health_router
|
|
4
|
+
app.include_router(health_router(Health(database=db.ping)))
|
|
5
|
+
|
|
6
|
+
GET /healthz answers 200 "ok" or 503 "unavailable: <checks>", plain text, never cached, outside the OpenAPI schema.
|
|
7
|
+
Sync checks run in the thread pool, so a slow database never blocks the event loop.
|
|
8
|
+
"""
|
|
9
|
+
from fastapi import APIRouter
|
|
10
|
+
from fastapi.concurrency import run_in_threadpool
|
|
11
|
+
from fastapi.responses import PlainTextResponse
|
|
12
|
+
import inspect
|
|
13
|
+
|
|
14
|
+
from cdtkit.http import HEALTH_PATH, Health
|
|
15
|
+
|
|
16
|
+
NO_STORE = {"Cache-Control": "no-store"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _threaded(check):
|
|
20
|
+
if inspect.iscoroutinefunction(check):
|
|
21
|
+
return check
|
|
22
|
+
return lambda: run_in_threadpool(check)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def health_router(health=None, path=HEALTH_PATH):
|
|
26
|
+
"""An APIRouter with the health route; `health` is a `cdtkit.http.Health` (none: always "ok")."""
|
|
27
|
+
checks = Health(**{name: _threaded(c) for name, c in (health.checks if health else {}).items()})
|
|
28
|
+
router = APIRouter()
|
|
29
|
+
|
|
30
|
+
@router.get(path, include_in_schema=False)
|
|
31
|
+
async def healthz():
|
|
32
|
+
ok, body = await checks.run()
|
|
33
|
+
return PlainTextResponse(body, status_code=200 if ok else 503, headers=NO_STORE)
|
|
34
|
+
|
|
35
|
+
return router
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The HTTP rules every app shares, framework-agnostic; `cdtkit.fastapi` adapts them to FastAPI.
|
|
2
|
+
|
|
3
|
+
0.1.0: logfmt logging (`cdtkit.http.logging`) and the health check (`Health`, answered on `/healthz`, the path
|
|
4
|
+
deploy/web.json's `"health"` names). Later releases add `/metrics`, RFC 9457 problem details, rate-limit classes and the
|
|
5
|
+
Idempotency-Key store (claude-deploy-toolkit #422).
|
|
6
|
+
"""
|
|
7
|
+
import inspect
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("cdtkit.http")
|
|
11
|
+
HEALTH_PATH = "/healthz"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Health:
|
|
15
|
+
"""Named checks (sync or async callables; a check passes when it returns without raising), e.g. the database's
|
|
16
|
+
`SELECT 1`. With no checks the process answers "ok" as soon as it serves requests."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, **checks):
|
|
19
|
+
self.checks = dict(checks)
|
|
20
|
+
|
|
21
|
+
def add(self, name, check):
|
|
22
|
+
self.checks[name] = check
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
async def run(self):
|
|
26
|
+
"""(ok, body): body is "ok\\n", or "unavailable: <names>\\n" naming the failed checks (never their errors,
|
|
27
|
+
which can hold internals; those go to the log)."""
|
|
28
|
+
failed = []
|
|
29
|
+
for name, check in self.checks.items():
|
|
30
|
+
try:
|
|
31
|
+
result = check()
|
|
32
|
+
if inspect.isawaitable(result):
|
|
33
|
+
await result
|
|
34
|
+
except Exception as e:
|
|
35
|
+
failed.append(name)
|
|
36
|
+
log.warning("health check failed check=%s: %s", name, type(e).__name__)
|
|
37
|
+
if failed:
|
|
38
|
+
return False, "unavailable: %s\n" % ",".join(failed)
|
|
39
|
+
return True, "ok\n"
|