Celery broker SSL via config dicts, not URL query#
ops-db-api #126, epic #129. Relates to ADR-0001 (live-check contract) and the #121 broker-URL escape-hatch.
Context#
POST /api/admin/diagnose/{type}/{id}/live-check crashes in staging/prod while
the producer (ccat_ops_db_api/live_check/producer.py) initialises the Celery
result backend:
ValueError: A rediss:// URL must have parameter ssl_cert_reqs and this must be
set to CERT_REQUIRED, CERT_OPTIONAL, or CERT_NONE
The previous design packed SSL into the broker URL query
(rediss://…?ssl_cert_reqs=…&ssl_ca_certs=…). That single URL string is consumed
by three libraries, each parsing ssl_cert_reqs with an incompatible accepted
spelling:
kombu (the Celery broker transport),
Celery’s
RedisBackend(the result backend), andredis-py via
Redis.from_urlinget_broker_redis()(the correlation client).
The pinned versions are the trap, and the two failure modes are distinct exceptions raised at different points:
Celery’s
RedisBackend→ValueErrorat backend init. A barerediss://result-backend URL with nossl_cert_reqs(in the URL or aredis_backend_use_ssldict) is rejected when the backend is constructed —app.backendparses the URL without connecting. This is the exactValueErrorin the prod traceback above, and it surfaces at the very first live-check request, before any Redis socket is opened. Celery 5.5.3 itself accepts both theCERT_*and lowercasessl_cert_reqsforms.redis-py 8.0.0 →
RedisErrorat connect time. redis-py accepts only the lowercaserequired/nonespelling; anssl_cert_reqs=CERT_REQUIREDcarried in the URL is not rejected atRedis.from_url(...)construction but raisesredis.exceptions.RedisError(“Invalid SSL Certificate Requirements Flag: CERT_REQUIRED”) only when a connection is instantiated from the pool. NoteRedisErroris not a subclass ofValueError— it is a different exception type surfacing at a different time.
There is therefore no single ssl_cert_reqs spelling in the URL that is safe for
all three consumers: the CERT_REQUIRED form Celery is happy with is exactly the
form redis-py rejects, while the lowercase form redis-py needs is fine for Celery
but must reach it via the config dict, not the bare-URL backend that RedisBackend
refuses outright.
Decision#
Stop putting SSL in the URL. Mirror data-transfer’s proven production pattern
(ccat_data_transfer/setup_celery_app.py): a bare rediss://[:pw@]host:port/db
URL plus SSL supplied through Celery’s dedicated config dicts and redis-py kwargs.
build_broker_url()returns a bare URL. Whencelery_broker_urlis injected (the #121 escape-hatch) its host/password/db are kept but anyssl_*query is stripped (_strip_ssl_query).A shared
_broker_ssl_options() -> dict | Nonederives{ssl_cert_reqs, ssl_ca_certs, ssl_certfile, ssl_keyfile}fromcelery_broker_ssl_verify_cert(→"required"/"none") and the existingredis_ca_cert/redis_certfile/redis_keyfilelayout. It returnsNonewhen SSL is disabled (non-redissinjected URL, orcelery_broker_ssl_enabledfalse). Keys match the data-transfer spelling exactly.get_producer_app()setsconf.broker_use_sslandconf.redis_backend_use_sslfrom that one dict (only when notNone).get_broker_redis()builds the redis-py client with SSL via kwargs (ssl=True+ thessl_*keys), never from the URL query.
The lowercase "required"/"none" spelling is chosen because it is the only one
redis-py 8.x accepts, and Celery 5.5 also accepts it — so one dict feeds all three
consumers.
This supersedes the #121 URL escape-hatch for SSL params only. Host, password and db still come from the injected URL; SSL no longer does.
Considered options#
(a) Bare URL + config dicts (chosen). SSL leaves the URL entirely; the one spelling that satisfies all three parsers is set programmatically. Matches the data-transfer worker the producer talks to.
(b) Pick one URL spelling. Impossible: no spelling satisfies both redis-py 8.x (
required/noneonly) and theCERT_*-shaped deploy strings already in the field.(c) Per-consumer URL rewriting. Rewrite the query differently for kombu vs RedisBackend vs redis-py. Rejected: fragile, three code paths, and Celery owns the broker/backend URL parsing internally — there is no clean seam to inject per-consumer query strings.
Consequences#
Deploy ordering (image before compose). The new image tolerates an injected full
?ssl_*URL (it strips the query), so it is safe to roll the image out before the compose/inject change that dropsssl_*from the URL. The reverse is not required: a bare-URL inject also works with the old image only if that image still reads SSL from settings — so the documented safe order is image first, then compose.The producer no longer reads SSL from the URL query at all; deployments that still inject
?ssl_*are silently tolerated (query stripped) but the params are ignored — SSL comes fromcelery_broker_ssl_*+ the cert settings.Injected-URL scheme is authoritative for the SSL decision. When a
celery_broker_urlis injected,_broker_ssl_options()keys the on/off decision off that URL’s scheme, notcelery_broker_ssl_enabled. So injecting a plainredis://URL whilecelery_broker_ssl_enabled=trueturns SSL silently off (the injected URL wins). This is intended and safe — an operator injecting a bareredis://is explicitly asking for a plaintext broker — but is non-obvious, so set the scheme to match the intent.Dev/local is unchanged: SSL off → plain
redis://, nouse_ssldicts, no SSL kwargs.The boundary holds only as long as no future code re-introduces
ssl_*into any broker URL. New broker wiring must route SSL through_broker_ssl_options().