feat(tools): Enhance guest tools installation and management
- Added new API endpoints for tool catalog and installation management. - Updated usage documentation to include new tool installation features. - Implemented asynchronous tool installation with job tracking. - Introduced integration tests for tool installation in disposable VMs. - Enhanced dashboard UI to reflect tool installation status and usage instructions. - Improved error handling and logging for tool installation processes. - Added support for multiple Python versions and Conda environments. - Refactored tool management logic to ensure better state handling and job persistence.
This commit is contained in:
+2
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from pathlib import Path
|
||||
@@ -62,7 +62,7 @@ class Settings:
|
||||
raise ConfigurationError("tool_workers must be an integer between 1 and 8")
|
||||
for name in ("tool_ready_timeout_s", "tool_install_timeout_s"):
|
||||
value = getattr(self, name)
|
||||
if type(value) not in (int, float) or not math.isfinite(value) or not 0 < value <= 3600:
|
||||
if type(value) not in (int, float) or not 0 < value <= 3600 or not math.isfinite(value):
|
||||
raise ConfigurationError(f"{name} must be finite, greater than zero, and at most 3600 seconds")
|
||||
|
||||
# Resolve defaults at construction time, while preserving individual URL overrides.
|
||||
|
||||
+9
-3
@@ -281,7 +281,7 @@ class Installer:
|
||||
raise ToolError(f"installation directory is already in use: {prefix}")
|
||||
regular_file(marker)
|
||||
previous = json.loads(marker.read_bytes())
|
||||
if any(previous.get(key) != value for key, value in identity.items()):
|
||||
if not isinstance(previous, dict) or any(previous.get(key) != value for key, value in identity.items()):
|
||||
raise ToolError(f"installation directory belongs to a different release: {prefix}")
|
||||
if tool["kind"] != "conda" or previous.get("complete"):
|
||||
return prefix
|
||||
@@ -348,6 +348,10 @@ class Installer:
|
||||
if manifest.exists():
|
||||
regular_file(manifest)
|
||||
self.managed = json.loads(manifest.read_bytes())
|
||||
if (not isinstance(self.managed, dict) or any(
|
||||
not isinstance(values, list) or len(values) > 2 or any(not isinstance(value, str) for value in values)
|
||||
for values in self.managed.values())):
|
||||
raise ToolError("invalid managed tool file manifest")
|
||||
with tempfile.TemporaryDirectory(prefix=".install-", dir=ROOT) as name:
|
||||
temporary = Path(name)
|
||||
if tool["kind"] == "php":
|
||||
@@ -369,11 +373,13 @@ class Installer:
|
||||
|
||||
def main(payload):
|
||||
installer = Installer(payload["username"])
|
||||
info = {}
|
||||
try:
|
||||
if os.geteuid() != 0:
|
||||
raise ToolError("tool installation and inspection require guest root")
|
||||
info = platform_info()
|
||||
if not info["supported"] or info["architecture"] != payload["architecture"]:
|
||||
info["supported"] = info["supported"] and info["architecture"] == payload["architecture"]
|
||||
if not info["supported"]:
|
||||
raise ToolError("one-click tools require an Ubuntu 22.04 guest on the selected x86_64/ARM64 architecture")
|
||||
if payload.get("boot_id") and payload["boot_id"] != info["boot_id"]:
|
||||
raise ToolError("the guest rebooted during installation; retry on the current boot")
|
||||
@@ -385,7 +391,7 @@ def main(payload):
|
||||
raise ToolError("unsupported tool operation")
|
||||
result["ok"] = True
|
||||
except (ToolError, OSError, ValueError, KeyError, tarfile.TarError, subprocess.SubprocessError) as error:
|
||||
result = {"ok": False, "error": str(error)[:2048]}
|
||||
result = {"ok": False, "error": str(error)[:2048], "supported": info.get("supported")}
|
||||
result.update(output=installer.log.decode("utf-8", errors="replace"), truncated=installer.truncated)
|
||||
print(json.dumps(result, ensure_ascii=True))
|
||||
|
||||
|
||||
+2
-2
@@ -152,11 +152,11 @@ def recipe(selection: ToolSelection, architecture: str) -> dict:
|
||||
url=("https://github.com/conda-forge/miniforge/releases/download/26.5.3-0/"
|
||||
f"Miniforge3-26.5.3-0-Linux-{architecture}.sh"), sha256=_CONDA_SHA256[architecture],
|
||||
links={"conda": "bin/conda", "mamba": "bin/mamba"},
|
||||
usage="conda create -n myenv python=3.13; conda activate myenv")
|
||||
usage='eval "$(conda shell.posix hook)"; conda create -n myenv python=3.13; conda activate myenv')
|
||||
elif tool == "micromamba":
|
||||
arch = "64" if architecture == "x86_64" else "aarch64"
|
||||
result.update(kind="binary", release="micromamba-2.9.0", packages=["ca-certificates"],
|
||||
url=f"https://github.com/mamba-org/micromamba-releases/releases/download/2.9.0-0/micromamba-linux-{arch}",
|
||||
sha256=_MICROMAMBA_SHA256[architecture], links={"micromamba": "micromamba"},
|
||||
usage="micromamba create -n myenv -c conda-forge python=3.13; micromamba activate myenv")
|
||||
usage='eval "$(micromamba shell hook --shell posix)"; micromamba create -n myenv -c conda-forge python=3.13; micromamba activate myenv')
|
||||
return result
|
||||
|
||||
@@ -56,6 +56,8 @@ class ToolJobStore:
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
except FileNotFoundError:
|
||||
return self._empty()
|
||||
except OSError as error:
|
||||
raise StateError("could not open private tool installation history") from error
|
||||
try:
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
metadata = os.fstat(stream.fileno())
|
||||
|
||||
+28
-9
@@ -77,13 +77,20 @@ class ToolService:
|
||||
self._owner = descriptor
|
||||
try:
|
||||
self.store.recover()
|
||||
self._workers_left = self.settings.tool_workers
|
||||
self._threads = [threading.Thread(target=self._worker, name=f"uvm-tools-{index}", daemon=True)
|
||||
for index in range(self.settings.tool_workers)]
|
||||
for thread in self._threads:
|
||||
thread.start()
|
||||
for index in range(self.settings.tool_workers):
|
||||
thread = threading.Thread(target=self._worker, name=f"uvm-tools-{index}", daemon=True)
|
||||
self._workers_left += 1
|
||||
try:
|
||||
thread.start()
|
||||
except BaseException:
|
||||
self._workers_left -= 1
|
||||
raise
|
||||
self._threads.append(thread)
|
||||
except BaseException:
|
||||
self._release_owner()
|
||||
self._stop.set()
|
||||
self.notify()
|
||||
if not self._workers_left:
|
||||
self._release_owner()
|
||||
raise
|
||||
|
||||
def _release_owner(self):
|
||||
@@ -181,8 +188,11 @@ class ToolService:
|
||||
data = self.store.read(vm_id)
|
||||
running = self.lifecycle.inspect(vm.id).observed_status == "running"
|
||||
if refresh and running:
|
||||
supported = data["supported"]
|
||||
try:
|
||||
result = self._execute(vm, self._payload(vm, "probe", all_selections()), 90)
|
||||
if type(result.get("supported")) is bool:
|
||||
supported = result["supported"]
|
||||
if not result["ok"]:
|
||||
raise UvmError(_text(result.get("error"), 2048))
|
||||
inventory = self._inventory(result, all_selections())
|
||||
@@ -192,7 +202,8 @@ class ToolService:
|
||||
data = self.store.read(vm_id)
|
||||
except UvmError as error:
|
||||
with self.store.transaction(vm_id) as current:
|
||||
current.update(error=_text(error, 2048), agent_ready=not isinstance(error, GuestAgentUnavailableError))
|
||||
current.update(error=_text(error, 2048), agent_ready=not isinstance(error, GuestAgentUnavailableError),
|
||||
supported=supported)
|
||||
data = self.store.read(vm_id)
|
||||
if not running:
|
||||
data.update(agent_ready=False, guest_busy=False)
|
||||
@@ -258,7 +269,7 @@ class ToolService:
|
||||
def ready(current, data):
|
||||
observed = {item["key"]: item for item in data["inventory"]}
|
||||
observed.update({item["key"]: item for item in initial})
|
||||
data.update(inventory=list(observed.values()), checked_at=time.time(), agent_ready=True,
|
||||
data.update(inventory=list(observed.values()), agent_ready=True,
|
||||
guest_busy=False, supported=True, error=None)
|
||||
self._update(job, ready)
|
||||
for index, selection in enumerate(selections):
|
||||
@@ -289,7 +300,7 @@ class ToolService:
|
||||
inventory[selection.key] = observed
|
||||
elif selection.key in inventory:
|
||||
inventory[selection.key].update(installed=False, error="Refresh to verify after the failed installation.")
|
||||
data.update(inventory=list(inventory.values()), checked_at=time.time())
|
||||
data.update(inventory=list(inventory.values()))
|
||||
self._update(job, finish_item)
|
||||
submitted = False
|
||||
|
||||
@@ -317,3 +328,11 @@ class ToolService:
|
||||
self._update(job, failed)
|
||||
except NotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception("Unexpected tool installation failure")
|
||||
try:
|
||||
self._update(job, lambda current, data: self.store.interrupt(
|
||||
current, "Unexpected installer failure; check the management server log and refresh before retrying.",
|
||||
time.time(), outcome_unknown=submitted))
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user