diff --git a/devices/cli.py b/devices/cli.py index 56cfd61..3824206 100644 --- a/devices/cli.py +++ b/devices/cli.py @@ -9,6 +9,7 @@ from config.cli import resolve_profile_field from utils import color_mark_selected, colors_supported from .device import get_devices, get_device +from version.cli import _check_kbs_version @click.command(name='devices') @@ -33,6 +34,7 @@ def cmd_devices( output_file: Optional[str] = None, ): 'list the available devices and descriptions' + _check_kbs_version(init_pkgbuilds=False) devices = get_devices() if not devices: raise Exception("No devices found!") diff --git a/distro/repo_config.py b/distro/repo_config.py index f5f63ab..8bde290 100644 --- a/distro/repo_config.py +++ b/distro/repo_config.py @@ -43,6 +43,8 @@ class BaseDistro(DictScheme): class ReposConfigFile(DictScheme): + kbs_min_version: Optional[str] + kbs_ci_version: Optional[str] remote_url: Optional[str] repos: dict[str, RepoConfig] base_distros: dict[Arch, BaseDistro] diff --git a/flavours/cli.py b/flavours/cli.py index a05bf3c..849620d 100644 --- a/flavours/cli.py +++ b/flavours/cli.py @@ -9,6 +9,7 @@ from config.state import config from utils import color_mark_selected, colors_supported from .flavour import get_flavours, get_flavour +from version.cli import _check_kbs_version profile_option = click.option('-p', '--profile', help="name of the profile to use", required=False, default=None) @@ -21,13 +22,14 @@ def cmd_flavours(json: bool = False, output_file: Optional[str] = None): results = [] json_results = {} profile_flavour = None - flavours = get_flavours() interactive_json = json and not output_file use_colors = colors_supported(config.runtime.colors) and not interactive_json profile_name = config.file.profiles.current selected, inherited_from = None, None if output_file: json = True + _check_kbs_version(init_pkgbuilds=False) + flavours = get_flavours() if not flavours: raise Exception("No flavours found!") if not interactive_json: diff --git a/image/boot.py b/image/boot.py index 33e1f8b..5dadc28 100644 --- a/image/boot.py +++ b/image/boot.py @@ -10,6 +10,7 @@ from exec.file import makedir from devices.device import get_profile_device from flavours.flavour import get_profile_flavour from flavours.cli import profile_option +from version.cli import _check_kbs_version from wrapper import enforce_wrap from .fastboot import fastboot_boot, fastboot_erase @@ -42,6 +43,7 @@ def cmd_boot( ): """Boot JumpDrive or the Kupfer aboot image. Erases Android DTBO in the process.""" enforce_wrap() + _check_kbs_version(init_pkgbuilds=True) device = get_profile_device(profile) flavour = get_profile_flavour(profile).name deviceinfo = device.parse_deviceinfo() diff --git a/image/flash.py b/image/flash.py index 5def17c..b229cfe 100644 --- a/image/flash.py +++ b/image/flash.py @@ -11,6 +11,7 @@ from exec.file import get_temp_dir from devices.device import get_profile_device from flavours.flavour import get_profile_flavour from flavours.cli import profile_option +from version.cli import _check_kbs_version from wrapper import enforce_wrap from .fastboot import fastboot_flash @@ -90,6 +91,7 @@ def cmd_flash( - jumpdrive: one of "emmc", "sdcard" or a path to a block device """ enforce_wrap() + _check_kbs_version(init_pkgbuilds=True) device = get_profile_device(profile) flavour = get_profile_flavour(profile).name device_image_path = get_image_path(device, flavour) diff --git a/image/image.py b/image/image.py index 6532af7..d04050c 100644 --- a/image/image.py +++ b/image/image.py @@ -19,6 +19,7 @@ from exec.file import get_temp_dir, root_write_file, root_makedir, makedir from flavours.flavour import Flavour, get_profile_flavour from net.ssh import copy_ssh_keys from packages.build import build_enable_qemu_binfmt, build_packages, filter_pkgbuilds +from version.cli import _check_kbs_version from wrapper import enforce_wrap # image files need to be slightly smaller than partitions to fit @@ -435,6 +436,7 @@ def cmd_build( config.enforce_profile_device_set() config.enforce_profile_flavour_set() enforce_wrap() + _check_kbs_version(init_pkgbuilds=True) device = get_profile_device(profile_name) arch = device.arch # check_programs_wrap(['makepkg', 'pacman', 'pacstrap']) @@ -515,6 +517,7 @@ def cmd_inspect(profile: Optional[str] = None, shell: bool = False, sector_size: config.enforce_profile_device_set() config.enforce_profile_flavour_set() enforce_wrap() + _check_kbs_version(init_pkgbuilds=True) device = get_profile_device(profile) arch = device.arch flavour = get_profile_flavour(profile).name diff --git a/main.py b/main.py index f212197..ffc62d5 100755 --- a/main.py +++ b/main.py @@ -20,6 +20,7 @@ from net.cli import cmd_net from chroot.cli import cmd_chroot from cache.cli import cmd_cache from image.cli import cmd_image +from version.cli import cmd_version @click.group() @@ -87,6 +88,7 @@ cli.add_command(cmd_flavours) cli.add_command(cmd_image) cli.add_command(cmd_net) cli.add_command(cmd_packages) +cli.add_command(cmd_version) if __name__ == '__main__': main() diff --git a/packages/cli.py b/packages/cli.py index 3878ba4..2989c21 100644 --- a/packages/cli.py +++ b/packages/cli.py @@ -15,6 +15,7 @@ from distro.distro import get_kupfer_local, get_kupfer_url, get_kupfer_repo_name from distro.package import LocalPackage from net.ssh import run_ssh_command, scp_put_files from utils import download_file, git, sha256sum +from version.cli import _check_kbs_version from wrapper import check_programs_wrap, enforce_wrap from .build import build_packages_by_paths, init_prebuilts @@ -37,6 +38,7 @@ def build( if arch not in ARCHES: raise Exception(f'Unknown architecture "{arch}". Choices: {", ".join(ARCHES)}') + _check_kbs_version(init_pkgbuilds=True) return build_packages_by_paths( paths, arch, @@ -160,6 +162,7 @@ def cmd_update( """Update PKGBUILDs git repo""" enforce_wrap() init_pkgbuilds(interactive=not non_interactive, lazy=False, update=True, switch_branch=switch_branch, discard_changes=discard_changes) + _check_kbs_version(init_pkgbuilds=False) if init_caches: init_pkgbuild_caches(clean_src_dirs=clean_src_dirs) logging.info("Refreshing outdated SRCINFO caches") @@ -183,6 +186,7 @@ def cmd_init( ): "Ensure PKGBUILDs git repo is checked out locally" init_pkgbuilds(interactive=not non_interactive, lazy=False, update=update, switch_branch=switch_branch, discard_changes=discard_changes) + _check_kbs_version(init_pkgbuilds=False) if init_caches: init_pkgbuild_caches(clean_src_dirs=clean_src_dirs) for arch in ARCHES: @@ -308,7 +312,8 @@ def cmd_list(): if not os.path.exists(pkgdir): raise Exception(f"PKGBUILDs seem not to be initialised yet: {pkgdir} doesn't exist!\n" f"Try running `kupferbootstrap packages init` first!") - check_programs_wrap(['makepkg', 'pacman']) + check_programs_wrap(['git', 'makepkg', 'pacman']) + _check_kbs_version(init_pkgbuilds=False) packages = discover_pkgbuilds() logging.info(f'Done! {len(packages)} Pkgbuilds:') for name in sorted(packages.keys()): @@ -318,11 +323,13 @@ def cmd_list(): @cmd_packages.command(name='check') +@click.option("--ci-mode", "--ci", is_flag=True, default=False) @click.argument('paths', nargs=-1) -def cmd_check(paths): +def cmd_check(paths: list[str], ci_mode: bool = False): """Check that specified PKGBUILDs are formatted correctly""" config.enforce_config_loaded() - check_programs_wrap(['makepkg']) + check_programs_wrap(['makepkg', 'git']) + _check_kbs_version(init_pkgbuilds=False, ci_mode=ci_mode) def check_quoteworthy(s: str) -> bool: quoteworthy = ['"', "'", "$", " ", ";", "&", "<", ">", "*", "?"] diff --git a/requirements.txt b/requirements.txt index df53ed4..248505c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ requests python-dateutil enlighten PyYAML +semver diff --git a/version/__init__.py b/version/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/version/cli.py b/version/cli.py new file mode 100644 index 0000000..9dc1ed4 --- /dev/null +++ b/version/cli.py @@ -0,0 +1,72 @@ +import click +import logging + +from constants import REPOS_CONFIG_FILE +from distro.repo_config import get_repo_config +from .kbs import get_kbs_version, compare_kbs_version, compare_kbs_ci_version + + +def _check_kbs_version(*, init_pkgbuilds: bool = False, ci_mode: bool = False): # quiet helper for other modules + repo_config, repo_config_found = get_repo_config(initialize_pkgbuilds=init_pkgbuilds) + if not repo_config_found: + return + kbs_version = get_kbs_version() + if not kbs_version: + return + compare_kbs_version(kbs_version, repo_config) + + +@click.group("version", no_args_is_help=False, invoke_without_command=True) +@click.pass_context +def cmd_version(ctx: click.Context): + """ + Print KBS version or check for PKGBUILDs compatibility + """ + if not ctx.invoked_subcommand: + ctx.invoke(cmd_version_show) + + +@cmd_version.command("show") +def cmd_version_show(): + """ + Print the current version and exit (default action) + """ + version = get_kbs_version() + if not version: + logging.error(f"Failed to fetch KBS version: {version=}") + exit(1) + print(version) + + +@cmd_version.command("check") +@click.option("--ci-mode", "--ci", is_flag=True, default=False, help="Compare local version against required Build-CI version") +def cmd_version_check(ci_mode: bool = False): + """ + Compare KBS version against minimum version from PKGBUILDs + + The PKGBUILDs repo contains a repos.yml file that contains a minimum KBS version needed/recommended. + + Returns 0 if the KBS version is >= the minimum version. + Returns 1 if the KBS version is smaller than the minimum version. + Returns 2 on other failures, e.g. missing files. + """ + kbs_version = get_kbs_version() + if not kbs_version: + logging.error("Can't compare KBS version as we failed to fetch it") + exit(2) + repo_config, file_found = get_repo_config(initialize_pkgbuilds=False) + if not file_found: + logging.error(f"{REPOS_CONFIG_FILE} not found in PKGBUILDs, can't check KBS version for compatibility") + exit(2) + res = compare_kbs_version(kbs_version=kbs_version, repo_config=repo_config) + if ci_mode: + res_ci = compare_kbs_ci_version(kbs_version=kbs_version, repo_config=repo_config) + if res_ci is None: + exit(2) + if res_ci: + logging.info("KBS CI version is new enough!") + if res is None: + exit(2) + if res: + logging.info(f"{'Success: ' if res_ci else ''}KBS version {kbs_version!r} is new enough for PKGBUILDs!") + exit(0 if res and res_ci else 1) diff --git a/version/compare.py b/version/compare.py new file mode 100644 index 0000000..65ad4b8 --- /dev/null +++ b/version/compare.py @@ -0,0 +1,12 @@ +from enum import IntEnum +from semver import Version + + +class VerComp(IntEnum): + RIGHT_NEWER = -1 + EQUAL = 0 + RIGHT_OLDER = 1 + + +def semver_compare(a: str, b: str) -> VerComp: + return VerComp(Version.parse(a).compare(b)) diff --git a/version/kbs.py b/version/kbs.py new file mode 100644 index 0000000..df56380 --- /dev/null +++ b/version/kbs.py @@ -0,0 +1,79 @@ +import logging +import os + +from typing import Union +from utils import git + +from .compare import semver_compare, VerComp +from distro.repo_config import ReposConfigFile + +KBS_VERSION: Union[str, None] = None + +KBS_VERSION_MIN_KEY = "kbs_min_version" +KBS_VERSION_CI_MIN_KEY = 'kbs_ci_version' + + +def get_kbs_version(kbs_folder: Union[str, None] = None) -> Union[str, None]: + if KBS_VERSION: + return KBS_VERSION + if not kbs_folder: + kbs_folder = os.path.join(os.path.dirname(__file__), "..") + try: + res = git( + ['describe', '--tags', '--match', 'v*.*.*'], + use_git_dir=True, + git_dir=os.path.join(kbs_folder, ".git"), + capture_output=True, + ) + if res.returncode: + output = res.stderr or res.stdout + if output and not isinstance(output, str): + output = output.decode().strip() + raise Exception(output or f'[Git failed without output. Return Code: {res.returncode}]') + return (res.stdout or b'').decode().strip() + except Exception as ex: + logging.warning(f"Failed to fetch KBS version with git: {ex!s}") + return None + + +def compare_kbs_version_generic( + kbs_version: str, + minimum_ver: str | None, +) -> Union[VerComp, None]: + if not minimum_ver: + return None + try: + return semver_compare(minimum_ver.lstrip("v"), kbs_version.lstrip("v")) + except Exception as ex: + logging.warning(f'Failed to compare KBS version {kbs_version!r} to required minimum version {minimum_ver!r}: {ex!r}') + return None + + +def compare_kbs_version(kbs_version: str, repo_config: ReposConfigFile) -> bool | None: + """Returns True if KBS is new enough for PKGBUILDs""" + minimum_ver = repo_config.get(KBS_VERSION_MIN_KEY) + kbs_state = compare_kbs_version_generic(kbs_version=kbs_version, minimum_ver=minimum_ver) + if not minimum_ver: + logging.warning(f"Can't check PKGBUILDs for compatible KBS version as {KBS_VERSION_MIN_KEY!r} " + 'is empty in PKGBUILDs repos.yml') + return None + if kbs_state == VerComp.RIGHT_OLDER: + logging.warning(f'KBS version {kbs_version!r} is older than {minimum_ver!r} required by PKGBUILDs.\n' + 'Some functionality may randomly be broken.\nYou have been warned.') + return False + return True + + +def compare_kbs_ci_version(kbs_version: str, repo_config: ReposConfigFile) -> bool | None: + """Returns True if KBS is new enough for PKGBUILDs in CI""" + minimum_ver = repo_config.get(KBS_VERSION_CI_MIN_KEY) + if not minimum_ver: + logging.warning("Can't check PKGBUILDs for compatible KBS CI version: " + f'Minimum CI KBS version {KBS_VERSION_CI_MIN_KEY!r} is empty in PKGBUILDs repos.yml!') + return None + kbs_state = compare_kbs_version_generic(kbs_version=kbs_version, minimum_ver=minimum_ver) + if kbs_state == VerComp.RIGHT_OLDER: + logging.error(f'KBS CI version {kbs_version!r} is older than {minimum_ver!r} required by PKGBUILDs kbs_ci_version!\n' + 'CI is likely to fail!') + return False + return True diff --git a/version/test_kbs_version.py b/version/test_kbs_version.py new file mode 100644 index 0000000..7d464f3 --- /dev/null +++ b/version/test_kbs_version.py @@ -0,0 +1,34 @@ +from pytest import mark +from typing import Optional + +from .kbs import get_kbs_version, compare_kbs_version_generic +from .compare import VerComp + + +def test_get_kbs_version(): + ver = get_kbs_version() + assert ver + assert ver.startswith("v") + + +@mark.parametrize( + "minimum_ver, kbs_ver, expected", + [ + ("v0.0.1", "v0.0.1", VerComp.EQUAL), + ("v0.0.1", "v0.0.2", VerComp.RIGHT_NEWER), + ("v0.0.1-rc0", "v0.0.1-rc1", VerComp.RIGHT_NEWER), + ("v0.0.1-rc1", "v0.0.1", VerComp.RIGHT_NEWER), + ("v0.0.1-rc3", "v0.0.2", VerComp.RIGHT_NEWER), + ("v0.0.1-rc4", "v0.0.1-rc4-3-g12ab34de", VerComp.RIGHT_NEWER), + ("v0.0.1-rc4-3", "v0.0.1-rc4-3-g12ab34de", VerComp.RIGHT_NEWER), + ("v0.0.1", "v0.0.1-rc4-3-g12ab34de", VerComp.RIGHT_OLDER), + ("v0.0.2", "v0.0.1-rc4-3-g12ab34de", VerComp.RIGHT_OLDER), + ("v0.0.2", None, None), + ("v0.0.2", "v0.1.0", VerComp.RIGHT_NEWER), + ("v0.0.2", "v1.0.0", VerComp.RIGHT_NEWER), + ("v0.2.2", "v0.1.0", VerComp.RIGHT_OLDER), + ("v0.2.2", "v1.0.0", VerComp.RIGHT_NEWER), + ], +) +def test_kbs_version_compare(minimum_ver: str, kbs_ver: str, expected: Optional[VerComp]): + assert compare_kbs_version_generic(kbs_version=kbs_ver, minimum_ver=minimum_ver) == expected