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:
@@ -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()
|
||||
Reference in New Issue
Block a user