diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cce551..008bd47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,11 @@ name: CI -# No push trigger for `dev`: everything on dev travels through a pull -# request anyway, and a push run would twin the PR run on the same commit — -# under *required* checks, a deduplicating cancellation poisons the gate. -# `main` keeps its push trigger because a rebase-merge mints a new SHA that -# no PR run ever checked. +# No push trigger at all: everything travels through a pull request, and +# main only ever receives a SQUASH of a green PR — the squashed tree is +# byte-identical to the PR head the checks already ran on, so a main +# re-run would test the same content twice. Merging runs release.yml +# alone; workflow_dispatch stays as the manual escape hatch. on: - push: - branches: [main] pull_request: workflow_dispatch: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41d49e2..1b017c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,23 +95,25 @@ jobs: if: needs.decide.outputs.release == 'true' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + # Trusted publishing: PyPI verifies THIS workflow's OIDC identity — + # no PYPI_TOKEN to store, leak or rotate. The `dynamic-config-py-web` + # project carries a Trusted Publisher entry on PyPI naming this + # repository and this file; OUTSTANDING.md spells the console entry. + id-token: write steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: distributions path: dist - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.12" - - run: pip install twine - # `--skip-existing`: a version on PyPI is permanent, so the only way + # `skip-existing`: a version on PyPI is permanent, so the only way # forward after a partial failure is a rerun that publishes the # remainder. `release-web.sh --check` is what keeps that from being # the normal case. - - run: twine upload --skip-existing dist/* - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist + skip-existing: true tag-and-release: name: tag and GitHub release diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 6141644..419adde 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -12,8 +12,6 @@ name: Security # No push trigger for `dev` — same reasoning as ci.yml: dev travels through # pull requests, and a push twin under required checks poisons the gate. on: - push: - branches: [main] pull_request: schedule: - cron: "17 6 * * 1" @@ -28,6 +26,24 @@ permissions: contents: read jobs: + policy: + name: the security policy names the current line + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + # The stale-table drift this org has already lived through once: + # SECURITY.md said 0.0.x while the workspace shipped 0.7. The table's + # top row must name the line that is actually published. + - run: | + version=$(grep -m1 '__version__' src/dynamic_config_web/__init__.py | cut -d'"' -f2) + line="${version%.*}.x" + if ! grep -qF "| ${line}" SECURITY.md; then + echo "::error file=SECURITY.md::the supported-versions table does not name ${line} (workspace is ${version})" + exit 1 + fi + echo "SECURITY.md names ${line} — current" + osv: name: advisories, every ecosystem runs-on: ubuntu-latest @@ -126,7 +142,7 @@ jobs: # — which is the whole point of running it. security-ok: name: Security is green - needs: [osv, supply-chain, published] + needs: [policy, osv, supply-chain, published] if: always() runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/CHANGELOG.md b/CHANGELOG.md index cd49f1a..05db49b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,28 @@ for an adapter and an adapter fix should not drag the wheels behind it. ## [Unreleased] +## 0.2.1 — 2026-08-20 + +### Changed + +- **`dynamic-config-py>=0.3.1` is the floor**: the base wheel's + failure events arrive natively there, and `stream_events` rides + them without a poll. The `failure_poll` parameter is accepted and + warns, exactly as the base wheel treats it. + +### Changed + +- **`stream_events` no longer polls.** A refused reload wakes the + stream natively (dynamic-config-py 0.3.1's failure hook), so + `ReloadFailed` events arrive when the refusal happens and nothing + runs on a timer. Requires `dynamic-config-py>=0.3.1`. + +### Deprecated + +- **`stream_events(failure_poll=...)`** is accepted, ignored, and + warns: the interval refusals were polled at, now that they wake the + stream themselves. Remove the argument. + ## 0.2.0 — 2026-08-18 ### Changed diff --git a/README.md b/README.md index 1781de2..ab9210f 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ add no cache, no copy and no second source of truth — `db.current()` is still the read path, and `current()` here is that read taken once per request. +What you may build on and find unchanged tomorrow is written down: the [Compatibility Contract](https://dynamic-config-rs.github.io/compatibility.html). + ## License MIT. diff --git a/RELEASING.md b/RELEASING.md index ac8049c..75dcdb0 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -76,7 +76,12 @@ workflow by hand. ## What an operator has to have ready -`PYPI_TOKEN` as a repository secret, with upload rights to this project. +**No token.** The publish job authenticates through PyPI's Trusted +Publishing (OIDC): the one-time console entry — PyPI → +`dynamic-config-py-web` → Settings → Publishing → *Add a trusted +publisher* — names owner `dynamic-config-rs`, repository +`dynamic-config-python-web`, workflow `release.yml`. A leftover +`PYPI_TOKEN` secret is inert and should be revoked. Nothing else. ## Afterwards diff --git a/SECURITY.md b/SECURITY.md index e66fb3c..5c86ef3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,12 +24,12 @@ the ones it explicitly does not. `Debug`; reload diffs, `check()` reports, unknown-key suggestions and *error messages* all report paths and types, never values, so nothing routes around the redaction. A report of a value appearing in a log or an error message is a -vulnerability. `dynamic-config/tests/security.rs` asserts each of these, and CI +vulnerability. the engine repository's [`tests/security.rs`](https://github.com/dynamic-config-rs/dynamic-config) asserts each of these, and its CI runs it as a job of its own. **The parsing surfaces are fuzzed.** The unit parsers, `Value` path lookup and the rule that decides whether a path touches a secret run under libFuzzer -(`fuzz/`, built on every CI run and run on a weekly schedule). A crash or a +(the engine repository's [`fuzz/`](https://github.com/dynamic-config-rs/dynamic-config), built on every CI run and run on a weekly schedule). A crash or a redaction rule that answers differently for the same path is a finding: report it through the process above, with the reproducing input attached. @@ -68,12 +68,16 @@ with your privileges. | Version | Supported | |---|---| -| 0.0.x | ✅ the latest patch | -| < 0.0.1 | — nothing older exists | +| 0.2.x (0.2.1+) | ✅ the latest patch | +| ≤ 0.1 | — end of life | -Before 1.0, fixes land on the latest published version and nothing is -backported: there is no version old enough to be worth pinning to. After 1.0, -the current and previous minor versions. +Security fixes land on the **latest patch of the line above** and +nothing is backported before 1.0: when a release ships, every prior +patch of its line is end-of-life the same day. Older toolchains resolve +older published versions through cargo's MSRV-aware resolver and are +explicitly unsupported. After 1.0, the current and previous minor +lines. The full promise lives in the engine book's +[Compatibility Contract](https://dynamic-config-rs.github.io/compatibility.html). ## Threat model, stated plainly @@ -100,7 +104,7 @@ runs on every change: | Claim | Enforced by | |---|---| -| Secrets stay out of diagnostics | `dynamic-config/tests/security.rs`, run as its own CI job | +| Secrets stay out of diagnostics | the engine repository's `tests/security.rs`, run as its own CI job there | | Files are created private, symlinks refused | `write::permissions` tests, same job | | A store cannot panic the process | `checked_add` on every server-supplied duration, plus a hostile-document test | | No unsafe code | `#![forbid(unsafe_code)]` in every crate, *and* a CI job that checks the attribute is still there | diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 7bf2f56..3cd7a45 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -27,6 +27,7 @@ - [Readiness a Load Balancer Can Use](use-cases/readiness.md) - [Diagnostics Behind a Token](use-cases/diagnostics.md) - [Configuration in a Test](use-cases/testing-a-service.md) +- [A Production FastAPI Service](use-cases/production-fastapi.md) # Advanced diff --git a/book/src/health.md b/book/src/health.md index d02c31b..8a7f3bf 100644 --- a/book/src/health.md +++ b/book/src/health.md @@ -4,6 +4,14 @@ Three routes, three questions, and the reason they are three: an operator who cannot tell them apart either restarts a process that was serving fine or keeps routing to one that has been stale since Tuesday. +The definitions — why serving last-known-good is *ready*, why degraded +is a detail and not a state, the JSON shape — are the engine book's +[Readiness & Liveness](https://dynamic-config-rs.github.io/readiness.html) +contract; the routes below are its ASGI rendering. The metric names are +likewise the +[Metrics Contract](https://dynamic-config-rs.github.io/metrics-contract.html)'s, +not this package's to invent. + ## `/healthz` — liveness Always 200. Configuration has no say in it. diff --git a/book/src/limitations.md b/book/src/limitations.md index 3c0a9ae..2c75f8c 100644 --- a/book/src/limitations.md +++ b/book/src/limitations.md @@ -11,15 +11,16 @@ of what this package is for, and pinning per message is a decision only the application can make. `latest(config)` is the honest read there, and `stream_events(config)` is how a connection follows reloads. -## A refused reload cannot wake anything - -The engine bumps a generation when a document *installs*. A load that -installed nothing does not, so nothing can be notified of it: a `/readyz` -scrape notices a refusal because it reads `status()`, and an event stream -notices it only if it was given `failure_poll`. There is no push. - -That is the engine's shape rather than this package's, and it is documented -there too. +## A refused reload wakes `stream_events` — and nothing else + +Since engine 0.7.1 a refusal pushes: `stream_events` delivers +`reload_failed` the moment it happens, no polling (`failure_poll` is +accepted, ignored, and warns). What a refusal still does not do is move +a *model*: `/readyz` reports it because it reads `status()`, and a +dependency keeps answering last-known-good — the split the +[engine book's Change Notification +page](https://dynamic-config-rs.github.io/change-notification.html) +holds for all three languages. ## No `/config/snapshot` diff --git a/book/src/use-cases/production-fastapi.md b/book/src/use-cases/production-fastapi.md new file mode 100644 index 0000000..3f51a68 --- /dev/null +++ b/book/src/use-cases/production-fastapi.md @@ -0,0 +1,117 @@ +# A Production FastAPI Service, End to End + +Every piece here is documented alone elsewhere; a production service +needs all of them at once, in the right order. This is the +order — a service with two configurations, its own lifespan, a +dedicated executor, guarded diagnostics and the three operational +routes, written once so it can be copied whole. + +```python +import os +from contextlib import asynccontextmanager +from dataclasses import dataclass + +import dynamic_config +from dynamic_config import ConfigGroup, DynamicConfig +from dynamic_config_web import token_guard +from dynamic_config_web.fastapi import config_dependency, setup +from fastapi import Depends, FastAPI + + +# ── The declarations — plain classes, importable from anywhere ───────── +@dataclass +class Database: + host: str = "localhost" + pool_size: int = 8 + + +@dataclass +class Features: + cache: bool = False + + +database = DynamicConfig(Database, key="db").file("config.toml").env("APP_") +features = DynamicConfig(Features, key="features").file("config.toml") + + +# ── Configuration's own thread pool ──────────────────────────────────── +# +# By default the blocking half of `init_async` shares the loop's +# executor with everything else in the process. Two named threads mean a +# reload never queues behind an unrelated batch job — and a thread dump +# that says `dynamic-config-blocking-0` answers a question +# `ThreadPoolExecutor-3_0` does not. +dynamic_config.configure_executor(2) + + +# ── The application's OWN lifespan — setup() wraps it, never replaces it +@asynccontextmanager +async def lifespan(app: FastAPI): + pool = await open_pool(database.current()) + app.state.pool = pool + + # Reconfigure the pool when the values move — on the loop, because + # the pool is the loop's. (`on_change_async` is a decorator factory; + # applied inline it answers the guard that unregisters.) + guard = database.on_change_async("pool_size")(resize(pool)) + + yield + + guard.close() + await pool.close() + + +app = FastAPI(lifespan=lifespan) + +# ── The wiring: loads, watches, routes, scope — one call ─────────────── +# +# `setup` composes with the lifespan above: yours runs inside its +# load-then-watch bracket, so `database.current()` is already answering +# by the time `open_pool` reads it. The group makes the two +# configurations one lifecycle; `/readyz` reports per-key. +setup( + app, + ConfigGroup(database, features), + debounce=0.25, + guard=token_guard(os.environ["CONFIG_TOKEN"]), +) + +db = config_dependency(database) +flags = config_dependency(features) + + +# ── Handlers: a `def` dependency is a dictionary lookup, no round trip ─ +@app.get("/") +def index(current: Database = Depends(db), toggles: Features = Depends(flags)): + return {"pool": current.pool_size, "cache": toggles.cache} +``` + +What `setup` put on the app without another line: + +| Route | Answers | +|---|---| +| `/healthz` | 200, always — liveness has no configuration opinion | +| `/readyz` | ready / degraded per key, LKG-serving counts as ready | +| `/metrics` | the [Metrics Contract](https://dynamic-config-rs.github.io/metrics-contract.html)'s names, Prometheus text | +| `/_config/explain` | per-path provenance, redacted, behind the token guard | + +## The deployment two-liner + +uvicorn with workers forks; each worker runs the lifespan and `setup`'s +bracket itself, so each owns its watcher — +[four workers are four engines](https://dynamic-config-rs.github.io/python/pre-forking.html): + +```console +$ CONFIG_TOKEN=... uvicorn app:app --workers 4 +``` + +gunicorn with the uvicorn worker class is the same story with the +[pre-forking chapter](deployment.md)'s hook. + +## Absent on purpose + +No `/reload` endpoint (the watcher owns reloads; an endpoint is an +unaudited write path), no configuration values in any route this page +added (paths, kinds, counts — the values stay in the handlers that use +them), and no restart-on-failure (a failing reload leaves last-known-good +serving; see [Readiness a Load Balancer Can Use](readiness.md)). diff --git a/pyproject.toml b/pyproject.toml index dca35cd..9b2515f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ classifiers = [ # One, and it is the whole point: this package is adapters over the engine. # No framework is required — each is an extra, and importing this package # imports none of them. -dependencies = ["dynamic-config-py>=0.2"] +dependencies = ["dynamic-config-py>=0.3.1"] dynamic = ["version"] [project.entry-points.pytest11] diff --git a/src/dynamic_config_web/__init__.py b/src/dynamic_config_web/__init__.py index 46228d8..3afcfcb 100644 --- a/src/dynamic_config_web/__init__.py +++ b/src/dynamic_config_web/__init__.py @@ -40,7 +40,7 @@ def health(db: Database = Depends(config_dependency(config))): #: The distribution's version, and the single place it is written: the #: build backend reads this file rather than the other way round. -__version__ = "0.2.0" +__version__ = "0.2.1" from ._diagnostics import ( Guard, diff --git a/src/dynamic_config_web/_events.py b/src/dynamic_config_web/_events.py index 869c0a6..c122f41 100644 --- a/src/dynamic_config_web/_events.py +++ b/src/dynamic_config_web/_events.py @@ -13,6 +13,7 @@ from __future__ import annotations import logging +import warnings from collections.abc import AsyncIterator from typing import TYPE_CHECKING, Any, Optional @@ -81,7 +82,7 @@ def hook(previous: Any, current: Any) -> None: async def stream_events( config: DynamicConfig[Any], *, - failure_poll: Optional[float] = 1.0, + failure_poll: Optional[float] = None, ) -> AsyncIterator[dict[str, Any]]: """Installs *and* refusals, as JSON-shaped dictionaries. @@ -89,14 +90,24 @@ async def stream_events( await websocket.send_json(event) What a server-sent-events route, a websocket or a structured logger - consumes. `failure_poll` is what makes a refusal visible at all: a - load that installed nothing bumps no generation, so there is nothing - to wake a stream with — the engine checks the status on that interval - instead. `None` reports installs only and starts no timer. + consumes. A refusal wakes the stream natively (dynamic-config-py + 0.4+): the engine's failure hook signals the same thread an install + does, so nothing is polled and nothing keeps the loop up. + + `failure_poll` is deprecated and ignored — the interval refusals + were polled at, before they could wake anything. No event carries a value. """ - async for event in config.events(failure_poll=failure_poll): + if failure_poll is not None: + warnings.warn( + "failure_poll is ignored: a refused reload wakes the stream " + "natively now, and nothing is polled", + DeprecationWarning, + stacklevel=2, + ) + + async for event in config.events(): if isinstance(event, ReloadFailed): yield { "type": "reload_failed",