DEV Community

ひとし 田畑
ひとし 田畑

Posted on

Your runtimes have an expiry date. I baked the EOL calendar into the app so it works offline.

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, ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

So canonical() does double duty: it normalizes spellings (nodenodejs) 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, and 20.11.1 all land on the right cycle.
  • Keep unknown distinct from ok, 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)