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:
2026-09-25 23:52:21 +05:30
parent a41aafce83
commit 3e17e3e72f
16 changed files with 729 additions and 34 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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