diff --git a/tests/unit/python/utils/install/test_node.py b/tests/unit/python/utils/install/test_node.py index 1be42c2db1..e3e2d3d5fc 100644 --- a/tests/unit/python/utils/install/test_node.py +++ b/tests/unit/python/utils/install/test_node.py @@ -2,17 +2,152 @@ from __future__ import annotations +import datetime as dt +import hashlib +import tempfile import unittest import unittest.mock as mock +from pathlib import Path from utils.install import node as node_mod +SCHEDULE = { + "v20": {"end": "2026-04-30"}, + "v22": {"end": "2027-04-30"}, + "v24": {"end": "2028-04-30"}, +} +TODAY = dt.date(2026, 10, 21) + + +class TestLineIsSupported(unittest.TestCase): + def test_line_before_its_end_is_supported(self) -> None: + self.assertTrue(node_mod.line_is_supported("v22", SCHEDULE, TODAY)) + + def test_line_on_its_end_date_is_supported(self) -> None: + self.assertTrue( + node_mod.line_is_supported("v20", SCHEDULE, dt.date(2026, 4, 30)) + ) + + def test_line_past_its_end_is_not_supported(self) -> None: + self.assertFalse(node_mod.line_is_supported("v20", SCHEDULE, TODAY)) + + def test_unknown_line_is_not_supported(self) -> None: + self.assertFalse(node_mod.line_is_supported("v18", SCHEDULE, TODAY)) + + +class TestNewestLts(unittest.TestCase): + def test_skips_current_releases(self) -> None: + index = [ + {"version": "v26.1.0", "lts": False}, + {"version": "v24.22.1", "lts": "Krypton"}, + {"version": "v22.30.0", "lts": "Jod"}, + ] + self.assertEqual(node_mod.newest_lts(index), "v24.22.1") + + def test_raises_without_lts(self) -> None: + with self.assertRaises(RuntimeError): + node_mod.newest_lts([{"version": "v26.1.0", "lts": False}]) + + +class TestTarballName(unittest.TestCase): + def test_maps_x86_64(self) -> None: + self.assertEqual( + node_mod.tarball_name("v24.22.1", "Linux", "x86_64"), + "node-v24.22.1-linux-x64.tar.gz", + ) + + def test_maps_aarch64(self) -> None: + self.assertEqual( + node_mod.tarball_name("v24.22.1", "Linux", "aarch64"), + "node-v24.22.1-linux-arm64.tar.gz", + ) + + def test_raises_for_unknown_machine(self) -> None: + with self.assertRaises(RuntimeError): + node_mod.tarball_name("v24.22.1", "Linux", "riscv64") + + def test_refuses_a_host_that_is_not_linux(self) -> None: + with self.assertRaises(RuntimeError): + node_mod.tarball_name("v24.22.1", "Darwin", "x86_64") + + +class TestExpectedSha256(unittest.TestCase): + def test_finds_the_listed_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + shasums = Path(tmp) / "SHASUMS256.txt" + shasums.write_text( + "aaa node-v24.22.1-linux-arm64.tar.gz\n" + "bbb node-v24.22.1-linux-x64.tar.gz\n", + encoding="utf-8", + ) + self.assertEqual( + node_mod.expected_sha256(shasums, "node-v24.22.1-linux-x64.tar.gz"), + "bbb", + ) + + def test_raises_for_an_unlisted_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + shasums = Path(tmp) / "SHASUMS256.txt" + shasums.write_text("aaa other.tar.gz\n", encoding="utf-8") + with self.assertRaises(RuntimeError): + node_mod.expected_sha256(shasums, "node-v24.22.1-linux-x64.tar.gz") + + +class TestInstallUpstreamLts(unittest.TestCase): + def test_refuses_a_tarball_whose_checksum_differs(self) -> None: + name = "node-v24.22.1-linux-x64.tar.gz" + + def fake_download(url: str, output: str, **_kwargs: object) -> None: + path = Path(output) + if url.endswith("index.json"): + path.write_text( + '[{"version": "v24.22.1", "lts": "Krypton"}]', encoding="utf-8" + ) + elif url.endswith("SHASUMS256.txt"): + listed = hashlib.sha256(b"published").hexdigest() + path.write_text(f"{listed} {name}\n", encoding="utf-8") + else: + path.write_bytes(b"tampered") + + with ( + mock.patch.object(node_mod, "download_file", side_effect=fake_download), + mock.patch.object(node_mod, "_verify_signature"), + mock.patch.object(node_mod.platform, "system", return_value="Linux"), + mock.patch.object(node_mod.platform, "machine", return_value="x86_64"), + mock.patch.object(node_mod, "run_privileged") as extract, + self.assertRaises(RuntimeError), + ): + node_mod.install_upstream_lts() + extract.assert_not_called() + class TestEnsureNodePresent(unittest.TestCase): - def test_delegates_to_the_shared_provisioner(self) -> None: - with mock.patch.object(node_mod, "ensure_command_present") as ensure: + def setUp(self) -> None: + node_mod.ensure_node_present.cache_clear() + self.addCleanup(node_mod.ensure_node_present.cache_clear) + + def _run(self, line: str) -> mock.MagicMock: + def fake_download(_url: str, output: str, **_kwargs: object) -> None: + Path(output).write_text( + '{"v20": {"end": "2026-04-30"}, "v22": {"end": "2099-04-30"}}', + encoding="utf-8", + ) + + with ( + mock.patch.object(node_mod, "ensure_command_present") as ensure, + mock.patch.object(node_mod, "installed_line", return_value=line), + mock.patch.object(node_mod, "download_file", side_effect=fake_download), + mock.patch.object(node_mod, "install_upstream_lts") as upstream, + ): node_mod.ensure_node_present() ensure.assert_called_once_with("node") + return upstream + + def test_keeps_a_supported_distro_node(self) -> None: + self._run("v22").assert_not_called() + + def test_replaces_an_end_of_life_distro_node(self) -> None: + self._run("v20").assert_called_once_with() def test_propagates_the_hard_failure(self) -> None: with ( diff --git a/tests/unit/python/utils/install/test_npm.py b/tests/unit/python/utils/install/test_npm.py index 9f3cb45246..5399f6f1a3 100644 --- a/tests/unit/python/utils/install/test_npm.py +++ b/tests/unit/python/utils/install/test_npm.py @@ -10,6 +10,19 @@ from utils.install import npm as npm_mod class TestEnsureNpmPresent(unittest.TestCase): + def setUp(self) -> None: + patcher = mock.patch("utils.install.node.ensure_node_present") + self.ensure_node = patcher.start() + self.addCleanup(patcher.stop) + + def test_provides_a_supported_node_first(self) -> None: + with ( + mock.patch.object(npm_mod.shutil, "which", return_value="/usr/bin/npm"), + mock.patch("utils.install.system_pkg.install_command_via_pkg"), + ): + npm_mod.ensure_npm_present() + self.ensure_node.assert_called_once_with() + def test_present_noop(self) -> None: with ( mock.patch.object(npm_mod.shutil, "which", return_value="/usr/bin/npm"), diff --git a/utils/install/README.md b/utils/install/README.md index 176f9638b3..cb65f7d83e 100644 --- a/utils/install/README.md +++ b/utils/install/README.md @@ -15,6 +15,7 @@ Modules at this level are channel-agnostic primitives; per-tool policy lives one | `primitives.py` | Logging, sudo wrapping, PATH mutation, and stdlib-based URL download. | | `pip.py` | Venv-aware pip installer with `--break-system-packages` and `--user` fallback. | | `npm.py` | Global / local npm install with per-user `--prefix` fallback. | +| `node.py` | Node.js runtime that is still supported upstream: the distro package, or the newest LTS from nodejs.org (signature- and checksum-verified) when the distro line has reached its end of life. | | `github_release.py` | Latest-tag resolver and asset downloader for GitHub releases. | | `system_pkg.py` | Per-manager dispatch for `pacman` / `apt-get` / `dnf` / `yum` / `brew`. | | `lint/` | One module per tool, each exposing `ensure()`. See [lint README](lint/README.md). | @@ -33,5 +34,6 @@ The matching dispatcher [wrapper.sh](../../scripts/install/wrapper.sh) routes `m - `primitives.py` MUST NOT import from any other module in this package. - `pip.py`, `npm.py`, `github_release.py`, and `system_pkg.py` MAY import from `primitives.py`. -- `npm.py` MAY import from `system_pkg.py` to bootstrap npm itself; no other cross-imports between these four are allowed. +- `npm.py` MAY import from `system_pkg.py` and `node.py` to bootstrap npm itself; no other cross-imports between these four are allowed. +- `node.py` MAY import from `primitives.py` and `system_pkg.py`. - Per-tool modules under `lint/` MUST NOT import each other (see [lint README](lint/README.md)). diff --git a/utils/install/node.py b/utils/install/node.py index 631c85451c..262ffa4e53 100644 --- a/utils/install/node.py +++ b/utils/install/node.py @@ -1,17 +1,204 @@ -"""Node.js runtime provisioning for the JavaScript unit suite.""" +"""Node.js runtime provisioning for the lint toolchain and the JavaScript unit suite.""" from __future__ import annotations +import datetime as dt +import functools +import hashlib +import json +import platform +import shutil +import subprocess +import tempfile +from pathlib import Path + +from utils.install.primitives import download_file, log, run_privileged from utils.install.system_pkg import ensure_command_present +DIST_URL = "https://nodejs.org/dist" +SCHEDULE_URL = "https://raw.githubusercontent.com/nodejs/Release/main/schedule.json" +KEYRING_URL = "https://raw.githubusercontent.com/nodejs/release-keys/main/gpg-only-active-keys/pubring.kbx" +PREFIX = Path("/usr/local") +ARCHITECTURES = {"x86_64": "x64", "aarch64": "arm64", "arm64": "arm64"} -def ensure_node_present() -> None: - """Install the Node.js runtime through the system package manager. + +def _load_json(path: Path) -> object: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def installed_line() -> str: + """Return the release line of the ``node`` on PATH, such as ``v20``. + + Returns: + The major version prefixed with ``v``, as the release schedule keys it. + """ + version = subprocess.run( + ["node", "--version"], check=True, capture_output=True, text=True + ).stdout.strip() + return version.split(".", 1)[0] + + +def line_is_supported(line: str, schedule: dict, today: dt.date) -> bool: + """Tell whether a release line still receives upstream fixes. + + Args: + line: release line such as ``v20``. + schedule: the parsed nodejs/Release ``schedule.json``. + today: the date the end of life is compared against. + + Returns: + False for an unknown line and for a line past its ``end`` date. + """ + entry = schedule.get(line) + if entry is None: + return False + return dt.date.fromisoformat(entry["end"]) >= today + + +def newest_lts(index: list) -> str: + """Return the version of the newest LTS release in ``index.json``. + + Args: + index: the parsed ``https://nodejs.org/dist/index.json``, newest first. Raises: - RuntimeError: node is still absent after the install attempt. + RuntimeError: the index lists no LTS release. + """ + for release in index: + if release["lts"]: + return release["version"] + raise RuntimeError(f"{DIST_URL}/index.json lists no LTS release") + + +def tarball_name(version: str, system: str, machine: str) -> str: + """Return the Linux tarball name of a release for this host. + + Args: + version: release version such as ``v24.22.1``. + system: ``platform.system()`` of the host. + machine: ``platform.machine()`` of the host. + + Raises: + RuntimeError: the host is not Linux, or Node.js publishes no Linux + build for the machine. + """ + if system != "Linux": + raise RuntimeError( + f"The upstream Node.js install supports Linux only; install a " + f"supported Node.js through the package manager on {system}." + ) + arch = ARCHITECTURES.get(machine) + if arch is None: + raise RuntimeError(f"No Node.js Linux build for machine {machine!r}") + return f"node-{version}-linux-{arch}.tar.gz" + + +def expected_sha256(shasums: Path, name: str) -> str: + """Return the checksum ``SHASUMS256.txt`` records for a file. + + Args: + shasums: the downloaded ``SHASUMS256.txt``. + name: file name to look up. + + Raises: + RuntimeError: the file is not listed. + """ + with shasums.open(encoding="utf-8") as handle: + for line in handle: + digest, _, listed = line.strip().partition(" ") + if listed == name: + return digest + raise RuntimeError(f"{name} is not listed in {shasums.name}") + + +def _verify_signature(workdir: Path, shasums: Path, signature: Path) -> None: + ensure_command_present("gpgv") + keyring = workdir / "pubring.kbx" + download_file(KEYRING_URL, str(keyring)) + subprocess.run( + ["gpgv", "--keyring", str(keyring), str(signature), str(shasums)], + check=True, + ) + + +def _sha256(path: Path) -> str: + with path.open("rb") as handle: + return hashlib.file_digest(handle, "sha256").hexdigest() + + +def install_upstream_lts() -> str: + """Install the newest Node.js LTS from nodejs.org under ``PREFIX``. + + Returns: + The installed version. + + Raises: + RuntimeError: the checksum does not match, or ``node`` on PATH is not + the installed release afterwards. + subprocess.CalledProcessError: the signature does not verify. + """ + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + index = workdir / "index.json" + download_file(f"{DIST_URL}/index.json", str(index)) + version = newest_lts(_load_json(index)) + name = tarball_name(version, platform.system(), platform.machine()) + shasums = workdir / "SHASUMS256.txt" + signature = workdir / "SHASUMS256.txt.sig" + download_file(f"{DIST_URL}/{version}/SHASUMS256.txt", str(shasums)) + download_file(f"{DIST_URL}/{version}/SHASUMS256.txt.sig", str(signature)) + _verify_signature(workdir, shasums, signature) + tarball = workdir / name + log(f"Installing Node.js {version} from {DIST_URL} into {PREFIX}") + download_file(f"{DIST_URL}/{version}/{name}", str(tarball), timeout=300.0) + if _sha256(tarball) != expected_sha256(shasums, name): + raise RuntimeError(f"{name} does not match its SHASUMS256.txt entry") + top = name.removesuffix(".tar.gz") + run_privileged( + [ + "tar", + "-xzf", + str(tarball), + "-C", + str(PREFIX), + "--strip-components=1", + f"{top}/bin", + f"{top}/include", + f"{top}/lib", + f"{top}/share", + ] + ) + resolved = shutil.which("node") + line = installed_line() + if resolved != str(PREFIX / "bin" / "node") or line != version.split(".", 1)[0]: + raise RuntimeError( + f"node on PATH is {resolved!r} ({line}) after installing {version} " + f"into {PREFIX}" + ) + return version + + +@functools.cache +def ensure_node_present() -> None: + """Provide a ``node`` whose release line is still supported upstream. + + Raises: + RuntimeError: node is still absent after the install attempt, or the + upstream install failed its checks. """ ensure_command_present("node") + line = installed_line() + with tempfile.TemporaryDirectory() as tmp: + schedule = Path(tmp) / "schedule.json" + download_file(SCHEDULE_URL, str(schedule)) + supported = line_is_supported( + line, _load_json(schedule), dt.datetime.now(dt.UTC).date() + ) + if supported: + return + log(f"Node.js {line} on PATH has reached its end of life upstream.") + install_upstream_lts() if __name__ == "__main__": diff --git a/utils/install/npm.py b/utils/install/npm.py index 2306222c3e..e439e6f489 100644 --- a/utils/install/npm.py +++ b/utils/install/npm.py @@ -10,6 +10,9 @@ from utils.install.primitives import ensure_dir_on_path, install_with_optional_s def ensure_npm_present() -> None: + from utils.install.node import ensure_node_present + + ensure_node_present() if shutil.which("npm") is not None: return diff --git a/utils/install/system_pkg.py b/utils/install/system_pkg.py index bf625e35a2..9342ed4d18 100644 --- a/utils/install/system_pkg.py +++ b/utils/install/system_pkg.py @@ -143,6 +143,13 @@ _COMMAND_PACKAGES: dict[str, dict[str, list[str]]] = { "shfmt": {m: ["shfmt"] for m in _SUPPORTED}, "shellcheck": {m: ["shellcheck"] for m in _SUPPORTED}, "unzip": {m: ["unzip"] for m in _SUPPORTED}, + "gpgv": { + "pacman": ["gnupg"], + "apt-get": ["gpgv"], + "dnf": ["gnupg2"], + "yum": ["gnupg2"], + "brew": ["gnupg"], + }, "php": { "pacman": ["php"], "apt-get": ["php-cli", "php"],