Skip to content

fix(docker): drop curl from the API runtime images - #4202

Merged
nicoloboschi merged 4 commits into
mainfrom
fix/api-images-drop-curl
Sep 8, 2026
Merged

fix(docker): drop curl from the API runtime images#4202
nicoloboschi merged 4 commits into
mainfrom
fix/api-images-drop-curl

Conversation

@nicoloboschi

Copy link
Copy Markdown
Collaborator

Closes #4198. Last of the three pieces #4057 was split into, after #4199 and #4200.

Why

curl's only in-container consumer was the readiness loop in start-all.sh — there is no HEALTHCHECK instruction anywhere in either Dockerfile. It is also the sole reverse-dependency of libcurl4t64, which in turn brings libssh2-1t64, so one line in each install list accounted for nine HIGH findings.

All nine are status=affected with no FixedVersion — Debian has published nothing, so the apt-get upgrade these stages already run cannot clear them, and not shipping the package is the only available remediation.

Trivy 0.74.0, HIGH+CRITICAL, locally built slim images:

image before after
api-only 3C / 60H 3C / 51H
standalone 3C / 60H 3C / 51H

Exactly curl ×4, libcurl4t64 ×4 and libssh2-1t64 ×1 removed, nothing new.

Being straight about what this is: none of those CVEs was reachable through how the images used curl (they concern SFTP/SCP host verification, TLS config mismatch, connection reuse and proxy auth state; the probe does GET http://localhost:8888/health). The value is scan hygiene — nine unfixable HIGH findings on a published image generate questions that can't be answered with "upgrade it" — plus removing standard post-exploitation tooling from a runtime container.

The replacement is a translation, not an approximation

This is the part #4057 got wrong and the reason this piece was split out. curl -sf without -L does not follow redirects, so a 302 is a completed transfer and succeeds regardless of what it points at. The obvious urllib.request.urlopen replacement follows it and raises on a 404 behind it — silently turning a healthy service that redirects into "not ready".

http_probe uses http.client and tests status < 400 itself, bypassing urllib's redirect handler entirely. Verified against curl -sf on a server returning each case:

case curl -sf http_probe
204 0 0
302 → 204 0 0
302 → 404 0 0
404 22 1
500 22 1
query string preserved 0 / 22 0 / 1
userinfo Basic auth 0 0
connection refused 7 1

Exit codes are not reproduced (22 and 7 become 1) — every call site tests zero/non-zero only.

One deliberate difference, called out because it's a change and not a translation: curl was called with --connect-timeout, which caps only the connection phase, and the API health loop passed no timeout at all. A server that accepted a connection and then never answered hung the probe forever. The timeout now covers the whole request.

No wget fallback

BusyBox wget can't reproduce these semantics — 1.37.0 in node:*-alpine has no --max-redirect, so it always follows. It also isn't needed: every image that probes anything is Python-based. cp-only, the one image with neither curl nor python3, performs no probe at all and dropped curl in #4199.

Missing python3 now fails loudly at startup via require_http_probe_runtime rather than returning 127, which the readiness loops would have read as "not ready yet" and spun on until timeout.

Tests

test-start-all.sh gains probe coverage, including /redirect-to-missing as an explicit regression guard. I mutation-tested it: swapping in the urllib implementation makes the suite fail with

http_probe should succeed for /redirect-to-missing (curl -sf does)

so the test has teeth rather than just passing.

The test server runs under serve_forever until the trap kills it, deliberately not a "handle N requests" loop — with a fixed count, adding a case later would block the script forever on a request the server had stopped waiting for. There is also no command -v wget lookup; under this file's set -euo pipefail that aborts the whole script on any machine without wget.

Verification

Built locally (arm64) — api-only, standalone, and api-only-freethreaded:

  • curl absent from all three; python3 + http.client present in all three
  • http_probe exercised inside the real api-only image against a live server: 200 → success, 404 → failure, connection refused → failure
  • full end-to-end boot: the full (non-slim) api-only image starts, the readiness loop goes green through the new probe, ✅ Hindsight is running!, and /health answers 200 from outside the container
  • bash docker/standalone/test-start-all.sh passes in full

Dockerfile.freethreaded is included — it ran the same probe and carried the same curl.

curl's only in-container consumer was the readiness loop in start-all.sh;
there is no HEALTHCHECK instruction anywhere. It is also the sole
reverse-dependency of libcurl4t64, which brings libssh2-1t64, so one line in
each install list accounted for nine HIGH findings - all status=affected with
no Debian fix published, so `apt-get upgrade` could not clear them and not
shipping the package was the only remediation.

Trivy 0.74.0 HIGH+CRITICAL, on locally built slim images:

  api-only     3C / 60H -> 3C / 51H
  standalone   3C / 60H -> 3C / 51H

with exactly the curl, libcurl4t64 and libssh2-1t64 findings removed and
nothing new.

Replace it with http_probe, which reproduces `curl -sf` WITHOUT -L rather than
approximating it. The distinction matters: curl does not follow redirects
unless asked, so a 302 is a completed transfer and succeeds regardless of what
it points at. urllib.request.urlopen follows it and raises on a 404 behind it,
which would report a healthy service that redirects as "not ready". http_probe
uses http.client and tests `status < 400` itself, bypassing urllib's redirect
handler, and carries userinfo through as Basic auth the way curl does.

Verified equivalent to `curl -sf` on 2xx, 3xx-to-good, 3xx-to-bad, 4xx, 5xx,
query strings, userinfo auth and connection refused. Exit codes are not
reproduced (curl's 22 and 7 become 1); every call site tests zero/non-zero.

One deliberate difference, since it is a change and not a translation: curl was
called with --connect-timeout, which caps only the connection phase, and the
API health loop passed no timeout at all - so a server that accepted a
connection and never answered hung the probe forever. The timeout now covers
the whole request.

There is no wget fallback. BusyBox wget cannot reproduce these semantics (no
--max-redirect, so it always follows), and it is not needed: every image that
probes anything is Python-based. cp-only, the one image with neither, performs
no probe at all and dropped curl in #4197. Missing python3 now fails loudly at
startup instead of degrading into a readiness loop that can never succeed.

Closes #4198
The first version of this probe was Python embedded in a shell string inside
start-all.sh. That was a bad shape for code encoding rules this fiddly: every
quote had to survive two levels of escaping, ruff and ty never saw it, and it
could only be exercised through the shell.

Move it to hindsight_api/http_probe.py, shipped with the code and covered by
tests/test_http_probe.py, which pins each case to what `curl -sf` does for the
same response. start-all.sh keeps a three-line wrapper that shells out to
`python3 -m hindsight_api.http_probe`.

hindsight-admin was the obvious home and is the wrong one: it takes 5.1s to
start in the built image, against 0.028s for bare stdlib, because it pulls in
the CLI and everything behind it. The readiness loop polls once per second, so
importing the API to ask whether the API is up would break the loop it drives.
This module imports stdlib only; measured 0.035s per probe in the image.
`hindsight_api/__init__` is cheap by design and has to stay that way for this
to hold - its docstring already says so.

The shell test drops to checking the wiring, since the semantics now have a
real home, and skips when the package is not importable: test-start-all.sh also
runs in CI from a bare checkout with no virtualenv.

Reformatting by `ruff format` on first contact is the point - the embedded
version could never have received it.
…rom the API

hindsight_api.http_probe was the wrong home. The probe answers "is an API
process up?", and living inside the package it probes invited exactly the
coupling that would break it: an import of the engine or the config would put
API startup cost - and API startup side effects - on a loop that runs once a
second.

Move it to hindsight_probe, a sibling top-level package in the same
distribution. Its dependencies are now explicit by construction: none. It
imports the standard library and nothing else.

Packaging alone does not enforce that. Both packages install into the same
virtualenv, so `import hindsight_api` from the probe would still resolve at
runtime. So the rule is a test, not a convention:
test_imports_nothing_but_the_standard_library imports the package in a clean
subprocess and asserts that nothing outside sys.stdlib_module_names was pulled
in. Adding `import hindsight_api` to the probe fails it with the offending name.

The audit ignores _sysconfigdata_*, a platform-specific stdlib internal whose
name embeds the build triple and so is absent from stdlib_module_names
everywhere.

Both Dockerfiles now copy the package; the api-builder previously copied only
hindsight_api, so the first build without this shipped an image whose probe
could not import. That surfaced as require_http_probe_runtime failing at
startup with a clear message rather than a readiness loop that could never
succeed, which is what that guard is for.

Verified in the built Linux image: no non-stdlib imports, hindsight_api never
loaded, 0.036s per probe, and the full end-to-end boot still reaches
"Hindsight is running" with /health answering 200.
Reverts the separate hindsight_probe package. It was justified on a bad
measurement: an earlier cold-cache timing suggested `import hindsight_api` cost
~0.12s against ~0.03s for a standalone package. Measured properly, warm, in the
built image, they are the same - ~0.03s each - and importing hindsight_api
pulls in zero third-party modules. Its PEP 562 lazy-attribute design already
does the work the split was meant to do, so the split bought nothing and cost a
second top-level package, four pyproject entries and a COPY in each Dockerfile.

What was worth keeping is the enforcement, which is orthogonal to where the
module lives. test_imports_nothing_heavy imports the probe in a clean
subprocess and asserts it pulled in no third-party package and nothing from
hindsight_api.engine, .api or .config. Adding `from hindsight_api.engine import
memory_engine` to the probe fails it with 43 packages named, numpy, sqlalchemy
and asyncpg among them - which is the failure mode the rule exists to prevent.

Verified in the built image: no third-party or engine imports, 0.034s per
import, probe wiring works, curl absent.
@nicoloboschi
nicoloboschi force-pushed the fix/api-images-drop-curl branch from 717ca56 to 1bd2f8b Compare September 8, 2026 08:31
@nicoloboschi
nicoloboschi merged commit 5f9bff9 into main Sep 8, 2026
112 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docker: drop curl from the API runtime images, with a probe that matches curl -sf exactly

1 participant