diff --git a/apollo/rpmworker/repomd.py b/apollo/rpmworker/repomd.py index da8165b..6724dd9 100644 --- a/apollo/rpmworker/repomd.py +++ b/apollo/rpmworker/repomd.py @@ -13,6 +13,10 @@ from common.logger import Logger NVRA_RE = re.compile( r"^(\S+)-([\w~%.+]+)-(\w+(?:\.[\w~%+]+)+?)(?:\.(\w+))?(?:\.rpm)?$" ) +NEVRA_RE = re.compile( + r"^(\S+)-(\d):([\w~%.+]+)-(\w+(?:\.[\w~%+]+)+?)(?:\.(\w+))?(?:\.rpm)?$" +) +EPOCH_RE = re.compile(r"(\d+):") DIST_RE = re.compile(r"(\.el\d(?:_\d|))") MODULE_DIST_RE = re.compile(r"\.module.+$") diff --git a/apollo/server/BUILD.bazel b/apollo/server/BUILD.bazel index 27e056a..b1b9eb7 100644 --- a/apollo/server/BUILD.bazel +++ b/apollo/server/BUILD.bazel @@ -10,6 +10,7 @@ py_library( "routes/api_advisories.py", "routes/api_compat.py", "routes/api_red_hat.py", + "routes/api_updateinfo.py", "routes/login.py", "routes/logout.py", "routes/red_hat_advisories.py", @@ -28,13 +29,16 @@ py_library( deps = [ "//apollo/db:db_lib", "//apollo/db/serialize:serialize_lib", + "//apollo/rpmworker:rpmworker_lib", "//common:common_lib", "@pypi_fastapi//:pkg", "@pypi_fastapi_pagination//:pkg", "@pypi_itsdangerous//:pkg", "@pypi_jinja2//:pkg", "@pypi_passlib//:pkg", + "@pypi_pydantic//:pkg", "@pypi_python_multipart//:pkg", + "@pypi_python_slugify//:pkg", "@pypi_rssgen//:pkg", "@pypi_starlette//:pkg", "@pypi_tortoise_orm//:pkg", diff --git a/apollo/server/routes/api_updateinfo.py b/apollo/server/routes/api_updateinfo.py new file mode 100644 index 0000000..fd03d89 --- /dev/null +++ b/apollo/server/routes/api_updateinfo.py @@ -0,0 +1,261 @@ +import datetime +from typing import Optional +from xml.etree import ElementTree as ET + +from fastapi import APIRouter, Response +from slugify import slugify + +from apollo.db import AdvisoryAffectedProduct +from apollo.server.settings import COMPANY_NAME, MANAGING_EDITOR, UI_URL, get_setting + +from apollo.rpmworker.repomd import NEVRA_RE, NVRA_RE, EPOCH_RE + +from common.fastapi import RenderErrorTemplateException + +router = APIRouter(tags=["updateinfo"]) + + +@router.get("/{product_name}/{repo}/updateinfo.xml") +async def get_updateinfo( + product_name: str, + repo: str, + req_arch: Optional[str] = None, +): + filters = { + "name": product_name, + "advisory__packages__repo_name": repo, + } + if req_arch: + filters["arch"] = req_arch + + affected_products = await AdvisoryAffectedProduct.filter( + **filters + ).prefetch_related( + "advisory", + "advisory__cves", + "advisory__fixes", + "advisory__packages", + "supported_product", + ).all() + if not affected_products: + raise RenderErrorTemplateException("No advisories found", 404) + + ui_url = await get_setting(UI_URL) + managing_editor = await get_setting(MANAGING_EDITOR) + company_name = await get_setting(COMPANY_NAME) + + advisories = {} + for affected_product in affected_products: + advisory = affected_product.advisory + if advisory.name not in advisories: + advisories[advisory.name] = { + "advisory": + advisory, + "arch": + affected_product.arch, + "major_version": + affected_product.major_version, + "minor_version": + affected_product.minor_version, + "supported_product_name": + affected_product.supported_product.name, + } + + tree = ET.Element("updates") + for _, adv in advisories.items(): + advisory = adv["advisory"] + product_arch = adv["arch"] + major_version = adv["major_version"] + minor_version = adv["minor_version"] + supported_product_name = adv["supported_product_name"] + + update = ET.SubElement(tree, "update") + + # Set update attributes + update.set("from", managing_editor) + update.set("status", "final") + + if advisory.kind == "Security": + update.set("type", "security") + elif advisory.kind == "Bug Fix": + update.set("type", "bugfix") + elif advisory.kind == "Enhancement": + update.set("type", "enhancement") + + update.set("version", "2") + + # Add id + ET.SubElement(update, "id").text = advisory.name + + # Add title + ET.SubElement(update, "title").text = advisory.synopsis + + # Add description + ET.SubElement(update, "description").text = advisory.description + + # Add time + time_format = "%Y-%m-%d %H:%M:%S" + ET.SubElement(update, "issued" + ).text = advisory.published_at.strftime(time_format) + ET.SubElement(update, "updated" + ).text = advisory.updated_at.strftime(time_format) + + # Add rights + now = datetime.datetime.utcnow() + ET.SubElement( + update, "rights" + ).text = f"Copyright {now.year} {company_name}" + + # Add release name + release_name = f"{supported_product_name} {major_version}" + if minor_version: + release_name += f".{minor_version}" + ET.SubElement(update, "release").text = release_name + + # Add pushcount + ET.SubElement(update, "pushcount").text = "1" + + # Add severity + ET.SubElement(update, "severity").text = advisory.severity + + # Add summary + ET.SubElement(update, "summary").text = advisory.topic + + # Add description + ET.SubElement(update, "description").text = advisory.description + + # Add solution + ET.SubElement(update, "solution").text = "" + + # Add references + references = ET.SubElement(update, "references") + for cve in advisory.cves: + reference = ET.SubElement(references, "reference") + reference.set( + "href", + f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cve.cve}", + ) + reference.set("id", cve.cve) + reference.set("type", "cve") + reference.set("title", cve.cve) + + for fix in advisory.fixes: + reference = ET.SubElement(references, "reference") + reference.set("href", fix.source) + reference.set("id", fix.ticket_id) + reference.set("type", "bugzilla") + reference.set("title", fix.description) + + # Add UI self reference + reference = ET.SubElement(references, "reference") + reference.set("href", f"{ui_url}/{advisory.name}") + reference.set("id", advisory.name) + reference.set("type", "self") + reference.set("title", advisory.name) + + # Add packages + packages = ET.SubElement(update, "pkglist") + + # Create collection + collection = ET.SubElement(packages, "collection") + collection_short = slugify(f"{product_name}-{repo}-rpms") + collection.set("short", collection_short) + + # Set short to name as well + ET.SubElement(collection, "name").text = collection_short + + pkg_name_map = {} + for pkg in advisory.packages: + if pkg.package_name not in pkg_name_map: + pkg_name_map[pkg.package_name] = [] + + pkg_name_map[pkg.package_name].append(pkg) + + pkg_src_rpm = {} + for top_pkg in advisory.packages: + if top_pkg.package_name not in pkg_src_rpm: + top_nvra_no_epoch = EPOCH_RE.sub("", top_pkg.nevra) + top_nvra = NVRA_RE.search(top_nvra_no_epoch) + top_arch = top_nvra.group(4) + + for pkg in pkg_name_map[top_pkg.package_name]: + nvra_no_epoch = EPOCH_RE.sub("", pkg.nevra) + nvra = NVRA_RE.search(nvra_no_epoch) + if nvra: + name = nvra.group(1) + arch = nvra.group(4) + if pkg.package_name == name and top_arch == arch: + src_rpm = nvra_no_epoch + if not src_rpm.endswith(".rpm"): + src_rpm += ".rpm" + pkg_src_rpm[pkg.package_name] = src_rpm + + # If we encounter modules, we need to add them to the collection later + modules = {} + + for pkg in advisory.packages: + if pkg.nevra.endswith(".src.rpm"): + continue + + name = pkg.package_name + epoch = "0" + if NEVRA_RE.match(pkg.nevra): + nevra = NEVRA_RE.search(pkg.nevra) + name = nevra.group(1) + epoch = nevra.group(2) + version = nevra.group(3) + release = nevra.group(4) + arch = nevra.group(5) + elif NVRA_RE.match(pkg.nevra): + nvra = NVRA_RE.search(pkg.nevra) + name = nvra.group(1) + version = nvra.group(2) + release = nvra.group(3) + arch = nvra.group(4) + else: + continue + + if pkg.package_name not in pkg_src_rpm: + continue + + package = ET.SubElement(collection, "package") + package.set("name", name) + package.set("arch", arch) + package.set("epoch", epoch) + package.set("version", version) + package.set("release", release) + package.set("src", pkg_src_rpm[pkg.package_name]) + + # Add filename element + ET.SubElement(package, + "filename").text = EPOCH_RE.sub("", pkg.nevra) + + # Add checksum + ET.SubElement( + package, "sum", type=pkg.checksum_type + ).text = pkg.checksum + + # Check if module + if pkg.module_name: + modules[pkg.module_name] = { + "name": pkg.module_name, + "context": pkg.module_context, + "stream": pkg.module_stream, + "version": pkg.module_version, + "arch": product_arch, + } + + # Add modules + for module in modules.values(): + module_element = ET.Element("module") + module_element.set("name", module["name"]) + module_element.set("stream", module["stream"]) + module_element.set("version", module["version"]) + module_element.set("context", module["context"]) + module_element.set("arch", module["arch"]) + collection.insert(1, module_element) + + ET.indent(tree) + xml_str = ET.tostring(tree, encoding="unicode", method="xml") + + return Response(content=xml_str, media_type="application/xml") diff --git a/apollo/server/server.py b/apollo/server/server.py index 33a2c95..948eef9 100644 --- a/apollo/server/server.py +++ b/apollo/server/server.py @@ -9,14 +9,15 @@ from fastapi.responses import JSONResponse from starlette.middleware.sessions import SessionMiddleware from fastapi_pagination import add_pagination +from apollo.server.routes.advisories import router as advisories_router from apollo.server.routes.statistics import router as statistics_router from apollo.server.routes.login import router as login_router from apollo.server.routes.logout import router as logout_router from apollo.server.routes.admin_index import router as admin_index_router from apollo.server.routes.api_advisories import router as api_advisories_router -from apollo.server.routes.api_compat import router as api_compat_router +from apollo.server.routes.api_updateinfo import router as api_updateinfo_router from apollo.server.routes.api_red_hat import router as api_red_hat_router -from apollo.server.routes.advisories import router as advisories_router +from apollo.server.routes.api_compat import router as api_compat_router from apollo.server.routes.red_hat_advisories import router as red_hat_advisories_router from apollo.server.settings import SECRET_KEY, SettingsMiddleware, get_setting from apollo.server.utils import admin_user_scheme, templates @@ -49,8 +50,9 @@ app.include_router( ) app.include_router(red_hat_advisories_router, prefix="/red_hat") app.include_router(api_advisories_router, prefix="/api/v3/advisories") -app.include_router(api_compat_router, prefix="/v2/advisories") +app.include_router(api_updateinfo_router, prefix="/api/v3/updateinfo") app.include_router(api_red_hat_router, prefix="/api/v3/red_hat") +app.include_router(api_compat_router, prefix="/v2/advisories") add_pagination(app) diff --git a/gazelle_python.yaml b/gazelle_python.yaml index bcebd8a..ca3d678 100644 --- a/gazelle_python.yaml +++ b/gazelle_python.yaml @@ -1128,6 +1128,9 @@ manifest: shellingham.posix.proc: shellingham shellingham.posix.ps: shellingham six: six + slugify: python_slugify + slugify.slugify: python_slugify + slugify.special: python_slugify sniffio: sniffio soupsieve: soupsieve soupsieve.css_match: soupsieve @@ -1303,6 +1306,7 @@ manifest: temporalio.worker.workflow_sandbox: temporalio temporalio.workflow: temporalio test_autoflake: autoflake + text_unidecode: text_unidecode toml: toml toml.decoder: toml toml.encoder: toml @@ -1503,4 +1507,4 @@ manifest: yarl: yarl pip_repository: name: pypi -integrity: 98955591e0f143193fb26aa58a3c5c9120c83f3270459709acd849f9613119ac +integrity: 8d848d11c467949c981e296a23211f42a009566f0d2ab5fe11ceb59ade4cb74e diff --git a/requirements.txt b/requirements.txt index 698a649..7c1edc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,5 @@ python-multipart==0.0.5 itsdangerous==2.1.2 PyYAML==6.0 beautifulsoup4==4.11.2 -rssgen==0.9.0 \ No newline at end of file +rssgen==0.9.0 +python-slugify==8.0.0 \ No newline at end of file diff --git a/requirements_lock.txt b/requirements_lock.txt index ab76dbd..2e11256 100644 --- a/requirements_lock.txt +++ b/requirements_lock.txt @@ -653,6 +653,10 @@ python-dateutil==2.8.2 \ python-multipart==0.0.5 \ --hash=sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43 # via -r ./requirements.txt +python-slugify==8.0.0 \ + --hash=sha256:51f217508df20a6c166c7821683384b998560adcf8f19a6c2ca8b460528ccd9c \ + --hash=sha256:f1da83f3c7ab839b3f84543470cd95bdb5a81f1a0b80fed502f78b7dca256062 + # via -r ./requirements.txt pytz==2022.7.1 \ --hash=sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0 \ --hash=sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a @@ -746,6 +750,10 @@ temporalio==1.0.0 \ --hash=sha256:7c82a875c3db9ab2c8492ddc01498dbb2636cad34cf8bc985a6f0f17bd627f99 \ --hash=sha256:b2454ef6b68335a554adca1e4f14831b5c3ea33ef8adb25742dd91652bd38a82 # via -r ./requirements.txt +text-unidecode==1.3 \ + --hash=sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8 \ + --hash=sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93 + # via python-slugify toml==0.10.2 \ --hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \ --hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f