From a7616231a40a60ce7b9bfc3882b90fce790c9dc4 Mon Sep 17 00:00:00 2001 From: Max Gautier Date: Fri, 2 Feb 2024 16:01:14 +0100 Subject: [PATCH 01/10] download_hash.py: generalized and data-driven The script is currently limited to one hardcoded URL for kubernetes related binaries, and a fixed set of architectures. The solution is three-fold: 1. Use an url template dictionary for each download -> this allow to easily add support for new downloads. 2. Source the architectures to search from the existing data 3. Enumerate the existing versions in the data and start searching from the last one until no newer version is found (newer in the version order sense, irrespective of actual age) --- scripts/download_hash.py | 50 +++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/scripts/download_hash.py b/scripts/download_hash.py index 61025c8bf..e3eb3bf4c 100644 --- a/scripts/download_hash.py +++ b/scripts/download_hash.py @@ -6,7 +6,7 @@ import sys -from itertools import count +from itertools import count, groupby from collections import defaultdict import requests from ruamel.yaml import YAML @@ -25,36 +25,48 @@ def open_checksums_yaml(): return data, yaml +def version_compare(version): + return Version(version.removeprefix("v")) def download_hash(minors): - architectures = ["arm", "arm64", "amd64", "ppc64le"] - downloads = ["kubelet", "kubectl", "kubeadm"] + downloads = { + "containerd_archive": "https://github.com/containerd/containerd/releases/download/v{version}/containerd-{version}-{os}-{arch}.tar.gz.sha256sum", + "kubeadm": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubeadm.sha256", + "kubectl": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubectl.sha256", + "kubelet": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubelet.sha256", + "runc": "https://github.com/opencontainers/runc/releases/download/{version}/runc.{arch}.sha256sum", + } data, yaml = open_checksums_yaml() - if not minors: - minors = {'.'.join(minor.split('.')[:-1]) for minor in data["kubelet_checksums"]["amd64"].keys()} - for download in downloads: + for download, url in downloads.items(): checksum_name = f"{download}_checksums" - data[checksum_name] = defaultdict(dict, data[checksum_name]) - for arch in architectures: - for minor in minors: - if not minor.startswith("v"): - minor = f"v{minor}" - for release in (f"{minor}.{patch}" for patch in count(start=0, step=1)): - if release in data[checksum_name][arch]: + for arch, versions in data[checksum_name].items(): + for minor, patches in groupby(versions.copy().keys(), lambda v : '.'.join(v.split('.')[:-1])): + for version in (f"{minor}.{patch}" for patch in + count(start=int(max(patches, key=version_compare).split('.')[-1]), + step=1)): + # Those barbaric generators do the following: + # Group all patches versions by minor number, take the newest and start from that + # to find new versions + if version in versions and versions[version] != 0: continue - hash_file = requests.get(f"https://dl.k8s.io/release/{release}/bin/linux/{arch}/{download}.sha256", allow_redirects=True) + hash_file = requests.get(downloads[download].format( + version = version, + os = "linux", + arch = arch + ), + allow_redirects=True) if hash_file.status_code == 404: - print(f"Unable to find {download} hash file for release {release} (arch: {arch})") + print(f"Unable to find {download} hash file for version {version} (arch: {arch}) at {hash_file.url}") break hash_file.raise_for_status() - sha256sum = hash_file.content.decode().strip() + sha256sum = hash_file.content.decode().split(' ')[0] if len(sha256sum) != 64: - raise Exception(f"Checksum has an unexpected length: {len(sha256sum)} (binary: {download}, arch: {arch}, release: 1.{minor}.{patch})") - data[checksum_name][arch][release] = sha256sum + raise Exception(f"Checksum has an unexpected length: {len(sha256sum)} (binary: {download}, arch: {arch}, release: {version}, checksum: '{sha256sum}')") + data[checksum_name][arch][version] = sha256sum data[checksum_name] = {arch : {r : releases[r] for r in sorted(releases.keys(), - key=lambda v : Version(v[1:]), + key=version_compare, reverse=True)} for arch, releases in data[checksum_name].items()} From da0e445d69fc9147afa719a78c7210996a54a533 Mon Sep 17 00:00:00 2001 From: Max Gautier Date: Fri, 2 Feb 2024 20:48:08 +0100 Subject: [PATCH 02/10] download_hash.py: support for 'multi-hash' file + runc runc upstream does not provide one hash file per assets in their releases, but one file with all the hashes. To handle this (and/or any arbitrary format from upstreams), add a dictionary mapping the name of the download to a lambda function which transform the file provided by upstream into a dictionary of hashes, keyed by architecture. --- scripts/download_hash.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/download_hash.py b/scripts/download_hash.py index e3eb3bf4c..ef6e159ac 100644 --- a/scripts/download_hash.py +++ b/scripts/download_hash.py @@ -34,8 +34,16 @@ def download_hash(minors): "kubeadm": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubeadm.sha256", "kubectl": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubectl.sha256", "kubelet": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubelet.sha256", - "runc": "https://github.com/opencontainers/runc/releases/download/{version}/runc.{arch}.sha256sum", + "runc": "https://github.com/opencontainers/runc/releases/download/{version}/runc.sha256sum", } + # Handle hashes not directly in one url per hash. Return dict of hashs indexed by arch + download_hash_extract = { + "runc": lambda hashes : { + parts[1].split('.')[1] : parts[0] + for parts in (line.split() + for line in hashes.split('\n')[3:9]) + }, + } data, yaml = open_checksums_yaml() @@ -61,7 +69,13 @@ def download_hash(minors): print(f"Unable to find {download} hash file for version {version} (arch: {arch}) at {hash_file.url}") break hash_file.raise_for_status() - sha256sum = hash_file.content.decode().split(' ')[0] + sha256sum = hash_file.content.decode() + if download in download_hash_extract: + sha256sum = download_hash_extract[download](sha256sum).get(arch) + if sha256sum == None: + break + sha256sum = sha256sum.split()[0] + if len(sha256sum) != 64: raise Exception(f"Checksum has an unexpected length: {len(sha256sum)} (binary: {download}, arch: {arch}, release: {version}, checksum: '{sha256sum}')") data[checksum_name][arch][version] = sha256sum From 2710e984c83f6198f6355af2324dee82ee70fe17 Mon Sep 17 00:00:00 2001 From: Max Gautier Date: Thu, 5 Sep 2024 15:58:36 +0200 Subject: [PATCH 03/10] download_hash: argument handling with argparse Allow the script to be called with a list of components, to only download new versions checksums for those. By default, we get new versions checksums for all supported (by the script) components. --- scripts/download_hash.py | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/scripts/download_hash.py b/scripts/download_hash.py index ef6e159ac..727a7e317 100644 --- a/scripts/download_hash.py +++ b/scripts/download_hash.py @@ -8,6 +8,7 @@ import sys from itertools import count, groupby from collections import defaultdict +import argparse import requests from ruamel.yaml import YAML from packaging.version import Version @@ -28,14 +29,15 @@ def open_checksums_yaml(): def version_compare(version): return Version(version.removeprefix("v")) -def download_hash(minors): - downloads = { - "containerd_archive": "https://github.com/containerd/containerd/releases/download/v{version}/containerd-{version}-{os}-{arch}.tar.gz.sha256sum", - "kubeadm": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubeadm.sha256", - "kubectl": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubectl.sha256", - "kubelet": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubelet.sha256", - "runc": "https://github.com/opencontainers/runc/releases/download/{version}/runc.sha256sum", - } +downloads = { + "containerd_archive": "https://github.com/containerd/containerd/releases/download/v{version}/containerd-{version}-{os}-{arch}.tar.gz.sha256sum", + "kubeadm": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubeadm.sha256", + "kubectl": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubectl.sha256", + "kubelet": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubelet.sha256", + "runc": "https://github.com/opencontainers/runc/releases/download/{version}/runc.sha256sum", +} + +def download_hash(only_downloads: [str]) -> None: # Handle hashes not directly in one url per hash. Return dict of hashs indexed by arch download_hash_extract = { "runc": lambda hashes : { @@ -47,7 +49,8 @@ def download_hash(minors): data, yaml = open_checksums_yaml() - for download, url in downloads.items(): + for download, url in (downloads if only_downloads == [] + else {k:downloads[k] for k in downloads.keys() & only_downloads}).items(): checksum_name = f"{download}_checksums" for arch, versions in data[checksum_name].items(): for minor, patches in groupby(versions.copy().keys(), lambda v : '.'.join(v.split('.')[:-1])): @@ -88,15 +91,8 @@ def download_hash(minors): yaml.dump(data, checksums_yml) print(f"\n\nUpdated {CHECKSUMS_YML}\n") +parser = argparse.ArgumentParser(description=f"Add new patch versions hashes in {CHECKSUMS_YML}") +parser.add_argument('binaries', nargs='*', choices=downloads.keys()) -def usage(): - print(f"USAGE:\n {sys.argv[0]} [k8s_version1] [[k8s_version2]....[k8s_versionN]]") - - -def main(argv=None): - download_hash(sys.argv[1:]) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) +args = parser.parse_args() +download_hash(args.binaries) From e256f74f2abba943e08c28601b82bab6acaf868c Mon Sep 17 00:00:00 2001 From: Max Gautier Date: Thu, 5 Sep 2024 16:39:04 +0200 Subject: [PATCH 04/10] download_hash: propagate new patch versions to all archs --- scripts/download_hash.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/download_hash.py b/scripts/download_hash.py index 727a7e317..ded61fb55 100644 --- a/scripts/download_hash.py +++ b/scripts/download_hash.py @@ -52,6 +52,18 @@ def download_hash(only_downloads: [str]) -> None: for download, url in (downloads if only_downloads == [] else {k:downloads[k] for k in downloads.keys() & only_downloads}).items(): checksum_name = f"{download}_checksums" + # Propagate new patch versions to all architectures + for arch in data[checksum_name].values(): + for arch2 in data[checksum_name].values(): + arch.update({ + v:("NONE" if arch2[v] == "NONE" else 0) + for v in (set(arch2.keys()) - set(arch.keys())) + if v.split('.')[2] == '0'}) + # this is necessary to make the script indempotent, + # by only adding a vX.X.0 version (=minor release) in each arch + # and letting the rest of the script populate the potential + # patch versions + for arch, versions in data[checksum_name].items(): for minor, patches in groupby(versions.copy().keys(), lambda v : '.'.join(v.split('.')[:-1])): for version in (f"{minor}.{patch}" for patch in From a2644c7a4f3a135daccefdc0b658f3b8d2610068 Mon Sep 17 00:00:00 2001 From: Max Gautier Date: Thu, 5 Sep 2024 16:19:09 +0200 Subject: [PATCH 05/10] download_hash: add support for 'simple hash' components --- scripts/download_hash.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/download_hash.py b/scripts/download_hash.py index ded61fb55..29f84cdaf 100644 --- a/scripts/download_hash.py +++ b/scripts/download_hash.py @@ -30,11 +30,16 @@ def version_compare(version): return Version(version.removeprefix("v")) downloads = { + "ciliumcli_binary": "https://github.com/cilium/cilium-cli/releases/download/{version}/cilium-{os}-{arch}.tar.gz.sha256sum", + "cni_binary": "https://github.com/containernetworking/plugins/releases/download/{version}/cni-plugins-{os}-{arch}-{version}.tgz.sha256", "containerd_archive": "https://github.com/containerd/containerd/releases/download/v{version}/containerd-{version}-{os}-{arch}.tar.gz.sha256sum", + "crictl": "https://github.com/kubernetes-sigs/cri-tools/releases/download/{version}/critest-{version}-{os}-{arch}.tar.gz.sha256", + "crio_archive": "https://storage.googleapis.com/cri-o/artifacts/cri-o.{arch}.{version}.tar.gz.sha256sum", "kubeadm": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubeadm.sha256", "kubectl": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubectl.sha256", "kubelet": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubelet.sha256", "runc": "https://github.com/opencontainers/runc/releases/download/{version}/runc.sha256sum", + "skopeo_binary": "https://github.com/lework/skopeo-binary/releases/download/{version}/skopeo-{os}-{arch}.sha256", } def download_hash(only_downloads: [str]) -> None: From b2e64aed4b24bb928eb52a3153898864d06e2b91 Mon Sep 17 00:00:00 2001 From: Max Gautier Date: Thu, 5 Sep 2024 17:15:12 +0200 Subject: [PATCH 06/10] download_hash: support 'multi-hash' components --- scripts/download_hash.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/scripts/download_hash.py b/scripts/download_hash.py index 29f84cdaf..ca65b05e2 100644 --- a/scripts/download_hash.py +++ b/scripts/download_hash.py @@ -30,26 +30,56 @@ def version_compare(version): return Version(version.removeprefix("v")) downloads = { + "calicoctl_binary": "https://github.com/projectcalico/calico/releases/download/{version}/SHA256SUMS", "ciliumcli_binary": "https://github.com/cilium/cilium-cli/releases/download/{version}/cilium-{os}-{arch}.tar.gz.sha256sum", "cni_binary": "https://github.com/containernetworking/plugins/releases/download/{version}/cni-plugins-{os}-{arch}-{version}.tgz.sha256", "containerd_archive": "https://github.com/containerd/containerd/releases/download/v{version}/containerd-{version}-{os}-{arch}.tar.gz.sha256sum", "crictl": "https://github.com/kubernetes-sigs/cri-tools/releases/download/{version}/critest-{version}-{os}-{arch}.tar.gz.sha256", "crio_archive": "https://storage.googleapis.com/cri-o/artifacts/cri-o.{arch}.{version}.tar.gz.sha256sum", + "etcd_binary": "https://github.com/etcd-io/etcd/releases/download/{version}/SHA256SUMS", "kubeadm": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubeadm.sha256", "kubectl": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubectl.sha256", "kubelet": "https://dl.k8s.io/release/{version}/bin/linux/{arch}/kubelet.sha256", + "nerdctl_archive": "https://github.com/containerd/nerdctl/releases/download/v{version}/SHA256SUMS", "runc": "https://github.com/opencontainers/runc/releases/download/{version}/runc.sha256sum", "skopeo_binary": "https://github.com/lework/skopeo-binary/releases/download/{version}/skopeo-{os}-{arch}.sha256", + "yq": "https://github.com/mikefarah/yq/releases/download/{version}/checksums-bsd", # see https://github.com/mikefarah/yq/pull/1691 for why we use this url } def download_hash(only_downloads: [str]) -> None: - # Handle hashes not directly in one url per hash. Return dict of hashs indexed by arch + # Handle file with multiples hashes, with various formats. + # the lambda is expected to produce a dictionary of hashes indexed by arch name download_hash_extract = { + "calicoctl_binary": lambda hashes : { + line.split('-')[-1] : line.split()[0] + for line in hashes.strip().split('\n') + if line.count('-') == 2 and line.split('-')[-2] == "linux" + }, + "etcd_binary": lambda hashes : { + line.split('-')[-1].removesuffix('.tar.gz') : line.split()[0] + for line in hashes.strip().split('\n') + if line.split('-')[-2] == "linux" + }, + "nerdctl_archive": lambda hashes : { + line.split()[1].removesuffix('.tar.gz').split('-')[3] : line.split()[0] + for line in hashes.strip().split('\n') + if [x for x in line.split(' ') if x][1].split('-')[2] == "linux" + }, "runc": lambda hashes : { parts[1].split('.')[1] : parts[0] for parts in (line.split() for line in hashes.split('\n')[3:9]) }, + "yq": lambda rhashes_bsd : { + pair[0].split('_')[-1] : pair[1] + # pair = (yq__, ) + for pair in ((line.split()[1][1:-1], line.split()[3]) + for line in rhashes_bsd.splitlines() + if line.startswith("SHA256")) + if pair[0].startswith("yq") + and pair[0].split('_')[1] == "linux" + and not pair[0].endswith(".tar.gz") + }, } data, yaml = open_checksums_yaml() From 86855be634e64ef3ae9e6b16c86e3129a5d66055 Mon Sep 17 00:00:00 2001 From: Max Gautier Date: Fri, 6 Sep 2024 10:56:03 +0200 Subject: [PATCH 07/10] download_hash: document missing support --- scripts/download_hash.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/download_hash.py b/scripts/download_hash.py index ca65b05e2..77ba34e82 100644 --- a/scripts/download_hash.py +++ b/scripts/download_hash.py @@ -45,6 +45,21 @@ downloads = { "skopeo_binary": "https://github.com/lework/skopeo-binary/releases/download/{version}/skopeo-{os}-{arch}.sha256", "yq": "https://github.com/mikefarah/yq/releases/download/{version}/checksums-bsd", # see https://github.com/mikefarah/yq/pull/1691 for why we use this url } +# TODO: downloads not supported +# youki: no checkusms in releases +# kata: no checksums in releases +# gvisor: sha512 checksums +# crun : PGP signatures +# cri_dockerd: no checksums or signatures +# helm_archive: PGP signatures +# krew_archive: different yaml structure +# calico_crds_archive: different yaml structure + +# TODO: +# noarch support -> k8s manifests, helm charts +# different checksum format (needs download role changes) +# different verification methods (gpg, cosign) ( needs download role changes) (or verify the sig in this script and only use the checksum in the playbook) +# perf improvements (async) def download_hash(only_downloads: [str]) -> None: # Handle file with multiples hashes, with various formats. From 1b1045c0e2f1a472aee625b78765c453be78eb13 Mon Sep 17 00:00:00 2001 From: Max Gautier Date: Fri, 6 Sep 2024 15:25:53 +0200 Subject: [PATCH 08/10] download_hash: use persistent session This allows to reuse http connection and be more efficient. From rough measuring it saves around 25-30% of execution time. --- scripts/download_hash.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/download_hash.py b/scripts/download_hash.py index 77ba34e82..53397c76d 100644 --- a/scripts/download_hash.py +++ b/scripts/download_hash.py @@ -98,6 +98,7 @@ def download_hash(only_downloads: [str]) -> None: } data, yaml = open_checksums_yaml() + s = requests.Session() for download, url in (downloads if only_downloads == [] else {k:downloads[k] for k in downloads.keys() & only_downloads}).items(): @@ -124,7 +125,7 @@ def download_hash(only_downloads: [str]) -> None: # to find new versions if version in versions and versions[version] != 0: continue - hash_file = requests.get(downloads[download].format( + hash_file = s.get(downloads[download].format( version = version, os = "linux", arch = arch From dec4e711d12c71a5e86025c057d7ce6a913c32ac Mon Sep 17 00:00:00 2001 From: Max Gautier Date: Fri, 6 Sep 2024 16:21:20 +0200 Subject: [PATCH 09/10] download_hash: cache request for 'multi-hash' files This avoid re-downloading the same file for different arch and re-parsing it --- scripts/download_hash.py | 44 ++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/scripts/download_hash.py b/scripts/download_hash.py index 53397c76d..b5616794b 100644 --- a/scripts/download_hash.py +++ b/scripts/download_hash.py @@ -8,6 +8,7 @@ import sys from itertools import count, groupby from collections import defaultdict +from functools import cache import argparse import requests from ruamel.yaml import YAML @@ -100,6 +101,20 @@ def download_hash(only_downloads: [str]) -> None: data, yaml = open_checksums_yaml() s = requests.Session() + @cache + def _get_hash_by_arch(download: str, version: str) -> {str: str}: + + hash_file = s.get(downloads[download].format( + version = version, + os = "linux", + ), + allow_redirects=True) + if hash_file.status_code == 404: + print(f"Unable to find {download} hash file for version {version} at {hash_file.url}") + return None + hash_file.raise_for_status() + return download_hash_extract[download](hash_file.content.decode()) + for download, url in (downloads if only_downloads == [] else {k:downloads[k] for k in downloads.keys() & only_downloads}).items(): checksum_name = f"{download}_checksums" @@ -125,22 +140,25 @@ def download_hash(only_downloads: [str]) -> None: # to find new versions if version in versions and versions[version] != 0: continue - hash_file = s.get(downloads[download].format( - version = version, - os = "linux", - arch = arch - ), - allow_redirects=True) - if hash_file.status_code == 404: - print(f"Unable to find {download} hash file for version {version} (arch: {arch}) at {hash_file.url}") - break - hash_file.raise_for_status() - sha256sum = hash_file.content.decode() if download in download_hash_extract: - sha256sum = download_hash_extract[download](sha256sum).get(arch) + hashes = _get_hash_by_arch(download, version) + if hashes == None: + break + sha256sum = hashes.get(arch) if sha256sum == None: break - sha256sum = sha256sum.split()[0] + else: + hash_file = s.get(downloads[download].format( + version = version, + os = "linux", + arch = arch + ), + allow_redirects=True) + if hash_file.status_code == 404: + print(f"Unable to find {download} hash file for version {version} (arch: {arch}) at {hash_file.url}") + break + hash_file.raise_for_status() + sha256sum = hash_file.content.decode().split()[0] if len(sha256sum) != 64: raise Exception(f"Checksum has an unexpected length: {len(sha256sum)} (binary: {download}, arch: {arch}, release: {version}, checksum: '{sha256sum}')") From 230cb37626ae081ee7136ecd2646662132e063ed Mon Sep 17 00:00:00 2001 From: Max Gautier Date: Sun, 8 Sep 2024 11:12:24 +0200 Subject: [PATCH 10/10] download_hash: document usage --- scripts/download_hash.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/download_hash.py b/scripts/download_hash.py index b5616794b..d4dbed5bb 100644 --- a/scripts/download_hash.py +++ b/scripts/download_hash.py @@ -172,7 +172,33 @@ def download_hash(only_downloads: [str]) -> None: yaml.dump(data, checksums_yml) print(f"\n\nUpdated {CHECKSUMS_YML}\n") -parser = argparse.ArgumentParser(description=f"Add new patch versions hashes in {CHECKSUMS_YML}") +parser = argparse.ArgumentParser(description=f"Add new patch versions hashes in {CHECKSUMS_YML}", + formatter_class=argparse.RawTextHelpFormatter, + epilog=f""" + This script only lookup new patch versions relative to those already existing + in the data in {CHECKSUMS_YML}, + which means it won't add new major or minor versions. + In order to add one of these, edit {CHECKSUMS_YML} + by hand, adding the new versions with a patch number of 0 (or the lowest relevant patch versions) + ; then run this script. + + Note that the script will try to add the versions on all + architecture keys already present for a given download target. + + The '0' value for a version hash is treated as a missing hash, so the script will try to download it again. + To notify a non-existing version (yanked, or upstream does not have monotonically increasing versions numbers), + use the special value 'NONE'. + + EXAMPLES: + + crictl_checksums: + ... + amd64: ++ v1.30.0: 0 + v1.29.0: d16a1ffb3938f5a19d5c8f45d363bd091ef89c0bc4d44ad16b933eede32fdcbb + v1.28.0: 8dc78774f7cbeaf787994d386eec663f0a3cf24de1ea4893598096cb39ef2508""" + +) parser.add_argument('binaries', nargs='*', choices=downloads.keys()) args = parser.parse_args()