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
+130
View File
@@ -0,0 +1,130 @@
"""Browser fixture with the real API/job workers and simulated guest execution."""
from __future__ import annotations
import argparse
import json
import shlex
import threading
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import uvicorn
from nest.server import create_app
from tests.browser_session_server import BrowserLifecycle
from uvm.config import Settings
from uvm.domain import new_vm_id
from uvm.errors import ConflictError, GuestExecutionError
from uvm.guest_exec import ExecResult
from uvm.state import StateStore
from uvm.tool_store import ToolJobStore
from uvm.tools import ToolService
class FixtureConnection:
def __init__(self):
self.closed = threading.Event()
def close(self):
self.closed.set()
class ToolBrowserLifecycle(BrowserLifecycle):
def __init__(self, settings, state, *, delay=1.2):
super().__init__()
self.settings, self.state, self.delay = settings, state, delay
self.tools = ToolJobStore(settings, state)
self.installed = {}
self.attempts = {}
for index, vm in enumerate(self.records.values()):
vm.runtime_owned = True
vm.pid, vm.process_start_time, vm.boot_id = index + 100, "1", "fixture-boot"
settings.vm_dir(vm.id).mkdir(parents=True, mode=0o700, exist_ok=True)
with state.transaction() as document:
document.vms.update(self.records)
def create(self, spec):
vm = replace(self.vm, id=new_vm_id(), name=spec.name, username=spec.username, cpu=spec.cpu,
ram_mib=spec.ram_mib, storage_mib=spec.storage_mib, ports=spec.ports)
with self.state.operation_lock():
self.settings.vm_dir(vm.id).mkdir(mode=0o700)
self.records[vm.id] = vm
with self.state.transaction() as document:
document.vms[vm.id] = vm
if spec.tools:
self.tools.enqueue_locked(vm.id, spec.tools, source="create")
return vm
def queue_tools(self, vm_id, selections, **options):
self.state.get(vm_id)
with self.state.operation_lock():
return self.tools.enqueue_locked(vm_id, selections, **options)
def prepare_tool_install(self, vm_id, *, start_if_stopped=False, restart=False):
with self.state.operation_lock():
vm = self.records[vm_id]
if vm.status == "stopped" and not (start_if_stopped or restart):
raise ConflictError("start the VM first")
vm.status = "running"
with self.state.transaction() as document:
document.vms[vm.id] = vm
return vm
def execute(self, vm_id, command, *, expected_identity=None, on_connect=None, **_options):
self.state.get(vm_id)
payload = json.loads(shlex.split(command)[-1])
connection = FixtureConnection()
if on_connect:
on_connect(connection)
installed = self.installed.setdefault(vm_id, {"curl"})
def observation(tool):
versions = {"nodejs": "24.21.0", "jdk": "21.0.12", "php": "8.5.11", "conda": "26.5.3", "micromamba": "2.9.0"}
version = tool["version"] + ".15" if tool["id"] == "python" else versions.get(tool["id"], "2.0.0")
return {"key": tool["key"], "id": tool["id"], "version": tool["version"], "installed": tool["key"] in installed,
"detected_version": version if tool["key"] in installed else None, "error": None, "usage": tool["usage"]}
if payload["action"] == "probe":
result = {"ok": True, "busy": False, "boot_id": "guest-fixture", "supported": True,
"tools": [observation(tool) for tool in payload["tools"]]}
else:
if connection.closed.wait(self.delay):
raise GuestExecutionError("fixture connection closed", outcome_unknown=True)
tool = payload["tools"][0]
key = (vm_id, tool["key"])
self.attempts[key] = self.attempts.get(key, 0) + 1
if tool["id"] == "php" and self.attempts[key] == 1:
result = {"ok": False, "error": "Simulated mirror failure", "output": "<script>bad()</script>\nTry again later."}
else:
installed.add(tool["key"])
result = {"ok": True, "status": "installed", "tool": observation(tool),
"output": f"Verified and installed {tool['key']} inside the guest."}
return ExecResult(0, json.dumps(result), "", False, False)
def fixture_application(base, *, delay=1.2):
settings = replace(Settings(), base=base, api_token="browser-test-token", tool_workers=2)
state = StateStore(settings)
lifecycle = ToolBrowserLifecycle(settings, state, delay=delay)
return SimpleNamespace(settings=settings, state_store=state, lifecycle=lifecycle,
tools=ToolService(settings, state, lifecycle))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base", type=Path, required=True)
parser.add_argument("--port", type=int, required=True)
args = parser.parse_args()
application = fixture_application(args.base)
with patch("uvm.tools.require_root"):
api = create_app(application, host="127.0.0.1")
server = uvicorn.Server(uvicorn.Config(api, host="127.0.0.1", port=args.port, log_level="warning"))
api.state.uvicorn_server = server
server.run()
if __name__ == "__main__":
main()