Skip to content

Commit b2afa02

Browse files
committed
boot-utils: Download rootfs images from GitHub releases
Now that rootfs images will be uploaded to GitHub releases, both boot-qemu.py and boot-uml.py need to download the images before running. The latest rootfs release can be queried from GitHub's API, which returns JSON with information about the release tag and assets to allow easy downloading. Unfortunately, GitHub's API has a rate limit, so always querying is not possible. To account for this, the script will first query the rate_limit endpoint to see how many queries are remaining for the current user. If there are queries remaining, the script will download the rootfs image if it does not already exist or if the tag in the '.release' file is different from the latest on. If there are no queries remaining and the image does not already exist, the script errors and instructs the user how to make an authenticated API request or manually download the images themselves from the web. Signed-off-by: Nathan Chancellor <nathan@kernel.org>
1 parent 858d555 commit b2afa02

3 files changed

Lines changed: 120 additions & 38 deletions

File tree

boot-qemu.py

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
import utils
1616

17-
BOOT_UTILS = Path(__file__).resolve().parent
1817
SUPPORTED_ARCHES = [
1918
'arm',
2019
'arm32_v5',
@@ -163,16 +162,7 @@ def _have_dev_kvm_access(self):
163162
def _prepare_initrd(self):
164163
if not self._initrd_arch:
165164
raise RuntimeError('No initrd architecture specified?')
166-
if not (src := Path(BOOT_UTILS, 'images', self._initrd_arch,
167-
'rootfs.cpio.zst')).exists():
168-
raise FileNotFoundError(f"initrd ('{src}') does not exist?")
169-
170-
(dst := src.with_suffix('')).unlink(missing_ok=True)
171-
172-
utils.check_cmd('zstd')
173-
subprocess.run(['zstd', '-d', src, '-o', dst, '-q'], check=True)
174-
175-
return dst
165+
return utils.prepare_initrd(self._initrd_arch)
176166

177167
def _run_fg(self):
178168
# Pretty print and run QEMU command
@@ -370,7 +360,7 @@ def _can_use_kvm(self):
370360
# 32-bit EL1 is not supported on all cores so support for it must be
371361
# explicitly queried via the KVM_CHECK_EXTENSION ioctl().
372362
try:
373-
subprocess.run(Path(BOOT_UTILS, 'utils',
363+
subprocess.run(Path(utils.BOOT_UTILS, 'utils',
374364
'aarch64_32_bit_el1_supported'),
375365
check=True)
376366
except subprocess.CalledProcessError:
@@ -437,7 +427,7 @@ def _setup_efi(self):
437427
]
438428
aavmf = utils.find_first_file(usr_share, aavmf_locations)
439429

440-
self._efi_img = Path(BOOT_UTILS, 'images', self._initrd_arch,
430+
self._efi_img = Path(utils.BOOT_UTILS, 'images', self._initrd_arch,
441431
'efi.img')
442432
# This file is in /usr/share, so it must be copied in order to be
443433
# modified.
@@ -652,7 +642,7 @@ def run(self):
652642
Path('OVMF/OVMF_VARS.fd'), # Debian and Ubuntu
653643
]
654644
ovmf_vars = utils.find_first_file(usr_share, ovmf_vars_locations)
655-
self._efi_vars = Path(BOOT_UTILS, 'images', self.initrd_arch,
645+
self._efi_vars = Path(utils.BOOT_UTILS, 'images', self.initrd_arch,
656646
ovmf_vars.name)
657647
# This file is in /usr/share, so it must be copied in order to be
658648
# modified.

boot-uml.py

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@
22
# pylint: disable=invalid-name
33

44
import argparse
5-
from pathlib import Path
65
import subprocess
76

87
import utils
98

10-
base_folder = Path(__file__).resolve().parent
11-
129

1310
def parse_arguments():
1411
"""
@@ -38,26 +35,6 @@ def parse_arguments():
3835
return parser.parse_args()
3936

4037

41-
def decomp_rootfs():
42-
"""
43-
Decompress and get the full path of the initial ramdisk for use with UML.
44-
45-
Returns:
46-
rootfs (Path): rootfs Path object containing full path to rootfs.
47-
"""
48-
rootfs = base_folder.joinpath("images", "x86_64", "rootfs.ext4")
49-
50-
# This could be 'rootfs.unlink(missing_ok=True)' but that was only added in Python 3.8.
51-
if rootfs.exists():
52-
rootfs.unlink()
53-
54-
utils.check_cmd("zstd")
55-
subprocess.run(["zstd", "-q", "-d", f"{rootfs}.zst", "-o", rootfs],
56-
check=True)
57-
58-
return rootfs
59-
60-
6138
def run_kernel(kernel_image, rootfs, interactive):
6239
"""
6340
Run UML command with path to rootfs and additional arguments based on user
@@ -78,5 +55,6 @@ def run_kernel(kernel_image, rootfs, interactive):
7855
if __name__ == '__main__':
7956
args = parse_arguments()
8057
kernel = utils.get_full_kernel_path(args.kernel_location, "linux")
58+
initrd = utils.prepare_initrd('x86_64', rootfs_format='ext4')
8159

82-
run_kernel(kernel, decomp_rootfs(), args.interactive)
60+
run_kernel(kernel, initrd, args.interactive)

utils.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
#!/usr/bin/env python3
22

3+
import json
4+
import os
35
from pathlib import Path
6+
import subprocess
47
import shutil
58
import sys
69

10+
BOOT_UTILS = Path(__file__).resolve().parent
11+
REPO = 'ClangBuiltLinux/boot-utils'
12+
713

814
def check_cmd(cmd):
915
"""
@@ -30,6 +36,36 @@ def die(string):
3036
sys.exit(1)
3137

3238

39+
def download_initrd(gh_json, local_dest):
40+
"""
41+
Download an initial ramdisk from a GitHub release
42+
43+
Parameters:
44+
gh_json (dict): A serialized JSON object from a repo's release endpoint
45+
local_dest (Path): A Path object pointing to the local file destination
46+
"""
47+
assets = gh_json['assets']
48+
tag = gh_json['tag_name']
49+
url = gh_json['url']
50+
51+
# Turns '<arch>/rootfs.<format>.zst' into '<arch>-rootfs.<format>.zst'
52+
remote_file = '-'.join(local_dest.parts[-2:])
53+
54+
for asset in assets:
55+
if asset['name'] == remote_file:
56+
curl_cmd = [
57+
'curl', '-LSs', '-o', local_dest, asset['browser_download_url']
58+
]
59+
subprocess.run(curl_cmd, check=True)
60+
61+
# Update the '.release' file in the same folder as the download
62+
local_dest.with_name('.release').write_text(tag, encoding='utf-8')
63+
64+
return
65+
66+
raise RuntimeError(f"Failed to find {remote_file} in downloads of {url}?")
67+
68+
3369
def find_first_file(relative_root, possible_files, required=True):
3470
"""
3571
Attempts to find the first option available in the list of files relative
@@ -93,6 +129,38 @@ def get_full_kernel_path(kernel_location, image, arch=None):
93129
return kernel.resolve()
94130

95131

132+
def get_gh_json(endpoint):
133+
"""
134+
Query a GitHub API endpoint.
135+
136+
Parameters:
137+
endpoint (str): The URL of the endpoint to query.
138+
139+
Returns:
140+
A JSON object from the result of the query.
141+
"""
142+
curl_cmd = ['curl', '-LSs']
143+
if 'GITHUB_TOKEN' in os.environ:
144+
curl_cmd += [
145+
'-H',
146+
'Accept: application/vnd.github+json',
147+
'-H',
148+
f"Authorization: Bearer {os.environ['GITHUB_TOKEN']}",
149+
]
150+
curl_cmd.append(endpoint)
151+
152+
try:
153+
curl_out = subprocess.run(curl_cmd,
154+
capture_output=True,
155+
check=True,
156+
text=True).stdout
157+
except subprocess.CalledProcessError as err:
158+
raise RuntimeError(
159+
f"Failed to query GitHub API at {endpoint}: {err.stderr}") from err
160+
161+
return json.loads(curl_out)
162+
163+
96164
def green(string):
97165
"""
98166
Prints string in bold green.
@@ -103,6 +171,52 @@ def green(string):
103171
print(f"\n\033[01;32m{string}\033[0m", flush=True)
104172

105173

174+
def prepare_initrd(architecture, rootfs_format='cpio'):
175+
"""
176+
Returns a decompressed initial ramdisk.
177+
178+
Parameters:
179+
architecture (str): Architecture to download image for.
180+
rootfs_format (str): Initrd format ('cpio' or 'ext4')
181+
"""
182+
src = Path(BOOT_UTILS, 'images', architecture,
183+
f"rootfs.{rootfs_format}.zst")
184+
src.parent.mkdir(exist_ok=True, parents=True)
185+
186+
# First, make sure that the current user is not rate limited by GitHub,
187+
# otherwise the next API call will not return valid information.
188+
gh_json_rl = get_gh_json('https://api.github.com/rate_limit')
189+
limit = gh_json_rl['resources']['core']['limit']
190+
remaining = gh_json_rl['resources']['core']['remaining']
191+
192+
# If we have API calls remaining, we can query for the latest release to
193+
# make sure that we are up to date.
194+
if remaining > 0:
195+
gh_json_rel = get_gh_json(
196+
f"https://api.github.com/repos/{REPO}/releases/latest")
197+
# Download the ramdisk if it is not already downloaded
198+
if not src.exists():
199+
download_initrd(gh_json_rel, src)
200+
# If it is already downloaded, check that it is up to date and download
201+
# an update only if necessary.
202+
elif (rel_file := src.with_name('.release')).exists():
203+
cur_rel = rel_file.read_text(encoding='utf-8')
204+
latest_rel = gh_json_rel['tag_name']
205+
if cur_rel != latest_rel:
206+
download_initrd(gh_json_rel, src)
207+
elif not src.exists():
208+
raise RuntimeError(
209+
f"Cannot query GitHub API for latest images release due to rate limit (remaining: {remaining}, limit: {limit}) and {src} does not exist already! "
210+
'Download it manually or supply a GitHub personal access token via the GITHUB_TOKEN environment variable to make an authenticated GitHub API request.'
211+
)
212+
213+
check_cmd('zstd')
214+
(dst := src.with_suffix('')).unlink(missing_ok=True)
215+
subprocess.run(['zstd', '-d', src, '-o', dst, '-q'], check=True)
216+
217+
return dst
218+
219+
106220
def red(string):
107221
"""
108222
Prints string in bold red.

0 commit comments

Comments
 (0)