|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# |
| 3 | +# Copyright Red Hat |
| 4 | +# |
| 5 | +# Licensed under the Apache License, Version 2.0 (the "License"); you may |
| 6 | +# not use this file except in compliance with the License. You may obtain |
| 7 | +# a copy of the License at |
| 8 | +# |
| 9 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +# |
| 11 | +# Unless required by applicable law or agreed to in writing, software |
| 12 | +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| 13 | +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| 14 | +# License for the specific language governing permissions and limitations |
| 15 | +# under the License. |
| 16 | +# |
| 17 | + |
| 18 | +""" |
| 19 | +Extract the download URL for software hosted on github, taking into account OS and Architecture. |
| 20 | +""" |
| 21 | +import argparse |
| 22 | +import enum |
| 23 | +import platform |
| 24 | +import re |
| 25 | +import sys |
| 26 | +from typing import Callable, Iterable, Literal, NamedTuple, cast |
| 27 | + |
| 28 | +import requests |
| 29 | +import semver |
| 30 | + |
| 31 | +GITHUB_RELEASE_TEMPLATE = "https://api.github.com/repos/{}/releases" |
| 32 | + |
| 33 | + |
| 34 | +SUPPORTED_SYSTEMS = {"Linux", "Darwin"} |
| 35 | + |
| 36 | +_os = platform.system() |
| 37 | +if _os not in SUPPORTED_SYSTEMS: |
| 38 | + sys.exit(f"Unsupported OS {_os}") |
| 39 | + |
| 40 | +OS = cast(Literal["Linux", "Darwin"], _os) |
| 41 | + |
| 42 | +X86_64_ARCH_NAMES = {"x86_64", "amd64"} |
| 43 | +SUPPORTED_ARCHES = X86_64_ARCH_NAMES | {"arm64"} |
| 44 | + |
| 45 | +_arch = platform.machine() |
| 46 | +if _arch not in SUPPORTED_ARCHES: |
| 47 | + sys.exit(f"Unsupported architecture {_arch}") |
| 48 | + |
| 49 | +ARCH = cast(Literal["x86_64", "arm64", "amd64"], _arch) |
| 50 | + |
| 51 | + |
| 52 | +# TOOLS: |
| 53 | +# these are ways to test the URLs of each release asset |
| 54 | +# to match our operating system and architecture. |
| 55 | +# the special exceptions have their repo name also, for convenience. |
| 56 | + |
| 57 | + |
| 58 | +class StandardTool: |
| 59 | + "The URL pattern for most tools we use." |
| 60 | + |
| 61 | + TAR_GZ_PATTERN = re.compile(r"https://.*\.tar\.[gx]z") |
| 62 | + KERNEL_PATTERN = re.compile(OS, re.IGNORECASE) |
| 63 | + |
| 64 | + if OS == "Darwin": |
| 65 | + # some projects ship "universal binaries" which support x86 and ARM. |
| 66 | + # they usually use "all" in the "arch" portion. |
| 67 | + if ARCH in X86_64_ARCH_NAMES: |
| 68 | + ARCH_PATTERN = re.compile("all|" + "|".join(X86_64_ARCH_NAMES)) |
| 69 | + elif ARCH == "arm64": |
| 70 | + ARCH_PATTERN = re.compile("all|arm64") |
| 71 | + else: |
| 72 | + sys.exit(f"Unsupported architecture {ARCH}") |
| 73 | + elif OS == "Linux" and ARCH in X86_64_ARCH_NAMES: |
| 74 | + if ARCH in X86_64_ARCH_NAMES: |
| 75 | + ARCH_PATTERN = re.compile("|".join(X86_64_ARCH_NAMES)) |
| 76 | + else: |
| 77 | + sys.exit(f"Unsupported architecture {ARCH}") |
| 78 | + else: |
| 79 | + sys.exit(f"Unsupported OS {OS}") |
| 80 | + |
| 81 | + PATTERNS = TAR_GZ_PATTERN, KERNEL_PATTERN, ARCH_PATTERN |
| 82 | + |
| 83 | + @classmethod |
| 84 | + def url_matches(cls, url: str) -> bool: |
| 85 | + return all(pat.search(url) for pat in cls.PATTERNS) |
| 86 | + |
| 87 | + |
| 88 | +class Nooba: |
| 89 | + "Nooba's URL pattern." |
| 90 | + |
| 91 | + repo = "noobaa/noobaa-operator" |
| 92 | + arch = "mac" if OS == "Darwin" else "linux" |
| 93 | + pattern = re.compile(f"https://(.*)-{arch}-(.*)[0-9]") |
| 94 | + |
| 95 | + @classmethod |
| 96 | + def url_matches(cls, url: str) -> bool: |
| 97 | + return cls.pattern.search(url) is not None |
| 98 | + |
| 99 | + |
| 100 | +class Tool(enum.Enum): |
| 101 | + "Maps the dev tools we want to their repos and respective URL matchers." |
| 102 | + |
| 103 | + def __init__(self, repo: str, matcher: Callable[[str], bool]): |
| 104 | + self.repo = repo |
| 105 | + self.matcher = matcher |
| 106 | + |
| 107 | + tkn = "tektoncd/cli", StandardTool.url_matches |
| 108 | + |
| 109 | + shellcheck = "koalaman/shellcheck", StandardTool.url_matches |
| 110 | + |
| 111 | + noobaa = Nooba.repo, Nooba.url_matches |
| 112 | + |
| 113 | + velero = "vmware-tanzu/velero", StandardTool.url_matches |
| 114 | + |
| 115 | + |
| 116 | +class ReleaseAsset(NamedTuple): |
| 117 | + "A file attached to a github release." |
| 118 | + |
| 119 | + name: str |
| 120 | + "The name of the file (for debug purposes)" |
| 121 | + |
| 122 | + url: str |
| 123 | + "The download url, not the release or asset URL." |
| 124 | + |
| 125 | + @classmethod |
| 126 | + def from_json(cls, asset_dict: dict): |
| 127 | + name = asset_dict["name"] |
| 128 | + url = asset_dict["browser_download_url"] |
| 129 | + return cls(name, url) |
| 130 | + |
| 131 | + |
| 132 | +def oc_url(): |
| 133 | + "Get the URL to download the OpenShift client (`oc`)" |
| 134 | + if OS == "Darwin": |
| 135 | + # there's currently a bug with how go handles certificates on macOS (https://github.com/golang/go/issues/52010), |
| 136 | + # and thus affects `oc`` >= 4.11 (https://bugzilla.redhat.com/show_bug.cgi?id=2097830). |
| 137 | + # The workaround is to install a 4.10 client. |
| 138 | + version = "4.10.40" |
| 139 | + |
| 140 | + if ARCH == "arm64": |
| 141 | + filename_suffix = "mac-arm64" |
| 142 | + else: |
| 143 | + filename_suffix = "mac" |
| 144 | + else: |
| 145 | + filename_suffix = "linux" |
| 146 | + version = "stable" |
| 147 | + |
| 148 | + # I can't say for sure why arm64 bins are available under x86_64, but this isn't a typo. |
| 149 | + BASE_URL = "https://mirror.openshift.com/pub/openshift-v4/x86_64/clients/ocp" |
| 150 | + return f"{BASE_URL}/{version}/openshift-client-{filename_suffix}.tar.gz" |
| 151 | + |
| 152 | + |
| 153 | +def get_latest_assets(repo: str) -> Iterable[ReleaseAsset]: |
| 154 | + "Get the release assets for the latest release." |
| 155 | + url = GITHUB_RELEASE_TEMPLATE.format(repo) |
| 156 | + |
| 157 | + response = requests.get(url) |
| 158 | + response.raise_for_status() |
| 159 | + |
| 160 | + body = response.json() |
| 161 | + |
| 162 | + highest_version = semver.VersionInfo(0, 0, 0) |
| 163 | + latest_release = None |
| 164 | + |
| 165 | + for release in body: |
| 166 | + version_string = release["tag_name"] |
| 167 | + if version_string.startswith("v"): |
| 168 | + version_string = version_string[1:] |
| 169 | + if version_string.endswith("+stringlabels"): |
| 170 | + # Special case for Prometheus, where there are |
| 171 | + # two different releases. We are interested in the |
| 172 | + # one without stringlabels: |
| 173 | + # https://prometheus.io/blog/2023/03/21/stringlabel/ |
| 174 | + continue |
| 175 | + if semver.VersionInfo.is_valid(version_string): |
| 176 | + version = semver.VersionInfo.parse(version_string) |
| 177 | + if version > highest_version: |
| 178 | + highest_version = version |
| 179 | + latest_release = release |
| 180 | + |
| 181 | + if latest_release: |
| 182 | + for asset in latest_release["assets"]: |
| 183 | + yield ReleaseAsset.from_json(asset) |
| 184 | + |
| 185 | + |
| 186 | +CLI_NAMES = {name.replace("_", "-") for name in Tool._member_names_} | {"oc"} |
| 187 | + |
| 188 | +parser = argparse.ArgumentParser(description=__doc__) |
| 189 | +parser.add_argument( |
| 190 | + "software", |
| 191 | + metavar="executable", |
| 192 | + type=str, |
| 193 | + help=f"The executable to retrieve the URL for.\nSupported: {', '.join(CLI_NAMES)}", |
| 194 | + choices=CLI_NAMES, |
| 195 | +) |
| 196 | + |
| 197 | +if __name__ == "__main__": |
| 198 | + args = parser.parse_args() |
| 199 | + software: str = args.software.replace("-", "_") |
| 200 | + |
| 201 | + if software == "oc": |
| 202 | + print(oc_url()) |
| 203 | + sys.exit() |
| 204 | + |
| 205 | + tool = Tool[software] |
| 206 | + |
| 207 | + for asset in get_latest_assets(tool.repo): |
| 208 | + if tool.matcher(asset.url): |
| 209 | + print(asset.url) |
| 210 | + sys.exit() |
| 211 | + |
| 212 | + sys.exit(f"No matching download URL found for {software} on {OS} {ARCH}") |
0 commit comments