Skip to content

Commit a36cb87

Browse files
authored
SIM RFC: Clean up runtime installer/loader code (UBC-Thunderbots#3580)
1 parent bd0cd8e commit a36cb87

9 files changed

Lines changed: 191 additions & 229 deletions

File tree

Lines changed: 37 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,105 +1,67 @@
11
import requests
22
from pathlib import Path
3-
from software.logger.logger import create_logger
4-
import zipfile
53
import tarfile
64
import shutil
7-
from software.thunderscope.constants import RuntimeManagerConstants
85
import platform
9-
10-
logger = create_logger(__name__)
6+
from software.thunderscope.constants import RuntimeManagerConstants
117

128

139
class RuntimeInstaller:
1410
"""Delegate class for handling runtime installation and remote interfacing"""
1511

1612
def __init__(self):
17-
self.download_urls = []
13+
self.runtime_install_targets = {}
1814

1915
def fetch_remote_runtimes(self) -> list[str]:
2016
"""Requests a list of available runtimes from the remote. This is an expensive operation
2117
and should only be called when necessary.
2218
:return: A unique list of names for available runtimes
2319
"""
24-
url = RuntimeManagerConstants.INSTALL_URL
25-
headers = {"Accept": "application/vnd.github+json"}
26-
27-
response = requests.get(url, headers=headers)
28-
logger.warning(response)
29-
30-
releases = response.json()
20+
releases = requests.get(
21+
RuntimeManagerConstants.RELEASES_URL,
22+
headers={"Accept": "application/vnd.github+json"},
23+
).json()
3124

32-
PREFIX = RuntimeManagerConstants.DOWNLOAD_PREFIX
25+
version_names = []
3326

34-
# I'm going to assume you are trying to reload the assets so reset the download_urls
35-
if len(self.download_urls) != 0:
36-
self.download_urls = []
37-
download_names = []
38-
os_name = platform.system()
27+
# Currently the only targets that are supported for each os
28+
os_to_target = {"Darwin": "mac-arm64", "Linux": "ubuntu-x86"}
29+
target = os_to_target[platform.system()]
3930

4031
for release in releases:
41-
tag = release.get("tag_name", "")
42-
43-
# ✅ Only keep tags that include the OS name
44-
if os_name not in tag:
45-
continue
32+
version = release["tag_name"]
4633
for asset in release.get("assets", []):
4734
url = asset["browser_download_url"]
48-
if "fullsystem" not in asset_name:
49-
continue
50-
self.download_urls.append(url)
51-
trimmed = url.removeprefix(PREFIX)
52-
download_names.append(trimmed)
5335

54-
return download_names[:5]
36+
if "unix_full_system" in url and target in url:
37+
version_names.append(version)
38+
self.runtime_install_targets[version] = url
39+
40+
return version_names[: RuntimeManagerConstants.MAX_RELEASES_FETCHED]
5541

5642
def install_runtime(self, version: str) -> None:
5743
"""Installs the runtime of the specified version or throws an error upon failure.
5844
Ensures that the runtime is compatible with the current platform
5945
:param version: Version of the runtime hosted on the remote to install
6046
"""
61-
url = RuntimeManagerConstants.INSTALL_URL
62-
headers = {"Accept": "application/vnd.github+json"}
63-
64-
response = requests.get(url, headers=headers)
65-
logger.warning(response)
66-
67-
releases = response.json()
68-
69-
TARGET_SUFFIX = version
70-
71-
selected_asset = None
72-
73-
for download_url in self.download_urls:
74-
if download_url.endswith(TARGET_SUFFIX):
75-
selected_asset = download_url
76-
77-
if selected_asset:
78-
url = selected_asset
79-
filename = Path(url).name
80-
81-
target_dir = Path(RuntimeManagerConstants.EXTERNAL_RUNTIMES_PATH)
82-
tmp_path = Path("/tmp") / filename
83-
84-
with requests.get(url, stream=True) as r:
85-
r.raise_for_status()
86-
with open(tmp_path, "wb") as f:
87-
for chunk in r.iter_content(chunk_size=8192):
88-
if chunk:
89-
f.write(chunk)
90-
91-
if filename.endswith((".tar.gz", ".tgz")):
92-
with tarfile.open(tmp_path, "r:*") as tar:
93-
tar.extractall(path=target_dir)
94-
95-
elif filename.endswith(".zip"):
96-
with zipfile.ZipFile(tmp_path, "r") as zipf:
97-
zipf.extractall(path=target_dir)
98-
99-
else:
100-
dest = target_dir / filename
101-
shutil.copy2(tmp_path, dest)
102-
dest.chmod(0o755) # make executable (common for runtimes)
103-
104-
if not selected_asset:
105-
logger.warning("Can't find binary")
47+
url = self.runtime_install_targets[version]
48+
49+
filename = Path(url).name
50+
target_dir = Path(RuntimeManagerConstants.EXTERNAL_RUNTIMES_PATH)
51+
tmp_dir = Path("/tmp")
52+
tmp_path = tmp_dir / filename
53+
extracted_binary_name = "unix_full_system"
54+
55+
with requests.get(url, stream=True) as r:
56+
r.raise_for_status()
57+
with open(tmp_path, "wb") as f:
58+
for chunk in r.iter_content(chunk_size=8192):
59+
if chunk:
60+
f.write(chunk)
61+
62+
dest = target_dir / f"{extracted_binary_name}_{version}"
63+
64+
# Our release assets for FullSystem are always tar.gz files
65+
with tarfile.open(tmp_path, "r:*") as tar:
66+
tar.extractall(tmp_dir)
67+
shutil.move(tmp_dir / extracted_binary_name, dest)
Lines changed: 93 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,138 +1,139 @@
11
from tomllib import TOMLDecodeError
22
from software.thunderscope.constants import RuntimeManagerConstants
3-
from dataclasses import dataclass
43
import os
54
import tomllib
65
import logging
76

87

9-
@dataclass
108
class RuntimeConfig:
11-
"""Data class to store the paths of the two binaries"""
9+
"""Class to store the names and get paths of the two binaries"""
10+
11+
def __init__(
12+
self,
13+
blue_runtime: str | None = None,
14+
yellow_runtime: str | None = None,
15+
) -> None:
16+
"""Create runtime config, replacing invalid runtimes with default FullSystem
17+
:param blue_runtime: blue runtime name, None if default
18+
:param yellow_runtime: yellow runtime name, None if default
19+
"""
20+
self.blue_runtime = (
21+
blue_runtime
22+
if blue_runtime
23+
and self._is_valid_runtime_path(self._get_runtime_path(blue_runtime))
24+
else RuntimeManagerConstants.DEFAULT_BINARY_NAME
25+
)
26+
self.yellow_runtime = (
27+
yellow_runtime
28+
if yellow_runtime
29+
and self._is_valid_runtime_path(self._get_runtime_path(yellow_runtime))
30+
else RuntimeManagerConstants.DEFAULT_BINARY_NAME
31+
)
32+
33+
def get_blue_runtime_path(self) -> str:
34+
"""Returns the path of the stored yellow runtime
35+
:return: the absolute path of the binary as a string, or the relative path of our FullSystem
36+
"""
37+
return self._get_runtime_path(self.blue_runtime)
1238

13-
chosen_blue_path: str = RuntimeManagerConstants.DEFAULT_BINARY_PATH
14-
"""Blue runtime path"""
39+
def get_yellow_runtime_path(self) -> str:
40+
"""Returns the path of the stored yellow runtime
41+
:return: the absolute path of the binary as a string, or the relative path of our FullSystem
42+
"""
43+
return self._get_runtime_path(self.yellow_runtime)
44+
45+
def _get_runtime_path(self, selected_runtime: str) -> str:
46+
"""Gets the absolute path of a binary given its name, or the path of our default FullSystem
47+
if the binary is not valid.
48+
:param selected_runtime: the name of the selected runtime binary
49+
:return: the absolute path of the binary as a string, or the relative path of our FullSystem
50+
"""
51+
file_path = os.path.join(
52+
RuntimeManagerConstants.EXTERNAL_RUNTIMES_PATH, selected_runtime
53+
)
54+
# Default to local FullSystem if it is selected or the selected binary isn't a valid runtime
55+
if (
56+
selected_runtime == RuntimeManagerConstants.DEFAULT_BINARY_NAME
57+
or not self._is_valid_runtime_path(file_path)
58+
):
59+
return RuntimeManagerConstants.DEFAULT_BINARY_PATH
60+
# Remove leading and trailing white space and return
61+
return file_path.strip()
1562

16-
chosen_yellow_path: str = RuntimeManagerConstants.DEFAULT_BINARY_PATH
17-
"""Yellow runtime path"""
63+
def _is_valid_runtime_path(self, runtime_path: str) -> bool:
64+
"""Returns if the binary exists and if it is an executable.
65+
:param runtime_path: the path to check
66+
:return: True if it is a valid runtime
67+
"""
68+
return os.path.isfile(runtime_path) and os.access(runtime_path, os.X_OK)
1869

1970

2071
class RuntimeLoader:
2172
"""Delegate class for handling local runtimes and managing runtime selection"""
2273

2374
def fetch_installed_runtimes(self) -> list[str]:
24-
"""Fetches the list of available runtimes, including our FullSystem, from the local disk. Makes the folder
25-
in our local disk if it does not exist yet.
26-
:return: A list of names for available runtimes, or just a list with our FullSystem if no available runtimes
27-
could be found
75+
"""Fetches the list of installed runtimes from the local disk.
76+
Creates the external runtimes directory in our local disk if it does not exist yet.
77+
:return: A list of installed runtime names
2878
"""
2979
runtime_list = [RuntimeManagerConstants.DEFAULT_BINARY_NAME]
3080

3181
if not os.path.isdir(RuntimeManagerConstants.EXTERNAL_RUNTIMES_PATH):
3282
os.mkdir(RuntimeManagerConstants.EXTERNAL_RUNTIMES_PATH)
33-
# Check for all executable files in the folder, and add its name to the list
83+
84+
# Check for all executable files in the directory, and add its name to the list
3485
for file_name in os.listdir(RuntimeManagerConstants.EXTERNAL_RUNTIMES_PATH):
3586
file_path = os.path.join(
3687
RuntimeManagerConstants.EXTERNAL_RUNTIMES_PATH, file_name
3788
)
3889
if os.access(file_path, os.X_OK):
3990
runtime_list.append(file_name)
4091

41-
# Cache external runtimes
4292
return runtime_list
4393

44-
def load_existing_runtimes(self, yellow_runtime: str, blue_runtime: str) -> None:
94+
def load_selected_runtimes(self, yellow_runtime: str, blue_runtime: str) -> None:
4595
"""Loads the yellow and blue runtimes specified by saving them in the local disk.
46-
:param blue_runtime: Unique name of the blue runtime to set
47-
:param yellow_runtime: Unique name of the yellow runtime to set
96+
:param blue_runtime: name of the blue runtime to set
97+
:param yellow_runtime: name of the yellow runtime to set
4898
"""
49-
config = RuntimeConfig(
50-
self._return_runtime_path(blue_runtime),
51-
self._return_runtime_path(yellow_runtime),
99+
# Format as TOML
100+
selected_runtimes = (
101+
f'{RuntimeManagerConstants.RUNTIME_CONFIG_BLUE_KEY} = "{blue_runtime}"\n'
102+
f'{RuntimeManagerConstants.RUNTIME_CONFIG_YELLOW_KEY} = "{yellow_runtime}"'
52103
)
53-
self._set_runtime_config(config)
104+
105+
# create a new config file if it doesn't exist, and write in the format above to it
106+
with open(RuntimeManagerConstants.RUNTIME_CONFIG_PATH, "w") as file:
107+
file.write(selected_runtimes)
54108

55109
def fetch_runtime_config(self) -> RuntimeConfig:
56-
"""Fetches the runtime configuration from the local disk. If the blue/yellow configuration is invalid,
57-
returns the default runtime configuration for blue/yellow
110+
"""Fetches the runtime configuration from the local disk, creating it if it doesn't exist.
58111
:return: Returns the runtime configuration as a RuntimeConfig
59112
"""
60-
# Create default FullSystem pair with our FullSystem binaries
61-
config = RuntimeConfig()
113+
# Create empty config file if doesn't exist yet
114+
os.makedirs(
115+
os.path.dirname(RuntimeManagerConstants.RUNTIME_CONFIG_PATH), exist_ok=True
116+
)
117+
open(RuntimeManagerConstants.RUNTIME_CONFIG_PATH, "a").close()
62118

63119
try:
64120
with open(RuntimeManagerConstants.RUNTIME_CONFIG_PATH, "rb") as file:
65121
selected_runtime_dict = tomllib.load(file)
66-
# Get the persisted blue path, or replace with the default arrangement if it doesn't exist
67-
toml_blue_path = selected_runtime_dict.get(
68-
RuntimeManagerConstants.RUNTIME_CONFIG_BLUE_KEY,
69-
RuntimeManagerConstants.DEFAULT_BINARY_PATH,
70-
)
71-
if self._is_valid_runtime(toml_blue_path):
72-
config.chosen_blue_path = toml_blue_path
73-
# Get the persisted yellow path, or replace with the default arrangement if it doesn't exist
74-
toml_yellow_path = selected_runtime_dict.get(
75-
RuntimeManagerConstants.RUNTIME_CONFIG_YELLOW_KEY,
76-
RuntimeManagerConstants.DEFAULT_BINARY_PATH,
122+
123+
# Get the persisted runtimes
124+
config = RuntimeConfig(
125+
selected_runtime_dict.get(
126+
RuntimeManagerConstants.RUNTIME_CONFIG_BLUE_KEY
127+
),
128+
selected_runtime_dict.get(
129+
RuntimeManagerConstants.RUNTIME_CONFIG_YELLOW_KEY
130+
),
77131
)
78-
if self._is_valid_runtime(toml_yellow_path):
79-
config.chosen_yellow_path = toml_yellow_path
80-
except (FileNotFoundError, PermissionError, TOMLDecodeError):
132+
133+
return config
134+
except TOMLDecodeError:
81135
logging.warning(
82136
f"Failed to read TOML file at: {RuntimeManagerConstants.RUNTIME_CONFIG_PATH}"
83137
)
84138

85-
return config
86-
87-
def _set_runtime_config(self, config: RuntimeConfig) -> None:
88-
"""Sets/persists the runtime configuration file on disk and creates the configuration
89-
file if it doesn't exist.
90-
:param config: The runtime configuration containing
91-
- color_runtime : absolute path of external runtime, or
92-
- color_runtime : relative path of DEFAULT_BINARY_PATH
93-
"""
94-
blue_path = config.chosen_blue_path
95-
yellow_path = config.chosen_yellow_path
96-
97-
"""Format in TOML as:
98-
blue_path_to_binary: '<runtime path>'
99-
yellow_path_to_binary: '<runtime path>'"""
100-
101-
selected_runtimes = (
102-
f'{RuntimeManagerConstants.RUNTIME_CONFIG_BLUE_KEY} = "{blue_path}"\n'
103-
f'{RuntimeManagerConstants.RUNTIME_CONFIG_YELLOW_KEY} = "{yellow_path}"'
104-
)
105-
106-
# create a new config file if it doesn't exist, and write in the format above to it
107-
with open(RuntimeManagerConstants.RUNTIME_CONFIG_PATH, "w") as file:
108-
file.write(selected_runtimes)
109-
110-
def _return_runtime_path(self, selected_runtime: str) -> str:
111-
"""Returns the absolute path of a binary given its name, or the path of our default FullSystem
112-
if the binary is not valid.
113-
:param selected_runtime: the name of the selected runtime binary
114-
:return: the absolute path of the binary as a string, or the relative path of our FullSystem
115-
"""
116-
file_path = os.path.join(
117-
RuntimeManagerConstants.EXTERNAL_RUNTIMES_PATH, selected_runtime
118-
)
119-
# Default to our full system if it is selected or the selected binary isn't a valid runtime
120-
if (
121-
selected_runtime == RuntimeManagerConstants.DEFAULT_BINARY_NAME
122-
or not self._is_valid_runtime(file_path)
123-
):
124-
return RuntimeManagerConstants.DEFAULT_BINARY_PATH
125-
# Remove leading and trailing white space and return
126-
return file_path.strip()
127-
128-
def _is_valid_runtime(self, runtime_path: str) -> bool:
129-
"""Returns if the path exists and if it is an executable. Logs a warning if it is not valid.
130-
:param runtime_path the path to check
131-
:return: whether it is a valid runtime or not
132-
"""
133-
if os.path.isfile(runtime_path) and os.access(runtime_path, os.X_OK):
134-
return True
135-
logging.warning(
136-
f"The runtime retrieved at {runtime_path} is not a valid runtime."
137-
)
138-
return False
139+
return RuntimeConfig()

src/software/thunderscope/binary_context_managers/runtime_manager.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def fetch_remote_runtimes(self) -> list[str]:
2121
return self.runtime_installer.fetch_remote_runtimes()
2222

2323
def install_runtime(self, version: str) -> None:
24-
"""Installs the runtime of the specified version or throws an error upon failure.
24+
"""Installs the runtime of the specified version or throws an error upon failure
2525
:param version: Version of the runtime hosted on the remote to install
2626
"""
2727
self.runtime_installer.install_runtime(version)
@@ -32,12 +32,12 @@ def fetch_installed_runtimes(self) -> list[str]:
3232
"""
3333
return self.runtime_loader.fetch_installed_runtimes()
3434

35-
def load_existing_runtimes(self, yellow_runtime: str, blue_runtime: str) -> None:
36-
"""Loads the runtimes of the specified name or logs a warning upon failure.
35+
def load_selected_runtimes(self, yellow_runtime: str, blue_runtime: str) -> None:
36+
"""Loads the runtimes into the runtime loader config file on the local disk
3737
:param blue_runtime: name of the blue runtime to load
3838
:param yellow_runtime: name of the yellow runtime to load
3939
"""
40-
self.runtime_loader.load_existing_runtimes(yellow_runtime, blue_runtime)
40+
self.runtime_loader.load_selected_runtimes(yellow_runtime, blue_runtime)
4141

4242
def fetch_runtime_config(self) -> RuntimeConfig:
4343
"""Fetches the runtime configuration from the local disk

0 commit comments

Comments
 (0)