Every runtime and piece of middleware you run has a quiet expiry date. PHP 8.0
went EOL in late 2023. Node 16 in 2023. Postgres 12 in 2024. After that: no
security patches. The problem is never "we didn't know EOL was a thing" — it's that
the EOL calendar lives in a dozen vendor pages and nobody checks them until an
auditor does.
I wanted my self-hosted ops tool to flag this automatically. The obvious move is to
call an API like endoflife.date. But I made the default
offline: the EOL calendar is baked into the app, and the external fetch is
strictly opt-in. Here's why, and the part that was actually fiddly — version
matching.
Offline by default: a self-hosted tool shouldn't phone home
If you ship a tool people run inside their own infra, "calls an external API on
startup" is a liability, not a feature. Air-gapped environments break. Security
reviewers ask why your inventory tool is making outbound requests. So the EOL data
is a plain dict compiled into the code:
# {canonical_name: {cycle: eol_date_or_None}} None means "actively supported"
_EOL = {
'python': {
'3.7': date(2023, 6, 27),
'3.8': date(2024, 10, 7),
'3.9': date(2025, 10, 5),
'3.11': None,
'3.12': None,
},
'postgresql': {
'12': date(2024, 11, 14),
'13': date(2025, 11, 13),
'16': None,
},
# redis, php, nodejs, nginx, mysql, ruby, go, java, ...
}
It works the moment you docker compose up, no network required. If you want
fresher data, you flip EOL_REFRESH_ENABLED=true and a daily job pulls from
endoflife.date and overlays the result on top of the baked-in dict:
def _effective() -> dict:
eff = {k: dict(v) for k, v in _EOL.items()} # start from the offline baseline
snap = _load_snapshot_data() # latest fetched snapshot, if any
if snap:
for product, cycles in snap.items():
eff.setdefault(product, {}).update(cycles)
return eff
Offline data is the floor; the network is an enhancement, never a dependency. (The
fetch side validates the product slug against a regex and only ever talks https to
endoflife.date — an inventory tool making arbitrary outbound requests is exactly the
SSRF footgun you don't want to ship.)
The fiddly part: matching a version string to a cycle
Here's where it stops being a lookup table. A dependency reports its version as a
string — "3.11.4", "8", "1.24.2" — but the EOL calendar is keyed by release
cycle: "3.11" for Python, "8" for MySQL. You can't just dict[version].
The rule that works: try the longest cycle key first (major.minor), then fall back
to major only.
for n_parts in (2, 1): # "3.11.4" → try "3.11", then "3"
cycle = _major_minor(version, n_parts)
if cycle not in cycles:
continue
eol_date = _parse_date(cycles[cycle])
if eol_date is None:
return 'ok' # cycle exists, no EOL date = supported
if eol_date < today:
return 'eol'
if eol_date < today + timedelta(days=180):
return 'warning' # within 6 months
return 'ok'
return 'unknown'
Python is tracked at major.minor (3.11 ≠ 3.12), but MySQL "8.0.36" should match
the "8.0" cycle and Node "20.11.1" should match "20". Longest-match-first gets
both right without per-product special-casing. The four states — eol / warning /
ok / unknown — matter too: unknown is not ok. "I have no data for this"
and "this is supported" are different answers, and collapsing them is how you ship a
green dashboard that's quietly lying.
The "this has no EOL" case
Not everything has an end-of-life. Gunicorn, Celery, Composer — there's no vendor
EOL cycle to track. Naively those show up as unknown forever, which is noise. The
trick is an alias table that can map a name to None meaning "no EOL concept":
_ALIASES = {
'node': 'nodejs', # normalize spelling
'postgres': 'postgresql',
'gunicorn': None, # explicitly: no formal EOL — don't nag about it
'celery': None,
}
def canonical(name):
return _ALIASES.get(_normalize(name), _normalize(name))
So canonical() does double duty: it normalizes spellings (node → nodejs) and
encodes "we deliberately don't track this" as None, which short-circuits to
unknown and keeps it out of the warnings. Suppressing a check on purpose is a
feature; the alias table is where that intent lives.
Takeaways
- For a self-hosted tool, make EOL data offline-first: bake in a baseline dict, make the external refresh opt-in, and overlay fetched data rather than depend on it. It runs air-gapped and doesn't surprise a security reviewer.
- Don't index the EOL table by raw version. Match longest cycle first
(major.minor → major) so
3.11.4,8.0.36, and20.11.1all land on the right cycle. - Keep
unknowndistinct fromok, and give yourself a way to say "this has no EOL" on purpose so the signal stays clean.
This EOL check is one layer of a self-hosted tool that inventories your AWS assets
and the apps/middleware running on each environment, then flags drift and aging
runtimes. Open source (MIT), one docker compose up: syncvey.com.
How do you track middleware/runtime EOL today — a spreadsheet, Dependabot, or hope?
Top comments (0)