diff --git a/src/gardenlinux/apt/__init__.py b/src/gardenlinux/apt/__init__.py index 3e0ca58c..3d2dfb7c 100644 --- a/src/gardenlinux/apt/__init__.py +++ b/src/gardenlinux/apt/__init__.py @@ -5,5 +5,6 @@ """ from .debsource import Debsrc, DebsrcFile +from .package_repo_info import GardenLinuxRepo -__all__ = ["Debsrc", "DebsrcFile"] +__all__ = ["Debsrc", "DebsrcFile", "GardenLinuxRepo"] diff --git a/src/gardenlinux/constants.py b/src/gardenlinux/constants.py index 814e42ee..895cac3b 100644 --- a/src/gardenlinux/constants.py +++ b/src/gardenlinux/constants.py @@ -1,4 +1,5 @@ -# -*- coding: utf-8 -*- +import os +from pathlib import Path ARCHS = ["amd64", "arm64"] @@ -147,3 +148,10 @@ OCI_IMAGE_INDEX_MEDIA_TYPE = "application/vnd.oci.image.index.v1+json" RELEASE_ID_FILE = ".github_release_id" + +REQUESTS_TIMEOUTS = (5, 30) # connect, read + +S3_DOWNLOADS_DIR = Path(os.path.dirname(__file__)) / ".." / "s3_downloads" + +GLVD_BASE_URL = "https://glvd.ingress.glvd.gardnlinux.shoot.canary.k8s-hana.ondemand.com/v1" +GL_DEB_REPO_BASE_URL = "https://packages.gardenlinux.io/gardenlinux" diff --git a/src/gardenlinux/github/__main__.py b/src/gardenlinux/github/__main__.py index 828f5ba6..9ded0bce 100644 --- a/src/gardenlinux/github/__main__.py +++ b/src/gardenlinux/github/__main__.py @@ -1,12 +1,25 @@ import argparse -from .release import upload_to_github_release_page +from gardenlinux.logger import LoggerSetup + +from .release import create_github_release, upload_to_github_release_page, write_to_release_id_file +from .release_notes import create_github_release_notes + +LOGGER = LoggerSetup.get_logger("gardenlinux.github", "INFO") def main(): parser = argparse.ArgumentParser(description="GitHub Release Script") subparsers = parser.add_subparsers(dest="command") + create_parser = subparsers.add_parser("create") + create_parser.add_argument("--owner", default="gardenlinux") + create_parser.add_argument("--repo", default="gardenlinux") + create_parser.add_argument("--tag", required=True) + create_parser.add_argument("--commit", required=True) + create_parser.add_argument('--latest', action='store_true', default=False) + create_parser.add_argument("--dry-run", action="store_true", default=False) + upload_parser = subparsers.add_parser("upload") upload_parser.add_argument("--owner", default="gardenlinux") upload_parser.add_argument("--repo", default="gardenlinux") @@ -16,7 +29,19 @@ def main(): args = parser.parse_args() - if args.command == "upload": + if args.command == "create": + body = create_github_release_notes(args.tag, args.commit) + if args.dry_run: + print("Dry Run ...") + print("This release would be created:") + print(body) + else: + release_id = create_github_release( + args.owner, args.repo, args.tag, args.commit, args.latest, body + ) + write_to_release_id_file(f"{release_id}") + LOGGER.info(f"Release created with ID: {release_id}") + elif args.command == "upload": upload_to_github_release_page( args.owner, args.repo, args.release_id, args.file_path, args.dry_run ) diff --git a/src/gardenlinux/github/release/__init__.py b/src/gardenlinux/github/release/__init__.py index 441c45f2..b908c0d3 100644 --- a/src/gardenlinux/github/release/__init__.py +++ b/src/gardenlinux/github/release/__init__.py @@ -4,12 +4,10 @@ import requests -from gardenlinux.constants import RELEASE_ID_FILE +from gardenlinux.constants import RELEASE_ID_FILE, REQUESTS_TIMEOUTS from gardenlinux.logger import LoggerSetup -LOGGER = LoggerSetup.get_logger("gardenlinux.github", "INFO") - -REQUESTS_TIMEOUTS = (5, 30) # connect, read +LOGGER = LoggerSetup.get_logger("gardenlinux.github.release", "INFO") def create_github_release(owner, repo, tag, commitish, latest, body): diff --git a/src/gardenlinux/github/release_notes/__init__.py b/src/gardenlinux/github/release_notes/__init__.py new file mode 100644 index 00000000..e977bc96 --- /dev/null +++ b/src/gardenlinux/github/release_notes/__init__.py @@ -0,0 +1,33 @@ +from .helpers import get_package_list +from .sections import ( + release_notes_changes_section, + release_notes_compare_package_versions_section, + release_notes_software_components_section, +) + + +def create_github_release_notes(gardenlinux_version, commitish): + package_list = get_package_list(gardenlinux_version) + + output = "" + + output += release_notes_changes_section(gardenlinux_version) + + output += release_notes_software_components_section(package_list) + + output += release_notes_compare_package_versions_section( + gardenlinux_version, package_list + ) + + # TODO: image ids + + output += "\n" + output += "## Kernel Module Build Container (kmodbuild)" + output += "\n" + output += "```" + output += "\n" + output += f"ghcr.io/gardenlinux/gardenlinux/kmodbuild:{gardenlinux_version}" + output += "\n" + output += "```" + output += "\n" + return output diff --git a/src/gardenlinux/github/release_notes/helpers.py b/src/gardenlinux/github/release_notes/helpers.py new file mode 100644 index 00000000..5ee2b7a8 --- /dev/null +++ b/src/gardenlinux/github/release_notes/helpers.py @@ -0,0 +1,35 @@ +import gzip +import io + +import requests + +from gardenlinux.apt import DebsrcFile, GardenLinuxRepo +from gardenlinux.apt.package_repo_info import compare_repo +from gardenlinux.constants import GL_DEB_REPO_BASE_URL, REQUESTS_TIMEOUTS + + +def get_package_list(gardenlinux_version): + url = f"{GL_DEB_REPO_BASE_URL}/dists/{gardenlinux_version}/main/binary-amd64/Packages.gz" + response = requests.get(url, timeout=REQUESTS_TIMEOUTS) + response.raise_for_status() + + d = DebsrcFile() + + with io.BytesIO(response.content) as buf: + with gzip.open(buf, "rt") as f: + d.read(f) + + return d + + +def compare_apt_repo_versions(previous_version, current_version): + previous_repo = GardenLinuxRepo(previous_version) + current_repo = GardenLinuxRepo(current_version) + pkg_diffs = sorted(compare_repo(previous_repo, current_repo), key=lambda t: t[0]) + + output = f"| Package | {previous_version} | {current_version} |\n" + output += "|---------|--------------------|-------------------|\n" + + for pkg in pkg_diffs: + output += f"|{pkg[0]} | {pkg[1] if pkg[1] is not None else '-'} | {pkg[2] if pkg[2] is not None else '-'} |\n" + return output diff --git a/src/gardenlinux/github/release_notes/sections.py b/src/gardenlinux/github/release_notes/sections.py new file mode 100644 index 00000000..fbfa21e2 --- /dev/null +++ b/src/gardenlinux/github/release_notes/sections.py @@ -0,0 +1,112 @@ +import re +import textwrap + +import requests + +from gardenlinux.constants import GLVD_BASE_URL, REQUESTS_TIMEOUTS +from gardenlinux.logger import LoggerSetup + +from .helpers import compare_apt_repo_versions + +LOGGER = LoggerSetup.get_logger("gardenlinux.github.release_notes", "INFO") + + +def release_notes_changes_section(gardenlinux_version): + """ + Get list of fixed CVEs, grouped by upgraded package. + Note: This result is not perfect, feel free to edit the generated release notes and + file issues in glvd for improvement suggestions https://github.com/gardenlinux/glvd/issues + """ + try: + url = f"{GLVD_BASE_URL}/patchReleaseNotes/{gardenlinux_version}" + response = requests.get(url, timeout=REQUESTS_TIMEOUTS) + response.raise_for_status() + data = response.json() + + if len(data["packageList"]) == 0: + return "" + + output = [ + "## Changes", + "The following packages have been upgraded, to address the mentioned CVEs:", + ] + for package in data["packageList"]: + upgrade_line = ( + f"- upgrade '{package['sourcePackageName']}' from `{package['oldVersion']}` " + f"to `{package['newVersion']}`" + ) + output.append(upgrade_line) + + if package["fixedCves"]: + for fixedCve in package["fixedCves"]: + output.append(f" - {fixedCve}") + + return "\n".join(output) + "\n\n" + except Exception as exn: + # There are expected error cases, + # for example with versions not supported by glvd (1443.x) + # or when the api is not available + # Fail gracefully by adding the placeholder we previously used, + # so that the release note generation does not fail. + LOGGER.error(f"Failed to process GLVD API output: {exn}") + return textwrap.dedent( + """ + ## Changes + The following packages have been upgraded, to address the mentioned CVEs: + **todo release facilitator: fill this in** + """ + ) + + +def release_notes_software_components_section(package_list): + output = "## Software Component Versions\n" + output += "```" + output += "\n" + packages_regex = re.compile( + r"^linux-image-amd64$|^systemd$|^containerd$|^runc$|^curl$|^openssl$|^openssh-server$|^libc-bin$" + ) + for entry in package_list.values(): + if packages_regex.match(entry.deb_source): + output += f"{entry!r}\n" + output += "```" + output += "\n\n" + return output + + +def release_notes_compare_package_versions_section(gardenlinux_version, package_list): + output = "" + version_components = gardenlinux_version.split(".") + # Assumes we always have version numbers like 1443.2 + if len(version_components) == 2: + try: + major = int(version_components[0]) + patch = int(version_components[1]) + + if patch > 0: + previous_version = f"{major}.{patch - 1}" + + output += ( + f"## Changes in Package Versions Compared to {previous_version}\n" + ) + output += compare_apt_repo_versions( + previous_version, gardenlinux_version + ) + elif patch == 0: + output += f"## Full List of Packages in Garden Linux version {major}\n" + output += "
Expand to see full list\n" + output += "
"
+                output += "\n"
+                for entry in package_list.values():
+                    output += f"{entry!r}\n"
+                output += "
" + output += "\n
\n\n" + + except ValueError: + LOGGER.error( + f"Could not parse {gardenlinux_version} as the Garden Linux version, skipping version compare section" + ) + else: + LOGGER.error( + f"Unexpected version number format {gardenlinux_version}, expected format (major is int).(patch is int)" + ) + return output diff --git a/tests/constants.py b/tests/constants.py index f1bcecae..877ad95f 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -1,8 +1,3 @@ -# -*- coding: utf-8 -*- - -import os -from pathlib import Path - from gardenlinux.git import Repository TEST_DATA_DIR = "test-data" @@ -26,7 +21,3 @@ TEST_GARDENLINUX_RELEASE = "1877.3" TEST_GARDENLINUX_COMMIT = "75df9f401a842914563f312899ec3ce34b24515c" - -RELEASE_ID_FILE = ".github_release_id" - -S3_DOWNLOADS_DIR = Path(os.path.dirname(__file__)) / ".." / "s3_downloads" diff --git a/tests/github/conftest.py b/tests/github/conftest.py index 68576a82..00ad77f6 100644 --- a/tests/github/conftest.py +++ b/tests/github/conftest.py @@ -4,7 +4,7 @@ import pytest -from ..constants import RELEASE_ID_FILE, S3_DOWNLOADS_DIR +from gardenlinux.constants import RELEASE_ID_FILE, S3_DOWNLOADS_DIR @pytest.fixture diff --git a/tests/github/test_create_github_release_notes.py b/tests/github/test_create_github_release_notes.py new file mode 100644 index 00000000..1a36fb26 --- /dev/null +++ b/tests/github/test_create_github_release_notes.py @@ -0,0 +1,39 @@ +import requests_mock + +from gardenlinux.constants import GLVD_BASE_URL +from gardenlinux.github.release_notes import ( + release_notes_changes_section, + release_notes_compare_package_versions_section, +) + +from ..constants import TEST_GARDENLINUX_RELEASE + + +def test_release_notes_changes_section_empty_packagelist(): + with requests_mock.Mocker() as m: + m.get( + f"{GLVD_BASE_URL}/patchReleaseNotes/{TEST_GARDENLINUX_RELEASE}", + text='{"packageList": []}', + status_code=200 + ) + assert release_notes_changes_section(TEST_GARDENLINUX_RELEASE) == "", \ + "Expected an empty result if GLVD returns an empty package list" + + +def test_release_notes_changes_section_broken_glvd_response(): + with requests_mock.Mocker() as m: + m.get( + f"{GLVD_BASE_URL}/patchReleaseNotes/{TEST_GARDENLINUX_RELEASE}", + text="

Personal Home Page

", + status_code=200 + ) + assert "fill this in" in release_notes_changes_section(TEST_GARDENLINUX_RELEASE), \ + "Expected a placeholder message to be generated if GVLD response is not valid" + + +def test_release_notes_compare_package_versions_section_semver_is_not_recognized(): + assert release_notes_compare_package_versions_section("1.2.0", []) == "", "Semver is not supported" + + +def test_release_notes_compare_package_versions_section_unrecognizable_version(): + assert release_notes_compare_package_versions_section("garden.linux", []) == ""