id: container-image-os-eol-audit
namespace: company.team
description: >
Read /etc/os-release inside each container image you run, look the distro
release up on endoflife.date, and alert Slack when an image is built on an
operating system that is past end of life or about to be. It checks the OS
that is really inside the image, so it also covers images tagged latest or
built in-house, where the tag says nothing.
inputs:
- id: images
type: ARRAY
itemType: STRING
displayName: Container images to audit
description: Image references to check. Each one is started once to read its
os-release file.
defaults:
- debian:11
- alpine:3.19
- ubuntu:22.04
- python:3.12-slim
- id: warn_days
type: INT
displayName: Warning window in days
description: Alert when the operating system reaches end of life within this many days.
defaults: 90
tasks:
- id: read_os_release
type: io.kestra.plugin.core.flow.Loop
description: Start every image and read the distro ID and VERSION_ID from its
os-release file.
values: "{{ inputs.images }}"
concurrencyLimit: 1
fetchType: FETCH
outputs:
- id: reading
type: STRING
value: "{{ {'image': item.value, 'id': outputs.read_image.vars.id ?? '',
'version_id': outputs.read_image.vars.version_id ?? '', 'pretty_name':
outputs.read_image.vars.pretty_name ?? ''} | toJson }}"
tasks:
- id: read_image
type: io.kestra.plugin.scripts.shell.Commands
description: >
Runs inside the image being audited, so it only needs a POSIX shell.
An image without a shell or without os-release (for example
distroless) fails here and is reported as UNKNOWN below, never as
supported.
containerImage: "{{ item.value }}"
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
pullPolicy: IF_NOT_PRESENT
allowFailure: true
commands:
- |
f=/etc/os-release
[ -r "$f" ] || f=/usr/lib/os-release
. "$f"
echo "::{\"outputs\":{\"id\":\"${ID:-}\",\"version_id\":\"${VERSION_ID:-}\",\"pretty_name\":\"${PRETTY_NAME:-}\"}}::"
- id: classify
type: io.kestra.plugin.scripts.python.Script
description: >
Map each distro to its endoflife.date product and release line and
classify it as EXPIRED, EXPIRING, SUPPORTED or UNKNOWN. A lookup that
fails is reported as UNKNOWN, never as supported. Python standard library
only.
containerImage: python:3.12-slim
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
outputFiles:
- os-eol-report.md
script: |
import json
import urllib.error
import urllib.request
from datetime import date, datetime
# /etc/os-release ID -> endoflife.date product
PRODUCTS = {
"debian": "debian", "ubuntu": "ubuntu", "alpine": "alpine-linux", "rhel": "rhel",
"centos": "centos", "almalinux": "almalinux", "rocky": "rocky-linux",
"fedora": "fedora", "amzn": "amazon-linux", "ol": "oracle-linux",
"sles": "sles", "opensuse-leap": "opensuse",
}
MAJOR_ONLY = {"debian", "rhel", "centos", "almalinux", "rocky-linux", "oracle-linux", "fedora", "amazon-linux"}
MAJOR_MINOR = {"alpine-linux", "opensuse", "ubuntu"}
def cycle_for(product, version_id):
if product in MAJOR_ONLY:
return version_id.split(".")[0]
if product in MAJOR_MINOR:
return ".".join(version_id.split(".")[:2])
return version_id
warn_days = int("{{ inputs.warn_days }}")
today = date.today()
images = json.loads(r'''{{ inputs.images | toJson }}''')
found = {}
for entry in json.loads(r'''{{ outputs.read_os_release.outputs | default([]) | toJson }}'''):
reading = (entry.get("outputs") or entry).get("reading") if isinstance(entry, dict) else entry
if isinstance(reading, str):
reading = json.loads(reading)
if isinstance(reading, dict) and reading.get("image"):
found[reading["image"]] = reading
rows = []
problems = []
def lookup(product, cycle):
url = f"https://endoflife.date/api/{product}/{cycle}.json"
req = urllib.request.Request(url, headers={"User-Agent": "kestra-os-eol-audit"})
with urllib.request.urlopen(req, timeout=20) as resp:
return json.load(resp)
for image in images:
vars_ = found.get(image, {})
distro, version_id = vars_.get("id", ""), vars_.get("version_id", "")
row = {"image": image, "os": vars_.get("pretty_name") or distro, "status": "UNKNOWN", "eol": "", "days_left": ""}
if not distro or not version_id:
problems.append(f"{image}: could not read os-release")
rows.append(row)
continue
product = PRODUCTS.get(distro)
if product is None:
problems.append(f"{image}: distro '{distro}' is not on endoflife.date")
rows.append(row)
continue
cycle = cycle_for(product, version_id)
try:
info = lookup(product, cycle)
except Exception as exc:
problems.append(f"{image}: {product} {cycle} lookup failed ({exc})")
rows.append(row)
continue
eol = info.get("eol")
if eol is True:
row.update(status="EXPIRED", eol="already ended")
elif eol is False or eol is None:
row.update(status="SUPPORTED", eol="no end date announced")
else:
days_left = (datetime.strptime(eol, "%Y-%m-%d").date() - today).days
status = "EXPIRED" if days_left < 0 else ("EXPIRING" if days_left <= warn_days else "SUPPORTED")
row.update(status=status, eol=eol, days_left=str(days_left))
rows.append(row)
expired = [r for r in rows if r["status"] == "EXPIRED"]
expiring = [r for r in rows if r["status"] == "EXPIRING"]
lines = ["| Image | Operating system | Status | End of life | Days left |", "|---|---|---|---|---|"]
for r in rows:
lines.append(f"| {r['image']} | {r['os']} | {r['status']} | {r['eol']} | {r['days_left']} |")
with open("os-eol-report.md", "w") as fh:
fh.write("\n".join(lines) + "\n")
summary = []
if expired:
summary.append("Past end of life: " + ", ".join(f"{r['image']} ({r['os']}, {r['eol']})" for r in expired))
if expiring:
summary.append(f"Ending within {warn_days} days: " + ", ".join(f"{r['image']} ({r['os']}, {r['eol']})" for r in expiring))
if problems:
summary.append("Could not check: " + "; ".join(problems))
print("::" + json.dumps({"outputs": {
"expired": len(expired),
"expiring": len(expiring),
"unknown": len(problems),
"attention": bool(expired or expiring or problems),
"summary": " | ".join(summary) if summary else "Every audited image is on a supported operating system.",
}}) + "::")
- id: needs_attention
type: io.kestra.plugin.core.flow.If
description: Alert only when an image is expired, expiring soon, or could not be
checked.
condition: "{{ outputs.classify.vars.attention }}"
then:
- id: alert_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": ":warning: Container OS support check - {{ outputs.classify.vars.summary }} (execution {{ execution.id }})"
}
else:
- id: log_all_supported
type: io.kestra.plugin.core.log.Log
message: "{{ outputs.classify.vars.summary }}"
errors:
- id: alert_audit_failed
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "Container OS support check FAILED in flow {{ flow.id }} (execution {{ execution.id }}). Check the task logs."
}
outputs:
- id: report
type: FILE
value: "{{ outputs.classify.outputFiles['os-eol-report.md'] }}"
triggers:
- id: weekly_audit
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 8 * * 1"
disabled: true