"""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="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") return result