diff --git a/src/phylum/__init__.py b/src/phylum/__init__.py index 356d4de..182e372 100644 --- a/src/phylum/__init__.py +++ b/src/phylum/__init__.py @@ -1,5 +1,5 @@ """Top-level package for phylum.""" -# TODO: Use only the standard library form (importlib.metadata) only after Python 3.7 support is dropped +# TODO: Use only the standard library form (importlib.metadata) after Python 3.7 support is dropped # https://github.com/phylum-dev/phylum-ci/issues/18 try: import importlib.metadata as importlib_metadata @@ -10,8 +10,8 @@ PKG_METADATA = importlib_metadata.metadata(__name__) __version__ = importlib_metadata.version(__name__) -__author__ = PKG_METADATA.get("Author") -__email__ = PKG_METADATA.get("Author-email") +__author__ = PKG_METADATA["Author"] +__email__ = PKG_METADATA["Author-email"] -PKG_NAME = PKG_METADATA.get("Name") -PKG_SUMMARY = PKG_METADATA.get("Summary") +PKG_NAME = PKG_METADATA["Name"] +PKG_SUMMARY = PKG_METADATA["Summary"] diff --git a/src/phylum/ci/cli.py b/src/phylum/ci/cli.py index 1cf7c0f..091dde1 100644 --- a/src/phylum/ci/cli.py +++ b/src/phylum/ci/cli.py @@ -15,7 +15,7 @@ from phylum.ci.ci_none import CINone from phylum.ci.ci_precommit import CIPreCommit from phylum.ci.common import ReturnCode -from phylum.constants import SUPPORTED_TARGET_TRIPLES, TOKEN_ENVVAR_NAME +from phylum.constants import TOKEN_ENVVAR_NAME from phylum.init.cli import get_target_triple, version_check @@ -188,7 +188,6 @@ def get_args(args: Optional[Sequence[str]] = None) -> Tuple[argparse.Namespace, cli_group.add_argument( "-t", "--target", - choices=SUPPORTED_TARGET_TRIPLES, default=get_target_triple(), help="The target platform type where the CLI will be installed.", ) diff --git a/src/phylum/constants.py b/src/phylum/constants.py index f8d94d7..af60a18 100644 --- a/src/phylum/constants.py +++ b/src/phylum/constants.py @@ -1,20 +1,8 @@ """Provide constants for use throughout the package.""" -# These are the currently supported Rust target triples -# -# Targets are identified by their "target triple" which is the string to inform the compiler what kind of output -# should be produced. A target triple consists of three strings separated by a hyphen, with a possible fourth string -# at the end preceded by a hyphen. The first is the architecture, the second is the "vendor", the third is the OS -# type, and the optional fourth is environment type. -# -# References: -# * https://doc.rust-lang.org/nightly/rustc/platform-support.html -# * https://rust-lang.github.io/rfcs/0131-target-specification.html -SUPPORTED_TARGET_TRIPLES = ( - "aarch64-apple-darwin", - "x86_64-apple-darwin", - "x86_64-unknown-linux-musl", -) +# The release layout structure changed starting with v2.0.0 and support is only for the new layout +MIN_SUPPORTED_CLI_VERSION = "v2.0.0" + # Keys are lowercase machine hardware names as returned from `uname -m`. # Values are the mapped rustc architecture. SUPPORTED_ARCHES = { @@ -23,6 +11,7 @@ "x86_64": "x86_64", "amd64": "x86_64", } + # Keys are lowercase operating system name as returned from `uname -s`. # Values are the mapped rustc platform, which is the vendor-os_type[-environment_type]. SUPPORTED_PLATFORMS = { diff --git a/src/phylum/init/cli.py b/src/phylum/init/cli.py index 4605e49..78d4e40 100644 --- a/src/phylum/init/cli.py +++ b/src/phylum/init/cli.py @@ -8,18 +8,19 @@ import sys import tempfile import zipfile +from functools import lru_cache from pathlib import Path -from typing import Optional, Tuple +from typing import List, Optional, Tuple import requests from packaging.utils import canonicalize_version from packaging.version import InvalidVersion, Version from phylum import __version__ from phylum.constants import ( + MIN_SUPPORTED_CLI_VERSION, REQ_TIMEOUT, SUPPORTED_ARCHES, SUPPORTED_PLATFORMS, - SUPPORTED_TARGET_TRIPLES, TOKEN_ENVVAR_NAME, ) from phylum.init import SCRIPT_NAME @@ -106,7 +107,8 @@ def get_latest_version(): # API Reference: https://docs.github.com/en/rest/releases/releases#get-the-latest-release github_api_url = "https://api.github.com/repos/phylum-dev/cli/releases/latest" - req = requests.get(github_api_url, timeout=REQ_TIMEOUT) + headers = {"Accept": "application/vnd.github+json"} + req = requests.get(github_api_url, headers=headers, timeout=REQ_TIMEOUT) req.raise_for_status() req_json = req.json() @@ -117,6 +119,71 @@ def get_latest_version(): return latest_version +@lru_cache(maxsize=1) +def supported_releases() -> List[str]: + """Get the most recent supported releases programmatically and return them.""" + # API Reference: https://docs.github.com/en/rest/releases/releases#list-releases + github_api_url = "https://api.github.com/repos/phylum-dev/cli/releases" + + headers = {"Accept": "application/vnd.github+json"} + query_params = {"per_page": 100} + req = requests.get(github_api_url, headers=headers, params=query_params, timeout=REQ_TIMEOUT) + req.raise_for_status() + req_json = req.json() + + # The "name" entry stores the GitHub Release name, which could be set to something other than the version. + # Using the "tag_name" entry is better since the tags are much more tightly coupled with the release version. + releases = [rel.get("tag_name") for rel in req_json if is_supported_version(rel.get("tag_name", "0.0.0"))] + + return releases + + +def is_supported_version(version: str) -> bool: + """Predicate for determining if a given version is supported.""" + try: + provided_version = Version(canonicalize_version(version)) + min_supported_version = Version(MIN_SUPPORTED_CLI_VERSION) + except InvalidVersion as err: + raise ValueError("An invalid version was provided") from err + + return provided_version >= min_supported_version + + +def supported_targets(release_tag: str) -> List[str]: + """Get the supported Rust target triples programmatically for a given release tag and return them. + + Targets are identified by their "target triple" which is the string to inform the compiler what kind of output + should be produced. A target triple consists of three strings separated by a hyphen, with a possible fourth string + at the end preceded by a hyphen. The first is the architecture, the second is the "vendor", the third is the OS + type, and the optional fourth is environment type. + + References: + * https://doc.rust-lang.org/nightly/rustc/platform-support.html + * https://rust-lang.github.io/rfcs/0131-target-specification.html + """ + if release_tag not in supported_releases(): + raise SystemExit(f" [!] Unsupported version: {release_tag}") + + # API Reference: https://docs.github.com/en/rest/releases/releases#get-a-release-by-tag-name + github_api_url = f"https://api.github.com/repos/phylum-dev/cli/releases/tags/{release_tag}" + + headers = {"Accept": "application/vnd.github+json"} + req = requests.get(github_api_url, headers=headers, timeout=REQ_TIMEOUT) + req.raise_for_status() + req_json = req.json() + + assets = req_json.get("assets", []) + targets: List[str] = [] + prefix, suffix = "phylum-", ".zip" + for asset in assets: + name = asset.get("name", "") + if name.startswith(prefix) and name.endswith(suffix): + target = name.replace(prefix, "").replace(suffix, "") + targets.append(target) + + return list(set(targets)) + + def version_check(version): """Check a given version for validity and return a normalized form of it.""" if version == "latest": @@ -126,12 +193,10 @@ def version_check(version): if not version.startswith("v"): version = f"v{version}" - try: - # The release layout structure changed starting with v2.0.0 and support here is only for the new layout - if Version("v2.0.0") > Version(canonicalize_version(version)): - raise argparse.ArgumentTypeError("version must be at least v2.0.0") - except InvalidVersion as err: - raise argparse.ArgumentTypeError("an invalid version was provided") from err + supported_versions = supported_releases() + if version not in supported_versions: + releases = ", ".join(supported_versions) + raise argparse.ArgumentTypeError(f"version must be from a supported release: {releases}") return version @@ -156,15 +221,11 @@ def save_file_from_url(url, path): print("Done") -def get_archive_url(version, archive_name): - """Craft an archive download URL from a given version and archive name. - - Despite the name, the `version` is really what the GitHub API for releases calls the `tag_name`. - Reference: https://docs.github.com/en/rest/releases/releases#get-a-release-by-tag-name - """ +def get_archive_url(tag_name, archive_name): + """Craft an archive download URL from a given tag name and archive name.""" + # Reference: https://docs.github.com/en/rest/releases/releases#get-a-release-by-tag-name github_base_uri = "https://github.com/phylum-dev/cli/releases" - archive_url = f"{github_base_uri}/download/{version}/{archive_name}" - + archive_url = f"{github_base_uri}/download/{tag_name}/{archive_name}" return archive_url @@ -252,6 +313,12 @@ def get_args(args=None): formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) + parser.add_argument( + "-V", + "--version", + action="version", + version=f"{SCRIPT_NAME} {__version__}", + ) parser.add_argument( "-r", "--phylum-release", @@ -264,7 +331,6 @@ def get_args(args=None): parser.add_argument( "-t", "--target", - choices=SUPPORTED_TARGET_TRIPLES, default=get_target_triple(), help="The target platform type where the CLI will be installed.", ) @@ -278,11 +344,17 @@ def get_args(args=None): already set in the Phylum config file or (2) to manually populate the token with a `phylum auth login` or `phylum auth register` command after install.""", ) - parser.add_argument( - "-V", - "--version", - action="version", - version=f"{SCRIPT_NAME} {__version__}", + + list_group = parser.add_mutually_exclusive_group() + list_group.add_argument( + "--list-releases", + action="store_true", + help="List the Phylum CLI releases available to install.", + ) + list_group.add_argument( + "--list-targets", + action="store_true", + help="List the target platform types available for installing a given Phylum CLI release.", ) return parser.parse_args(args=args) @@ -292,15 +364,29 @@ def main(args=None): """Main entrypoint.""" args = get_args(args=args) + if args.list_releases: + print("Looking up supported releases ...") + releases = ", ".join(supported_releases()) + print(f"Supported releases: {releases}") + return 0 + + tag_name = args.version + supported_target_triples = supported_targets(tag_name) + if args.list_targets: + print(f"Looking up supported targets for release {tag_name} ...") + targets = ", ".join(supported_target_triples) + print(f"Supported targets for release {tag_name}: {targets}") + return 0 + target_triple = args.target - if target_triple not in SUPPORTED_TARGET_TRIPLES: - raise ValueError(f"The identified target triple `{target_triple}` is not currently supported") + if target_triple not in supported_target_triples: + raise SystemExit(f" [!] The identified target triple `{target_triple}` is not supported for release {tag_name}") archive_name = f"phylum-{target_triple}.zip" minisig_name = f"{archive_name}.minisig" - archive_url = get_archive_url(args.version, archive_name) + archive_url = get_archive_url(tag_name, archive_name) minisig_url = f"{archive_url}.minisig" - phylum_bin_path = get_expected_phylum_bin_path(args.version) + phylum_bin_path = get_expected_phylum_bin_path(tag_name) with tempfile.TemporaryDirectory() as temp_dir: temp_dir_path = pathlib.Path(temp_dir)