Files
nest/uvm/tool_catalog.py
T
kstyagi 3e17e3e72f 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.
2026-09-25 23:52:21 +05:30

163 lines
9.2 KiB
Python

"""The allowlisted guest tool catalog and pinned, architecture-specific recipes.
Download digests come from the publishers' release SHA256SUMS/.sha256 files:
Node v24.21.0, python-build-standalone 20260924, Miniforge 26.5.3-0, and
micromamba-releases 2.9.0-0. Update versions, URLs, and digests together.
"""
from __future__ import annotations
from dataclasses import dataclass
from .errors import ValidationError
CATALOG = (
{"id": "git", "name": "Git", "description": "Version control for your projects.",
"version_label": "Ubuntu package", "versions": [], "source": "Ubuntu"},
{"id": "nodejs", "name": "Node.js", "description": "JavaScript runtime with npm and npx.",
"version_label": "24 LTS · 24.21.0", "versions": [], "source": "nodejs.org"},
{"id": "python", "name": "Python", "description": "Side-by-side Python runtimes with pip and venv.",
"version_label": "3.11 / 3.12 / 3.13", "versions": ["3.11", "3.12", "3.13"],
"source": "Astral python-build-standalone"},
{"id": "jdk", "name": "JDK", "description": "OpenJDK development kit: java and javac.",
"version_label": "OpenJDK 21 LTS", "versions": [], "source": "Ubuntu"},
{"id": "wget", "name": "wget", "description": "Download files from the command line.",
"version_label": "Ubuntu package", "versions": [], "source": "Ubuntu"},
{"id": "curl", "name": "curl", "description": "Transfer data and call HTTP APIs.",
"version_label": "Ubuntu package", "versions": [], "source": "Ubuntu"},
{"id": "htop", "name": "htop", "description": "Interactive process and resource monitor.",
"version_label": "Ubuntu package", "versions": [], "source": "Ubuntu"},
{"id": "php", "name": "PHP", "description": "PHP command-line runtime.",
"version_label": "8.5 CLI", "versions": [], "source": "Ondřej Surý PHP PPA"},
{"id": "conda", "name": "Conda", "description": "Miniforge with the conda-forge package channel.",
"version_label": "Miniforge 26.5.3-0", "versions": [], "source": "conda-forge"},
{"id": "micromamba", "name": "micromamba", "description": "Lightweight Conda-compatible environment manager.",
"version_label": "2.9.0", "versions": [], "source": "mamba-org"},
)
TOOL_IDS = frozenset(tool["id"] for tool in CATALOG)
MAX_SELECTIONS = 12
@dataclass(frozen=True, slots=True)
class ToolSelection:
id: str
version: str | None = None
def __post_init__(self) -> None:
if not isinstance(self.id, str) or self.id not in TOOL_IDS:
raise ValidationError("unknown tool; choose a tool from GET /tools")
if self.id == "python":
if self.version not in ("3.11", "3.12", "3.13"):
raise ValidationError("Python requires version 3.11, 3.12, or 3.13")
elif self.version is not None:
raise ValidationError("only Python accepts a version; other tools use the catalog default")
@property
def key(self) -> str:
return f"{self.id}-{self.version}" if self.version else self.id
def to_dict(self) -> dict:
return {"id": self.id, "version": self.version}
def normalize_tools(values: object, *, allow_empty: bool = False) -> tuple[ToolSelection, ...]:
if not isinstance(values, (list, tuple)) or len(values) > MAX_SELECTIONS:
raise ValidationError(f"tools must be a list of at most {MAX_SELECTIONS} catalog selections")
result = {}
for value in values:
if isinstance(value, ToolSelection):
selection = ToolSelection(value.id, value.version)
elif isinstance(value, dict) and "id" in value and not set(value) - {"id", "version"}:
selection = ToolSelection(value["id"], value.get("version"))
else:
raise ValidationError("each tool selection requires an id and optional Python version")
result[selection.key] = selection
if not result and not allow_empty:
raise ValidationError("select at least one tool to install")
# Stable ordering also makes duplicate requests independent of checkbox order.
order = {tool["id"]: index for index, tool in enumerate(CATALOG)}
return tuple(sorted(result.values(), key=lambda item: (order[item.id], item.version or "")))
def all_selections() -> tuple[ToolSelection, ...]:
return tuple(ToolSelection(tool["id"], version) for tool in CATALOG for version in tool["versions"] or [None])
_PYTHON_RELEASE = "20260924"
_PYTHON = {
"3.11": ("3.11.16", {
"x86_64": "68c6739376b65258dee5058ccf6777232fe38d31a578965ae8bda327ec7da3a8",
"aarch64": "763dae20c6abe982de86ceb7829cc050c5bda79a9824846d106cc1c86d57701b",
}),
"3.12": ("3.12.14", {
"x86_64": "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41",
"aarch64": "c8499b61252c433280f134df954464d19811527b31cb920c35fc6967c1222e35",
}),
"3.13": ("3.13.15", {
"x86_64": "d0b640eed27fbdd6f5f2bd33444aee53df2c8863f8b2a96f4094717411e3de9c",
"aarch64": "5ad58156cbec94e5643c13caa792e92df72a23f33d3a6425d4cbff5c4b7a040c",
}),
}
_NODE_SHA256 = {
"x86_64": "fd8e59d5a511510f6a298afb548f18c7d2b1be404d8b4a27d94fbe49f56cb2d6",
"aarch64": "6ad1325edbdb5649c379b75a237147a666c95d4f9ae8d340fef2d1575d289ad2",
}
_CONDA_SHA256 = {
"x86_64": "14db468222ad564658656f769506056209b6dc375f5e7dfd31eb5ebbf08fa529",
"aarch64": "0391e42075a7632e9665d6e728387ee6b905f6c3e704d3513e1c1133d0d69b89",
}
_MICROMAMBA_SHA256 = {
"x86_64": "366cd9cd8be14df1ab8ed50352a82111082a36686b2d389fdb79a92c3fafb3e3",
"aarch64": "9f93b974adcb4d166996af969b6cd371287d1a3e52733704727884d9b74cb7a7",
}
def recipe(selection: ToolSelection, architecture: str) -> dict:
"""Return a JSON-serializable recipe; no request text becomes a shell program."""
if architecture not in {"x86_64", "aarch64"}:
raise ValidationError("tool installation supports x86_64 and ARM64 guests")
tool = selection.id
result = {**selection.to_dict(), "key": selection.key, "kind": "apt", "packages": [tool],
"checks": [[tool, "--version"]], "match": r"(\d+(?:\.\d+)+)", "prefix_match": "",
"links": {}, "usage": f"{tool} --version"}
if tool == "nodejs":
arch = "x64" if architecture == "x86_64" else "arm64"
result.update(kind="archive", release="nodejs-24.21.0", packages=["ca-certificates", "libstdc++6"],
url=f"https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-{arch}.tar.xz",
sha256=_NODE_SHA256[architecture], archive_root=f"node-v24.21.0-linux-{arch}",
checks=[["node", "--version"], ["npm", "--version"], ["npx", "--version"]], prefix_match="24.",
links={name: f"bin/{name}" for name in ("node", "npm", "npx")}, usage="node --version; npm --version")
elif tool == "python":
version, hashes = _PYTHON[selection.version]
binary = f"python{selection.version}"
result.update(kind="archive", release=f"python-{version}", packages=["ca-certificates", "libcrypt1"],
url=(f"https://github.com/astral-sh/python-build-standalone/releases/download/{_PYTHON_RELEASE}/"
f"cpython-{version}+{_PYTHON_RELEASE}-{architecture}-unknown-linux-gnu-install_only_stripped.tar.gz"),
sha256=hashes[architecture], archive_root="python", prefix_match=selection.version + ".",
checks=[[binary, "-c", "import sys, ssl, sqlite3, venv, ensurepip; print(sys.version.split()[0])"],
[binary, "-m", "pip", "--version"]], links={binary: f"bin/{binary}"},
usage=f"{binary} -m venv .venv; . .venv/bin/activate")
elif tool == "jdk":
arch = "amd64" if architecture == "x86_64" else "arm64"
home = f"/usr/lib/jvm/java-21-openjdk-{arch}"
result.update(packages=["openjdk-21-jdk-headless"], checks=[["javac", "-version"], ["java", "-version"]],
prefix_match="21.", java_home=home, links={name: f"{home}/bin/{name}" for name in ("java", "javac")},
usage="java -version; javac -version")
elif tool == "php":
result.update(kind="php", packages=["php8.5-cli"], prefix_match="8.5.",
links={"php": "/usr/bin/php8.5"}, usage="php --version")
elif tool == "conda":
result.update(kind="conda", release="miniforge-26.5.3-0", packages=["ca-certificates", "bzip2"],
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='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='eval "$(micromamba shell hook --shell posix)"; micromamba create -n myenv -c conda-forge python=3.13; micromamba activate myenv')
return result