mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
Merge pull request #1108 from infinito-nexus/fix/install/node-lts-runtime/1088
fix(install): replace an end-of-life distro Node.js with the current LTS
This commit is contained in:
6 files changed
+354
-7
No files matched your search
@@ -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 (
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)).
|
||||
+191
-4
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user