diff --git a/docs/source/config_files.rst b/docs/source/config_files.rst index 975f82c8..0b057e82 100644 --- a/docs/source/config_files.rst +++ b/docs/source/config_files.rst @@ -117,6 +117,32 @@ To set the date of the snapshot add a runtime.txt_. For an example ``install.R`` file, visit our `example install.R file `_. +.. _rstudio.yml: + +``rstudio.yml`` - Pin the RStudio Server version +================================================ + +Repositories with an R environment get RStudio Server installed. By +default, the latest stable release published by Posit is installed at +build time. To pin a specific version instead, add an ``rstudio.yml``: + +.. code-block:: yaml + + version: 2023.12.1+402 + sha256: 2ceeebe5d1d77068b36e85f7cf366cd1409f7642a80261b6bbeb3da945ef0888 + +Available versions are listed on +`Posit's previous versions page `_. +The ``.deb`` package is downloaded from Posit over HTTPS. ``sha256`` is +optional; when given, the download is verified against it. + +.. note:: + + For R versions older than 3.6, which current RStudio releases no longer + support, an older compatible RStudio (2023.12.1) is installed by default + instead of the latest release. + + .. _apt.txt: ``apt.txt`` - Install packages with apt-get diff --git a/docs/source/howto/languages.rst b/docs/source/howto/languages.rst index af51a6fd..9f3c75d7 100644 --- a/docs/source/howto/languages.rst +++ b/docs/source/howto/languages.rst @@ -63,7 +63,10 @@ you need to create a ``runtime.txt`` file with the following format: This will provide you R of given version (such as 4.1, 3.6, etc), and a CRAN snapshot to install libraries from on the given date. You can install more R packages from CRAN by adding a :ref:`install.R` file to your repo. RStudio and IRKernel are -installed by default for all R versions. +installed by default for all R versions. The latest stable RStudio Server is +installed at build time (for R older than 3.6, an older compatible RStudio is +installed instead); you can pin a specific version with a +:ref:`rstudio.yml` file. `packagemanager.posit.co `_ will be used to provide much faster installations via `binary packages `_. diff --git a/repo2docker/buildpacks/_r_base.py b/repo2docker/buildpacks/_r_base.py index a0ec2b92..b2fbad8a 100644 --- a/repo2docker/buildpacks/_r_base.py +++ b/repo2docker/buildpacks/_r_base.py @@ -4,10 +4,13 @@ Keeping this in r.py would lead to cyclic imports. """ +import shlex + from ..semver import parse_version as V +from ._rstudio import rstudio_server_installer -def rstudio_base_scripts(r_version): +def rstudio_base_scripts(r_version, rstudio_config): """Base steps to install RStudio and shiny-server.""" # Shiny server (not the package!) seems to be the same version for all R versions @@ -15,18 +18,15 @@ def rstudio_base_scripts(r_version): shiny_proxy_version = "1.1" shiny_sha256sum = "80f1e48f6c824be7ef9c843bb7911d4981ac7e8a963e0eff823936a8b28476ee" - # RStudio server has different builds based on wether OpenSSL 3 or 1.1 is available in the base - # image. 3 is present Jammy+, 1.1 until then. Instead of hardcoding URLs based on distro, we actually - # check for the dependency itself directly in the code below. You can find these URLs in - # https://posit.co/download/rstudio-server/, toggling between Ubuntu 22 (for openssl3) vs earlier versions (openssl 1.1) - # you may forget about openssl, but openssl never forgets you. - - # RStudio server 2023.12.1 seems to be provided for only Ubuntu 20.04 and later - # and it does not work with Ubuntu 18.04(bionic) - rstudio_url = "https://download2.rstudio.org/server/jammy/amd64/rstudio-server-2023.12.1-402-amd64.deb" - rstudio_sha256sum = ( - "2ceeebe5d1d77068b36e85f7cf366cd1409f7642a80261b6bbeb3da945ef0888" - ) + rstudio_url, rstudio_sha256sum = rstudio_server_installer(r_version, rstudio_config) + # the URL comes from external metadata (downloads.json) or is derived + # from repository-provided rstudio.yml; never splice it in unquoted + rstudio_url = shlex.quote(rstudio_url) + if rstudio_sha256sum: + rstudio_verify = f'echo "{rstudio_sha256sum} /tmp/rstudio.deb" | sha256sum -c -' + else: + # user-pinned version without a hash; trust HTTPS + rstudio_verify = "true" rsession_proxy_version = "2.2.0" return [ @@ -35,15 +35,17 @@ def rstudio_base_scripts(r_version): # we should have --no-install-recommends on all our apt-get install commands, # but here it's important because these recommend r-base, # which will upgrade the installed version of R, undoing our pinned version + # + # RStudio's postinst initializes /var/lib/rstudio-server (session-rpc-key + # etc.) as root; rserver runs as NB_USER and needs to read/write it rf""" apt-get update > /dev/null && \ - RSTUDIO_URL="{rstudio_url}" && \ - RSTUDIO_HASH="{rstudio_sha256sum}" && \ - curl --silent --location --fail ${{RSTUDIO_URL}} > /tmp/rstudio.deb && \ - curl --silent --location --fail {shiny_server_url} > /tmp/shiny.deb && \ - echo "${{RSTUDIO_HASH}} /tmp/rstudio.deb" | sha256sum -c - && \ + curl --silent --show-error --location --fail {rstudio_url} > /tmp/rstudio.deb && \ + curl --silent --show-error --location --fail {shiny_server_url} > /tmp/shiny.deb && \ + {rstudio_verify} && \ echo '{shiny_sha256sum} /tmp/shiny.deb' | sha256sum -c - && \ apt install -y --no-install-recommends /tmp/rstudio.deb /tmp/shiny.deb && \ + chown -R ${{NB_USER}}:${{NB_USER}} /var/lib/rstudio-server && \ rm /tmp/*.deb && \ apt-get -qq purge && \ apt-get -qq clean && \ diff --git a/repo2docker/buildpacks/_rstudio.py b/repo2docker/buildpacks/_rstudio.py new file mode 100644 index 00000000..d0ba10f7 --- /dev/null +++ b/repo2docker/buildpacks/_rstudio.py @@ -0,0 +1,108 @@ +""" +Resolution of the RStudio Server version to install. + +By default the latest stable release is resolved from Posit's +downloads.json at Dockerfile generation time, so that RStudio keeps up +with current R releases (newer R graphics engines require newer RStudio). + +Users can pin a specific version with an `rstudio.yml` in their +repository: + + version: 2023.12.1+402 + sha256: 2ceeebe5... # optional + +Pinned versions are downloaded over HTTPS but only verified against +sha256 when the user provides one. +""" + +import os +import re +from functools import lru_cache + +import requests +from ruamel.yaml import YAML + +from ..semver import parse_version as V + +RSTUDIO_DOWNLOADS_URL = "https://www.rstudio.com/wp-content/downloads.json" + +# Ubuntu major version of the base image (see Repo2Docker.base_image), +# matched against platform names in downloads.json ("Ubuntu 22/Debian 12"). +# The version number is a more stable identity than Posit's series keys, +# whose naming conventions vary across their products. +RSTUDIO_UBUNTU_VERSION = "22" + +# Ubuntu series segment of download2.rstudio.org paths, used to construct +# URLs for user-pinned versions without depending on downloads.json +RSTUDIO_UBUNTU_SERIES = "jammy" + +# Current RStudio releases require R >= 3.6.0 +# https://docs.posit.co/ide/desktop-pro/getting_started/prerequisites.html +RSTUDIO_MIN_R_VERSION = "3.6" + +# Last release supporting R >= 3.3, for repos pinning R < 3.6 +LEGACY_RSTUDIO_URL = "https://download2.rstudio.org/server/jammy/amd64/rstudio-server-2023.12.1-402-amd64.deb" +LEGACY_RSTUDIO_SHA256 = ( + "2ceeebe5d1d77068b36e85f7cf366cd1409f7642a80261b6bbeb3da945ef0888" +) + + +@lru_cache() +def fetch_latest_rstudio_server(): + """Return (url, sha256) of the latest stable RStudio Server .deb.""" + resp = requests.get(RSTUDIO_DOWNLOADS_URL, timeout=30) + resp.raise_for_status() + installers = resp.json()["rstudio"]["open_source"]["stable"]["server"]["installer"] + matches = [ + entry + for entry in installers.values() + if re.search(rf"\bUbuntu {RSTUDIO_UBUNTU_VERSION}\b", entry["platform"]["name"]) + ] + if len(matches) != 1: + names = ", ".join(entry["platform"]["name"] for entry in installers.values()) + raise ValueError( + f"could not find exactly one RStudio Server build for " + f"Ubuntu {RSTUDIO_UBUNTU_VERSION} in {RSTUDIO_DOWNLOADS_URL} " + f"(available: {names}); the base image may no longer be within " + f"RStudio's support window" + ) + return matches[0]["url"], matches[0]["sha256"] + + +def load_rstudio_yaml(path): + """Return the parsed rstudio.yml, or None if it does not exist.""" + if not os.path.exists(path): + return None + with open(path) as f: + return YAML().load(f) + + +def rstudio_server_installer(r_version, rstudio_config): + """Return (url, sha256) of the RStudio Server .deb to install. + + sha256 is None when the user pinned a version without a hash. + """ + if rstudio_config is not None: + # rstudio.yml comes from the repository being built; these values end + # up in root-executed build scripts, so validate them strictly + if "version" not in rstudio_config: + raise ValueError("rstudio.yml must contain a 'version' field") + version = str(rstudio_config["version"]).replace("+", "-") + if not re.fullmatch(r"[0-9A-Za-z.-]+", version): + raise ValueError( + f"invalid RStudio version in rstudio.yml: {rstudio_config['version']!r}" + ) + sha256 = rstudio_config.get("sha256") + if sha256 is not None and not re.fullmatch(r"[0-9a-fA-F]{64}", str(sha256)): + raise ValueError("sha256 in rstudio.yml must be a 64-character hex string") + url = f"https://download2.rstudio.org/server/{RSTUDIO_UBUNTU_SERIES}/amd64/rstudio-server-{version}-amd64.deb" + resp = requests.head(url, timeout=30) + if resp.status_code != 200: + raise ValueError( + f"RStudio Server {rstudio_config['version']} specified in rstudio.yml " + f"does not exist: {url} returned {resp.status_code}" + ) + return url, sha256 + if r_version and V(r_version) < V(RSTUDIO_MIN_R_VERSION): + return LEGACY_RSTUDIO_URL, LEGACY_RSTUDIO_SHA256 + return fetch_latest_rstudio_server() diff --git a/repo2docker/buildpacks/conda/__init__.py b/repo2docker/buildpacks/conda/__init__.py index e277cc40..d7056ec3 100644 --- a/repo2docker/buildpacks/conda/__init__.py +++ b/repo2docker/buildpacks/conda/__init__.py @@ -12,6 +12,7 @@ from ...semver import parse_version as V from ...utils import is_local_pip_requirement from .._r_base import rstudio_base_scripts +from .._rstudio import load_rstudio_yaml from ..base import BaseImage from .matlab import ( matlab_installation_scripts, @@ -442,7 +443,9 @@ def get_env_scripts(self): raise RuntimeError( f"RStudio is only available for linux/amd64 ({self.platform})" ) - scripts += rstudio_base_scripts(self.r_version) + scripts += rstudio_base_scripts( + self.r_version, load_rstudio_yaml(self.binder_path("rstudio.yml")) + ) scripts += [ ( "root", diff --git a/repo2docker/buildpacks/r.py b/repo2docker/buildpacks/r.py index 4c77e591..75c78c2c 100644 --- a/repo2docker/buildpacks/r.py +++ b/repo2docker/buildpacks/r.py @@ -7,6 +7,7 @@ from ..semver import parse_version as V from ._r_base import rstudio_base_scripts +from ._rstudio import load_rstudio_yaml from .python import PythonBuildPack # Aproximately the first snapshot on RSPM (Posit package manager) @@ -304,7 +305,9 @@ def get_build_scripts(self): ), ] - scripts += rstudio_base_scripts(self.r_version) + scripts += rstudio_base_scripts( + self.r_version, load_rstudio_yaml(self.binder_path("rstudio.yml")) + ) scripts += [ ( diff --git a/tests/unit/test_rstudio.py b/tests/unit/test_rstudio.py new file mode 100644 index 00000000..0ca205f7 --- /dev/null +++ b/tests/unit/test_rstudio.py @@ -0,0 +1,180 @@ +""" +Test RStudio Server version resolution +""" + +from unittest.mock import patch + +import pytest + +from repo2docker.buildpacks._r_base import rstudio_base_scripts +from repo2docker.buildpacks._rstudio import ( + LEGACY_RSTUDIO_SHA256, + LEGACY_RSTUDIO_URL, + fetch_latest_rstudio_server, + load_rstudio_yaml, + rstudio_server_installer, +) + +LATEST_URL = ( + "https://download2.rstudio.org/server/jammy/amd64/rstudio-server-latest-amd64.deb" +) +LATEST_SHA256 = "0" * 64 + + +def downloads_json(installers): + return { + "rstudio": {"open_source": {"stable": {"server": {"installer": installers}}}} + } + + +DOWNLOADS_JSON = downloads_json( + { + "jammy": { + "url": LATEST_URL, + "sha256": LATEST_SHA256, + "platform": {"name": "Ubuntu 22/Debian 12", "key": "jammy"}, + }, + "noble": { + "url": "https://example.com/noble.deb", + "sha256": "1" * 64, + "platform": {"name": "Ubuntu 24", "key": "noble"}, + }, + } +) + + +@pytest.fixture(autouse=True) +def clear_fetch_cache(): + fetch_latest_rstudio_server.cache_clear() + yield + fetch_latest_rstudio_server.cache_clear() + + +@pytest.mark.parametrize("r_version", ["3.3.3", "3.5.3"]) +def test_legacy_rstudio_for_old_r(r_version): + assert rstudio_server_installer(r_version, None) == ( + LEGACY_RSTUDIO_URL, + LEGACY_RSTUDIO_SHA256, + ) + + +@pytest.mark.parametrize("r_version", ["", "3.6", "4.2.1"]) +def test_latest_rstudio(r_version): + with patch("repo2docker.buildpacks._rstudio.requests.get") as mock_get: + mock_get.return_value.json.return_value = DOWNLOADS_JSON + assert rstudio_server_installer(r_version, None) == (LATEST_URL, LATEST_SHA256) + mock_get.assert_called_once() + + +def test_latest_rstudio_base_no_longer_supported(): + # Posit removes installer entries when an OS leaves their support window + with patch("repo2docker.buildpacks._rstudio.requests.get") as mock_get: + mock_get.return_value.json.return_value = downloads_json( + { + "noble": { + "url": "https://example.com/noble.deb", + "sha256": "1" * 64, + "platform": {"name": "Ubuntu 24", "key": "noble"}, + } + } + ) + with pytest.raises(ValueError, match="support window"): + rstudio_server_installer("4.2.1", None) + + +def test_latest_rstudio_ambiguous_match(): + with patch("repo2docker.buildpacks._rstudio.requests.get") as mock_get: + mock_get.return_value.json.return_value = downloads_json( + { + "jammy": { + "url": LATEST_URL, + "sha256": LATEST_SHA256, + "platform": {"name": "Ubuntu 22/Debian 12", "key": "jammy"}, + }, + "ubuntu22": { + "url": "https://example.com/other.deb", + "sha256": "2" * 64, + "platform": {"name": "Ubuntu 22 (server)", "key": "ubuntu22"}, + }, + } + ) + with pytest.raises(ValueError, match="exactly one"): + rstudio_server_installer("4.2.1", None) + + +@pytest.fixture +def pinned_version_exists(): + with patch("repo2docker.buildpacks._rstudio.requests.head") as mock_head: + mock_head.return_value.status_code = 200 + yield mock_head + + +def test_pinned_rstudio(pinned_version_exists): + url, sha256 = rstudio_server_installer("4.2.1", {"version": "2023.12.1+402"}) + assert url == ( + "https://download2.rstudio.org/server/jammy/amd64/rstudio-server-2023.12.1-402-amd64.deb" + ) + assert sha256 is None + + +def test_pinned_rstudio_with_sha256(pinned_version_exists): + config = {"version": "2023.12.1+402", "sha256": LEGACY_RSTUDIO_SHA256} + assert rstudio_server_installer("4.2.1", config) == ( + LEGACY_RSTUDIO_URL, + LEGACY_RSTUDIO_SHA256, + ) + + +def test_pinned_rstudio_overrides_legacy(pinned_version_exists): + # explicit user pin wins over the old-R legacy fallback + url, _ = rstudio_server_installer("3.5.3", {"version": "2026.06.0+242"}) + assert "2026.06.0-242" in url + + +def test_pinned_rstudio_requires_version(): + with pytest.raises(ValueError): + rstudio_server_installer("4.2.1", {"sha256": LEGACY_RSTUDIO_SHA256}) + + +def test_pinned_rstudio_unknown_version(): + with patch("repo2docker.buildpacks._rstudio.requests.head") as mock_head: + mock_head.return_value.status_code = 404 + with pytest.raises(ValueError, match="does not exist"): + rstudio_server_installer("4.2.1", {"version": "9999.99.9+999"}) + + +@pytest.mark.parametrize( + "version", ['2023.12.1"; rm -rf /; echo "', "2023.12.1 402", "$(reboot)"] +) +def test_pinned_rstudio_rejects_unsafe_version(version): + with pytest.raises(ValueError, match="invalid RStudio version"): + rstudio_server_installer("4.2.1", {"version": version}) + + +@pytest.mark.parametrize( + "sha256", ['x" /dev/null; reboot; echo "', "deadbeef", "2ceeebe5" * 9] +) +def test_pinned_rstudio_rejects_unsafe_sha256(sha256): + with pytest.raises(ValueError, match="sha256"): + rstudio_server_installer( + "4.2.1", {"version": "2023.12.1+402", "sha256": sha256} + ) + + +def test_load_rstudio_yaml(tmpdir): + assert load_rstudio_yaml(str(tmpdir / "rstudio.yml")) is None + path = tmpdir / "rstudio.yml" + path.write("version: 2023.12.1+402\n") + assert load_rstudio_yaml(str(path))["version"] == "2023.12.1+402" + + +def test_build_script_verifies_sha256_when_pinned_with_hash(pinned_version_exists): + config = {"version": "2023.12.1+402", "sha256": LEGACY_RSTUDIO_SHA256} + user, script = rstudio_base_scripts("4.2.1", config)[0] + assert f'echo "{LEGACY_RSTUDIO_SHA256} /tmp/rstudio.deb" | sha256sum -c -' in script + + +def test_build_script_skips_sha256_when_pinned_without_hash(pinned_version_exists): + user, script = rstudio_base_scripts("4.2.1", {"version": "2023.12.1+402"})[0] + assert "rstudio.deb" in script + assert '/tmp/rstudio.deb" | sha256sum' not in script