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:
parent
b3fe510408
commit
412e39286b
2 changed files with 452 additions and 0 deletions
|
|
@ -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: []
|
||||
430
files/scripts/openshift-app-image-report/openshift-app-image-report.py
Executable file
430
files/scripts/openshift-app-image-report/openshift-app-image-report.py
Executable 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue