1
0
Fork 0
forked from infra/ansible

feat: add a script for checking os versions in openshift-apps

Assisted-by: OpenAI gpt-5.5 in Codex
Signed-off-by: Vít Smolík <me@smoliicek.cz>
This commit is contained in:
Vít Smolík 2026-05-18 09:00:59 +02:00 committed by Kevin Fenzi
commit 412e39286b
2 changed files with 452 additions and 0 deletions

View file

@ -0,0 +1,22 @@
---
# Optional annotations for openshift-app-image-report.py.
#
# The scanner reads the OpenShift app files directly. Use this file for cases
# that static scanning cannot infer, such as Jinja variables, external
# Dockerfiles, or manually maintained tags.
# fill in like
# annotations:
# - app: <roles/openshift-apps/THIS>
# match: <string to match in image/source or path>
# os_version: <replacemet version text>
# kind: <optional replacement kind>
# note: <display note>
# EXAMPLE:
# annotations:
# - app: transtats
# match: registry.fedoraproject.org/fedora:34
# os_version: Fedora 34
# note: Inline Dockerfile base image; should be reviewed for EOL.
annotations: []

View file

@ -0,0 +1,430 @@
#! /usr/bin/python3
import argparse
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None
SCRIPT_DIR = Path(__file__).resolve().parent
NOTES_FILE = SCRIPT_DIR / "openshift-app-image-notes.yml"
def find_repo_root(start):
for path in (start, *start.parents):
if (path / "roles" / "openshift-apps").is_dir():
return path
print("Error: could not find roles/openshift-apps above script path", file=sys.stderr)
sys.exit(2)
REPO_ROOT = find_repo_root(SCRIPT_DIR)
APP_ROOT = REPO_ROOT / "roles" / "openshift-apps"
IMAGE_PREFIXES = (
"docker.io/",
"docker-registry.",
"fedora:",
"image-registry.",
"quay.io/",
"redis:",
"registry.access.redhat.com/",
"registry.fedoraproject.org/",
"solr:",
"valkey/",
"busybox",
)
@dataclass(frozen=True)
class Finding:
app: str
kind: str
image: str
os_version: str
floating: bool
unresolved: bool
path: str
line: int
note: str = ""
def _strip_value(value):
value = value.strip()
if value.startswith(("'", '"')) and value.endswith(("'", '"')):
value = value[1:-1]
return value.strip()
def _looks_like_image(value):
value = _strip_value(value)
if not value:
return False
if value.startswith(IMAGE_PREFIXES):
return True
if "/" in value and ":" in value:
return True
if ":" in value and not value.startswith(("http://", "https://")):
return True
return False
def _tag_from_image(image):
if "@" in image:
return image.rsplit("@", 1)[1]
tail = image.rsplit("/", 1)[-1]
if ":" not in tail:
return ""
return tail.rsplit(":", 1)[1]
def _is_floating(image):
if "{{" in image:
return False
tag = _tag_from_image(image)
return tag in ("", "latest")
def _python_version(text):
match = re.search(r"python[-:]?(\d)(\d{1,2})", text)
if not match:
return ""
major, minor = match.groups()
return f"Python {major}.{minor}"
def _infer_os_version(image):
low = image.lower()
match = re.search(
r"(?:registry\.fedoraproject\.org/|quay\.io/fedora/)?fedora:(\d+|latest)", low
)
if match:
return f"Fedora {match.group(1)}"
match = re.search(r"ubi(\d+)", low)
if match:
parts = [f"UBI {match.group(1)}"]
pyver = _python_version(low)
if pyver:
parts.append(pyver)
nginx = re.search(r"nginx[-:]?(\d)(\d{2})", low)
if nginx:
parts.append(f"nginx {nginx.group(1)}.{nginx.group(2)}")
return " / ".join(parts)
pyver = _python_version(low)
if pyver:
return pyver
if low.startswith("busybox"):
return "busybox"
if low.startswith("redis:"):
return "redis"
if "bitnami" in low and "redis" in low:
return "redis"
if low.startswith("solr:"):
return "Solr"
if low.startswith("valkey/") or low.startswith("valkey:"):
return "Valkey"
return "?"
def _source_kind(line, value):
stripped = line.strip()
if stripped.startswith("FROM "):
return "dockerfile-from"
if stripped.startswith("image:"):
return "runtime-image"
if stripped.startswith("dockerfilePath:"):
return "dockerfile-path"
if stripped.startswith("uri:"):
return "git-source"
if stripped.startswith("ref:"):
return "git-ref"
if stripped.startswith("name:"):
if value.startswith("image-registry.") or value.startswith("docker-registry."):
return "internal-image"
return "image-or-builder"
return "image-reference"
def _iter_scan_files(app_dir):
for path in sorted(app_dir.rglob("*")):
if not path.is_file():
continue
rel_parts = path.relative_to(app_dir).parts
if not rel_parts or rel_parts[0] not in ("files", "templates", "vars"):
continue
name = path.name.lower()
suffix = path.suffix.lower()
if suffix in (".yml", ".yaml", ".j2", ".toml", ".cfg"):
yield path
continue
if "dockerfile" in name or "containerfile" in name:
yield path
def _scan_line(line):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
return None
if stripped.startswith("FROM "):
return stripped.split(None, 1)[1].split()[0], True
for key in ("image:", "name:", "dockerfilePath:", "uri:", "ref:"):
if not stripped.startswith(key):
continue
value = _strip_value(stripped[len(key) :])
if key == "dockerfilePath:":
return value, False
if key in ("uri:", "ref:"):
if "github.com" in value or "gitlab" in value or "{{" in value:
return value, False
return None
if key == "image:" and "{{" in value:
return value, True
if _looks_like_image(value):
return value, True
return None
return None
def scan_apps(app_filter=None):
findings = []
wanted = set(app_filter or [])
for app_dir in sorted(APP_ROOT.iterdir()):
if not app_dir.is_dir():
continue
app = app_dir.name
if wanted and app not in wanted:
continue
for path in _iter_scan_files(app_dir):
rel_path = path.relative_to(REPO_ROOT).as_posix()
try:
lines = path.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
continue
for lineno, line in enumerate(lines, 1):
scanned = _scan_line(line)
if not scanned:
continue
value, is_image = scanned
unresolved = "{{" in value or "{%" in value
if line.strip().startswith("dockerfilePath:"):
unresolved = True
kind = _source_kind(line, value)
os_version = _infer_os_version(value) if is_image else "?"
findings.append(
Finding(
app=app,
kind=kind,
image=value,
os_version=os_version,
floating=_is_floating(value) if is_image else False,
unresolved=unresolved,
path=rel_path,
line=lineno,
)
)
return findings
def load_annotations(notes_file):
if not notes_file.exists():
return []
if yaml is None:
print(
"Error: python3-yaml is required to read openshift-app-image-notes.yml",
file=sys.stderr,
)
sys.exit(2)
with notes_file.open(encoding="utf-8") as stream:
data = yaml.safe_load(stream) or {}
annotations = data.get("annotations", [])
if not isinstance(annotations, list):
print(
"Error: annotations must be a list in openshift-app-image-notes.yml",
file=sys.stderr,
)
sys.exit(2)
return annotations
def apply_annotations(findings, annotations):
by_key = {}
for item in annotations:
if not isinstance(item, dict):
continue
app = item.get("app")
match = item.get("match", "")
note = item.get("note", "")
os_version = item.get("os_version", "")
kind = item.get("kind", "")
if not app or not match:
continue
by_key[(app, match)] = (note, os_version, kind)
used = set()
updated = []
for finding in findings:
note = finding.note
os_version = finding.os_version
kind = finding.kind
for (app, match), values in by_key.items():
if app != finding.app:
continue
if match not in finding.image and match not in finding.path:
continue
used.add((app, match))
ann_note, ann_os, ann_kind = values
note = ann_note or note
os_version = ann_os or os_version
kind = ann_kind or kind
updated.append(
Finding(
app=finding.app,
kind=kind,
image=finding.image,
os_version=os_version,
floating=finding.floating,
unresolved=finding.unresolved,
path=finding.path,
line=finding.line,
note=note,
)
)
return updated, sorted(set(by_key) - used)
def filter_findings(findings, args):
filtered = findings
if args.kind:
wanted = set(args.kind)
filtered = [item for item in filtered if item.kind in wanted]
if args.floating_only:
filtered = [item for item in filtered if item.floating]
if args.unresolved_only:
filtered = [item for item in filtered if item.unresolved]
return filtered
def _short_path(finding):
prefix = f"roles/openshift-apps/{finding.app}/"
path = finding.path
if path.startswith(prefix):
path = path[len(prefix) :]
return f"{path}:{finding.line}"
def _clip(value, width):
if len(value) <= width:
return value
if width <= 1:
return value[:width]
return value[: width - 3] + "..."
def print_table(findings):
columns = (
("APP", 22),
("KIND", 16),
("OS/VERSION", 20),
("FLOAT", 5),
("IMAGE/SOURCE", 54),
("PATH", 58),
("NOTE", 30),
)
rows = []
for finding in findings:
rows.append(
(
finding.app,
finding.kind,
finding.os_version,
"yes" if finding.floating else "",
("?" if finding.unresolved else "") + finding.image,
_short_path(finding),
finding.note,
)
)
header = " ".join(name.ljust(width) for name, width in columns)
print(header)
print(" ".join("-" * width for _name, width in columns))
for row in rows:
print(
" ".join(
_clip(value, width).ljust(width)
for value, (_name, width) in zip(row, columns)
)
)
print(f"\n{len(rows)} findings")
def parse_args(argv):
parser = argparse.ArgumentParser(
description="Print OpenShift app image and base-image references found in roles/openshift-apps.",
)
parser.add_argument(
"--app",
action="append",
help="Only report one app. May be used more than once.",
)
parser.add_argument(
"--kind",
action="append",
help="Only report one finding kind. May be used more than once.",
)
parser.add_argument(
"--floating-only",
action="store_true",
help="Only show unpinned/latest image references.",
)
parser.add_argument(
"--unresolved-only",
action="store_true",
help="Only show Jinja or external Dockerfile references.",
)
parser.add_argument(
"--notes", default=os.fspath(NOTES_FILE), help="Annotation YAML file to load."
)
parser.add_argument(
"--no-note-warnings",
action="store_true",
help="Do not warn about annotations that match no rows.",
)
return parser.parse_args(argv)
def main(argv=None):
args = parse_args(argv or sys.argv[1:])
notes_file = Path(args.notes)
findings = scan_apps(args.app)
findings, unused_annotations = apply_annotations(findings, load_annotations(notes_file))
findings = filter_findings(findings, args)
findings = sorted(
findings, key=lambda item: (item.app, item.path, item.line, item.image)
)
if unused_annotations and not args.no_note_warnings:
for app, match in unused_annotations:
print(
f"Warning: annotation for {app!r} with match {match!r} matched no rows",
file=sys.stderr,
)
print_table(findings)
return 0
if __name__ == "__main__":
sys.exit(main())