From a5b98ef86f99d755250c7ea1726456972a709fbb Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 9 Sep 2026 11:36:18 +0200 Subject: [PATCH 1/9] feat(roles/r): add a role to install and configure R Installs R and pandoc from EPEL, points R at the Posit Public Package Manager and installs the CRAN packages an application needs. CRAN serves source only, so every package with compiled code is built on the host. P3M serves prebuilt binaries for the RHEL family, but only when the HTTP user agent identifies R and the platform, so Rprofile.site carries the repository and HTTPUserAgent together. The path component differs per generation and is selected in vars/RedHat.yml; RHEL 8 uses "centos8", there is no "rhel8" path. Rprofile.site and Renviron.site are not shipped by the R packages. The role creates both. Renviron.site is the only reliable place for the environment of an R process started by Shiny Server, which goes through `su --login`. --- .ansible-lint-ignore | 1 + CHANGELOG.md | 1 + COMPATIBILITY.md | 1 + playbooks/README.md | 9 + playbooks/all.yml | 1 + playbooks/r.yml | 52 ++++++ roles/r/README.md | 174 ++++++++++++++++++ roles/r/defaults/main.yml | 35 ++++ roles/r/meta/argument_specs.yml | 72 ++++++++ roles/r/tasks/main.yml | 94 ++++++++++ .../usr/lib64/R/etc/Renviron.site.j2 | 14 ++ .../usr/lib64/R/etc/Rprofile.site.j2 | 20 ++ roles/r/vars/RedHat.yml | 29 +++ roles/r/vars/RedHat10.yml | 1 + roles/r/vars/RedHat8.yml | 3 + roles/r/vars/RedHat9.yml | 1 + 16 files changed, 508 insertions(+) create mode 100644 playbooks/r.yml create mode 100644 roles/r/README.md create mode 100644 roles/r/defaults/main.yml create mode 100644 roles/r/meta/argument_specs.yml create mode 100644 roles/r/tasks/main.yml create mode 100644 roles/r/templates/usr/lib64/R/etc/Renviron.site.j2 create mode 100644 roles/r/templates/usr/lib64/R/etc/Rprofile.site.j2 create mode 100644 roles/r/vars/RedHat.yml create mode 100644 roles/r/vars/RedHat10.yml create mode 100644 roles/r/vars/RedHat8.yml create mode 100644 roles/r/vars/RedHat9.yml diff --git a/.ansible-lint-ignore b/.ansible-lint-ignore index 51baa4e8..3daacea8 100644 --- a/.ansible-lint-ignore +++ b/.ansible-lint-ignore @@ -107,6 +107,7 @@ roles/postgresql_server/defaults/main.yml jinja[spacing] roles/python/defaults/main.yml jinja[spacing] roles/python_venv/defaults/main.yml jinja[spacing] roles/qemu_guest_agent/defaults/main.yml jinja[spacing] +roles/r/defaults/main.yml jinja[spacing] roles/redis/defaults/main.yml jinja[spacing] roles/repo_baseos/defaults/main.yml jinja[spacing] roles/repo_collabora/defaults/main.yml jinja[spacing] diff --git a/CHANGELOG.md b/CHANGELOG.md index e7787906..20c60c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:r, playbook:r**: Add a role and playbook to install R and pandoc, point R at the Posit Public Package Manager so that CRAN packages arrive as prebuilt binaries instead of being compiled on the host, and install the CRAN packages an application needs. * **role:borg_local, role:icinga2_master, role:nextcloud, role:schedule_reboot, role:tools**: `icinga2_master__downtime_api_user` creates an Icinga2 API user that may only schedule and remove downtimes, and the roles that set a downtime around a backup, a Nextcloud update or a reboot use it unless their own `*__icinga2_api_user_login` is set. * **role:crypto_policy, role:kernel_modules, role:selinux**: A change that only takes effect after a reboot requests one at the maintenance window instead of being left to the operator to notice: a switched crypto policy, a blocked kernel module that is still loaded, and switching SELinux on or off. Where the reboot mechanism is not deployed, the role reports the pending reboot as before. `lfops__reboot_now` performs it in the same run. * Every playbook prints the manual steps a run leaves to the operator as one block directly above the `PLAY RECAP`, collected from all roles of the play instead of scattered over its output. The roles keep printing their message where it occurs as well, so a role used outside this collection still reports it. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 772ba312..7cb1eddc 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -127,6 +127,7 @@ Which Ansible role is proven to run on which OS? | python | x | x | x | x | x | (x) | (x) | (x) | Windows | | python_venv | x | x | x | x | x | (x) | (x) | (x) | Fedora 35 | | qemu_guest_agent | (x) | (x) | x | x | x | (x) | (x) | (x) | | +| r | | | x | x | x | | | | | | redis | x | x | x | x | | (x) | (x) | (x) | RHEL 10: no Redis, use the `valkey` role | | repo_baseos | | | x | x | x | | | | | | repo_collabora | | | x | (x) | (x) | | | | | diff --git a/playbooks/README.md b/playbooks/README.md index c3c036b9..d43d3f2e 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -834,6 +834,15 @@ Calls the following roles (in order): * [qemu_guest_agent](https://github.com/Linuxfabrik/lfops/tree/main/roles/qemu_guest_agent) +## r.yml + +Calls the following roles (in order): + +* [repo_baseos](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_baseos): `r__skip_repo_baseos` +* [repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel): `r__skip_repo_epel` +* [r](https://github.com/Linuxfabrik/lfops/tree/main/roles/r) + + ## redis.yml Calls the following roles (in order): diff --git a/playbooks/all.yml b/playbooks/all.yml index 9ffad117..04d764f5 100644 --- a/playbooks/all.yml +++ b/playbooks/all.yml @@ -96,6 +96,7 @@ - import_playbook: 'python.yml' - import_playbook: 'python_venv.yml' - import_playbook: 'qemu_guest_agent.yml' +- import_playbook: 'r.yml' - import_playbook: 'redis.yml' - import_playbook: 'repo_baseos.yml' - import_playbook: 'repo_collabora.yml' diff --git a/playbooks/r.yml b/playbooks/r.yml new file mode 100644 index 00000000..17e3ce05 --- /dev/null +++ b/playbooks/r.yml @@ -0,0 +1,52 @@ +- name: 'Playbook linuxfabrik.lfops.r' + hosts: + - 'lfops_r' + + pre_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-start.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'global-variables.yml' + tags: + - 'always' + + + roles: + + # CRB (RHEL 9 and 10) / PowerTools (RHEL 8) carries the development packages the CRAN + # packages are built against, and on RHEL 8 also pandoc itself. + - role: 'linuxfabrik.lfops.repo_baseos' + repo_baseos__crb_repo_enabled__dependent_var: '{{ + repo_epel__repo_baseos__crb_repo_enabled__dependent_var + }}' + when: + - 'ansible_facts["distribution"] == "Rocky" and ansible_facts["distribution_major_version"] | int >= 9' + - 'not r__skip_repo_baseos | d(false)' + + # EPEL ships R, and pandoc on RHEL 9 and 10. + - role: 'linuxfabrik.lfops.repo_epel' + when: + - 'ansible_facts["os_family"] == "RedHat"' + - 'ansible_facts["distribution"] != "Fedora"' + - 'not r__skip_repo_epel | d(false)' + + - role: 'linuxfabrik.lfops.r' + + + post_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-end.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'print-messages.yml' + tags: + - 'always' diff --git a/roles/r/README.md b/roles/r/README.md new file mode 100644 index 00000000..4dc31dfb --- /dev/null +++ b/roles/r/README.md @@ -0,0 +1,174 @@ +# Ansible Role linuxfabrik.lfops.r + +This role installs [R](https://www.r-project.org/) together with pandoc, points R at a binary CRAN mirror, and installs the CRAN packages an application needs. + +EPEL ships R itself but next to no CRAN packages: around two dozen `R-*` packages per RHEL generation, among which `R-shiny`, `R-rmarkdown`, `R-dplyr` and `R-data.table` are not. Everything beyond the base therefore comes through `install.packages()`, which this role drives. + + +*Available in the next LFOps release.* + + +## How the Role Behaves + +* CRAN itself serves source only, so every package with a C or C++ part is compiled locally, which costs minutes for `data.table`, `dplyr` or `ggplot2`. The role configures the [Posit Public Package Manager](https://packagemanager.posit.co/) instead, which serves prebuilt binaries for the RHEL family. The path component differs per generation and is selected automatically (RHEL 8 uses `centos8`, there is no `rhel8` path). +* The package manager decides from the HTTP user agent whether it hands out a binary or a source package. Without `HTTPUserAgent`, R silently compiles from source despite the binary repository, so the role writes both settings together. Check with `R --quiet -e 'cat(getOption("HTTPUserAgent"))'`. +* `Rprofile.site` and `Renviron.site` are not shipped by the R packages. The role creates both and re-renders them on every run, so manual edits are overwritten; a timestamped backup is kept. +* `latest` in the repository URL always resolves to the current CRAN state, so two installations on different days produce different package versions. Where that matters, set `r__cran_repo_url` to a dated snapshot URL, or manage an application's dependencies with [renv](https://rstudio.github.io/renv/) and keep its `renv.lock` next to the application code. +* A CRAN package is installed only when it is absent from the library, and removed only when it is present, so a second run reports no change. The role does not upgrade installed packages. +* `install.packages()` reports a failed installation as a warning and still exits 0, so the role asks the library afterwards whether the package really arrived and fails the run when it did not. +* The role does not manage per-user or per-project libraries. Everything is installed into the system library, which is the first entry of `.libPaths()`. + + +## Dependent Roles + +Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/README.md) that installs this role runs these for you. Optional ones can be disabled via the playbook's skip variables. + +* The EPEL repository must be enabled (role: [linuxfabrik.lfops.repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel)). R comes from it, and on RHEL 9 and 10 pandoc as well. +* The CRB repository must be enabled on RHEL 9 and 10 (role: [linuxfabrik.lfops.repo_baseos](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_baseos)). It carries the development packages the CRAN packages are built against. On RHEL 8 the same repository is called PowerTools, is enabled by the `repo_epel` role, and additionally provides pandoc itself. + + +## Requirements + +* Outbound HTTPS access from the target host to `packagemanager.posit.co`, or to the mirror configured in `r__cran_repo_url`. + + +## Tags + +`r` + +* Installs R, pandoc and the build dependencies. +* Deploys `Rprofile.site` and `Renviron.site`. +* Installs and removes the CRAN packages. +* Triggers: none. + +`r:configure` + +* Deploys `Rprofile.site` and `Renviron.site`. +* Triggers: none. + +`r:modules` + +* Installs and removes the CRAN packages. +* Triggers: none. + + +## Optional Role Variables + +`r__cran_packages__host_var` / `r__cran_packages__group_var` + +* CRAN packages to install. +* Type: List of dictionaries. +* Default: `[]` +* Subkeys: + + * `name`: + + * Mandatory. Name of the CRAN package. + * Type: String. + + * `state`: + + * Optional. `present` or `absent`. + * Type: String. + * Default: `'present'` + +`r__cran_repo_url` + +* Repository `install.packages()` reads from. +* Type: String. +* Default: RHEL 8: `'https://packagemanager.posit.co/cran/__linux__/centos8/latest'`, RHEL 9: `'https://packagemanager.posit.co/cran/__linux__/rhel9/latest'`, RHEL 10: `'https://packagemanager.posit.co/cran/__linux__/rhel10/latest'` +* Deviates from the upstream default `https://cloud.r-project.org`: CRAN serves source packages only, so the upstream default turns every installation of a package with compiled code into a local build. + +`r__http_user_agent_enabled` + +* Set `HTTPUserAgent` so that the Posit Public Package Manager serves binary packages. Turn this off only for a mirror that ignores the user agent. +* Type: Bool. +* Default: `true` + +`r__renviron_site__host_var` / `r__renviron_site__group_var` + +* Environment variables written to `Renviron.site`. This is the only reliable place for the environment of an R process started by Shiny Server, which goes through `su --login` and therefore never sees the environment of the service. +* Type: List of dictionaries. +* Default: `[]` +* Subkeys: + + * `name`: + + * Mandatory. Name of the environment variable. + * Type: String. + + * `value`: + + * Mandatory. Value of the environment variable. + * Type: String. + + * `state`: + + * Optional. `present` or `absent`. + * Type: String. + * Default: `'present'` + +`r__renviron_site_raw` + +* Verbatim content appended to `Renviron.site`. +* Type: String. +* Default: `''` + +`r__rprofile_site_raw` + +* Verbatim R code appended to `Rprofile.site`. +* Type: String. +* Default: `''` + +`r__skip_build_dependency_installation` + +* Skip the installation of the development packages of the C libraries CRAN packages link against. They all come from AppStream, and without them a package the Posit Public Package Manager has no binary for cannot be built. +* Type: Bool. +* Default: `false` + +Example: +```yaml +# optional +r__cran_packages__host_var: + - name: 'data.table' + - name: 'ggplot2' + - name: 'obsolete-package' + state: 'absent' +r__cran_repo_url: 'https://packagemanager.posit.co/cran/__linux__/rhel9/2026-06-01' +r__http_user_agent_enabled: true +r__renviron_site__host_var: + # reticulate embeds a Python interpreter in the R session; the interpreter is best named + # centrally, because the application code cannot rely on the environment it inherits + - name: 'RETICULATE_PYTHON' + value: '/usr/bin/python3' +r__renviron_site_raw: | + R_LIBS_SITE=/opt/r-site-library +r__rprofile_site_raw: | + options(warn = 1) +r__skip_build_dependency_installation: false +``` + + +## Troubleshooting + +**`install.packages()` compiles from source for hours** + +* `HTTPUserAgent` is missing or wrong, so the Posit Public Package Manager serves source packages. Check with `R --quiet -e 'cat(getOption("HTTPUserAgent"))'`. With the setting in place, R reports `* installing *binary* package '...'` and finishes in seconds, and `R --quiet -e "cat(packageDescription('data.table')\$Built)"` shows Posit's build date instead of today's. + +**`pandoc version 1.12.3 or higher is required and was not found`** + +* The `pandoc` package is missing. Without it, *rmarkdown* renders no reports. Check with `R --quiet -e 'rmarkdown::pandoc_available()'`. + +**An application reports `there is no package called '...'`** + +* The package is not installed in the system library, or the account running the application cannot read it. Check which library paths that account really sees with `sudo --user=shiny R --quiet -e ".libPaths()"`. + + +## License + +[The Unlicense](https://unlicense.org/) + + +## Author Information + +[Linuxfabrik GmbH, Zurich](https://www.linuxfabrik.ch) diff --git a/roles/r/defaults/main.yml b/roles/r/defaults/main.yml new file mode 100644 index 00000000..673078a6 --- /dev/null +++ b/roles/r/defaults/main.yml @@ -0,0 +1,35 @@ +r__cran_packages__combined_var: '{{ ( + r__cran_packages__role_var + + r__cran_packages__dependent_var + + r__cran_packages__group_var + + r__cran_packages__host_var + ) | linuxfabrik.lfops.combine_lod + }}' + +r__cran_packages__dependent_var: [] +r__cran_packages__group_var: [] +r__cran_packages__host_var: [] +r__cran_packages__role_var: [] + +# Default lives in vars/RedHat.yml, which is why this is absent from +# argument_specs.yml. +r__cran_repo_url: '{{ __r__cran_repo_url }}' # upstream default: https://cloud.r-project.org + +r__http_user_agent_enabled: true + +r__renviron_site__combined_var: '{{ ( + r__renviron_site__role_var + + r__renviron_site__dependent_var + + r__renviron_site__group_var + + r__renviron_site__host_var + ) | linuxfabrik.lfops.combine_lod + }}' + +r__renviron_site__dependent_var: [] +r__renviron_site__group_var: [] +r__renviron_site__host_var: [] +r__renviron_site__role_var: [] +r__renviron_site_raw: '' +r__rprofile_site_raw: '' + +r__skip_build_dependency_installation: false diff --git a/roles/r/meta/argument_specs.yml b/roles/r/meta/argument_specs.yml new file mode 100644 index 00000000..aeb77ecd --- /dev/null +++ b/roles/r/meta/argument_specs.yml @@ -0,0 +1,72 @@ +# r__cran_repo_url is deliberately absent: its default in defaults/main.yml references +# __r__cran_repo_url from vars/RedHat.yml, which is not loaded yet when argument_specs +# validation runs at role entry. It is documented in the README instead. +argument_specs: + main: + options: + + r__cran_packages__dependent_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'CRAN packages to install. Dependent-role injection.' + + r__cran_packages__group_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'CRAN packages to install. Group-level override.' + + r__cran_packages__host_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'CRAN packages to install. Host-level override.' + + r__http_user_agent_enabled: + type: 'bool' + required: false + default: true + description: 'Set HTTPUserAgent so that the Posit Public Package Manager serves binary packages.' + + r__renviron_site__dependent_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Environment variables written to Renviron.site. Dependent-role injection.' + + r__renviron_site__group_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Environment variables written to Renviron.site. Group-level override.' + + r__renviron_site__host_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Environment variables written to Renviron.site. Host-level override.' + + r__renviron_site_raw: + type: 'str' + required: false + default: '' + description: 'Verbatim content appended to Renviron.site.' + + r__rprofile_site_raw: + type: 'str' + required: false + default: '' + description: 'Verbatim R code appended to Rprofile.site.' + + r__skip_build_dependency_installation: + type: 'bool' + required: false + default: false + description: 'Skip the development packages needed to build CRAN packages from source.' diff --git a/roles/r/tasks/main.yml b/roles/r/tasks/main.yml new file mode 100644 index 00000000..c59c07e2 --- /dev/null +++ b/roles/r/tasks/main.yml @@ -0,0 +1,94 @@ +- block: + + - name: 'Set platform/version specific variables' + ansible.builtin.import_role: + name: 'shared' + tasks_from: 'platform-variables.yml' + + tags: + - 'always' + + +- block: + + - name: 'Install required packages' + ansible.builtin.package: + name: '{{ __r__required_packages }}' + state: 'present' + + - name: 'Install the build dependencies of the CRAN packages' + ansible.builtin.package: + name: '{{ __r__build_dependency_packages }}' + state: 'present' + when: + - 'not r__skip_build_dependency_installation | bool' + + tags: + - 'r' + + +- block: + + # Neither file is shipped by the R packages, so both are created here. They are read by every + # R process, including the workers Shiny Server starts. + - name: 'Deploy {{ __r__home }}/etc/Rprofile.site' + ansible.builtin.template: + backup: true + src: 'usr/lib64/R/etc/Rprofile.site.j2' + dest: '{{ __r__home }}/etc/Rprofile.site' + owner: 'root' + group: 'root' + mode: 0o644 + + - name: 'Deploy {{ __r__home }}/etc/Renviron.site' + ansible.builtin.template: + backup: true + src: 'usr/lib64/R/etc/Renviron.site.j2' + dest: '{{ __r__home }}/etc/Renviron.site' + owner: 'root' + group: 'root' + mode: 0o644 + + tags: + - 'r' + - 'r:configure' + + +- block: + + - name: 'Combined CRAN packages' + ansible.builtin.debug: + var: 'r__cran_packages__combined_var' + + # install.packages() and remove.packages() are not idempotent themselves, so the presence of + # the package directory in the library decides whether they run at all. + - name: 'R -e remove.packages()' + ansible.builtin.command: >- + R --quiet --no-save --no-restore + -e 'remove.packages("{{ item["name"] }}")' + args: + removes: '{{ __r__library }}/{{ item["name"] }}/DESCRIPTION' + loop: '{{ r__cran_packages__combined_var }}' + loop_control: + label: '{{ item["name"] }}' + when: + - 'item["state"] | d("present") == "absent"' + + # install.packages() reports a failed installation as a warning on stdout and still exits 0, + # so the library is asked afterwards whether the package really arrived, in the same call. + - name: 'R -e install.packages()' + ansible.builtin.command: >- + R --quiet --no-save --no-restore + -e 'install.packages("{{ item["name"] }}"); + if (!nzchar(system.file(package = "{{ item["name"] }}"))) quit(status = 1)' + args: + creates: '{{ __r__library }}/{{ item["name"] }}/DESCRIPTION' + loop: '{{ r__cran_packages__combined_var }}' + loop_control: + label: '{{ item["name"] }}' + when: + - 'item["state"] | d("present") != "absent"' + + tags: + - 'r' + - 'r:modules' diff --git a/roles/r/templates/usr/lib64/R/etc/Renviron.site.j2 b/roles/r/templates/usr/lib64/R/etc/Renviron.site.j2 new file mode 100644 index 00000000..29835956 --- /dev/null +++ b/roles/r/templates/usr/lib64/R/etc/Renviron.site.j2 @@ -0,0 +1,14 @@ +# {{ ansible_managed }} +# 2026090801 +# +# R reads this file on every start. It is the only reliable place for environment variables an +# R process needs, because a worker started by Shiny Server goes through `su --login`, which +# discards the environment of the calling service. + +{% for item in r__renviron_site__combined_var if item['state'] | d('present') != 'absent' %} +{{ item['name'] }}={{ item['value'] }} +{% endfor %} +{% if r__renviron_site_raw | length %} + +{{ r__renviron_site_raw }} +{% endif %} diff --git a/roles/r/templates/usr/lib64/R/etc/Rprofile.site.j2 b/roles/r/templates/usr/lib64/R/etc/Rprofile.site.j2 new file mode 100644 index 00000000..ddb1fef7 --- /dev/null +++ b/roles/r/templates/usr/lib64/R/etc/Rprofile.site.j2 @@ -0,0 +1,20 @@ +# {{ ansible_managed }} +# 2026090801 + +options( + repos = c(P3M = "{{ r__cran_repo_url }}") +{%- if r__http_user_agent_enabled | bool %}, + # The Posit Public Package Manager serves prebuilt binaries only when the user agent + # identifies R and the platform. Without HTTPUserAgent, R silently falls back to compiling + # from source, which turns a package like data.table into a multi-minute build. + HTTPUserAgent = sprintf( + "R/%s R (%s)", + getRversion(), + paste(getRversion(), R.version$platform, R.version$arch, R.version$os) + ) +{%- endif %} +) +{% if r__rprofile_site_raw | length %} + +{{ r__rprofile_site_raw }} +{% endif %} diff --git a/roles/r/vars/RedHat.yml b/roles/r/vars/RedHat.yml new file mode 100644 index 00000000..556c9f5f --- /dev/null +++ b/roles/r/vars/RedHat.yml @@ -0,0 +1,29 @@ +# R_HOME on the RHEL family. The EPEL package installs below /usr/lib64 on every supported +# architecture, so this is not architecture specific. +__r__home: '/usr/lib64/R' + +# install.packages() writes to the first entry of .libPaths(), which is this directory. +__r__library: '/usr/lib64/R/library' + +__r__required_packages: + - 'R' + - 'pandoc' + +# Development packages of the C libraries the common CRAN packages link against. Only needed +# when a package is built from source, which happens for anything the Posit Public Package +# Manager has no binary for. All of them live in AppStream. +__r__build_dependency_packages: + - 'cairo-devel' + - 'fontconfig-devel' + - 'freetype-devel' + - 'fribidi-devel' + - 'gcc' + - 'gcc-c++' + - 'harfbuzz-devel' + - 'libcurl-devel' + - 'libjpeg-turbo-devel' + - 'libpng-devel' + - 'libtiff-devel' + - 'libxml2-devel' + - 'make' + - 'openssl-devel' diff --git a/roles/r/vars/RedHat10.yml b/roles/r/vars/RedHat10.yml new file mode 100644 index 00000000..51e8c2f4 --- /dev/null +++ b/roles/r/vars/RedHat10.yml @@ -0,0 +1 @@ +__r__cran_repo_url: 'https://packagemanager.posit.co/cran/__linux__/rhel10/latest' diff --git a/roles/r/vars/RedHat8.yml b/roles/r/vars/RedHat8.yml new file mode 100644 index 00000000..52339a5d --- /dev/null +++ b/roles/r/vars/RedHat8.yml @@ -0,0 +1,3 @@ +# The Posit Public Package Manager publishes the RHEL 8 binaries under "centos8"; there is no +# "rhel8" path (verified 2026-09: it answers 404, while centos8, rhel9 and rhel10 answer 200). +__r__cran_repo_url: 'https://packagemanager.posit.co/cran/__linux__/centos8/latest' diff --git a/roles/r/vars/RedHat9.yml b/roles/r/vars/RedHat9.yml new file mode 100644 index 00000000..a5e60a9f --- /dev/null +++ b/roles/r/vars/RedHat9.yml @@ -0,0 +1 @@ +__r__cran_repo_url: 'https://packagemanager.posit.co/cran/__linux__/rhel9/latest' From 21aa87031d78ef1bb45f707f5d90f3e3565e32c5 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 9 Sep 2026 11:37:08 +0200 Subject: [PATCH 2/9] feat(roles/shiny_server): add a role to install and configure Shiny Server Installs Shiny Server Open Source from the vendor RPM and serves several tenants from one host: one Apache httpd vHost per tenant with basic authentication, one location per application, and one R worker and system account per tenant. Shiny Server Open Source authenticates nobody and passes every client header through to the R worker, so the listener is bound to 127.0.0.1 and the role refuses to run when a vHost passes Shiny-Server-Credentials on a non-loopback listener. The configuration is deployed before the package, because the package writes its own only when none exists and then starts the service immediately. The vHost and htpasswd injections are built in tasks/apache-httpd-vhosts.yml, imported from the playbook's pre_tasks: apache_httpd runs first, and rendering an arbitrary number of tenants needs a Jinja loop that a variable definition cannot carry. A tenant account gets its own primary group for isolation and a secondary membership in shiny_server__shared_group for the shared code tree. That group is deliberately not the service account's, which owns the application directory of every location left at the default run_as. --- .ansible-lint-ignore | 1 + CHANGELOG.md | 1 + COMPATIBILITY.md | 1 + .../setup_shiny_server/install/converge.yml | 2 + .../group_vars/systems_under_test.yml | 50 ++ .../install/inventory/hosts.yml | 19 + .../setup_shiny_server/install/molecule.yml | 10 + .../setup_shiny_server/install/prepare.yml | 48 ++ .../setup_shiny_server/install/verify.yml | 135 ++++ .../setup_shiny_server/remove/converge.yml | 6 + .../group_vars/systems_under_test.yml | 51 ++ .../remove/inventory/hosts.yml | 19 + .../setup_shiny_server/remove/molecule.yml | 13 + .../setup_shiny_server/remove/verify.yml | 86 +++ playbooks/README.md | 14 + playbooks/all.yml | 1 + playbooks/setup_shiny_server.yml | 104 +++ roles/shiny_server/README.md | 675 ++++++++++++++++++ roles/shiny_server/defaults/main.yml | 98 +++ roles/shiny_server/handlers/main.yml | 18 + roles/shiny_server/meta/argument_specs.yml | 243 +++++++ .../tasks/apache-httpd-vhosts.yml | 8 + roles/shiny_server/tasks/main.yml | 457 ++++++++++++ .../templates/apache-httpd-htpasswd.yml.j2 | 12 + .../templates/apache-httpd-vhosts.yml.j2 | 61 ++ .../etc/logrotate.d/shiny-server-apps.j2 | 16 + .../etc/shiny-server/shiny-server.conf.j2 | 54 ++ .../z00-linuxfabrik.conf.j2 | 30 + roles/shiny_server/vars/RedHat.yml | 13 + roles/shiny_server/vars/main.yml | 111 +++ 30 files changed, 2357 insertions(+) create mode 100644 extensions/molecule/setup_shiny_server/install/converge.yml create mode 100644 extensions/molecule/setup_shiny_server/install/inventory/group_vars/systems_under_test.yml create mode 100644 extensions/molecule/setup_shiny_server/install/inventory/hosts.yml create mode 100644 extensions/molecule/setup_shiny_server/install/molecule.yml create mode 100644 extensions/molecule/setup_shiny_server/install/prepare.yml create mode 100644 extensions/molecule/setup_shiny_server/install/verify.yml create mode 100644 extensions/molecule/setup_shiny_server/remove/converge.yml create mode 100644 extensions/molecule/setup_shiny_server/remove/inventory/group_vars/systems_under_test.yml create mode 100644 extensions/molecule/setup_shiny_server/remove/inventory/hosts.yml create mode 100644 extensions/molecule/setup_shiny_server/remove/molecule.yml create mode 100644 extensions/molecule/setup_shiny_server/remove/verify.yml create mode 100644 playbooks/setup_shiny_server.yml create mode 100644 roles/shiny_server/README.md create mode 100644 roles/shiny_server/defaults/main.yml create mode 100644 roles/shiny_server/handlers/main.yml create mode 100644 roles/shiny_server/meta/argument_specs.yml create mode 100644 roles/shiny_server/tasks/apache-httpd-vhosts.yml create mode 100644 roles/shiny_server/tasks/main.yml create mode 100644 roles/shiny_server/templates/apache-httpd-htpasswd.yml.j2 create mode 100644 roles/shiny_server/templates/apache-httpd-vhosts.yml.j2 create mode 100644 roles/shiny_server/templates/etc/logrotate.d/shiny-server-apps.j2 create mode 100644 roles/shiny_server/templates/etc/shiny-server/shiny-server.conf.j2 create mode 100644 roles/shiny_server/templates/etc/systemd/system/shiny-server.service.d/z00-linuxfabrik.conf.j2 create mode 100644 roles/shiny_server/vars/RedHat.yml create mode 100644 roles/shiny_server/vars/main.yml diff --git a/.ansible-lint-ignore b/.ansible-lint-ignore index 3daacea8..55f3bd74 100644 --- a/.ansible-lint-ignore +++ b/.ansible-lint-ignore @@ -138,6 +138,7 @@ roles/rsyslog/defaults/main.yml jinja[spacing] roles/selinux/defaults/main.yml jinja[spacing] roles/shared/defaults/main.yml jinja[spacing] roles/shell/defaults/main.yml jinja[spacing] +roles/shiny_server/defaults/main.yml jinja[spacing] roles/snmp/defaults/main.yml jinja[spacing] roles/squid/defaults/main.yml jinja[spacing] roles/sshd/defaults/main.yml jinja[spacing] diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c60c5e..95a22cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:shiny_server, playbook:setup_shiny_server**: Add a role and playbook to install Shiny Server Open Source and serve several tenants from one host, each with its own hostname, password file, R worker and system account, behind an Apache httpd reverse proxy that also passes the authenticated user into the application. Shiny Server itself listens on the loopback only, because it authenticates nobody and hands every client header to the application. * **role:r, playbook:r**: Add a role and playbook to install R and pandoc, point R at the Posit Public Package Manager so that CRAN packages arrive as prebuilt binaries instead of being compiled on the host, and install the CRAN packages an application needs. * **role:borg_local, role:icinga2_master, role:nextcloud, role:schedule_reboot, role:tools**: `icinga2_master__downtime_api_user` creates an Icinga2 API user that may only schedule and remove downtimes, and the roles that set a downtime around a backup, a Nextcloud update or a reboot use it unless their own `*__icinga2_api_user_login` is set. * **role:crypto_policy, role:kernel_modules, role:selinux**: A change that only takes effect after a reboot requests one at the maintenance window instead of being left to the operator to notice: a switched crypto policy, a blocked kernel module that is still loaded, and switching SELinux on or off. Where the reboot mechanism is not deployed, the role reports the pending reboot as before. `lfops__reboot_now` performs it in the same run. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 7cb1eddc..d470f358 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -159,6 +159,7 @@ Which Ansible role is proven to run on which OS? | selinux | (x) | (x) | x | x | x | (x) | (x) | (x) | | | shared | | | | | | | | | controller-side helper, target OS irrelevant | | shell | (x) | (x) | x | x | x | (x) | (x) | (x) | | +| shiny_server | | | x | x | x | | | | x86_64 only, the vendor ships no other build | | snmp | | | x | x | (x) | | | | | | squid | | | (x) | x | (x) | | | | | | sshd | x | x | x | x | x | x | x | x | Fedora | diff --git a/extensions/molecule/setup_shiny_server/install/converge.yml b/extensions/molecule/setup_shiny_server/install/converge.yml new file mode 100644 index 00000000..8ebcd081 --- /dev/null +++ b/extensions/molecule/setup_shiny_server/install/converge.yml @@ -0,0 +1,2 @@ +- name: 'Converge setup_shiny_server playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.setup_shiny_server' diff --git a/extensions/molecule/setup_shiny_server/install/inventory/group_vars/systems_under_test.yml b/extensions/molecule/setup_shiny_server/install/inventory/group_vars/systems_under_test.yml new file mode 100644 index 00000000..7f0209d9 --- /dev/null +++ b/extensions/molecule/setup_shiny_server/install/inventory/group_vars/systems_under_test.yml @@ -0,0 +1,50 @@ +# Mandatory variable of the apache_httpd role, which this playbook pulls in. +apache_httpd__conf_server_admin: 'root@localhost' + +shiny_server__version: '1.5.23.1030' + +# Two tenants, so that the separation between them can be verified rather than +# assumed. Each gets its own account, its own worker and its own vHost. Tenant +# acme runs two applications on one account, which is what the remove +# sub-scenario needs to show that retiring one of them keeps the other running. +shiny_server__locations__group_var: + - path: '/acme/app1' + app_dir: '/srv/shiny-server/tenants/acme/app1' + log_dir: '/var/log/shiny-server/acme' + run_as: 'shiny-acme' + # Long enough that the worker started by the request in verify.yml is still + # alive when the following task looks for it. At the default of 5 seconds it + # would be reaped in between. + app_idle_timeout: 900 + - path: '/acme/app2' + app_dir: '/srv/shiny-server/tenants/acme/app2' + log_dir: '/var/log/shiny-server/acme' + run_as: 'shiny-acme' + app_idle_timeout: 900 + - path: '/globex/app1' + app_dir: '/srv/shiny-server/tenants/globex/app1' + log_dir: '/var/log/shiny-server/globex' + run_as: 'shiny-globex' + app_idle_timeout: 900 + simple_scheduler: 20 + +shiny_server__vhosts__group_var: + - conf_server_name: 'shiny-acme.example.com' + htpasswd: + - username: 'firstname.lastname' + password: 'linuxfabrik' + locations: + - shiny_location: '/acme/app1' + path: '/app1' + - shiny_location: '/acme/app2' + path: '/app2' + - conf_server_name: 'shiny-globex.example.com' + htpasswd: + - username: 'globex.admin' + password: 'linuxfabrik' + locations: + - shiny_location: '/globex/app1' + path: '/app1' + +shiny_server__memory_max: '2G' +shiny_server__tasks_max: 200 diff --git a/extensions/molecule/setup_shiny_server/install/inventory/hosts.yml b/extensions/molecule/setup_shiny_server/install/inventory/hosts.yml new file mode 100644 index 00000000..9e449ec1 --- /dev/null +++ b/extensions/molecule/setup_shiny_server/install/inventory/hosts.yml @@ -0,0 +1,19 @@ +# yamllint disable rule:empty-values + +# setup_shiny_server targets 'lfops_setup_shiny_server' (see +# playbooks/setup_shiny_server.yml: hosts). +# +# Debian and Ubuntu are deliberately absent: Posit builds its Debian package +# against Ubuntu 20.04 only, and the role supports the RHEL family (see +# COMPATIBILITY.md). VMs rather than containers, because the tenant workers are +# started through `su`, which needs a working PAM stack, and because the memory +# limit the role deploys is only meaningful in a real control group. +lfops_setup_shiny_server: + children: + systems_under_test: + +systems_under_test: + hosts: + rocky8-vm: + rocky9-vm: + rocky10-vm: diff --git a/extensions/molecule/setup_shiny_server/install/molecule.yml b/extensions/molecule/setup_shiny_server/install/molecule.yml new file mode 100644 index 00000000..0a03a06a --- /dev/null +++ b/extensions/molecule/setup_shiny_server/install/molecule.yml @@ -0,0 +1,10 @@ +# Molecule scenario marker for the 'setup_shiny_server' scenario, run with +# `molecule test --scenario-name setup_shiny_server`. +# +# It overrides the prepare step (see prepare.yml): the role deploys the directory structure but no +# application code, and Shiny Server answers 500 for an application directory that holds neither +# app.R nor server.R. The tenant applications therefore have to be on the host before converge +# runs the playbook under test. +provisioner: + playbooks: + prepare: '${MOLECULE_SCENARIO_DIRECTORY}/prepare.yml' diff --git a/extensions/molecule/setup_shiny_server/install/prepare.yml b/extensions/molecule/setup_shiny_server/install/prepare.yml new file mode 100644 index 00000000..fcc3f656 --- /dev/null +++ b/extensions/molecule/setup_shiny_server/install/prepare.yml @@ -0,0 +1,48 @@ +# Replaces the shared vm-prepare.yml for this scenario (see molecule.yml), so it +# imports it first and only adds the precondition on top. +- name: 'Prepare VMs for Ansible' + ansible.builtin.import_playbook: '../../playbooks/vm-prepare.yml' + + +# The role deploys the directory structure but no application code, so the test +# provides a minimal Shiny application per location. Shiny Server decides how to +# launch a worker from the files it finds in the application directory, and +# answers 500 with "unknown mode" for a directory holding neither app.R nor +# server.R, so this cannot wait until verify. +# +# The applications are derived from the locations rather than listed again, so +# that the two cannot drift apart. The remove sub-scenario reuses this file, and +# relies on it to create the directories whose removal it then asserts. +- name: 'Prepare the tenant applications' + hosts: 'systems_under_test' + gather_facts: false + tasks: + + - name: 'mkdir -p the application directories' + ansible.builtin.file: + path: '{{ item["app_dir"] }}' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + loop: '{{ shiny_server__locations__group_var }}' + loop_control: + label: '{{ item["path"] }}' + + - name: 'Deploy the tenant applications' + ansible.builtin.copy: + content: | + library(shiny) + ui <- fluidPage(textOutput('location'), textOutput('whoami')) + server <- function(input, output, session) { + output$location <- renderText(paste('location:', '{{ item["path"] }}')) + output$whoami <- renderText(paste('user:', session$user)) + } + shinyApp(ui, server) + dest: '{{ item["app_dir"] }}/app.R' + owner: 'root' + group: 'root' + mode: 0o644 + loop: '{{ shiny_server__locations__group_var }}' + loop_control: + label: '{{ item["path"] }}' diff --git a/extensions/molecule/setup_shiny_server/install/verify.yml b/extensions/molecule/setup_shiny_server/install/verify.yml new file mode 100644 index 00000000..26853df7 --- /dev/null +++ b/extensions/molecule/setup_shiny_server/install/verify.yml @@ -0,0 +1,135 @@ +# What is worth asserting here is what only the running system can confirm: that +# each tenant really gets its own R worker under its own account, that one +# tenant cannot read the other's files, and that the reverse proxy in front +# actually asks for a password. That the configuration file was written is +# guaranteed by the template task and is not checked. +- name: 'Verify setup_shiny_server' + hosts: 'systems_under_test' + gather_facts: false + tasks: + + - name: 'Gather the service facts' + ansible.builtin.service_facts: # yamllint disable-line rule:empty-values + + - name: 'Assert that shiny-server.service is running' + ansible.builtin.assert: + that: 'ansible_facts["services"]["shiny-server.service"]["state"] == "running"' + + - name: 'Assert that httpd.service is running' + ansible.builtin.assert: + that: 'ansible_facts["services"]["httpd.service"]["state"] == "running"' + + - name: 'systemctl show shiny-server' # noqa command-instead-of-module (read-only property query) + ansible.builtin.command: 'systemctl show shiny-server --property=MemoryMax --property=TasksMax' + changed_when: false + register: '__molecule__shiny_server_unit_result' + + # Proves that the drop-in reaches the running unit, which the package would + # have overwritten had the role templated the unit file itself. + - name: 'Assert that the resource limits of the drop-in are in effect' + ansible.builtin.assert: + that: + - '"MemoryMax=2147483648" in __molecule__shiny_server_unit_result["stdout"]' + - '"TasksMax=200" in __molecule__shiny_server_unit_result["stdout"]' + + - name: 'curl the applications on the Shiny Server listener' + ansible.builtin.uri: + url: 'http://127.0.0.1:3838{{ item }}/' + status_code: 200 + loop: + - '/acme/app1' + - '/acme/app2' + - '/globex/app1' + + # One single-threaded worker per application is what the whole per-tenant layout + # exists for, and each has to run under the tenant's own account. Tenant acme runs + # two applications on one account, so it has to end up with two workers: that is + # the difference between one location per application and one per tenant, and the + # reason a long computation in one application does not block the other. + - name: 'pgrep --list-full SockJSAdapter' + ansible.builtin.command: 'ps -eo user:20,args' + changed_when: false + register: '__molecule__shiny_server_worker_result' + + - name: 'Assert that every application has its own worker under its own account' + ansible.builtin.assert: + that: + - '__molecule__shiny_server_worker_result["stdout_lines"] + | select("search", "^" ~ item["user"] ~ "\\s+.*SockJSAdapter") | list | length == item["workers"]' + fail_msg: >- + Expected {{ item["workers"] }} R worker(s) running as {{ item["user"] }}, found + {{ __molecule__shiny_server_worker_result["stdout_lines"] + | select("search", "^" ~ item["user"] ~ "\\s+.*SockJSAdapter") | list | length }}. + loop: + - user: 'shiny-acme' + workers: 2 + - user: 'shiny-globex' + workers: 1 + loop_control: + label: '{{ item["user"] }}' + + - name: 'sudo --user=shiny-acme cat the globex application' + ansible.builtin.command: 'su -s /bin/bash -- shiny-acme -c "cat /srv/shiny-server/tenants/globex/app1/app.R"' + changed_when: false + failed_when: false + register: '__molecule__shiny_server_cross_tenant_result' + + - name: 'Assert that one tenant cannot read the other tenant files' + ansible.builtin.assert: + that: + - '__molecule__shiny_server_cross_tenant_result["rc"] != 0' + fail_msg: 'shiny-acme was able to read the application of shiny-globex.' + + - name: 'curl http://shiny-acme.example.com/app1/ without credentials' + ansible.builtin.uri: + url: 'http://127.0.0.1/app1/' + headers: + Host: 'shiny-acme.example.com' + status_code: 401 + + - name: 'curl http://shiny-acme.example.com/app1/ with credentials' + ansible.builtin.uri: + url: 'http://127.0.0.1/app1/' + headers: + Host: 'shiny-acme.example.com' + url_username: 'firstname.lastname' + url_password: 'linuxfabrik' + force_basic_auth: true + status_code: 200 + + # An upload arrives by POST and the SockJS fallback transports use it too, + # so a proxy that answers 405 breaks both without any visible error. + - name: 'curl --request POST http://shiny-acme.example.com/app1/' + ansible.builtin.uri: + url: 'http://127.0.0.1/app1/' + method: 'POST' + headers: + Host: 'shiny-acme.example.com' + url_username: 'firstname.lastname' + url_password: 'linuxfabrik' + force_basic_auth: true + status_code: + - 200 + - 404 + register: '__molecule__shiny_server_post_result' + + - name: 'Assert that POST is not rejected by the proxy' + ansible.builtin.assert: + that: + - '__molecule__shiny_server_post_result["status"] != 405' + + # Proves the whole chain of the r role: Rprofile.site is read, the package + # manager is configured, and the user agent that makes it serve binaries is + # set. Without the latter, R silently compiles every package from source. + - name: 'R -e getOption("repos")' + ansible.builtin.command: >- + R --quiet --no-save --no-restore + -e 'cat(getOption("repos")[["P3M"]], getOption("HTTPUserAgent"))' + changed_when: false + register: '__molecule__r_repos_result' + + - name: 'Assert that R reads from the Posit Public Package Manager with a user agent' + ansible.builtin.assert: + that: + - '"packagemanager.posit.co" in __molecule__r_repos_result["stdout"]' + - '"R (" in __molecule__r_repos_result["stdout"]' diff --git a/extensions/molecule/setup_shiny_server/remove/converge.yml b/extensions/molecule/setup_shiny_server/remove/converge.yml new file mode 100644 index 00000000..ad103b74 --- /dev/null +++ b/extensions/molecule/setup_shiny_server/remove/converge.yml @@ -0,0 +1,6 @@ +# Same playbook as the install sub-scenario; only the scenario inventory differs. The remove +# sub-scenario retires one of tenant acme's two applications and all of tenant globex, which +# exercises both halves of the removal path: what is shared with a still-active location has to +# survive, what nothing else uses has to go. +- name: 'Converge setup_shiny_server playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.setup_shiny_server' diff --git a/extensions/molecule/setup_shiny_server/remove/inventory/group_vars/systems_under_test.yml b/extensions/molecule/setup_shiny_server/remove/inventory/group_vars/systems_under_test.yml new file mode 100644 index 00000000..140f7741 --- /dev/null +++ b/extensions/molecule/setup_shiny_server/remove/inventory/group_vars/systems_under_test.yml @@ -0,0 +1,51 @@ +# Mandatory variable of the apache_httpd role, which this playbook pulls in. +apache_httpd__conf_server_admin: 'root@localhost' + +shiny_server__version: '1.5.23.1030' + +# Tenant acme keeps app2 and retires app1: the account, the log directory and the tenant +# directory are shared with app2 and therefore have to survive. Tenant globex is retired +# completely, so its account, home directory, application directory, tenant directory and log +# directory all have to go. +shiny_server__locations__group_var: + - path: '/acme/app1' + app_dir: '/srv/shiny-server/tenants/acme/app1' + log_dir: '/var/log/shiny-server/acme' + run_as: 'shiny-acme' + app_idle_timeout: 900 + state: 'absent' + - path: '/acme/app2' + app_dir: '/srv/shiny-server/tenants/acme/app2' + log_dir: '/var/log/shiny-server/acme' + run_as: 'shiny-acme' + app_idle_timeout: 900 + - path: '/globex/app1' + app_dir: '/srv/shiny-server/tenants/globex/app1' + log_dir: '/var/log/shiny-server/globex' + run_as: 'shiny-globex' + app_idle_timeout: 900 + simple_scheduler: 20 + state: 'absent' + +shiny_server__vhosts__group_var: + - conf_server_name: 'shiny-acme.example.com' + htpasswd: + - username: 'firstname.lastname' + password: 'linuxfabrik' + locations: + - shiny_location: '/acme/app1' + path: '/app1' + state: 'absent' + - shiny_location: '/acme/app2' + path: '/app2' + - conf_server_name: 'shiny-globex.example.com' + htpasswd: + - username: 'globex.admin' + password: 'linuxfabrik' + locations: + - shiny_location: '/globex/app1' + path: '/app1' + state: 'absent' + +shiny_server__memory_max: '2G' +shiny_server__tasks_max: 200 diff --git a/extensions/molecule/setup_shiny_server/remove/inventory/hosts.yml b/extensions/molecule/setup_shiny_server/remove/inventory/hosts.yml new file mode 100644 index 00000000..9e449ec1 --- /dev/null +++ b/extensions/molecule/setup_shiny_server/remove/inventory/hosts.yml @@ -0,0 +1,19 @@ +# yamllint disable rule:empty-values + +# setup_shiny_server targets 'lfops_setup_shiny_server' (see +# playbooks/setup_shiny_server.yml: hosts). +# +# Debian and Ubuntu are deliberately absent: Posit builds its Debian package +# against Ubuntu 20.04 only, and the role supports the RHEL family (see +# COMPATIBILITY.md). VMs rather than containers, because the tenant workers are +# started through `su`, which needs a working PAM stack, and because the memory +# limit the role deploys is only meaningful in a real control group. +lfops_setup_shiny_server: + children: + systems_under_test: + +systems_under_test: + hosts: + rocky8-vm: + rocky9-vm: + rocky10-vm: diff --git a/extensions/molecule/setup_shiny_server/remove/molecule.yml b/extensions/molecule/setup_shiny_server/remove/molecule.yml new file mode 100644 index 00000000..7a4f2a86 --- /dev/null +++ b/extensions/molecule/setup_shiny_server/remove/molecule.yml @@ -0,0 +1,13 @@ +# Molecule scenario marker for the 'setup_shiny_server/remove' sub-scenario, run with +# `molecule test --scenario-name setup_shiny_server/remove`. +# +# It borrows the prepare step of the install sub-scenario, which derives the application +# directories from the locations in group_vars. Here that includes the retired ones, so the +# directories this run is meant to remove demonstrably exist before converge starts. +# +# In a real suite, run install first to create the state and then this one on the same +# instances (`molecule test --destroy=never`). As a standalone run it still exercises the +# removal path, because prepare creates what converge then takes away. +provisioner: + playbooks: + prepare: '${MOLECULE_SCENARIO_DIRECTORY}/../install/prepare.yml' diff --git a/extensions/molecule/setup_shiny_server/remove/verify.yml b/extensions/molecule/setup_shiny_server/remove/verify.yml new file mode 100644 index 00000000..3ec4bd64 --- /dev/null +++ b/extensions/molecule/setup_shiny_server/remove/verify.yml @@ -0,0 +1,86 @@ +# Verify the removal. Same mindset as install/verify.yml: check the observable result, and +# confirm the removal did not take the surviving tenant down with it. The two halves matter +# equally, because the dangerous failure mode here is not "too little was removed" but "a +# still-active location lost its account or its data". +- name: 'Verify the retired locations are gone and the remaining one still runs' + hosts: 'systems_under_test' + gather_facts: false + tasks: + + - name: 'Gather the service facts' + ansible.builtin.service_facts: # yamllint disable-line rule:empty-values + + - name: 'Assert that shiny-server.service is still running' + ansible.builtin.assert: + that: 'ansible_facts["services"]["shiny-server.service"]["state"] == "running"' + + # The surviving application is what proves the removal was surgical. + - name: 'curl http://127.0.0.1:3838/acme/app2/' + ansible.builtin.uri: + url: 'http://127.0.0.1:3838/acme/app2/' + status_code: 200 + + - name: 'curl http://shiny-acme.example.com/app2/ with credentials' + ansible.builtin.uri: + url: 'http://127.0.0.1/app2/' + headers: + Host: 'shiny-acme.example.com' + url_username: 'firstname.lastname' + url_password: 'linuxfabrik' + force_basic_auth: true + status_code: 200 + + # The retired locations are out of the running configuration, so Shiny Server no longer + # knows the paths at all. + - name: 'curl the retired locations' + ansible.builtin.uri: + url: 'http://127.0.0.1:3838{{ item }}/' + status_code: 404 + loop: + - '/acme/app1' + - '/globex/app1' + + - name: 'getent passwd' + ansible.builtin.getent: + database: 'passwd' + + - name: 'Assert that the account of the fully retired tenant is gone' + ansible.builtin.assert: + that: '"shiny-globex" not in ansible_facts["getent_passwd"]' + fail_msg: 'shiny-globex still exists although all of its locations were retired.' + + # The account is shared with the surviving acme/app2, so retiring acme/app1 must not touch + # it. This is the assertion that catches a naive "remove every account of an absent + # location" implementation. + - name: 'Assert that the account shared with the surviving application is kept' + ansible.builtin.assert: + that: '"shiny-acme" in ansible_facts["getent_passwd"]' + fail_msg: 'shiny-acme was removed although acme/app2 still uses it.' + + - name: 'stat the directories' + ansible.builtin.stat: + path: '{{ item }}' + register: '__molecule__shiny_server_stat_result' + loop: + - '/srv/shiny-server/tenants/acme' + - '/srv/shiny-server/tenants/acme/app1' + - '/srv/shiny-server/tenants/acme/app2' + - '/srv/shiny-server/tenants/globex' + - '/var/lib/shiny-server/home/shiny-globex' + - '/var/log/shiny-server/acme' + - '/var/log/shiny-server/globex' + + - name: 'Assert what the removal kept and what it took' + ansible.builtin.assert: + that: 'item["stat"]["exists"] == (item["item"] in __molecule__shiny_server_expected_present)' + fail_msg: >- + {{ item["item"] }} is {{ item["stat"]["exists"] | ternary("present", "absent") }}, + expected {{ (item["item"] in __molecule__shiny_server_expected_present) | ternary("present", "absent") }}. + vars: + __molecule__shiny_server_expected_present: + - '/srv/shiny-server/tenants/acme' + - '/srv/shiny-server/tenants/acme/app2' + - '/var/log/shiny-server/acme' + loop: '{{ __molecule__shiny_server_stat_result["results"] }}' + loop_control: + label: '{{ item["item"] }}' diff --git a/playbooks/README.md b/playbooks/README.md index d43d3f2e..83aa84a6 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -1331,6 +1331,20 @@ Calls the following roles (in order): * [podman_containers](https://github.com/Linuxfabrik/lfops/tree/main/roles/podman_containers): `setup_rocketchat__skip_podman_containers` +## setup_shiny_server.yml + +Calls the following roles (in order): + +* [repo_baseos](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_baseos): `setup_shiny_server__skip_repo_baseos` +* [repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel): `setup_shiny_server__skip_repo_epel` +* [policycoreutils](https://github.com/Linuxfabrik/lfops/tree/main/roles/policycoreutils): `setup_shiny_server__skip_policycoreutils` +* [selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux): `setup_shiny_server__skip_selinux` +* [r](https://github.com/Linuxfabrik/lfops/tree/main/roles/r): `setup_shiny_server__skip_r` +* [shiny_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/shiny_server) +* [python](https://github.com/Linuxfabrik/lfops/tree/main/roles/python): `setup_shiny_server__skip_python` +* [apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd): `setup_shiny_server__skip_apache_httpd` + + ## setup_wordpress.yml Calls the following roles (in order): diff --git a/playbooks/all.yml b/playbooks/all.yml index 04d764f5..2b9c456c 100644 --- a/playbooks/all.yml +++ b/playbooks/all.yml @@ -136,6 +136,7 @@ - import_playbook: 'setup_moodle.yml' - import_playbook: 'setup_nextcloud.yml' - import_playbook: 'setup_rocketchat.yml' +- import_playbook: 'setup_shiny_server.yml' - import_playbook: 'setup_wordpress.yml' - import_playbook: 'shell.yml' - import_playbook: 'snmp.yml' diff --git a/playbooks/setup_shiny_server.yml b/playbooks/setup_shiny_server.yml new file mode 100644 index 00000000..88ee8d31 --- /dev/null +++ b/playbooks/setup_shiny_server.yml @@ -0,0 +1,104 @@ +- name: 'Playbook linuxfabrik.lfops.setup_shiny_server' + hosts: + - 'lfops_setup_shiny_server' + + pre_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-start.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'global-variables.yml' + tags: + - 'always' + + # apache_httpd runs before shiny_server, so the vHost and htpasswd lists have to exist + # before the roles start. Rendering the Apache configuration of an arbitrary number of + # tenants needs a Jinja loop, which a variable definition cannot carry. + - ansible.builtin.import_role: + name: 'shiny_server' + tasks_from: 'apache-httpd-vhosts.yml' + tags: + - 'always' + + + roles: + + # === Repositories === + + - role: 'linuxfabrik.lfops.repo_baseos' + repo_baseos__crb_repo_enabled__dependent_var: '{{ + repo_epel__repo_baseos__crb_repo_enabled__dependent_var + }}' + # CRB is shipped in the Rocky default repo file (Rocky 9+, formerly from EPEL), so enable it there only. + when: + - 'ansible_facts["distribution"] == "Rocky" and ansible_facts["distribution_major_version"] | int >= 9' + - 'not setup_shiny_server__skip_repo_baseos | d(false)' + + - role: 'linuxfabrik.lfops.repo_epel' + when: + - 'ansible_facts["os_family"] == "RedHat" and ansible_facts["distribution"] != "Fedora"' + - 'not setup_shiny_server__skip_repo_epel | d(false)' + + # === SELinux === + + - role: 'linuxfabrik.lfops.policycoreutils' + when: + - 'ansible_facts["os_family"] == "RedHat"' + - 'not setup_shiny_server__skip_policycoreutils | d(false)' + + - role: 'linuxfabrik.lfops.selinux' + selinux__booleans__dependent_var: '{{ + apache_httpd__selinux__booleans__dependent_var + }}' + when: + - 'ansible_facts["os_family"] == "RedHat"' + - 'not setup_shiny_server__skip_selinux | d(false)' + + # === R === + + - role: 'linuxfabrik.lfops.r' + r__cran_packages__dependent_var: '{{ + shiny_server__r__cran_packages__dependent_var + }}' + r__renviron_site__dependent_var: '{{ + shiny_server__r__renviron_site__dependent_var + }}' + when: + - 'not setup_shiny_server__skip_r | d(false)' + + # === Application === + + - role: 'linuxfabrik.lfops.shiny_server' + + # === Web server === + + # python3-passlib is what the htpasswd task of the apache_httpd role needs. + - role: 'linuxfabrik.lfops.python' + python__modules__dependent_var: '{{ apache_httpd__python__modules__dependent_var | d([]) }}' + when: + - 'not setup_shiny_server__skip_python | d(false)' + + - role: 'linuxfabrik.lfops.apache_httpd' + apache_httpd__htpasswd__dependent_var: '{{ shiny_server__apache_httpd__htpasswd__dependent_var | d([]) }}' + apache_httpd__mods__dependent_var: '{{ shiny_server__apache_httpd__mods__dependent_var | d([]) }}' + apache_httpd__vhosts__dependent_var: '{{ shiny_server__apache_httpd__vhosts__dependent_var | d([]) }}' + when: + - 'not setup_shiny_server__skip_apache_httpd | d(false)' + + + post_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-end.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'print-messages.yml' + tags: + - 'always' diff --git a/roles/shiny_server/README.md b/roles/shiny_server/README.md new file mode 100644 index 00000000..215cf404 --- /dev/null +++ b/roles/shiny_server/README.md @@ -0,0 +1,675 @@ +# Ansible Role linuxfabrik.lfops.shiny_server + +This role installs and configures [Shiny Server Open Source](https://posit.co/products/open-source/shiny-server/), the Node.js server from Posit that hosts R Shiny applications, and makes it multi-tenant: one Apache httpd vHost per tenant, HTTP basic authentication per tenant, one R worker and one system account per tenant application. + +Shiny Server Open Source knows no users, no authentication and no roles. The separation therefore does not happen inside the application but in front of it, in the reverse proxy, and below it, in separate application instances and system accounts. + + +*Available in the next LFOps release.* + + +## How the Role Behaves + +* Posit publishes one RPM for the whole Red Hat family, built against CentOS 8. It declares no dependencies at all and carries its own Node.js runtime, which is why the same file runs on RHEL 8, 9 and 10. The package is downloaded on the Ansible controller and copied to the target, so targets without Internet access can be provisioned; the controller needs outbound access to `download3.rstudio.org`. It is fetched only when the installed version differs from `shiny_server__version`. +* Updating Shiny Server means raising `shiny_server__version` and running the role again. Posit documents no separate upgrade procedure: the Admin Guide installs the RPM of the wanted version and stops there, which is exactly what the role does, and the package manager resolves it as an upgrade of the installed one. Lowering the version works the same way and downgrades. The package restarts the service as part of its own installation, so an update is a brief outage and takes every running R worker with it. The configuration, the systemd drop-in and the role's logrotate file survive it: the package writes `shiny-server.conf` only when none exists, and it replaces the unit and its own `/etc/logrotate.d/shiny-server` rather than anything below `shiny-server.service.d/`. +* The configuration is deployed **before** the package. The package writes its own `shiny-server.conf` only when none exists, and then enables and starts the service immediately, so without this the service would come up once listening on every interface and serving `/srv/shiny-server` without any access control. +* Shiny Server listens on `127.0.0.1` only. It passes every client header straight through to the R worker, so on a listener reachable from the network a client bypasses the proxy and sets `Shiny-Server-Credentials` itself, which is exactly the value an application reads as `session$user`. The role refuses to run when that header is enabled on a non-loopback listener. +* A configuration change is applied with a reload. The service maps `ExecReload` to `SIGHUP`, on which Shiny Server re-reads its configuration, so no session is dropped. This also means `lfops__skip_restart_handlers` does not defer a configuration change. +* The package overwrites `/etc/systemd/system/shiny-server.service` and `/etc/logrotate.d/shiny-server` on every update. The role therefore never touches either: unit settings go into a drop-in under `/etc/systemd/system/shiny-server.service.d/`, and the rotation of the per-session application logs into a separate `/etc/logrotate.d/shiny-server-apps`. The vendor's own file keeps rotating the daemon log `/var/log/shiny-server.log`. +* Every application gets its own `location`, and therefore its own R worker. This is the reason to give each tenant its own entry even when two tenants run the same code: the default scheduler starts a **single, single-threaded** R worker per application, shared by all its visitors, so two tenants on one instance block each other while one of them computes. +* The role creates the directory structure, the system accounts and the log directories. It does not deploy application code. Put the shared code below `shiny_server__shared_dir` and the per-tenant configuration and data below `shiny_server__tenants_dir`, using symlinks to the shared files rather than copies. Link the individual files, not the whole directory: the working directory of the R process stays the tenant directory that way, so relative paths in the application code find the right tenant's data. +* A tenant account is created with its own primary group and a secondary membership in `shiny_server__shared_group`. The primary group keeps it out of the other tenants' directories, the secondary one lets it read the shared code tree. That shared group is deliberately not the group of the service account: the latter owns the application directory of every location left at the default `run_as`, so using it for both jobs would make those directories readable by all tenants. The account gets no login shell, which does not hinder Shiny Server: it starts the worker as root with `su -s /bin/bash`, which overrides the shell of the account. It does need a home directory, because a worker whose account has none is not started at all. +* An R worker inherits nothing from the service environment. Shiny Server hands it `HOME`, `LANG` and `PATH`, and the `su --login` it goes through then discards those again in favour of the login environment. `TMPDIR`, `R_LIBS`, `RETICULATE_PYTHON` and a full locale therefore belong in `Renviron.site` or `Rprofile.site`, which the `r` role manages. +* Retiring a location with `state: 'absent'` removes what the role created for it: the `location` from the configuration, the application directory **with the tenant's data in it**, the log directory, and the account the application ran as together with its group and home directory. Anything a still-active location shares is kept, so retiring one of a tenant's two applications leaves the account, the log directory and the tenant directory in place. The tenant directory itself goes only once it is empty. +* Deleting a location's entry from the inventory is not the same as retiring it. The entry simply disappears from the merged list, so the location stops being served and nothing on disk is touched. Only an explicit `state: 'absent'` deletes, which is what keeps a mistyped `path` from destroying data. +* The role does not manage TLS. The generated vHosts listen on port 80, for the topology where a reverse proxy in front terminates TLS and the network restricts who may reach this host. Where users connect to this Apache directly, TLS is mandatory, because basic authentication sends the credentials on every request; set `virtualhost_port: 443` per vHost and add the certificate directives through its `raw` key. + + +## Known Limitations + +* Shiny Server Open Source has no authentication, no session management and no per-application resource limits. Basic authentication in front of it cannot log out, expire a session, lock an account after failed attempts or offer a second factor. Where that is needed, Posit Connect or ShinyProxy with one container per user is the answer. +* The separation inside a single application is guaranteed by the application code, not by the platform. All visitors of one application share one R process; everything inside `server <- function(input, output, session)` is per session, everything in `global.R` or above `server()` is shared by all sessions of that application. +* The memory limit in the systemd drop-in protects the host, not the individual session. All sessions of all tenants share the budget, and the OOM killer picks a process inside the control group when it is exceeded. +* Only the x86_64 package is supported. Posit publishes no aarch64 build of Shiny Server. + + +## Dependent Roles + +Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/README.md) that installs this role runs these for you. Optional ones can be disabled via the playbook's skip variables. + +* R and the CRAN packages a Shiny application needs must be installed (role: [linuxfabrik.lfops.r](https://github.com/Linuxfabrik/lfops/tree/main/roles/r)). This role injects `shiny`, `rmarkdown`, `knitr` and `htmltools` into it, plus the `TMPDIR` the uploads land in. +* The reverse proxy in front of the tenants must be configured (role: [linuxfabrik.lfops.apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd)). This role injects one vHost per tenant, the `.htpasswd` entries, and the `proxy_http` and `proxy_wstunnel` modules that role leaves disabled by default. +* The `python3-passlib` library must be installed (role: [linuxfabrik.lfops.python](https://github.com/Linuxfabrik/lfops/tree/main/roles/python)), because the `.htpasswd` files are written with it. +* On RHEL-compatible systems, the `httpd_can_network_connect` SELinux boolean must be enabled (roles: [linuxfabrik.lfops.policycoreutils](https://github.com/Linuxfabrik/lfops/tree/main/roles/policycoreutils), [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). Without it Apache cannot reach the Shiny Server port. + + +## Requirements + +* Outbound HTTPS access from the Ansible controller to `download3.rstudio.org`. + +Manual steps: + +* Look up the current version on the [GitHub Tags page](https://github.com/rstudio/shiny-server/tags) and pin it in `shiny_server__version`. +* Deploy the application code below `shiny_server__shared_dir` and the per-tenant files below `shiny_server__tenants_dir` yourself, from Git or with the [files](https://github.com/Linuxfabrik/lfops/tree/main/roles/files) role. +* Optional: where users reach this Apache directly rather than through a proxy in front, obtain a certificate per tenant hostname (role: [linuxfabrik.lfops.acme_sh](https://github.com/Linuxfabrik/lfops/tree/main/roles/acme_sh)) and add the TLS directives to the vHost through its `raw` key. + + +## Multi-Tenant Setup + +Two tenants with two applications each need two vHosts, not four: the applications become `location` blocks inside the tenant's vHost, and one password per tenant covers all of them. + +### 1. Declare one Shiny Server location per tenant application + +Each entry becomes a `location` in `shiny-server.conf` and gets its own R worker and its own account. + +```yaml +shiny_server__locations__group_var: + - path: '/acme/app1' + app_dir: '/srv/shiny-server/tenants/acme/app1' + log_dir: '/var/log/shiny-server/acme' + run_as: 'shiny-acme' + - path: '/acme/app2' + app_dir: '/srv/shiny-server/tenants/acme/app2' + log_dir: '/var/log/shiny-server/acme' + run_as: 'shiny-acme' +``` + +### 2. Declare one vHost per tenant + +`locations` maps the URL path inside the vHost to the Shiny Server location behind it, so the tenant reaches its applications at `/app1` and `/app2` without the tenant name in the URL. + +```yaml +shiny_server__vhosts__group_var: + - conf_server_name: 'shiny-acme.example.com' + htpasswd: + - username: 'firstname.lastname' + password: 'linuxfabrik' + locations: + - shiny_location: '/acme/app1' + path: '/app1' + - shiny_location: '/acme/app2' + path: '/app2' +``` + +### 3. Lay out the application code + +The role creates the directories; the files are yours to deploy. Symlink the shared code file by file so that the working directory of the R process stays the tenant directory: + +``` +/srv/shiny-server/shared/app1/app.R +/srv/shiny-server/tenants/acme/app1/app.R -> ../../../shared/app1/app.R +/srv/shiny-server/tenants/acme/app1/config.R (tenant specific) +/srv/shiny-server/tenants/acme/app1/data/ (tenant specific) +``` + + +## Tags + +`shiny_server` + +* Installs Shiny Server. +* Creates the service account, the tenant accounts and the directory structure. +* Deploys the configuration, the systemd drop-in and the logrotate configuration. +* Ensures the service is in the desired state. +* Triggers: shiny-server.service reload, shiny-server.service restart. + +`shiny_server:configure` + +* Creates the directory structure and deploys the configuration and the systemd drop-in. +* Triggers: shiny-server.service reload, shiny-server.service restart. + +`shiny_server:logrotate` + +* Deploys the logrotate configuration of the application logs. +* Triggers: none. + +`shiny_server:state` + +* Manages the service state (start, stop, enable, disable). +* Triggers: none. + +`shiny_server:users` + +* Creates the accounts the applications run as. +* Triggers: none. + + +## Mandatory Role Variables + +`shiny_server__version` + +* The version of Shiny Server to install, as it appears in the package filename. Look it up on the [GitHub Tags page](https://github.com/rstudio/shiny-server/tags). +* Type: String. + +Example: +```yaml +# mandatory +shiny_server__version: '1.5.23.1030' +``` + + +## Optional Role Variables + +`shiny_server__conf_allow_app_override` + +* Allow an application directory to override server settings through a `.shiny_app.conf` next to the application code. +* Type: Bool. +* Default: `false` +* Deviates from the upstream default `true`: on a host where tenants own their application directories, this lets a tenant raise its own request limit, turn error sanitizing off, make its log files world readable and choose its own Python interpreter. + +`shiny_server__conf_app_idle_timeout` + +* Seconds an R process without a connection keeps running. `0` disables the cleanup. Keep it low for memory-hungry applications, otherwise abandoned sessions hold memory indefinitely. +* Type: Number. +* Default: `5` + +`shiny_server__conf_app_init_timeout` + +* Seconds an application is given to start. +* Type: Number. +* Default: `60` + +`shiny_server__conf_frame_options` + +* `X-Frame-Options` header sent on URLs served from Shiny applications, as a mitigation against clickjacking. +* Type: String. One of `allow`, `deny`, `sameorigin`. +* Default: `'sameorigin'` +* Deviates from the upstream default `allow`, which sends no header at all and lets any site embed the application in a frame. + +`shiny_server__conf_http_keepalive_timeout` + +* Seconds an HTTP connection stays open between requests. +* Type: Number. +* Default: `45` + +`shiny_server__conf_listen_host` + +* Address Shiny Server listens on. Leave this on the loopback: Shiny Server authenticates nobody and passes every client header through to the application, so any address reachable from the network is a way around the reverse proxy and its authentication. +* Type: String. +* Default: `'127.0.0.1'` +* Deviates from the upstream default `*`, which serves every interface. + +`shiny_server__conf_listen_port` + +* Port Shiny Server listens on. +* Type: Number. +* Default: `3838` + +`shiny_server__conf_preserve_logs` + +* Keep the log files of Shiny processes that exited successfully. Useful while hunting a startup error, together with the browser-only error message. Thousands of files accumulate quickly, which is why the role also rotates them. +* Type: Bool. +* Default: `false` + +`shiny_server__conf_run_as` + +* Account the applications run as unless a location names its own. This is the account the package creates. +* Type: String. +* Default: `'shiny'` + +`shiny_server__conf_sanitize_errors` + +* Send only generic error messages to the browser. Individual messages reach the user through `stop(safeError(e))` in the application code. +* Type: Bool. +* Default: `true` + +`shiny_server__conf_simple_scheduler` + +* Maximum number of concurrent requests per application before the server answers `503 Service Unavailable`. Lower it noticeably for applications that need a lot of memory. +* Type: Number. +* Default: `100` + +`shiny_server__download_url` + +* Full URL of the Shiny Server package. Empty derives it from `shiny_server__version` and the platform. Set it to install from a local mirror. +* Type: String. +* Default: `''` + +`shiny_server__htpasswd_dir` + +* Directory the per-tenant `.htpasswd` files are written to. +* Type: String. +* Default: `'/etc/httpd'` + +`shiny_server__log_dir` + +* Directory the application logs are written to, unless a location names its own. +* Type: String. +* Default: `'/var/log/shiny-server'` + +`shiny_server__logrotate` + +* Application log files are rotated `count` days before being removed. +* Type: Number. +* Default: `{{ logrotate__rotate | d(14) }}` + +`shiny_server__memory_max` + +* `MemoryMax` of the service. Shiny Server Open Source enforces no memory limit of its own, and a single R session can grow until the host is full without load or process count looking unusual. The R workers are started through `su` but stay in the control group of the service, so the cap reaches them. Empty leaves the setting out of the drop-in. +* Type: String. +* Default: `''` + +`shiny_server__service_enabled` + +* Enables or disables the service, analogous to `systemctl enable/disable --now`. +* Type: Bool. +* Default: `true` + +`shiny_server__service_state` + +* Changes the state of the service, analogous to `systemctl start/stop/restart/reload`. +* Type: String. One of `reloaded`, `restarted`, `started`, `stopped`. +* Default: `'started'` + +`shiny_server__shared_dir` + +* Directory holding the application code shared between the tenants. Readable for the accounts the applications run as through their membership in `shiny_server__shared_group`, invisible to the rest of the host. +* Type: String. +* Default: `'/srv/shiny-server/shared'` + +`shiny_server__shared_group` + +* Group that owns the shared code tree, and that every account an application runs as is a secondary member of. Keep it separate from `shiny_server__conf_run_as`: that account's group owns the application directory of every location left at the default `run_as`, so sharing one group for both purposes would let every tenant read those directories. +* Type: String. +* Default: `'shiny-shared'` + +`shiny_server__site_dir` + +* Root directory the role creates. `shiny_server__shared_dir` and `shiny_server__tenants_dir` live below it. The packaged Shiny Server serves this tree on `/`; this role does not, because it is handed out without any access control. A location with `site_dir` gives the same behaviour where it is actually wanted, behind the tenant's vHost. +* Type: String. +* Default: `'/srv/shiny-server'` + +`shiny_server__tasks_max` + +* `TasksMax` of the service, capping processes and threads in its control group. R forks for parallel work, so without it a runaway application is bounded only by systemd's `DefaultTasksMax`, which is 15% of `kernel.pid_max` and therefore commonly in the tens of thousands. Empty leaves the setting out of the drop-in. +* Type: Number. +* Default: `''` + +`shiny_server__tenants_dir` + +* Directory below which the per-tenant application directories live. +* Type: String. +* Default: `'/srv/shiny-server/tenants'` + +`shiny_server__tmp_dir` + +* `TMPDIR` of the R workers, injected into the `r` role. Shiny stores uploaded files in a per-session directory below `tempdir()` and refuses locations outside it, so this is where uploads land. Put it on its own partition where large uploads are expected, so that a full upload cannot fill the root file system. Shiny cleans the session directory up at the end of the session; where the R process dies first, the files stay behind, so data worth protecting needs a deletion concept of its own, for example with `systemd-tmpfiles`. +* Type: String. +* Default: `'/var/lib/shiny-server/tmp'` + +`shiny_server__users_home_dir` + +* Home directory root of the accounts the applications run as. Shiny Server does not start a worker whose account has no home directory. +* Type: String. +* Default: `'/var/lib/shiny-server/home'` + +Example: +```yaml +# optional +shiny_server__conf_allow_app_override: false +shiny_server__conf_app_idle_timeout: 60 +shiny_server__conf_app_init_timeout: 60 +shiny_server__conf_frame_options: 'sameorigin' +shiny_server__conf_http_keepalive_timeout: 45 +shiny_server__conf_listen_host: '127.0.0.1' +shiny_server__conf_listen_port: 3838 +shiny_server__conf_preserve_logs: true +shiny_server__conf_run_as: 'shiny' +shiny_server__conf_sanitize_errors: true +shiny_server__conf_simple_scheduler: 20 +shiny_server__download_url: 'https://mirror.example.com/shiny-server-1.5.23.1030-x86_64.rpm' +shiny_server__htpasswd_dir: '/etc/httpd' +shiny_server__log_dir: '/var/log/shiny-server' +shiny_server__logrotate: 7 +shiny_server__memory_max: '8G' +shiny_server__service_enabled: true +shiny_server__service_state: 'started' +shiny_server__shared_dir: '/srv/shiny-server/shared' +shiny_server__shared_group: 'shiny-shared' +shiny_server__site_dir: '/srv/shiny-server' +shiny_server__tasks_max: 200 +shiny_server__tenants_dir: '/srv/shiny-server/tenants' +shiny_server__tmp_dir: '/var/lib/shiny-server/tmp' +shiny_server__users_home_dir: '/var/lib/shiny-server/home' +``` + + +## Optional Role Variables - Tenants + +`shiny_server__locations__host_var` / `shiny_server__locations__group_var` + +* The applications Shiny Server serves, one entry per tenant application. Each becomes a `location` and gets its own R worker. +* Type: List of dictionaries. +* Default: `[]` +* Subkeys: + + * `path`: + + * Mandatory. The request path Shiny Server serves this entry at. + * Type: String. + + * `app_dir`: + + * Mandatory, unless `site_dir` is given. Directory of the Shiny application. + * Type: String. + + * `site_dir`: + + * Optional, alternative to `app_dir`. Directory tree served as a website, containing applications and static files. Served without access control. + * Type: String. + * Default: unset, so the location serves the single application in `app_dir` + + * `log_dir`: + + * Optional. Directory the application logs are written to. + * Type: String. + * Default: the value of `shiny_server__log_dir` + + * `run_as`: + + * Optional. Account the application runs as. Naming one other than `shiny_server__conf_run_as` makes the role create it, which is what extends the separation into the file system. + * Type: String. + * Default: the value of `shiny_server__conf_run_as` + + * `app_idle_timeout`: + + * Optional. Seconds this application's R process keeps running without a connection. + * Type: Number. + * Default: the value of `shiny_server__conf_app_idle_timeout` + + * `app_init_timeout`: + + * Optional. Seconds this application is given to start. + * Type: Number. + * Default: the value of `shiny_server__conf_app_init_timeout` + + * `directory_index`: + + * Optional. List the directory contents when no `index.html` is present. Only meaningful together with `site_dir`. + * Type: Bool. + * Default: `false` (Shiny Server disables directory indexes when the directive is absent) + + * `python`: + + * Optional. Python interpreter or virtual environment for a Shiny for Python application. + * Type: String. + * Default: unset, so the location serves R applications + + * `sanitize_errors`: + + * Optional. Send only generic error messages to the browser. + * Type: Bool. + * Default: the value of `shiny_server__conf_sanitize_errors` + + * `simple_scheduler`: + + * Optional. Maximum number of concurrent requests before this application answers 503. Lower it for memory-hungry applications. + * Type: Number. + * Default: the value of `shiny_server__conf_simple_scheduler` + + * `state`: + + * Optional. `present` or `absent`. `absent` deletes the application directory including the tenant's data, the log directory, and the account the application ran as, unless another location still uses them. Removing the entry from the inventory instead only stops the location from being served and deletes nothing. + * Type: String. + * Default: `'present'` + +`shiny_server__vhosts__host_var` / `shiny_server__vhosts__group_var` + +* The Apache httpd vHosts, one entry per tenant. Injected into the `apache_httpd` role as a `proxy` vHost. +* Type: List of dictionaries. +* Default: `[]` +* Subkeys: + + * `conf_server_name`: + + * Mandatory. The tenant's hostname. + * Type: String. + + * `locations`: + + * Mandatory. The applications this tenant reaches through this vHost. + * Type: List of dictionaries. + * Subkeys: + + * `shiny_location`: + + * Mandatory. The `path` of the `shiny_server__locations` entry behind it. + * Type: String. + + * `path`: + + * Optional. The request path inside this vHost. + * Type: String. + * Default: the value of `shiny_location` + + * `state`: + + * Optional. `present` or `absent`. + * Type: String. + * Default: `'present'` + + * `htpasswd`: + + * Optional. The accounts allowed into this tenant's applications. + * Type: List of dictionaries. + * Subkeys: + + * `username`: + + * Mandatory. The login name. + * Type: String. + + * `password`: + + * Mandatory for `state: 'present'`. The password. + * Type: String. + + * `state`: + + * Optional. `present` or `absent`. + * Type: String. + * Default: the `state` of the vHost + + * `allowed_http_methods`: + + * Optional. The HTTP methods this vHost allows. + * Type: List of strings. + * Default: the value of `shiny_server__vhost_default_allowed_http_methods` + + * `auth_enabled`: + + * Optional. Protect this vHost with HTTP basic authentication. + * Type: Bool. + * Default: the value of `shiny_server__vhost_default_auth_enabled` + + * `auth_name`: + + * Optional. The realm shown in the browser's login prompt. + * Type: String. + * Default: `'Shiny '` + + * `auth_user_file`: + + * Optional. Path of this tenant's `.htpasswd` file. + * Type: String. + * Default: `'{{ shiny_server__htpasswd_dir }}/.htpasswd-'` + + * `conf_proxy_preserve_host`: + + * Optional. `ProxyPreserveHost` of this vHost. + * Type: String. + * Default: `'On'` + + * `conf_proxy_timeout`: + + * Optional. `ProxyTimeout` of this vHost, in seconds. + * Type: Number. + * Default: the value of `shiny_server__vhost_default_conf_proxy_timeout` + + * `credentials_header_enabled`: + + * Optional. Pass the authenticated user into the application, readable there as `session$user`. + * Type: Bool. + * Default: the value of `shiny_server__vhost_default_credentials_header_enabled` + + * `enabled`: + + * Optional. Whether the vHost is linked into `sites-enabled`. + * Type: Bool. + * Default: `true` + + * `filename`: + + * Optional. Filename of the vHost configuration. + * Type: String. + * Default: the value of `conf_server_name` + + * `raw`: + + * Optional. Verbatim Apache directives appended to this vHost, for TLS certificates and anything else the subkeys do not cover. + * Type: String. + * Default: `''` + + * `virtualhost_port`: + + * Optional. Port of this vHost. + * Type: Number. + * Default: the value of `shiny_server__vhost_default_virtualhost_port` + + * `state`: + + * Optional. `present` or `absent`. + * Type: String. + * Default: `'present'` + +Example: +```yaml +# optional +shiny_server__locations__group_var: + - path: '/acme/app1' + app_dir: '/srv/shiny-server/tenants/acme/app1' + log_dir: '/var/log/shiny-server/acme' + run_as: 'shiny-acme' + app_idle_timeout: 900 + - path: '/globex/app1' + app_dir: '/srv/shiny-server/tenants/globex/app1' + log_dir: '/var/log/shiny-server/globex' + run_as: 'shiny-globex' + simple_scheduler: 20 +shiny_server__vhosts__group_var: + - conf_server_name: 'shiny-acme.example.com' + htpasswd: + - username: 'firstname.lastname' + password: 'linuxfabrik' + locations: + - shiny_location: '/acme/app1' + path: '/app1' + - conf_server_name: 'shiny-globex.example.com' + virtualhost_port: 443 + htpasswd: + - username: 'globex.admin' + password: 'linuxfabrik' + locations: + - shiny_location: '/globex/app1' + path: '/app1' + raw: | + SSLEngine on + SSLCertificateFile /etc/pki/tls/certs/shiny-globex.example.com.crt + SSLCertificateKeyFile /etc/pki/tls/private/shiny-globex.example.com.key +``` + + +## Optional Role Variables - vHost Defaults + +These apply to every generated vHost. Each can be overridden per vHost with the key of the same name without the `shiny_server__vhost_default_` prefix. + +`shiny_server__vhost_default_allowed_http_methods` + +* The HTTP methods the generated vHosts allow. +* Type: List of strings. +* Default: `['GET', 'HEAD', 'OPTIONS', 'POST']` +* `POST` is mandatory: file uploads and the SockJS fallback transports use it, and without it a `fileInput()` fails with a 405 the user interface does not explain. `HEAD` keeps header-only monitoring checks from answering 405. + +`shiny_server__vhost_default_auth_enabled` + +* Protect the generated vHosts with HTTP basic authentication. +* Type: Bool. +* Default: `true` + +`shiny_server__vhost_default_conf_proxy_timeout` + +* `ProxyTimeout` of the generated vHosts, in seconds. A WebSocket idles while R computes, and Apache drops it when this expires, which shows up in the browser as the application "greying out". Keep it above the longest expected computation. +* Type: Number. +* Default: `300` +* Deviates from the upstream default `60` (Apache's `Timeout`, which `ProxyTimeout` inherits when unset): a Shiny session carries no traffic while R works, so at the upstream value any computation longer than a minute kills the connection. + +`shiny_server__vhost_default_credentials_header_enabled` + +* Pass the user authenticated by Apache into the application through the `Shiny-Server-Credentials` header, where it is readable as `session$user`. Only safe because Shiny Server binds the loopback and the header is set, not added, so a value sent by the client is overwritten. This is not an authorization check: what the application does with the value is up to its own code. +* Type: Bool. +* Default: `true` + +`shiny_server__vhost_default_virtualhost_port` + +* Port of the generated vHosts. +* Type: Number. +* Default: `80` + +Example: +```yaml +# optional +shiny_server__vhost_default_allowed_http_methods: + - 'GET' + - 'HEAD' + - 'OPTIONS' + - 'POST' +shiny_server__vhost_default_auth_enabled: true +shiny_server__vhost_default_conf_proxy_timeout: 900 +shiny_server__vhost_default_credentials_header_enabled: true +shiny_server__vhost_default_virtualhost_port: 443 +``` + + +## Troubleshooting + +**An application greys out a short while after it loads** + +* The WebSocket was cut, usually by a timeout in the reverse proxy or a firewall in between. While R computes, no data flows over the connection and the proxy takes it for dead. Raise `conf_proxy_timeout` on the tenant's vHost above the longest expected computation. + +**A file upload aborts and the browser console shows a 405** + +* The reverse proxy does not let `POST` through, which is the only method Shiny accepts uploads on. Add it to `allowed_http_methods` on that vHost. + +**An application fails to start and the error is only visible in the browser** + +* The per-session logs below `/var/log/shiny-server/` are deleted once an application has started successfully. Set `shiny_server__conf_preserve_logs: true`, reload the service, and reproduce the error; the logs then stay. + +**An application log reports `there is no package called '...'`** + +* The R package is not installed in the system library, or the account the application runs as cannot read it. Check with `sudo --user= R --quiet -e ".libPaths()"` and add the package to `r__cran_packages__group_var`. + +**The run aborts with `A vHost passes Shiny-Server-Credentials, but Shiny Server listens on ...`** + +* The listener is reachable from the network, where a client can bypass Apache and set the identity header itself. Set `shiny_server__conf_listen_host` back to `127.0.0.1`, or turn the header off with `credentials_header_enabled: false` on the affected vHosts. + +**`session$user` is empty in the application** + +* The value arrives on the WebSocket upgrade request only, so `RequestHeader` must apply to it: the `early` flag would run the directive before authentication, and `%{REMOTE_USER}s` or `%{REMOTE_USER}e` both yield the literal `(null)`. The role writes the working form; a hand-written vHost has to use `expr=` as well. + +**The service will not start and `/var/log/shiny-server.log` gains no new lines at all** + +* systemd is refusing to run it. The unit ships `Restart=on-failure` with `StartLimitBurst=3` and `StartLimitInterval=45`, so after three failed starts within 45 seconds the process is no longer launched and therefore cannot log anything, which makes it look as though the log had stopped rather than the service. Clear it with `systemctl reset-failed shiny-server` and start again. The usual underlying cause is a configuration Shiny Server rejects; the reason for that is in the last lines the log did manage to write. + +**Orphaned R processes after a restart** + +* The unit uses `KillMode=process`, so systemd only stops the main process and leaves the workers to Shiny Server's own shutdown. Check with `systemctl status shiny-server` whether any survived. + + +## License + +[The Unlicense](https://unlicense.org/) + + +## Author Information + +[Linuxfabrik GmbH, Zurich](https://www.linuxfabrik.ch) diff --git a/roles/shiny_server/defaults/main.yml b/roles/shiny_server/defaults/main.yml new file mode 100644 index 00000000..fd729930 --- /dev/null +++ b/roles/shiny_server/defaults/main.yml @@ -0,0 +1,98 @@ +shiny_server__conf_allow_app_override: false # upstream default: true +shiny_server__conf_app_idle_timeout: 5 +shiny_server__conf_app_init_timeout: 60 +shiny_server__conf_frame_options: 'sameorigin' # upstream default: allow +shiny_server__conf_http_keepalive_timeout: 45 +shiny_server__conf_listen_host: '127.0.0.1' # upstream default: * (every interface) +shiny_server__conf_listen_port: 3838 +shiny_server__conf_preserve_logs: false +shiny_server__conf_run_as: 'shiny' +shiny_server__conf_sanitize_errors: true +shiny_server__conf_simple_scheduler: 100 + +# Derived from shiny_server__version and the platform in vars/RedHat.yml when empty. +shiny_server__download_url: '' + +shiny_server__htpasswd_dir: '/etc/httpd' +shiny_server__log_dir: '/var/log/shiny-server' +shiny_server__logrotate: '{{ logrotate__rotate | d(14) }}' + +# Resource limit for the systemd drop-in. Empty leaves the setting out of it. +shiny_server__memory_max: '' + +shiny_server__service_enabled: true +shiny_server__service_state: 'started' +shiny_server__shared_dir: '/srv/shiny-server/shared' + +# Must stay distinct from shiny_server__conf_run_as: that account's group owns the +# application directory of every location left at the default run_as, so merging the +# two would make those directories readable by all tenants. +shiny_server__shared_group: 'shiny-shared' + +# Root the role creates; shiny_server__shared_dir and shiny_server__tenants_dir live below it. +shiny_server__site_dir: '/srv/shiny-server' + +# Resource limit for the systemd drop-in. Empty leaves the setting out of it. +shiny_server__tasks_max: '' + +shiny_server__tenants_dir: '/srv/shiny-server/tenants' +shiny_server__tmp_dir: '/var/lib/shiny-server/tmp' + +# Shiny Server aborts the launch of a worker whose run_as user has no home directory, +# so these have to be real directories (lib/worker/app-worker.ts:127). +shiny_server__users_home_dir: '/var/lib/shiny-server/home' + +# --- Shiny Server locations --- +shiny_server__locations__combined_var: '{{ ( + shiny_server__locations__role_var + + shiny_server__locations__dependent_var + + shiny_server__locations__group_var + + shiny_server__locations__host_var + ) | linuxfabrik.lfops.combine_lod(unique_key="path") + }}' + +shiny_server__locations__dependent_var: [] +shiny_server__locations__group_var: [] +shiny_server__locations__host_var: [] +shiny_server__locations__role_var: [] + +# --- Apache httpd vHosts --- +shiny_server__vhosts__combined_var: '{{ ( + shiny_server__vhosts__role_var + + shiny_server__vhosts__dependent_var + + shiny_server__vhosts__group_var + + shiny_server__vhosts__host_var + ) | linuxfabrik.lfops.combine_lod(unique_key="conf_server_name") + }}' + +shiny_server__vhosts__dependent_var: [] +shiny_server__vhosts__group_var: [] +shiny_server__vhosts__host_var: [] +shiny_server__vhosts__role_var: [] + +# Applied to every generated vHost, overridable per vHost with the key of the same +# name without the `vhost_default_` prefix. +shiny_server__vhost_default_allowed_http_methods: + # POST carries the uploads and the SockJS fallback transports, HEAD the monitoring + # checks. + - 'GET' + - 'HEAD' + - 'OPTIONS' + - 'POST' +shiny_server__vhost_default_auth_enabled: true +shiny_server__vhost_default_conf_proxy_timeout: 300 # upstream default: 60 (Apache Timeout) +shiny_server__vhost_default_credentials_header_enabled: true +shiny_server__vhost_default_virtualhost_port: 80 + +# --- Injections into other roles --- +# These two are filled in by tasks/apache-httpd-vhosts.yml, which the setup playbook +# imports in its pre_tasks. Rendering the Apache configuration of an arbitrary number +# of tenants needs a Jinja loop, which a variable definition cannot carry. +shiny_server__apache_httpd__htpasswd__dependent_var: [] +shiny_server__apache_httpd__vhosts__dependent_var: [] + +# The worker reaches its environment only through Renviron.site, because Shiny Server +# starts it with `su --login`, which discards the environment of the service. +shiny_server__r__renviron_site__dependent_var: + - name: 'TMPDIR' + value: '{{ shiny_server__tmp_dir }}' diff --git a/roles/shiny_server/handlers/main.yml b/roles/shiny_server/handlers/main.yml new file mode 100644 index 00000000..0901e20f --- /dev/null +++ b/roles/shiny_server/handlers/main.yml @@ -0,0 +1,18 @@ +# Shiny Server re-reads its configuration on SIGHUP, and the unit maps ExecReload to it, so a +# configuration change costs no outage and needs no restart. +- name: 'shiny_server: reload shiny-server' + ansible.builtin.service: + name: 'shiny-server' + state: 'reloaded' + when: + - '__shiny_server__service_state_result is not defined or __shiny_server__service_state_result is not changed' + - 'shiny_server__service_state != "stopped"' + +- name: 'shiny_server: restart shiny-server' + ansible.builtin.service: + name: 'shiny-server' + state: 'restarted' + when: + - 'not (lfops__skip_restart_handlers | d(false) | bool)' + - '__shiny_server__service_state_result is not defined or __shiny_server__service_state_result is not changed' + - 'shiny_server__service_state != "stopped"' diff --git a/roles/shiny_server/meta/argument_specs.yml b/roles/shiny_server/meta/argument_specs.yml new file mode 100644 index 00000000..acec5406 --- /dev/null +++ b/roles/shiny_server/meta/argument_specs.yml @@ -0,0 +1,243 @@ +argument_specs: + main: + options: + + shiny_server__conf_allow_app_override: + type: 'bool' + required: false + default: false + description: 'Allow an application directory to override server settings through its own .shiny_app.conf.' + + shiny_server__conf_app_idle_timeout: + type: 'int' + required: false + default: 5 + description: 'Seconds an R process without a connection keeps running. 0 disables the cleanup.' + + shiny_server__conf_app_init_timeout: + type: 'int' + required: false + default: 60 + description: 'Seconds an application is given to start.' + + shiny_server__conf_frame_options: + type: 'str' + required: false + default: 'sameorigin' + choices: + - 'allow' + - 'deny' + - 'sameorigin' + description: 'X-Frame-Options header sent on URLs served from Shiny applications.' + + shiny_server__conf_http_keepalive_timeout: + type: 'int' + required: false + default: 45 + description: 'Seconds an HTTP connection stays open between requests.' + + shiny_server__conf_listen_host: + type: 'str' + required: false + default: '127.0.0.1' + description: 'Address Shiny Server listens on.' + + shiny_server__conf_listen_port: + type: 'int' + required: false + default: 3838 + description: 'Port Shiny Server listens on.' + + shiny_server__conf_preserve_logs: + type: 'bool' + required: false + default: false + description: 'Keep the log files of Shiny processes that exited successfully.' + + shiny_server__conf_run_as: + type: 'str' + required: false + default: 'shiny' + description: 'Account the applications run as unless a location names its own.' + + shiny_server__conf_sanitize_errors: + type: 'bool' + required: false + default: true + description: 'Send only generic error messages to the browser.' + + shiny_server__conf_simple_scheduler: + type: 'int' + required: false + default: 100 + description: 'Maximum number of concurrent requests per application before the server answers 503.' + + shiny_server__download_url: + type: 'str' + required: false + default: '' + description: 'Full URL of the Shiny Server package. Empty derives it from the version and the platform.' + + shiny_server__htpasswd_dir: + type: 'str' + required: false + default: '/etc/httpd' + description: 'Directory the per-tenant .htpasswd files are written to.' + + shiny_server__locations__dependent_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Shiny Server locations. Dependent-role injection.' + + shiny_server__locations__group_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Shiny Server locations. Group-level override.' + + shiny_server__locations__host_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Shiny Server locations. Host-level override.' + + shiny_server__log_dir: + type: 'str' + required: false + default: '/var/log/shiny-server' + description: 'Directory the application logs are written to.' + + shiny_server__logrotate: + type: 'int' + required: false + description: 'Number of rotated application log files to keep.' + + shiny_server__memory_max: + type: 'raw' + required: false + default: '' + description: 'MemoryMax of the service, covering the R workers. Empty leaves the setting out of the drop-in.' + + shiny_server__service_enabled: + type: 'bool' + required: false + default: true + description: 'Enables or disables the service.' + + shiny_server__service_state: + type: 'str' + required: false + default: 'started' + choices: + - 'reloaded' + - 'restarted' + - 'started' + - 'stopped' + description: 'Desired state of the shiny-server service.' + + shiny_server__shared_dir: + type: 'str' + required: false + default: '/srv/shiny-server/shared' + description: 'Directory holding the application code shared between tenants.' + + shiny_server__shared_group: + type: 'str' + required: false + default: 'shiny-shared' + description: 'Group that owns the shared code tree and that every run_as account is a member of.' + + shiny_server__site_dir: + type: 'str' + required: false + default: '/srv/shiny-server' + description: 'Root directory the role creates; the shared and tenant trees live below it.' + + shiny_server__tasks_max: + type: 'raw' + required: false + default: '' + description: 'TasksMax of the service, covering the R workers. Empty leaves the setting out of the drop-in.' + + shiny_server__tenants_dir: + type: 'str' + required: false + default: '/srv/shiny-server/tenants' + description: 'Directory below which the per-tenant application directories live.' + + shiny_server__tmp_dir: + type: 'str' + required: false + default: '/var/lib/shiny-server/tmp' + description: 'TMPDIR of the R workers, where uploaded files land.' + + shiny_server__users_home_dir: + type: 'str' + required: false + default: '/var/lib/shiny-server/home' + description: 'Home directory root of the per-location run_as users.' + + shiny_server__version: + type: 'str' + required: true + description: 'The version of Shiny Server to install.' + + shiny_server__vhost_default_allowed_http_methods: + type: 'list' + elements: 'str' + required: false + default: + - 'GET' + - 'HEAD' + - 'OPTIONS' + - 'POST' + description: 'HTTP methods the generated vHosts allow.' + + shiny_server__vhost_default_auth_enabled: + type: 'bool' + required: false + default: true + description: 'Protect the generated vHosts with HTTP basic authentication.' + + shiny_server__vhost_default_conf_proxy_timeout: + type: 'int' + required: false + default: 300 + description: 'ProxyTimeout of the generated vHosts, in seconds.' + + shiny_server__vhost_default_credentials_header_enabled: + type: 'bool' + required: false + default: true + description: 'Pass the authenticated user into the application as session$user.' + + shiny_server__vhost_default_virtualhost_port: + type: 'int' + required: false + default: 80 + description: 'Port of the generated vHosts.' + + shiny_server__vhosts__dependent_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Apache httpd vHosts, one per tenant. Dependent-role injection.' + + shiny_server__vhosts__group_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Apache httpd vHosts, one per tenant. Group-level override.' + + shiny_server__vhosts__host_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Apache httpd vHosts, one per tenant. Host-level override.' diff --git a/roles/shiny_server/tasks/apache-httpd-vhosts.yml b/roles/shiny_server/tasks/apache-httpd-vhosts.yml new file mode 100644 index 00000000..dd4bfdca --- /dev/null +++ b/roles/shiny_server/tasks/apache-httpd-vhosts.yml @@ -0,0 +1,8 @@ +# Builds the injections for the apache_httpd role. The setup playbook imports this file in its +# pre_tasks, because apache_httpd runs before shiny_server and a variable definition cannot +# carry the Jinja loop that renders the Apache configuration of an arbitrary number of tenants. + +- name: 'Build the apache_httpd vHost and htpasswd injections' + ansible.builtin.set_fact: + shiny_server__apache_httpd__htpasswd__dependent_var: '{{ lookup("ansible.builtin.template", "apache-httpd-htpasswd.yml.j2") | from_yaml | d([], true) }}' + shiny_server__apache_httpd__vhosts__dependent_var: '{{ lookup("ansible.builtin.template", "apache-httpd-vhosts.yml.j2") | from_yaml | d([], true) }}' diff --git a/roles/shiny_server/tasks/main.yml b/roles/shiny_server/tasks/main.yml new file mode 100644 index 00000000..0f407596 --- /dev/null +++ b/roles/shiny_server/tasks/main.yml @@ -0,0 +1,457 @@ +- block: + + - name: 'Set platform/version specific variables' + ansible.builtin.import_role: + name: 'shared' + tasks_from: 'platform-variables.yml' + + tags: + - 'always' + + +- block: + + - name: 'Check that every location defines an application or a site directory' + ansible.builtin.assert: + that: + - 'item["app_dir"] is defined or item["site_dir"] is defined' + quiet: true + fail_msg: 'The shiny_server location "{{ item["path"] }}" needs either app_dir or site_dir.' + loop: '{{ __shiny_server__active_locations }}' + loop_control: + label: '{{ item["path"] }}' + + # A site_dir hands out a whole directory tree, turning every application below it into a + # second, separately reachable URL. Where that tree contains another location's app_dir, the + # application is served twice, and the copy reached through the site_dir carries whatever the + # site_dir's location is protected by rather than its own. + - name: 'Check that no application directory lies below a site directory' + ansible.builtin.assert: + that: + - '__shiny_server__active_app_dirs + | select("match", item["site_dir"] | regex_escape ~ "/") | list | length == 0' + quiet: true + fail_msg: >- + The site directory "{{ item["site_dir"] }}" of location "{{ item["path"] }}" contains the application + directory of another location + ({{ __shiny_server__active_app_dirs | select("match", item["site_dir"] | regex_escape ~ "/") | join(", ") }}), + which would serve that application a second time under "{{ item["path"] }}". Move the application out + of that tree, or narrow the site directory. + loop: '{{ __shiny_server__active_locations | selectattr("site_dir", "defined") | list }}' + loop_control: + label: '{{ item["path"] }}' + + # Shiny Server Open Source authenticates nobody and passes every client header through to the + # R worker. On a listener reachable from the network, a client bypasses the reverse proxy and + # sets Shiny-Server-Credentials itself, which is the value the application reads as + # session$user. + - name: 'Check that the credentials header is only passed on a loopback listener' + ansible.builtin.assert: + that: + - 'shiny_server__conf_listen_host in ["127.0.0.1", "::1", "localhost"]' + quiet: true + fail_msg: >- + A vHost passes Shiny-Server-Credentials, but Shiny Server listens on + {{ shiny_server__conf_listen_host }}. Anyone reaching that address can set the header themselves and + give an application any identity. Set shiny_server__conf_listen_host to 127.0.0.1, or turn the header + off per vHost with credentials_header_enabled. + when: + - 'shiny_server__vhosts__combined_var | selectattr("credentials_header_enabled", "defined") | selectattr("credentials_header_enabled", "eq", true) | list | length > 0 + or (shiny_server__vhost_default_credentials_header_enabled | bool + and shiny_server__vhosts__combined_var | rejectattr("credentials_header_enabled", "defined") | list | length > 0)' + + tags: + - 'always' + + +- block: + + # Created here rather than left to the package, so that the configuration below can already + # reference the user and the service comes up with it in place. The package's postinstall + # skips its own useradd when the account exists. + - name: 'groupadd {{ shiny_server__conf_run_as }}' + ansible.builtin.group: + name: '{{ shiny_server__conf_run_as }}' + state: 'present' + system: true + + - name: 'useradd {{ shiny_server__conf_run_as }}' + ansible.builtin.user: + name: '{{ shiny_server__conf_run_as }}' + comment: 'Shiny Server' + group: '{{ shiny_server__conf_run_as }}' + home: '/home/{{ shiny_server__conf_run_as }}' + shell: '/bin/sh' + system: true + state: 'present' + + tags: + - 'shiny_server' + + +- block: + + - name: 'Combined locations' + ansible.builtin.debug: + var: 'shiny_server__locations__combined_var' + + # One system account per tenant, so that the separation also holds in the file system. The + # account needs no login shell: Shiny Server starts the worker as root with + # `su -s /bin/bash`, which overrides the shell of the account. It does need a home directory, + # because the launch is aborted when that is missing. + - name: 'mkdir -p {{ shiny_server__users_home_dir }}' + ansible.builtin.file: + path: '{{ shiny_server__users_home_dir }}' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + + # A group of its own for read access to the shared code tree, kept apart from the group of the + # service account. Were the service account's group used for both jobs, an application left at + # the default run_as would have its directory owned by the very group every tenant is a member + # of, and all of them could read it. + - name: 'groupadd {{ shiny_server__shared_group }}' + ansible.builtin.group: + name: '{{ shiny_server__shared_group }}' + state: 'present' + system: true + + - name: 'usermod --append --groups {{ shiny_server__shared_group }} {{ shiny_server__conf_run_as }}' + ansible.builtin.user: + name: '{{ shiny_server__conf_run_as }}' + groups: + - '{{ shiny_server__shared_group }}' + append: true + + - name: 'groupadd for the run_as users' + ansible.builtin.group: + name: '{{ item }}' + state: 'present' + system: true + loop: '{{ __shiny_server__run_as_users }}' + + # Its own primary group is what keeps the account out of the other tenants' directories, the + # secondary membership in the shared group is what lets it read the shared code tree, which is + # therefore group readable instead of world readable. + - name: 'useradd for the run_as users' + ansible.builtin.user: + name: '{{ item }}' + comment: 'Shiny Server tenant {{ item }}' + group: '{{ item }}' + groups: + - '{{ shiny_server__shared_group }}' + append: true + home: '{{ shiny_server__users_home_dir }}/{{ item }}' + shell: '/sbin/nologin' + system: true + state: 'present' + loop: '{{ __shiny_server__run_as_users }}' + + tags: + - 'shiny_server' + - 'shiny_server:users' + + +- block: + + - name: 'Gather package facts' + ansible.builtin.package_facts: # yamllint disable-line rule:empty-values + check_mode: false + + - name: 'Set installed version fact' + ansible.builtin.set_fact: + __shiny_server__installed_version: '{{ ansible_facts["packages"]["shiny-server"][0]["version"] | d("") }}' + + # Deploy the configuration before the package, not after. The package copies its own + # configuration only when none exists and then enables and starts the service straight away, + # so without this the service would come up once listening on every interface and serving + # /srv/shiny-server without access control. + - name: 'mkdir -p /etc/shiny-server' + ansible.builtin.file: + path: '/etc/shiny-server' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + + - block: + + # Posit publishes one RPM, built against CentOS 8, for the whole RHEL family. It declares no + # dependencies and brings its own Node.js runtime, which is why it runs unchanged on RHEL 8, + # 9 and 10. + - name: 'curl --output /tmp/ansible.{{ __shiny_server__package_filename }} {{ __shiny_server__download_url }}' # noqa risky-file-permissions (temporary file) + ansible.builtin.get_url: + url: '{{ __shiny_server__download_url }}' + dest: '/tmp/ansible.{{ __shiny_server__package_filename }}' + mode: 0o644 + delegate_to: 'localhost' + become: false + changed_when: false # not an actual config change on the target + check_mode: false # run task even if `--check` is specified + + - name: 'Copy /tmp/ansible.{{ __shiny_server__package_filename }} to the target' # noqa risky-file-permissions (temporary file) + ansible.builtin.copy: + src: '/tmp/ansible.{{ __shiny_server__package_filename }}' + dest: '/tmp/{{ __shiny_server__package_filename }}' + changed_when: false # the install task below reports the change + + # ansible.builtin.package cannot install a local file with the GPG check disabled, and Posit + # does not sign the package. + # + # This is also the update path: dnf resolves an install of a newer file as an upgrade of the + # installed package, and of an older one as a downgrade, which is all Posit documents. + # Verified on Rocky 9 with shiny-server 1.5.23.1030 -> 1.5.24.1038: dnf reports "Upgrading", + # the service is restarted by the package's own posttrans and comes back enabled, and + # shiny-server.conf, the systemd drop-in and shiny-server-apps are left untouched. + - name: 'dnf install /tmp/{{ __shiny_server__package_filename }}' + ansible.builtin.dnf: + name: '/tmp/{{ __shiny_server__package_filename }}' + disable_gpg_check: true # the vendor does not sign the package + state: 'present' + + - name: 'rm -f /tmp/{{ __shiny_server__package_filename }}' + ansible.builtin.file: + path: '/tmp/{{ __shiny_server__package_filename }}' + state: 'absent' + changed_when: false + + when: + - '__shiny_server__installed_version != shiny_server__version' + + tags: + - 'shiny_server' + + +- block: + + - name: 'mkdir -p {{ shiny_server__site_dir }}' + ansible.builtin.file: + path: '{{ shiny_server__site_dir }}' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + + # The shared tree holds the application code every tenant runs. Readable and traversable for + # all of them through their membership in the shared group, writable for none, and invisible + # to the rest of the host. + - name: 'mkdir -p {{ shiny_server__shared_dir }}' + ansible.builtin.file: + path: '{{ shiny_server__shared_dir }}' + state: 'directory' + owner: 'root' + group: '{{ shiny_server__shared_group }}' + mode: 0o750 + + - name: 'mkdir -p {{ shiny_server__tenants_dir }}' + ansible.builtin.file: + path: '{{ shiny_server__tenants_dir }}' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + + # Shiny writes uploads into a per-session directory below TMPDIR, created by the worker + # itself, so every run_as user has to be able to create one here. The sticky bit keeps one + # tenant from removing another one's files, as on /tmp. + - name: 'mkdir -p {{ shiny_server__tmp_dir }}' + ansible.builtin.file: + path: '{{ shiny_server__tmp_dir }}' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o1777 + + # 0750 with the tenant's own group is what keeps one tenant's R worker out of another + # tenant's application directory and data. + - name: 'mkdir -p the application directories' + ansible.builtin.file: + path: '{{ item["app_dir"] }}' + state: 'directory' + owner: 'root' + group: '{{ item["run_as"] | d(shiny_server__conf_run_as) }}' + mode: 0o750 + loop: '{{ __shiny_server__active_locations }}' + loop_control: + label: '{{ item["path"] }}' + when: + - 'item["app_dir"] is defined' + + # Ownership as the package sets it. With the default log_as_user, the log files themselves are + # created by the root server process and handed to the account the worker runs as. + - name: 'mkdir -p {{ shiny_server__log_dir }}' + ansible.builtin.file: + path: '{{ shiny_server__log_dir }}' + state: 'directory' + owner: '{{ shiny_server__conf_run_as }}' + group: '{{ shiny_server__conf_run_as }}' + mode: 0o755 + + # Shiny Server creates a missing log directory itself, but only at the moment the first worker + # starts. Creating them here lets logrotate find them before anyone has visited an application. + - name: 'mkdir -p the per-location log directories' + ansible.builtin.file: + path: '{{ item }}' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + loop: '{{ __shiny_server__log_dirs }}' + + tags: + - 'shiny_server' + - 'shiny_server:configure' + + +- block: + + - name: 'Deploy /etc/shiny-server/shiny-server.conf' + ansible.builtin.template: + backup: true + src: 'etc/shiny-server/shiny-server.conf.j2' + dest: '/etc/shiny-server/shiny-server.conf' + owner: 'root' + group: 'root' + mode: 0o644 + notify: 'shiny_server: reload shiny-server' + + - name: 'mkdir -p /etc/systemd/system/shiny-server.service.d/' + ansible.builtin.file: + path: '/etc/systemd/system/shiny-server.service.d/' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + + - name: 'Deploy /etc/systemd/system/shiny-server.service.d/z00-linuxfabrik.conf' + ansible.builtin.template: + backup: true + src: 'etc/systemd/system/shiny-server.service.d/z00-linuxfabrik.conf.j2' + dest: '/etc/systemd/system/shiny-server.service.d/z00-linuxfabrik.conf' + owner: 'root' + group: 'root' + mode: 0o644 + register: '__shiny_server__z00_linuxfabrik_result' + notify: 'shiny_server: restart shiny-server' + + - name: 'systemctl daemon-reload' # noqa no-handler would require flush_handlers here anyway + ansible.builtin.systemd: + daemon_reload: true + when: '__shiny_server__z00_linuxfabrik_result is changed' + + tags: + - 'shiny_server' + - 'shiny_server:configure' + + +- block: + + # A separate file, because the package overwrites /etc/logrotate.d/shiny-server on every + # update. That one covers the daemon log, this one the per-session application logs. + - name: 'Deploy /etc/logrotate.d/shiny-server-apps' + ansible.builtin.template: + backup: true + src: 'etc/logrotate.d/shiny-server-apps.j2' + dest: '/etc/logrotate.d/shiny-server-apps' + owner: 'root' + group: 'root' + mode: 0o644 + + tags: + - 'shiny_server' + - 'shiny_server:logrotate' + + +- block: + + - name: 'systemctl {{ shiny_server__service_enabled | bool | ternary("enable", "disable") }} shiny-server.service' + ansible.builtin.service: + name: 'shiny-server.service' + enabled: '{{ shiny_server__service_enabled | bool }}' + + - name: 'systemctl {{ shiny_server__service_state | regex_replace("p?ed$", "") }} shiny-server.service' + ansible.builtin.service: + name: 'shiny-server.service' + state: '{{ shiny_server__service_state }}' + register: '__shiny_server__service_state_result' + + tags: + - 'shiny_server' + - 'shiny_server:state' + + +- block: + + - name: 'Flush handlers so that the service can be used by other roles later' + ansible.builtin.meta: 'flush_handlers' + + tags: + - 'shiny_server' + - 'shiny_server:configure' + - 'shiny_server:state' + # so that a run limited to :users still reloads the configuration before the removals + # below take the accounts of the retired locations away + - 'shiny_server:users' + + +# Runs after the flush above on purpose: the reload has to have taken the retired locations out +# of the running configuration before their accounts and directories go, otherwise the worker +# still serving one of them holds its account open. +- block: + + - name: 'Removals for the retired locations' + ansible.builtin.debug: + msg: + app_dirs: '{{ __shiny_server__removable_app_dirs }}' + log_dirs: '{{ __shiny_server__removable_log_dirs }}' + run_as_users: '{{ __shiny_server__removable_run_as_users }}' + tenant_dirs: '{{ __shiny_server__removable_tenant_dirs }}' + + - name: 'rm -rf the application directories of the retired locations' + ansible.builtin.file: + path: '{{ item }}' + state: 'absent' + loop: '{{ __shiny_server__removable_app_dirs }}' + + - name: 'rm -rf the log directories of the retired locations' + ansible.builtin.file: + path: '{{ item }}' + state: 'absent' + loop: '{{ __shiny_server__removable_log_dirs }}' + + # rmdir removes an empty directory only, which is exactly the wanted guard: the tenant + # directory goes when its last application went, and stays when the operator keeps files there. + - name: 'rmdir the tenant directories of the retired locations' + ansible.builtin.command: 'rmdir {{ item | quote }}' + args: + removes: '{{ item }}' + register: '__shiny_server__rmdir_result' + changed_when: '__shiny_server__rmdir_result["rc"] == 0' + failed_when: + - '__shiny_server__rmdir_result["rc"] != 0' + - '"not empty" not in __shiny_server__rmdir_result["stderr"]' + loop: '{{ __shiny_server__removable_tenant_dirs }}' + + # force, because a worker that has not reached its app_idle_timeout yet still holds the + # account. It serves nothing at this point, the location is gone from the configuration, and + # it exits on its own; userdel would otherwise abort the run depending on the traffic the + # application happened to see. + - name: 'userdel --remove --force the accounts of the retired locations' + ansible.builtin.user: + name: '{{ item }}' + state: 'absent' + remove: true + force: true + loop: '{{ __shiny_server__removable_run_as_users }}' + + - name: 'groupdel the accounts of the retired locations' + ansible.builtin.group: + name: '{{ item }}' + state: 'absent' + loop: '{{ __shiny_server__removable_run_as_users }}' + + tags: + - 'shiny_server' + - 'shiny_server:configure' + - 'shiny_server:users' diff --git a/roles/shiny_server/templates/apache-httpd-htpasswd.yml.j2 b/roles/shiny_server/templates/apache-httpd-htpasswd.yml.j2 new file mode 100644 index 00000000..0305af84 --- /dev/null +++ b/roles/shiny_server/templates/apache-httpd-htpasswd.yml.j2 @@ -0,0 +1,12 @@ +{# + Renders the htpasswd list that is injected into the apache_httpd role, one file per tenant. +#} +{% for vhost in shiny_server__vhosts__combined_var | sort(attribute='conf_server_name') %} +{% set __auth_user_file = vhost['auth_user_file'] | d(shiny_server__htpasswd_dir ~ '/.htpasswd-' ~ vhost['conf_server_name']) %} +{% for user in vhost['htpasswd'] | d([]) %} +- username: '{{ user['username'] }}' + password: '{{ user['password'] | d('') }}' + path: '{{ __auth_user_file }}' + state: '{{ user['state'] | d(vhost['state'] | d('present')) }}' +{% endfor %} +{% endfor %} diff --git a/roles/shiny_server/templates/apache-httpd-vhosts.yml.j2 b/roles/shiny_server/templates/apache-httpd-vhosts.yml.j2 new file mode 100644 index 00000000..8687e6c5 --- /dev/null +++ b/roles/shiny_server/templates/apache-httpd-vhosts.yml.j2 @@ -0,0 +1,61 @@ +{# + Renders the vHost list that is injected into the apache_httpd role. This is a template and + not a variable definition because an arbitrary number of tenants, each with an arbitrary + number of applications, needs a Jinja loop to produce the Apache configuration. +#} +{% for vhost in shiny_server__vhosts__combined_var | sort(attribute='conf_server_name') %} +{% set __auth_user_file = vhost['auth_user_file'] | d(shiny_server__htpasswd_dir ~ '/.htpasswd-' ~ vhost['conf_server_name']) %} +{% set __auth_enabled = vhost['auth_enabled'] | d(shiny_server__vhost_default_auth_enabled) %} +{% set __credentials_header_enabled = vhost['credentials_header_enabled'] | d(shiny_server__vhost_default_credentials_header_enabled) %} +- by_role: 'shiny_server' + comment: 'vHost for the Shiny Server tenant {{ vhost['conf_server_name'] }}' + conf_proxy_preserve_host: '{{ vhost['conf_proxy_preserve_host'] | d('On') }}' + conf_proxy_timeout: {{ vhost['conf_proxy_timeout'] | d(shiny_server__vhost_default_conf_proxy_timeout) }} + conf_server_name: '{{ vhost['conf_server_name'] }}' + enabled: {{ vhost['enabled'] | d(true) | bool | ternary('true', 'false') }} + filename: '{{ vhost['filename'] | d(vhost['conf_server_name']) }}' + state: '{{ vhost['state'] | d('present') }}' + template: 'proxy' + virtualhost_port: {{ vhost['virtualhost_port'] | d(shiny_server__vhost_default_virtualhost_port) }} + allowed_http_methods: +{% for method in vhost['allowed_http_methods'] | d(shiny_server__vhost_default_allowed_http_methods) %} + - '{{ method }}' +{% endfor %} + raw: | +{% if __auth_enabled | bool %} + + AuthType Basic + AuthName "{{ vhost['auth_name'] | d('Shiny ' ~ vhost['conf_server_name']) }}" + AuthBasicProvider file + AuthUserFile {{ __auth_user_file }} + Require valid-user + +{% endif %} +{% if __credentials_header_enabled | bool %} + + # headers_module + # Pass the authenticated user into the application, where it is readable as session$user. + # "set" overwrites whatever the client sent; with "add" a client could name itself. Only + # the "expr=" form yields the user: %{REMOTE_USER}s reads SSL variables and %{REMOTE_USER}e + # the subprocess environment, both of which resolve to the literal "(null)" here. The + # "early" flag would run the directive before authentication and leave the value empty. + RequestHeader set Shiny-Server-Credentials "expr={\"user\":\"%{REMOTE_USER}\",\"groups\":[]}" +{% endif %} +{% for location in vhost['locations'] | d([]) if location['state'] | d('present') != 'absent' %} +{% set __path = location['path'] | d(location['shiny_location']) %} + + # {{ location['shiny_location'] }} +{% if __path | regex_replace('/$', '') | length %} + # Without this, the application is only reachable with the trailing slash. + RewriteRule ^{{ __path | regex_replace('/$', '') }}$ {{ __path | regex_replace('/$', '') }}/ [redirect,last] +{% endif %} + RewriteCond %{HTTP:Upgrade} =websocket [nocase] + RewriteRule ^{{ __path | regex_replace('/$', '') }}/(.*) ws://{{ shiny_server__conf_listen_host }}:{{ shiny_server__conf_listen_port }}{{ location['shiny_location'] | regex_replace('/$', '') }}/$1 [proxy,last] + RewriteCond %{HTTP:Upgrade} !=websocket [nocase] + RewriteRule ^{{ __path | regex_replace('/$', '') }}/(.*) http://{{ shiny_server__conf_listen_host }}:{{ shiny_server__conf_listen_port }}{{ location['shiny_location'] | regex_replace('/$', '') }}/$1 [proxy,last] +{% endfor %} +{% if vhost['raw'] | d() %} + +{{ vhost['raw'] | indent(4, first=True) }} +{% endif %} +{% endfor %} diff --git a/roles/shiny_server/templates/etc/logrotate.d/shiny-server-apps.j2 b/roles/shiny_server/templates/etc/logrotate.d/shiny-server-apps.j2 new file mode 100644 index 00000000..74f3a38e --- /dev/null +++ b/roles/shiny_server/templates/etc/logrotate.d/shiny-server-apps.j2 @@ -0,0 +1,16 @@ +# {{ ansible_managed }} +# 2026090801 +# +# The logrotate file the package ships as /etc/logrotate.d/shiny-server covers the daemon log +# /var/log/shiny-server.log only, and is overwritten on every package update, so the per-session +# application logs get their own file here. Without preserve_logs those logs are deleted when a +# worker exits cleanly; with it, thousands of files accumulate. +{{ shiny_server__log_dir }}/*.log {{ shiny_server__log_dir }}/*/*.log { + compress + copytruncate + daily + delaycompress + missingok + notifempty + rotate {{ shiny_server__logrotate }} +} diff --git a/roles/shiny_server/templates/etc/shiny-server/shiny-server.conf.j2 b/roles/shiny_server/templates/etc/shiny-server/shiny-server.conf.j2 new file mode 100644 index 00000000..529320ea --- /dev/null +++ b/roles/shiny_server/templates/etc/shiny-server/shiny-server.conf.j2 @@ -0,0 +1,54 @@ +# {{ ansible_managed }} +# 2026090901 + +{% if not shiny_server__conf_allow_app_override | bool %} +# Without this, an application directory may raise its own request limit, turn error +# sanitizing off, make its log files world readable and pick its own Python interpreter +# through a .shiny_app.conf next to the application code. +allow_app_override false; +{% endif %} +http_keepalive_timeout {{ shiny_server__conf_http_keepalive_timeout }}; +preserve_logs {{ shiny_server__conf_preserve_logs | bool | ternary('true', 'false') }}; +run_as {{ shiny_server__conf_run_as }}; + +server { + listen {{ shiny_server__conf_listen_port }} {{ shiny_server__conf_listen_host }}; + + app_idle_timeout {{ shiny_server__conf_app_idle_timeout }}; + app_init_timeout {{ shiny_server__conf_app_init_timeout }}; + frame_options {{ shiny_server__conf_frame_options }}; + sanitize_errors {{ shiny_server__conf_sanitize_errors | bool | ternary('true', 'false') }}; + simple_scheduler {{ shiny_server__conf_simple_scheduler }}; +{% for item in shiny_server__locations__combined_var | sort(attribute='path') if item['state'] | d('present') != 'absent' %} + + location {{ item['path'] }} { +{% if item['site_dir'] | d() %} + site_dir {{ item['site_dir'] }}; +{% else %} + app_dir {{ item['app_dir'] }}; +{% endif %} + log_dir {{ item['log_dir'] | d(shiny_server__log_dir) }}; +{% if item['run_as'] | d() %} + run_as {{ item['run_as'] }}; +{% endif %} +{% if item['app_idle_timeout'] is defined %} + app_idle_timeout {{ item['app_idle_timeout'] }}; +{% endif %} +{% if item['app_init_timeout'] is defined %} + app_init_timeout {{ item['app_init_timeout'] }}; +{% endif %} +{% if item['directory_index'] is defined %} + directory_index {{ item['directory_index'] | bool | ternary('on', 'off') }}; +{% endif %} +{% if item['python'] | d() %} + python {{ item['python'] }}; +{% endif %} +{% if item['sanitize_errors'] is defined %} + sanitize_errors {{ item['sanitize_errors'] | bool | ternary('true', 'false') }}; +{% endif %} +{% if item['simple_scheduler'] is defined %} + simple_scheduler {{ item['simple_scheduler'] }}; +{% endif %} + } +{% endfor %} +} diff --git a/roles/shiny_server/templates/etc/systemd/system/shiny-server.service.d/z00-linuxfabrik.conf.j2 b/roles/shiny_server/templates/etc/systemd/system/shiny-server.service.d/z00-linuxfabrik.conf.j2 new file mode 100644 index 00000000..8f5b5dc3 --- /dev/null +++ b/roles/shiny_server/templates/etc/systemd/system/shiny-server.service.d/z00-linuxfabrik.conf.j2 @@ -0,0 +1,30 @@ +# {{ ansible_managed }} +# 2026090901 +# +# The package writes its unit to /etc/systemd/system/shiny-server.service and overwrites it on +# every install and update, so every override lives here instead. +# +# Only the resource limits below are overridden. LimitNOFILE is deliberately not set: the +# packaged unit sets none either, so the service runs on systemd's default of 1024 soft and +# 524288 hard. Upstream's own init job asked for a million, but that was the legacy upstart job +# and nothing here has been measured against a real workload, so the default stands until +# something says otherwise. + +[Service] +{% if shiny_server__memory_max | string | length %} +# Shiny Server Open Source enforces no memory limit of its own, and a single R session can grow +# until the host is full, without load or process count looking unusual. The R workers are +# started through `su`, yet they stay in this control group, so the cap reaches them. +# +# It protects the host, not the individual session: every session of every tenant draws on the +# same budget, and on overrun the OOM killer picks a process inside the group, which may well +# belong to a tenant other than the one that caused it. +MemoryMax={{ shiny_server__memory_max }} +{% endif %} +{% if shiny_server__tasks_max | string | length %} +# Caps processes and threads in the control group. R forks for parallel work (parallel::mclapply +# and anything an application shells out to), so without this a runaway application is bounded +# only by systemd's DefaultTasksMax, which is 15% of kernel.pid_max and therefore commonly in +# the tens of thousands. +TasksMax={{ shiny_server__tasks_max }} +{% endif %} diff --git a/roles/shiny_server/vars/RedHat.yml b/roles/shiny_server/vars/RedHat.yml new file mode 100644 index 00000000..a24b74c2 --- /dev/null +++ b/roles/shiny_server/vars/RedHat.yml @@ -0,0 +1,13 @@ +# Posit builds one RPM against CentOS 8 and ships it for the whole RHEL family. It declares no +# dependencies at all (`rpm --query --requires` lists only /bin/sh and rpmlib entries) and +# carries its own Node.js runtime under /opt/shiny-server/ext/node/bin/, which is why the same +# file runs on RHEL 8, 9 and 10. +__shiny_server__package_dist: 'centos8' +__shiny_server__package_arch: 'x86_64' +__shiny_server__package_extension: 'rpm' + +__shiny_server__package_filename: 'shiny-server-{{ shiny_server__version }}-{{ __shiny_server__package_arch }}.{{ __shiny_server__package_extension }}' +__shiny_server__download_url: '{{ + shiny_server__download_url if shiny_server__download_url | length + else "https://download3.rstudio.org/" ~ __shiny_server__package_dist ~ "/" ~ __shiny_server__package_arch ~ "/" ~ __shiny_server__package_filename + }}' diff --git a/roles/shiny_server/vars/main.yml b/roles/shiny_server/vars/main.yml new file mode 100644 index 00000000..4435c114 --- /dev/null +++ b/roles/shiny_server/vars/main.yml @@ -0,0 +1,111 @@ +# Published for roles that run earlier in the play than shiny_server. vars/main.yml is loaded at +# play parse and visible to every role in the play, which defaults/main.yml also is, but keeping +# the injections that other roles consume in one place makes them easy to find. + +# The R packages a Shiny application needs at a minimum. rmarkdown and knitr are what the +# interactive-report half of Shiny is built on, htmltools is what shiny itself renders through. +shiny_server__r__cran_packages__dependent_var: + - name: 'htmltools' + - name: 'knitr' + - name: 'rmarkdown' + - name: 'shiny' + +# Apache modules the generated vHosts need on top of what apache_httpd enables by default. +# proxy_wstunnel carries the WebSocket a Shiny session runs on, proxy_http the ordinary +# requests. headers, proxy and rewrite are enabled by that role already. +shiny_server__apache_httpd__mods__dependent_var: + - filename: 'proxy_http' + enabled: true + state: 'present' + template: 'proxy_http' + + - filename: 'proxy_wstunnel' + enabled: true + state: 'present' + template: 'proxy_wstunnel' + + +# --- role-internal derived values --- + +# The locations that are actually deployed. Kept here so the state filtering is written once +# instead of in every loop. +__shiny_server__active_locations: '{{ + shiny_server__locations__combined_var + | rejectattr("state", "defined") | list + + (shiny_server__locations__combined_var + | selectattr("state", "defined") + | selectattr("state", "ne", "absent") | list) + }}' + +# The application directories in use, for the site_dir overlap check. +__shiny_server__active_app_dirs: '{{ + __shiny_server__active_locations + | map(attribute="app_dir", default="") | list + | reject("eq", "") | list + }}' + +# The tenant accounts to create: every run_as of an active location except the account the +# package already brings. +__shiny_server__run_as_users: '{{ + __shiny_server__active_locations + | map(attribute="run_as", default="") | list + | reject("eq", "") + | reject("eq", shiny_server__conf_run_as) + | unique | list + }}' + +# The per-location log directories to create. shiny_server__log_dir itself is handled +# separately, because it keeps the ownership the package gives it. +__shiny_server__log_dirs: '{{ + __shiny_server__active_locations + | map(attribute="log_dir", default="") | list + | reject("eq", "") + | reject("eq", shiny_server__log_dir) + | unique | list + }}' + +# The locations the inventory has retired. Dropping an entry from the inventory only stops the +# location from being served; removing what the role created takes an explicit state: 'absent'. +__shiny_server__absent_locations: '{{ + shiny_server__locations__combined_var + | selectattr("state", "defined") + | selectattr("state", "eq", "absent") | list + }}' + +# What a retired location leaves behind, minus everything an active location still uses. Two +# locations of one tenant commonly share an account and a log directory, so retiring one of them +# must not take the other one's account with it. The service account is never removed, it +# belongs to the package. +__shiny_server__removable_run_as_users: '{{ + (__shiny_server__absent_locations + | map(attribute="run_as", default="") | list + | reject("eq", "") + | reject("eq", shiny_server__conf_run_as) + | unique | list) + | difference(__shiny_server__run_as_users) + }}' + +__shiny_server__removable_app_dirs: '{{ + (__shiny_server__absent_locations + | map(attribute="app_dir", default="") | list + | reject("eq", "") | unique | list) + | difference(__shiny_server__active_locations | map(attribute="app_dir", default="") | list) + }}' + +__shiny_server__removable_log_dirs: '{{ + (__shiny_server__absent_locations + | map(attribute="log_dir", default="") | list + | reject("eq", "") | unique | list) + | difference(__shiny_server__log_dirs + [shiny_server__log_dir]) + }}' + +# The per-tenant directory above the application directories, removed only when the last +# application of that tenant is gone. rmdir refuses a non-empty directory, which is the guard. +__shiny_server__removable_tenant_dirs: '{{ + (__shiny_server__removable_app_dirs + | map("dirname") | list + | select("match", shiny_server__tenants_dir | regex_escape ~ "/") + | unique | list) + | difference(__shiny_server__active_locations + | map(attribute="app_dir", default="") | map("dirname") | list) + }}' From fa3ec079da6b5e81f78755a1365d337fcea507e9 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 9 Sep 2026 12:32:25 +0200 Subject: [PATCH 3/9] fix(roles/shiny_server): report no change when the tenant directory is already gone The command module's `removes` returns rc 0 without running the command, which is indistinguishable from a successful rmdir, so the task reported a change on every run and the idempotence test failed. stat now decides whether there is anything to remove. rmdir stays, because refusing a non-empty directory is the guard that keeps a tenant directory the operator still has files in. The remove sub-scenario seeds the state it retires. It cannot inherit it from the install sub-scenario, because Molecule gives every sub-scenario its own ephemeral directory and therefore its own SSH key, so the instances of one are unreachable from the other. The accounts in particular have to be seeded, because the role does not create an account for a location that is already absent and the assertions would otherwise pass vacuously. Verified on Rocky 8, 9 and 10: both sub-scenarios converge, verify and report no change on the second run. --- .../group_vars/systems_under_test.yml | 4 + .../setup_shiny_server/remove/molecule.yml | 14 ++- .../setup_shiny_server/remove/prepare.yml | 86 +++++++++++++++++++ roles/shiny_server/tasks/main.yml | 20 ++++- 4 files changed, 112 insertions(+), 12 deletions(-) create mode 100644 extensions/molecule/setup_shiny_server/remove/prepare.yml diff --git a/extensions/molecule/setup_shiny_server/remove/inventory/group_vars/systems_under_test.yml b/extensions/molecule/setup_shiny_server/remove/inventory/group_vars/systems_under_test.yml index 140f7741..41caee5a 100644 --- a/extensions/molecule/setup_shiny_server/remove/inventory/group_vars/systems_under_test.yml +++ b/extensions/molecule/setup_shiny_server/remove/inventory/group_vars/systems_under_test.yml @@ -3,6 +3,10 @@ apache_httpd__conf_server_admin: 'root@localhost' shiny_server__version: '1.5.23.1030' +# Set explicitly because prepare.yml seeds the accounts and needs the same path the role uses; +# a prepare playbook does not run the role and therefore does not see its defaults. +shiny_server__users_home_dir: '/var/lib/shiny-server/home' + # Tenant acme keeps app2 and retires app1: the account, the log directory and the tenant # directory are shared with app2 and therefore have to survive. Tenant globex is retired # completely, so its account, home directory, application directory, tenant directory and log diff --git a/extensions/molecule/setup_shiny_server/remove/molecule.yml b/extensions/molecule/setup_shiny_server/remove/molecule.yml index 7a4f2a86..68508f74 100644 --- a/extensions/molecule/setup_shiny_server/remove/molecule.yml +++ b/extensions/molecule/setup_shiny_server/remove/molecule.yml @@ -1,13 +1,11 @@ # Molecule scenario marker for the 'setup_shiny_server/remove' sub-scenario, run with # `molecule test --scenario-name setup_shiny_server/remove`. # -# It borrows the prepare step of the install sub-scenario, which derives the application -# directories from the locations in group_vars. Here that includes the retired ones, so the -# directories this run is meant to remove demonstrably exist before converge starts. -# -# In a real suite, run install first to create the state and then this one on the same -# instances (`molecule test --destroy=never`). As a standalone run it still exercises the -# removal path, because prepare creates what converge then takes away. +# It overrides the prepare step (see prepare.yml), which seeds the accounts, directories and +# applications that converge then retires. That state cannot be inherited by running the install +# sub-scenario first: Molecule gives every sub-scenario its own ephemeral directory and therefore +# its own SSH key, so the instances created by one are unreachable from the other. The +# sub-scenario is standalone by necessity. provisioner: playbooks: - prepare: '${MOLECULE_SCENARIO_DIRECTORY}/../install/prepare.yml' + prepare: '${MOLECULE_SCENARIO_DIRECTORY}/prepare.yml' diff --git a/extensions/molecule/setup_shiny_server/remove/prepare.yml b/extensions/molecule/setup_shiny_server/remove/prepare.yml new file mode 100644 index 00000000..c0ee6c7b --- /dev/null +++ b/extensions/molecule/setup_shiny_server/remove/prepare.yml @@ -0,0 +1,86 @@ +# Replaces the shared vm-prepare.yml for this sub-scenario (see molecule.yml), so it imports it +# first and only adds the precondition on top. +- name: 'Prepare VMs for Ansible' + ansible.builtin.import_playbook: '../../playbooks/vm-prepare.yml' + + +# This sub-scenario asserts that retiring a location removes what the role created for it, so +# that state has to exist before converge runs. It cannot be inherited from the install +# sub-scenario: Molecule gives every sub-scenario its own ephemeral directory and therefore its +# own SSH key, so the instances of one cannot be reached by the other. +# +# The accounts in particular have to be seeded here rather than left to the role, because the +# role does not create an account for a location that is already absent. Without them the +# removal would have nothing to remove and the assertions would pass vacuously. +- name: 'Seed the state an earlier install would have left behind' + hosts: 'systems_under_test' + gather_facts: false + vars: + __molecule__shiny_server_run_as_users: '{{ + shiny_server__locations__group_var + | map(attribute="run_as", default="") | list + | reject("eq", "") | unique | list + }}' + tasks: + + - name: 'groupadd the accounts of every location' + ansible.builtin.group: + name: '{{ item }}' + state: 'present' + system: true + loop: '{{ __molecule__shiny_server_run_as_users }}' + + - name: 'mkdir -p {{ shiny_server__users_home_dir }}' + ansible.builtin.file: + path: '{{ shiny_server__users_home_dir }}' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + + - name: 'useradd the accounts of every location' + ansible.builtin.user: + name: '{{ item }}' + group: '{{ item }}' + home: '{{ shiny_server__users_home_dir }}/{{ item }}' + shell: '/sbin/nologin' + system: true + state: 'present' + loop: '{{ __molecule__shiny_server_run_as_users }}' + + - name: 'mkdir -p the log directories' + ansible.builtin.file: + path: '{{ item }}' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + loop: '{{ shiny_server__locations__group_var | map(attribute="log_dir", default="") | list | reject("eq", "") | unique | list }}' + + - name: 'mkdir -p the application directories' + ansible.builtin.file: + path: '{{ item["app_dir"] }}' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + loop: '{{ shiny_server__locations__group_var }}' + loop_control: + label: '{{ item["path"] }}' + + - name: 'Deploy the tenant applications' + ansible.builtin.copy: + content: | + library(shiny) + ui <- fluidPage(textOutput('location')) + server <- function(input, output, session) { + output$location <- renderText(paste('location:', '{{ item["path"] }}')) + } + shinyApp(ui, server) + dest: '{{ item["app_dir"] }}/app.R' + owner: 'root' + group: 'root' + mode: 0o644 + loop: '{{ shiny_server__locations__group_var }}' + loop_control: + label: '{{ item["path"] }}' diff --git a/roles/shiny_server/tasks/main.yml b/roles/shiny_server/tasks/main.yml index 0f407596..8f17e428 100644 --- a/roles/shiny_server/tasks/main.yml +++ b/roles/shiny_server/tasks/main.yml @@ -422,16 +422,28 @@ # rmdir removes an empty directory only, which is exactly the wanted guard: the tenant # directory goes when its last application went, and stays when the operator keeps files there. + # + # stat decides whether there is anything to do, rather than the command module's `removes`. + # That returns rc 0 without running the command when the path is gone, which is + # indistinguishable from a successful rmdir and made the task report a change on every run. + - name: 'stat the tenant directories of the retired locations' + ansible.builtin.stat: + path: '{{ item }}' + register: '__shiny_server__tenant_dir_stat_result' + loop: '{{ __shiny_server__removable_tenant_dirs }}' + - name: 'rmdir the tenant directories of the retired locations' - ansible.builtin.command: 'rmdir {{ item | quote }}' - args: - removes: '{{ item }}' + ansible.builtin.command: 'rmdir {{ item["item"] | quote }}' register: '__shiny_server__rmdir_result' changed_when: '__shiny_server__rmdir_result["rc"] == 0' failed_when: - '__shiny_server__rmdir_result["rc"] != 0' - '"not empty" not in __shiny_server__rmdir_result["stderr"]' - loop: '{{ __shiny_server__removable_tenant_dirs }}' + loop: '{{ __shiny_server__tenant_dir_stat_result["results"] }}' + loop_control: + label: '{{ item["item"] }}' + when: + - 'item["stat"]["exists"]' # force, because a worker that has not reached its app_idle_timeout yet still holds the # account. It serves nothing at this point, the location is gone from the configuration, and From 34e5e7f128983aa734f7ba2f64337d64fa54d755 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 9 Sep 2026 12:44:41 +0200 Subject: [PATCH 4/9] test(roles/r): add a molecule scenario The role was only covered indirectly through setup_shiny_server. The scenario asks a live R process rather than reading the files the role wrote, because the chain that matters is file, R startup, option. It asserts both halves of the package manager configuration: without the user agent the repository silently serves sources instead of binaries. The path component is checked against the RHEL generation as well, since a wrong vars/RedHat.yml would satisfy a plain "packagemanager.posit.co" match; RHEL 8 has to resolve to centos8. It further confirms that a variable written to Renviron.site reaches the R process, which is the only way to give an R worker started by Shiny Server its environment, and that R can find pandoc, which comes from PowerTools on RHEL 8 but from EPEL on 9 and 10. Verified on Rocky 8, 9 and 10. --- extensions/molecule/r/converge.yml | 2 + .../group_vars/systems_under_test.yml | 11 +++ extensions/molecule/r/inventory/hosts.yml | 18 ++++ extensions/molecule/r/molecule.yml | 1 + extensions/molecule/r/verify.yml | 86 +++++++++++++++++++ 5 files changed, 118 insertions(+) create mode 100644 extensions/molecule/r/converge.yml create mode 100644 extensions/molecule/r/inventory/group_vars/systems_under_test.yml create mode 100644 extensions/molecule/r/inventory/hosts.yml create mode 100644 extensions/molecule/r/molecule.yml create mode 100644 extensions/molecule/r/verify.yml diff --git a/extensions/molecule/r/converge.yml b/extensions/molecule/r/converge.yml new file mode 100644 index 00000000..09e78aba --- /dev/null +++ b/extensions/molecule/r/converge.yml @@ -0,0 +1,2 @@ +- name: 'Converge r playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.r' diff --git a/extensions/molecule/r/inventory/group_vars/systems_under_test.yml b/extensions/molecule/r/inventory/group_vars/systems_under_test.yml new file mode 100644 index 00000000..3bb9a874 --- /dev/null +++ b/extensions/molecule/r/inventory/group_vars/systems_under_test.yml @@ -0,0 +1,11 @@ +# A CRAN package the Posit Public Package Manager ships a binary for and that +# carries compiled code, so the run also proves the build dependencies are not +# needed for the common case. +r__cran_packages__group_var: + - name: 'jsonlite' + +# Written to Renviron.site. verify.yml reads it back out of a fresh R process, +# which is the only way to confirm the file is actually picked up. +r__renviron_site__group_var: + - name: 'MOLECULE_R_PROBE' + value: '/var/lib/molecule-r-probe' diff --git a/extensions/molecule/r/inventory/hosts.yml b/extensions/molecule/r/inventory/hosts.yml new file mode 100644 index 00000000..1e236421 --- /dev/null +++ b/extensions/molecule/r/inventory/hosts.yml @@ -0,0 +1,18 @@ +# yamllint disable rule:empty-values + +# r targets 'lfops_r' (see playbooks/r.yml: hosts). +# +# Debian and Ubuntu are deliberately absent: the role supports the RHEL family +# (see COMPATIBILITY.md), where R comes from EPEL and the Posit Public Package +# Manager path component differs per generation, which is what this scenario +# covers. VMs rather than containers, because the role installs R and builds +# against the distribution's own toolchain. +lfops_r: + children: + systems_under_test: + +systems_under_test: + hosts: + rocky8-vm: + rocky9-vm: + rocky10-vm: diff --git a/extensions/molecule/r/molecule.yml b/extensions/molecule/r/molecule.yml new file mode 100644 index 00000000..1e47cbff --- /dev/null +++ b/extensions/molecule/r/molecule.yml @@ -0,0 +1 @@ +# Molecule scenario marker diff --git a/extensions/molecule/r/verify.yml b/extensions/molecule/r/verify.yml new file mode 100644 index 00000000..4dcaf930 --- /dev/null +++ b/extensions/molecule/r/verify.yml @@ -0,0 +1,86 @@ +# What only the running system can confirm is whether R itself honours what the +# role wrote. The files under $R_HOME/etc are not checked; a fresh R process is +# asked instead, because that is the whole chain: file, R startup, option. +- name: 'Verify R is installed and configured' + hosts: 'systems_under_test' + gather_facts: false + tasks: + + - name: 'R --version' + ansible.builtin.command: 'R --version' + changed_when: false + register: '__molecule__r_version_result' + + - name: 'Assert that R runs' + ansible.builtin.assert: + that: '"R version" in __molecule__r_version_result["stdout"]' + + # Rprofile.site is what makes install.packages() fetch prebuilt binaries. + # Without the user agent the package manager serves sources instead, which + # turns every install with compiled code into a local build, so both halves + # are asserted rather than just the repository. + - name: 'R -e getOption("repos"), getOption("HTTPUserAgent")' + ansible.builtin.command: >- + R --quiet --no-save --no-restore + -e 'cat(getOption("repos")[["P3M"]], "|", getOption("HTTPUserAgent"))' + changed_when: false + register: '__molecule__r_options_result' + + - name: 'Assert that R reads from the package manager with a user agent' + ansible.builtin.assert: + that: + - '"packagemanager.posit.co" in __molecule__r_options_result["stdout"]' + - '"R (" in __molecule__r_options_result["stdout"]' + fail_msg: 'R reports repos/HTTPUserAgent as: {{ __molecule__r_options_result["stdout"] }}' + + # The path component differs per RHEL generation and RHEL 8 uses "centos8", + # so a wrong vars/RedHat.yml would still look plausible above. + - name: 'Assert that the package manager path matches the RHEL generation' + ansible.builtin.assert: + that: '__molecule__r_expected_path in __molecule__r_options_result["stdout"]' + fail_msg: >- + Expected the {{ __molecule__r_expected_path }} repository on + {{ ansible_facts["distribution"] }} {{ ansible_facts["distribution_major_version"] }}, + got: {{ __molecule__r_options_result["stdout"] }} + vars: + __molecule__r_expected_path: >- + {{ (ansible_facts["distribution_major_version"] | int == 8) + | ternary("centos8", "rhel" ~ ansible_facts["distribution_major_version"]) }} + + # Renviron.site is the only way to reach an R process started by Shiny + # Server, so confirm a variable written there arrives in R's environment. + - name: 'R -e Sys.getenv("MOLECULE_R_PROBE")' + ansible.builtin.command: >- + R --quiet --no-save --no-restore -e 'cat(Sys.getenv("MOLECULE_R_PROBE"))' + changed_when: false + register: '__molecule__r_environ_result' + + - name: 'Assert that Renviron.site reaches the R process' + ansible.builtin.assert: + that: '"/var/lib/molecule-r-probe" in __molecule__r_environ_result["stdout"]' + + - name: 'R -e library(jsonlite)' + ansible.builtin.command: >- + R --quiet --no-save --no-restore + -e 'library(jsonlite); cat(toJSON(list(ok = TRUE)))' + changed_when: false + register: '__molecule__r_package_result' + + - name: 'Assert that the CRAN package is installed and loadable' + ansible.builtin.assert: + that: '"ok" in __molecule__r_package_result["stdout"]' + fail_msg: 'jsonlite did not load: {{ __molecule__r_package_result["stdout"] }}' + + # rmarkdown refuses to render without pandoc, and the package comes from + # PowerTools on RHEL 8 but from EPEL on 9 and 10, so it is worth asking R + # whether it can actually see the binary. + - name: 'R -e Sys.which("pandoc")' + ansible.builtin.command: >- + R --quiet --no-save --no-restore -e 'cat(Sys.which("pandoc"))' + changed_when: false + register: '__molecule__r_pandoc_result' + + - name: 'Assert that pandoc is on the PATH of the R process' + ansible.builtin.assert: + that: '"pandoc" in __molecule__r_pandoc_result["stdout"]' + fail_msg: 'R cannot find pandoc: {{ __molecule__r_pandoc_result["stdout"] }}' From e5a1791b7da672ea0efa45916f68eb37433850a6 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 9 Sep 2026 12:44:53 +0200 Subject: [PATCH 5/9] docs(extensions/molecule): correct the remove sub-scenario guidance The example scenario told the reader to run the install sub-scenario first and then the remove one on the same instances. That does not work: Molecule gives every sub-scenario its own ephemeral directory, vm-create.yml puts the SSH keypair in it, and prepare then fails with "Permission denied (publickey)" against instances another sub-scenario created. A remove sub-scenario is therefore standalone and has to seed whatever it asserts is gone. That matters most for state the role does not create for an entry that is already absent, a user account for instance, because the assertions would otherwise pass without proving anything. --- .../inventory/group_vars/systems_under_test.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/extensions/molecule/example/remove/inventory/group_vars/systems_under_test.yml b/extensions/molecule/example/remove/inventory/group_vars/systems_under_test.yml index a26d01b4..5d826a23 100644 --- a/extensions/molecule/example/remove/inventory/group_vars/systems_under_test.yml +++ b/extensions/molecule/example/remove/inventory/group_vars/systems_under_test.yml @@ -2,9 +2,15 @@ # state: 'absent'. This exercises the list-with-state removal pattern the LFOps # roles use, and gives verify.yml something concrete to assert is gone. # -# In a real suite, run the install sub-scenario first to create the state, then -# this one to remove it. As a standalone run it still demonstrates that a second -# converge with state: 'absent' leaves the host without the plugin/user. +# A remove sub-scenario cannot inherit the state of the install one. Molecule +# gives every sub-scenario its own ephemeral directory, and vm-create.yml puts +# the SSH keypair in it, so the instances created by one are unreachable from +# the other: prepare fails with "Permission denied (publickey)". A remove +# sub-scenario is therefore standalone and has to seed whatever it asserts is +# gone afterwards, in its own prepare.yml. Seeding matters most for state the +# role does not create for an entry that is already absent, such as a user +# account; without it the assertions pass without proving anything. +# extensions/molecule/setup_shiny_server/remove does this. example__version: '3.2.1' example__skip_repo_example: true From f1d796f9daf54d88fe336fcd47202b621698cad6 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 9 Sep 2026 17:14:22 +0200 Subject: [PATCH 6/9] feat(roles/rstudio_server): add a role to install and configure RStudio Server Installs RStudio Server Open Source from the vendor RPM. Users sign in with their account on the host through PAM and get an R session under their own account; the role installs and configures the service and creates the login group, the accounts themselves come from elsewhere. The configuration is deployed before the package, because the package writes rserver.conf, rsession.conf and /etc/pam.d/rstudio only when none exists and then starts the service immediately: otherwise the host would come up once on every interface with every account allowed to sign in. Posit installs rserver below /usr/lib, where it carries lib_t, so systemd keeps the service in init_t and the fork of a session is denied. Nobody gets an R session at all while SELinux is enforcing, and the browser just waits. The role publishes a bin_t file context for the binary, which the selinux role applies before the package is installed, and restorecons it afterwards for hosts that already had the package. The vendor PAM profile authenticates against pam_unix alone, so a directory user cannot sign in and pam_faillock never sees a failure. The role deploys its own, including password-auth. The package leaves an existing file alone. Posit signs the RPM, so it is installed with the signature check on and the public key ships with the role: it is published on a web page and on the keyservers, but at no stable URL a task could fetch. The build of every RHEL generation carries the same file name, so the copies on the controller and on the target carry the generation in theirs. RHEL 10 gets the RHEL 9 build, Posit publishes none for it. --- CHANGELOG.md | 1 + COMPATIBILITY.md | 1 + playbooks/README.md | 12 + playbooks/all.yml | 1 + playbooks/rstudio_server.yml | 78 +++++ roles/rstudio_server/README.md | 298 ++++++++++++++++++ roles/rstudio_server/defaults/main.yml | 26 ++ .../files/etc/pki/rpm-gpg/RPM-GPG-KEY-posit | 98 ++++++ roles/rstudio_server/handlers/main.yml | 29 ++ roles/rstudio_server/meta/argument_specs.yml | 163 ++++++++++ roles/rstudio_server/tasks/main.yml | 268 ++++++++++++++++ .../templates/etc/pam.d/rstudio.j2 | 20 ++ .../templates/etc/rstudio/rserver.conf.j2 | 39 +++ .../templates/etc/rstudio/rsession.conf.j2 | 21 ++ .../z00-linuxfabrik.conf.j2 | 24 ++ roles/rstudio_server/vars/RedHat.yml | 28 ++ roles/rstudio_server/vars/RedHat10.yml | 2 + roles/rstudio_server/vars/RedHat8.yml | 1 + roles/rstudio_server/vars/RedHat9.yml | 1 + roles/rstudio_server/vars/main.yml | 21 ++ 20 files changed, 1132 insertions(+) create mode 100644 playbooks/rstudio_server.yml create mode 100644 roles/rstudio_server/README.md create mode 100644 roles/rstudio_server/defaults/main.yml create mode 100644 roles/rstudio_server/files/etc/pki/rpm-gpg/RPM-GPG-KEY-posit create mode 100644 roles/rstudio_server/handlers/main.yml create mode 100644 roles/rstudio_server/meta/argument_specs.yml create mode 100644 roles/rstudio_server/tasks/main.yml create mode 100644 roles/rstudio_server/templates/etc/pam.d/rstudio.j2 create mode 100644 roles/rstudio_server/templates/etc/rstudio/rserver.conf.j2 create mode 100644 roles/rstudio_server/templates/etc/rstudio/rsession.conf.j2 create mode 100644 roles/rstudio_server/templates/etc/systemd/system/rstudio-server.service.d/z00-linuxfabrik.conf.j2 create mode 100644 roles/rstudio_server/vars/RedHat.yml create mode 100644 roles/rstudio_server/vars/RedHat10.yml create mode 100644 roles/rstudio_server/vars/RedHat8.yml create mode 100644 roles/rstudio_server/vars/RedHat9.yml create mode 100644 roles/rstudio_server/vars/main.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a22cd1..a934c4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:rstudio_server, playbook:rstudio_server**: Add a role and playbook to install RStudio Server Open Source, the browser-based R development environment. Users sign in with their account on the host and have to be a member of a group to be let in at all, the PAM profile covers directory users where the vendor's covers local ones only, and the R sessions can be given a memory and process budget. * **role:shiny_server, playbook:setup_shiny_server**: Add a role and playbook to install Shiny Server Open Source and serve several tenants from one host, each with its own hostname, password file, R worker and system account, behind an Apache httpd reverse proxy that also passes the authenticated user into the application. Shiny Server itself listens on the loopback only, because it authenticates nobody and hands every client header to the application. * **role:r, playbook:r**: Add a role and playbook to install R and pandoc, point R at the Posit Public Package Manager so that CRAN packages arrive as prebuilt binaries instead of being compiled on the host, and install the CRAN packages an application needs. * **role:borg_local, role:icinga2_master, role:nextcloud, role:schedule_reboot, role:tools**: `icinga2_master__downtime_api_user` creates an Icinga2 API user that may only schedule and remove downtimes, and the roles that set a downtime around a backup, a Nextcloud update or a reboot use it unless their own `*__icinga2_api_user_login` is set. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index d470f358..8bb98ca1 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -154,6 +154,7 @@ Which Ansible role is proven to run on which OS? | repo_rpmfusion | | | x | (x) | (x) | | | | | | repo_sury | x | x | - | - | | (x) | (x) | (x) | | | rocketchat | | | x | (x) | (x) | | | | Fedora 35 | +| rstudio_server | | | x | x | x | | | | x86_64 only, RHEL 10 uses the RHEL 9 build | | rsyslog | | | x | x | x | | | | | | schedule_reboot | x | x | x | x | x | (x) | (x) | (x) | | | selinux | (x) | (x) | x | x | x | (x) | (x) | (x) | | diff --git a/playbooks/README.md b/playbooks/README.md index 83aa84a6..a8fb0484 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -1028,6 +1028,18 @@ Calls the following roles (in order): * [repo_sury](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_sury) +## rstudio_server.yml + +Calls the following roles (in order): + +* [repo_baseos](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_baseos): `rstudio_server__skip_repo_baseos` +* [repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel): `rstudio_server__skip_repo_epel` +* [policycoreutils](https://github.com/Linuxfabrik/lfops/tree/main/roles/policycoreutils): `rstudio_server__skip_policycoreutils` +* [selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux): `rstudio_server__skip_selinux` +* [r](https://github.com/Linuxfabrik/lfops/tree/main/roles/r): `rstudio_server__skip_r` +* [rstudio_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/rstudio_server) + + ## rsyslog.yml Calls the following roles (in order): diff --git a/playbooks/all.yml b/playbooks/all.yml index 2b9c456c..cda263dd 100644 --- a/playbooks/all.yml +++ b/playbooks/all.yml @@ -122,6 +122,7 @@ - import_playbook: 'repo_remi.yml' - import_playbook: 'repo_rpmfusion.yml' - import_playbook: 'repo_sury.yml' +- import_playbook: 'rstudio_server.yml' - import_playbook: 'rsyslog.yml' - import_playbook: 'schedule_reboot.yml' - import_playbook: 'selinux.yml' diff --git a/playbooks/rstudio_server.yml b/playbooks/rstudio_server.yml new file mode 100644 index 00000000..b4490834 --- /dev/null +++ b/playbooks/rstudio_server.yml @@ -0,0 +1,78 @@ +- name: 'Playbook linuxfabrik.lfops.rstudio_server' + hosts: + - 'lfops_rstudio_server' + + pre_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-start.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'global-variables.yml' + tags: + - 'always' + + + roles: + + # CRB (RHEL 9 and 10) / PowerTools (RHEL 8) carries the development packages the CRAN + # packages are built against, and on RHEL 8 also pandoc itself. + - role: 'linuxfabrik.lfops.repo_baseos' + repo_baseos__crb_repo_enabled__dependent_var: '{{ + repo_epel__repo_baseos__crb_repo_enabled__dependent_var + }}' + # CRB is shipped in the Rocky default repo file (Rocky 9+, formerly from EPEL), so enable it there only. + when: + - 'ansible_facts["distribution"] == "Rocky" and ansible_facts["distribution_major_version"] | int >= 9' + - 'not rstudio_server__skip_repo_baseos | d(false)' + + # EPEL ships R, and pandoc on RHEL 9 and 10. + - role: 'linuxfabrik.lfops.repo_epel' + when: + - 'ansible_facts["os_family"] == "RedHat"' + - 'ansible_facts["distribution"] != "Fedora"' + - 'not rstudio_server__skip_repo_epel | d(false)' + + # === SELinux === + + - role: 'linuxfabrik.lfops.policycoreutils' + when: + - 'ansible_facts["os_family"] == "RedHat"' + - 'not rstudio_server__skip_policycoreutils | d(false)' + + # Runs before the rstudio_server role, so that the file context is in place when the package + # writes its files. Without it the service stays in systemd's own SELinux domain, where it + # cannot start a single R session. + - role: 'linuxfabrik.lfops.selinux' + selinux__fcontexts__dependent_var: '{{ + rstudio_server__selinux__fcontexts__dependent_var + }}' + when: + - 'ansible_facts["os_family"] == "RedHat"' + - 'not rstudio_server__skip_selinux | d(false)' + + # === Application === + + # rserver does not start without R. + - role: 'linuxfabrik.lfops.r' + when: + - 'not rstudio_server__skip_r | d(false)' + + - role: 'linuxfabrik.lfops.rstudio_server' + + + post_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-end.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'print-messages.yml' + tags: + - 'always' diff --git a/roles/rstudio_server/README.md b/roles/rstudio_server/README.md new file mode 100644 index 00000000..2a6c3224 --- /dev/null +++ b/roles/rstudio_server/README.md @@ -0,0 +1,298 @@ +# Ansible Role linuxfabrik.lfops.rstudio_server + +This role installs and configures [RStudio Server Open Source](https://posit.co/products/open-source/rstudio-server/), the browser-based R development environment from Posit. + +RStudio Server authenticates against the accounts of the host through PAM and runs the R session of a signed-in user under that user's own account. The role installs and configures the service; the accounts themselves come from wherever the host gets its users. + + +*Available in the next LFOps release.* + + +## How the Role Behaves + +* The package is downloaded on the Ansible controller and copied to the target, so targets without Internet access can be provisioned; the controller needs outbound access to `download2.rstudio.org`. It is fetched only when the installed version differs from `rstudio_server__version`. +* Posit signs its packages, so the role installs the RPM with the signature check on. The public key (`Posit Software, PBC `, fingerprint `8B65 E5A1 07BB EFE3 BA99 C597 51C0 B5BB 19F9 2D60`) ships with the role and is imported into the rpm keyring, because Posit publishes it on a web page and on the keyservers, but at no stable URL a task could fetch. It expires in May 2027 and has to be refreshed here before then. +* Posit builds one package per RHEL generation and publishes x86_64 only. RHEL 10 gets the RHEL 9 build: Posit publishes none for it, and the package needs nothing but `psmisc`, `sqlite` and `/bin/sh`, carrying its own Node.js and Boost below `/usr/lib/rstudio-server`. +* The configuration is deployed **before** the package. The package writes `rserver.conf`, `rsession.conf` and `/etc/pam.d/rstudio` only when none exists, and then enables and starts the service immediately, so without this the service would come up once on every interface with every account on the host allowed to sign in. +* Updating means raising `rstudio_server__version` and running the role again. The package manager resolves the file as an upgrade of the installed package, and a lower version as a downgrade. The package restarts the service as part of its own installation, which takes every running R session with it. +* `rserver` reads its configuration at startup only. `systemctl reload` sends `SIGHUP`, on which it re-reads the logging and the environment-variable configuration and nothing else, so the role restarts the service after a change to `rserver.conf`. The restart is preceded by a configuration check (`rserver --check-config`, or `--test-config` on releases that predate it), so a misspelled option aborts the run instead of leaving the service down. The check reports options `rserver` does not know; it does not validate their values, so a wrong value still surfaces at the restart. +* `rsession.conf` is read by each R session as it starts, and `/etc/pam.d/rstudio` on every sign-in, so neither needs a restart. A session that is already running keeps the settings it started with. +* The role does not create user accounts. It creates the group named in `rstudio_server__conf_auth_required_user_group` and lets `rserver` refuse everybody who is not a member. Deploy the accounts with the [login](https://github.com/Linuxfabrik/lfops/tree/main/roles/login) role, join the host to a directory with [freeipa_client](https://github.com/Linuxfabrik/lfops/tree/main/roles/freeipa_client), or bring them along however else the host gets its users. +* The role replaces the vendor's `/etc/pam.d/rstudio`, which authenticates against `pam_unix` alone and therefore knows neither a directory service nor `pam_faillock`. The role's file includes `password-auth`, the stack RHEL keeps for network services, so SSSD users can sign in and failed attempts are counted like everywhere else on the host. The package leaves an existing file alone, so this survives an update. +* RStudio Server Open Source never opens a PAM session, only `pam_authenticate()` and `pam_acct_mgmt()`. Nothing in the sign-in path creates a home directory, and a user whose home directory does not exist cannot work, so the accounts have to bring theirs along. +* On a host with SELinux enforcing, the `rserver` binary is labelled `bin_t`. Posit installs it below `/usr/lib`, where it carries `lib_t`, and systemd leaves a service whose binary is not `bin_t` in its own domain, `init_t`. From there the fork of a session is denied, so a user signs in and then waits forever for an R session that never starts, and the audit log holds `avc: denied { setpgid } for comm="rserver"`. The label moves the service into `unconfined_service_t`, the domain RHEL keeps for services that bring no policy of their own. The role applies the label with `restorecon`; the rule behind it comes from the `selinux` role. +* Both `rserver` and the R sessions log to syslog, which on these platforms is the journal (`journalctl --unit rstudio-server.service`). Nothing writes to a file, so the role deploys no logrotate configuration. +* The package overwrites `/usr/lib/systemd/system/rstudio-server.service` on every update, so unit settings go into a drop-in below `/etc/systemd/system/rstudio-server.service.d/` instead. +* The role does not manage TLS, the firewall or a reverse proxy. See "Known Limitations" for what that means in practice. + + +## Known Limitations + +* RStudio Server Open Source speaks plain HTTP and has no TLS of its own (`ssl-enabled` is a Workbench option). The password does not travel in the clear, because the browser encrypts it with the server's public key before posting it, but everything else does, the session cookie included. A TLS-terminating reverse proxy in front is therefore part of the deployment, which is what the defaults assume. Marking the cookies as secure keeps a browser from signing in over the unencrypted port, but it is no access control: the attribute is enforced by the browser, and a client that replays the CSRF cookie itself is unaffected. Keeping that port off the network stays the job of the listen address and the firewall. +* Only the x86_64 package is supported. Posit publishes no aarch64 build. +* The resource limits in the systemd drop-in protect the host, not the individual session. All sessions of all users share the budget, and the OOM killer picks a process inside the control group when it is exceeded. +* A user with a shell in RStudio has the rights of their account on the host. The terminal, `system()` and R's own file functions all run as that user, so RStudio is not a sandbox: treat an account here like an SSH account on the same host. + + +## Dependent Roles + +Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/README.md) that installs this role runs these for you. Optional ones can be disabled via the playbook's skip variables. + +* R must be installed (role: [linuxfabrik.lfops.r](https://github.com/Linuxfabrik/lfops/tree/main/roles/r)). `rserver` aborts at startup when it finds no R, and the package starts the service as part of its own installation, so the R installation has to be complete before this role runs. +* The EPEL repository must be enabled (role: [linuxfabrik.lfops.repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel)), because R comes from there. +* On RHEL-compatible systems, the SELinux file context of the `rserver` binary must be set (roles: [linuxfabrik.lfops.policycoreutils](https://github.com/Linuxfabrik/lfops/tree/main/roles/policycoreutils), [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). Skipping them on a host with SELinux enforcing leaves a server nobody can start a session on. + + +## Requirements + +* Outbound HTTPS access from the Ansible controller to `download2.rstudio.org`. + +Manual steps: + +* Look up the current version on the [RStudio Server download page](https://posit.co/download/rstudio-server/) and pin it in `rstudio_server__version`. It is the version as it appears in the package file name, for example `2026.08.2-200` in `rstudio-server-rhel-2026.08.2-200-x86_64.rpm`. +* Provide the user accounts, each with a home directory, and add them to the group in `rstudio_server__conf_auth_required_user_group`. +* Optional: put a TLS-terminating reverse proxy in front (roles [apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd), [acme_sh](https://github.com/Linuxfabrik/lfops/tree/main/roles/acme_sh)) and open the port in the firewall (role [firewall](https://github.com/Linuxfabrik/lfops/tree/main/roles/firewall)). + + +## Tags + +`rstudio_server` + +* Installs RStudio Server. +* Creates the login group and deploys the configuration, the PAM profile and the systemd drop-in. +* Ensures the service is in the desired state. +* Triggers: rstudio-server.service restart. + +`rstudio_server:configure` + +* Deploys `rserver.conf`, `rsession.conf`, the PAM profile and the systemd drop-in. +* Triggers: rstudio-server.service restart. + +`rstudio_server:state` + +* Manages the service state (start, stop, enable, disable). +* Triggers: none. + +`rstudio_server:users` + +* Creates the group a user has to be a member of to sign in. +* Triggers: none. + + +## Mandatory Role Variables + +`rstudio_server__version` + +* The version of RStudio Server to install, as it appears in the package file name. Look it up on the [RStudio Server download page](https://posit.co/download/rstudio-server/). +* Type: String. + +Example: +```yaml +# mandatory +rstudio_server__version: '2026.08.2-200' +``` + + +## Optional Role Variables + +`rstudio_server__conf_auth_cookies_force_secure` + +* Mark the authentication cookies as secure, so that a browser sends them over HTTPS only. `rserver` does this by itself on an HTTPS connection and never has one, because RStudio Server Open Source speaks plain HTTP, so behind a TLS-terminating proxy this is the only way the flag is ever set. +* Type: Bool. +* Default: `true` +* Deviates from the upstream default `false`: the role assumes a TLS-terminating reverse proxy in front. Where users reach `rserver` over plain HTTP instead, set this to `false`, otherwise the browser withholds the cookies and no sign-in completes; see "Troubleshooting". + +`rstudio_server__conf_auth_encrypt_password` + +* Have the browser encrypt the password with the server's public key before posting it to the sign-in form. Turn it off only where a proxy in front mangles the form, and then only on a connection that carries TLS. +* Type: Bool. +* Default: `true` + +`rstudio_server__conf_auth_minimum_user_id` + +* Lowest user id allowed to sign in. `auto` reads `UID_MIN` from `/etc/login.defs`, which is 1000 on all supported platforms. +* Type: String or Number. +* Default: `'auto'` + +`rstudio_server__conf_auth_required_user_group` + +* Group a user has to be a member of to sign in. Comma-separated for several groups, empty to let every account on the host in. The role creates the group. +* Type: String. +* Default: `'rstudio-users'` +* Deviates from the upstream default `''`: without a group, every account on the host can sign in, including the service accounts of everything else running on it. + +`rstudio_server__conf_auth_sign_in_throttle_seconds` + +* Seconds a user has to wait before signing in again after signing out. +* Type: Number. +* Default: `5` + +`rstudio_server__conf_auth_timeout_minutes` + +* Minutes a user stays signed in while idle. `0` switches to RStudio's legacy behaviour, where the session lasts as long as the browser is open, or as many days as `auth-stay-signed-in-days` says when the user ticks "Stay signed in"; set that option through `rstudio_server__rserver_conf_raw` if you want it. +* Type: Number. +* Default: `60` + +`rstudio_server__conf_limit_file_upload_size_mb` + +* Maximum size of a file uploaded through the web interface, in MB. `0` lifts the limit. Uploads land in the user's home directory, so this is the guard against one user filling the file system from the browser. +* Type: Number. +* Default: `0` + +`rstudio_server__conf_rsession_which_r` + +* Path to the R program the sessions run, for example `/usr/lib64/R/bin/R`. Empty lets `rserver` find R itself, which works wherever R is on the `PATH`. +* Type: String. +* Default: `''` + +`rstudio_server__conf_session_timeout_minutes` + +* Minutes an idle session is kept before it is suspended or ended. `0` disables the timeout, which means an abandoned session holds its memory until the service restarts. +* Type: Number. +* Default: `120` + +`rstudio_server__conf_session_timeout_suspend` + +* Suspend the session when the timeout is reached instead of ending it. Suspending writes the workspace to disk and frees the memory; the user finds their variables again on the next sign-in. +* Type: Bool. +* Default: `true` + +`rstudio_server__conf_www_address` + +* Address `rserver` listens on. `0.0.0.0` covers a reverse proxy on another host; set it to `127.0.0.1` where the proxy runs on this one, so that nothing else can reach the service. Either way the port carries no TLS, so restrict who may connect to it. +* Type: String. +* Default: `'0.0.0.0'` + +`rstudio_server__conf_www_enable_origin_check` + +* Reject a request whose `Origin` names a host other than the one it was sent to. +* Type: Bool. +* Default: `true` +* Deviates from the upstream default `false`: it is what keeps another site from driving a signed-in browser session. Where a proxy in front rewrites the `Host` header, the origin no longer matches and this has to be turned off or the proxy has to pass the original host through. + +`rstudio_server__conf_www_frame_origin` + +* Origin allowed to embed RStudio in a frame. `none` allows no embedding at all. +* Type: String. +* Default: `'none'` + +`rstudio_server__conf_www_port` + +* Port `rserver` listens on. +* Type: Number. +* Default: `8787` + +`rstudio_server__conf_www_root_path` + +* Path prefix a proxy in front adds to the URL, so that RStudio knows what it is served as. Empty means the server is reached at `/`. +* Type: String. +* Default: `''` + +`rstudio_server__conf_www_thread_pool_size` + +* Threads serving incoming requests. A sign-in occupies one of them for as long as PAM takes, so a host with many users wants more than the default. +* Type: Number. +* Default: `6` + +`rstudio_server__download_url` + +* Full URL of the RStudio Server package. Empty derives it from `rstudio_server__version` and the platform. Set it to install from a local mirror. +* Type: String. +* Default: `''` + +`rstudio_server__memory_max` + +* `MemoryMax` of the service unit, for example `8G`. Empty leaves the setting out of the drop-in. Every signed-in user gets an R process of their own, and R holds its data in memory, so a single careless `read.csv()` can otherwise take the host down. +* Type: String. +* Default: `''` + +`rstudio_server__pam_include` + +* PAM stack `/etc/pam.d/rstudio` includes for authentication and account checks. `password-auth` is the stack RHEL keeps for network services and covers local accounts, SSSD and `pam_faillock`. +* Type: String. +* Default: `'password-auth'` + +`rstudio_server__pam_minimum_uid` + +* Lowest user id PAM lets through, checked before the password is. This is what keeps a stream of failed root sign-ins through the web interface from tripping `pam_faillock` on root and locking it out everywhere else on the host. +* Type: Number. +* Default: `1000` + +`rstudio_server__rserver_conf_raw` + +* Verbatim content appended to `rserver.conf`, for the options this role has no variable for. `rserver --help` lists them all. +* Type: String. +* Default: `''` + +`rstudio_server__rsession_conf_raw` + +* Verbatim content appended to `rsession.conf`, for the options this role has no variable for. `rsession --help` lists them all. +* Type: String. +* Default: `''` + +`rstudio_server__service_enabled` + +* Start `rstudio-server.service` at boot. +* Type: Bool. +* Default: `true` + +`rstudio_server__service_state` + +* Runtime state of `rstudio-server.service`. Note that `reloaded` re-reads the logging and environment-variable configuration only, not `rserver.conf`. +* Type: String. One of `reloaded`, `restarted`, `started`, `stopped`. +* Default: `'started'` + +`rstudio_server__tasks_max` + +* `TasksMax` of the service unit. Empty leaves the setting out of the drop-in. R forks for parallel work, and without a cap the host is bounded only by systemd's `DefaultTasksMax`, which is 15% of `kernel.pid_max`. +* Type: String. +* Default: `''` + +Example: +```yaml +# optional +rstudio_server__conf_auth_required_user_group: 'rstudio-users' +rstudio_server__conf_auth_timeout_minutes: 60 +rstudio_server__conf_www_address: '127.0.0.1' +rstudio_server__memory_max: '8G' +rstudio_server__tasks_max: 500 +``` + + +## Troubleshooting + +**Sign-in answers "Temporary server error, please try again", and the journal says `Failed to validate sign-in with invalid CSRF form`** + +* The browser reaches `rserver` over plain HTTP while `rstudio_server__conf_auth_cookies_force_secure` is on. Every cookie is then marked `secure`, the CSRF cookie included, so the browser withholds it and the check fails before the password is looked at. Put a TLS-terminating proxy in front, or set the variable to `false` for a deployment that deliberately runs without one. + + +**The service does not start, and the journal says `R doc dir (/usr/share/doc/R) not found`** + +* `rserver` validates the R installation at startup and refuses to run without R's documentation directory. A host that installs packages with `tsflags=nodocs` in its `dnf.conf`, which many minimal and cloud images do, has R without it. Reinstall R with `dnf reinstall --setopt=tsflags= R`. The role checks this before it installs the package and aborts with the same advice. + +**A user of the directory service cannot sign in, a local user can** + +* The vendor's PAM profile authenticates against `pam_unix` only. Check that `/etc/pam.d/rstudio` is the one this role deploys and that `rstudio_server__pam_include` names a stack that includes `pam_sss`. + +**A user with the right password is refused, and the journal says `they do not belong to one of the required groups`** + +* The account is not a member of the group in `rstudio_server__conf_auth_required_user_group`. Add it there; a new membership takes effect on the next sign-in, without restarting anything. + +**A user signs in, the browser then waits and no session appears** + +* Check `journalctl --unit rstudio-server.service` for `exited with status 256 before a connection was made`. On an enforcing host this is the missing `bin_t` label on `/usr/lib/rstudio-server/bin/rserver`: `ps -eZ | grep rserver` shows the service in `init_t` instead of `unconfined_service_t`. Run the role with the `selinux` role enabled, or set the context by hand with `semanage fcontext --add --type bin_t /usr/lib/rstudio-server/bin/rserver`, `restorecon -v /usr/lib/rstudio-server/bin/rserver` and a restart of the service. The matching denial is `avc: denied { setpgid } for comm="rserver" ... tclass=process` in `/var/log/audit/audit.log`. + + +**`rstudio-server verify-installation` reports `Server is running and must be stopped before running verify-installation`** + +* That command starts a server of its own and therefore needs the port. Stop the service, run it, start the service again. + + +## License + +[The Unlicense](https://unlicense.org/) + + +## Author Information + +[Linuxfabrik GmbH, Zurich](https://www.linuxfabrik.ch) diff --git a/roles/rstudio_server/defaults/main.yml b/roles/rstudio_server/defaults/main.yml new file mode 100644 index 00000000..f76592e7 --- /dev/null +++ b/roles/rstudio_server/defaults/main.yml @@ -0,0 +1,26 @@ +rstudio_server__conf_auth_cookies_force_secure: true # upstream default: false +rstudio_server__conf_auth_encrypt_password: true +rstudio_server__conf_auth_minimum_user_id: 'auto' +rstudio_server__conf_auth_required_user_group: 'rstudio-users' # upstream default: '' (every account) +rstudio_server__conf_auth_sign_in_throttle_seconds: 5 +rstudio_server__conf_auth_timeout_minutes: 60 +rstudio_server__conf_limit_file_upload_size_mb: 0 +rstudio_server__conf_rsession_which_r: '' +rstudio_server__conf_session_timeout_minutes: 120 +rstudio_server__conf_session_timeout_suspend: true +rstudio_server__conf_www_address: '0.0.0.0' +rstudio_server__conf_www_enable_origin_check: true # upstream default: false +rstudio_server__conf_www_frame_origin: 'none' +rstudio_server__conf_www_port: 8787 +rstudio_server__conf_www_root_path: '' +rstudio_server__conf_www_thread_pool_size: 6 +# Derived from rstudio_server__version and the platform in vars/RedHat.yml when empty. +rstudio_server__download_url: '' +rstudio_server__memory_max: '' +rstudio_server__pam_include: 'password-auth' +rstudio_server__pam_minimum_uid: 1000 +rstudio_server__rserver_conf_raw: '' +rstudio_server__rsession_conf_raw: '' +rstudio_server__service_enabled: true +rstudio_server__service_state: 'started' +rstudio_server__tasks_max: '' diff --git a/roles/rstudio_server/files/etc/pki/rpm-gpg/RPM-GPG-KEY-posit b/roles/rstudio_server/files/etc/pki/rpm-gpg/RPM-GPG-KEY-posit new file mode 100644 index 00000000..cd8600ba --- /dev/null +++ b/roles/rstudio_server/files/etc/pki/rpm-gpg/RPM-GPG-KEY-posit @@ -0,0 +1,98 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGRr4rMBEADUlzsA78RMj7WiPfJf+n1HSajArYYfbpckEZXbHBV/ohtoL1gg +i2P8i8qVmKyMZHYzXYBCEd5QFhy/txU7G1ICSSKauuLL+S0sc6GltIGwzJWt8xpG +hx/ibaIDfIGKkEfcKmcvKd+M59xRJO5qNS2dIldHJLNUr0BN6xF16lf2CMAS0T+3 +u6hakaGTh2PDuFCEX8eBq47h26iG8NyTruh/faNO/QoN7JZ3f8gV3gtut35DuWhh +se5MnvPv8EP1CycXJeMyWKVdU5i/ez88qQQNDKDPbBtG+38kcAs9H37Pm0fKGGoP +uUHgR8sQ1hMZ7ZdW6CbxTHitP6qFbo9pCJ1NieXf2KSiDmk/VOrtF9mlfPu+kIYB +T6WxEMva0Rb4NXESZpOgnPleOQOmo6v77n8lMUjIFpzbJCVvm391zuOAk52I3HmJ +HnBftepdqrjjzW/EG9+zhaKwMgvEk7VAKNq4vkkw4V/q1lbzRBQXJ3Ile0+AZJCf +0TnbvqnIL1wcC1932rdJRm7RCHoDdkhzmBloin9uwsPmmrbNPByz0D9HjtH2iRQV +ES+F52oLaAxzsHbFaRhyRi0UXGMnYEtdml0cL1wcGH9/XsHo+OASDOzS1a7xDbNx +e+gQP/YVze80/54rNi5RLdzR6eSFoOfpCEGC93kwKkUqEPGEdC15PO+S5QARAQAB +tCxQb3NpdCBTb2Z0d2FyZSwgUEJDIDxzZWN1cml0eS10ZWFtQHBvc2l0LmNvPokC +VAQTAQgAPgIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgBYhBItl5aEHu+/jupnF +l1HAtbsZ+S1gBQJmRiMeBQkHhh8/AAoJEFHAtbsZ+S1gcHsP/0hxGNeIqJtSajj0 +Kq41AxssNEEv2xhox7PkYpbAOcQFUJvlIKIJxDWytD9zZkz+u7nGgY8FUhMoMEN9 +qGZqYV8yuu4Tg4RV73GxU/6wrOijnWxCY6g/xEPdaTC+fNCuvUAzZQL4ZI/GO+25 +9Gc0w3TazpGMUPK9RPr2ud5lJcOGV3C1AqG5QC9GOxsUNsU2cvUH0gmbbEh2L4Ix +H8qkERZWXYP8pZdnmecjthJ4ySgtgqh10JBQswvRMO6rgTDgkJ49gX6V9c54Cap7 +diSAjMDzI/FgRFJCME41pxjQ3OWnYGtKgvX3BtKHpLWsaITtGtsVciraNn5prF/L +uKJV3vzadpebtTqZgKpBiCpSTSW8kPrEyqLeeDTbmvjcWSxU6BJYVgabXVi6DwZ/ +pabG3eOKbIhk3MGYvVXB2pCPv44qz2bQIif0nQ2xqJ2nMCgUVVa2TJx8klmDwOJo +rqEmLOyAxPAWXbl2tqyFq7WnPovULUUNUCFf4o+t8nmQAV4xUvFhSpNZ8YKFVJ36 +pvj8vQ3zUAwACnOAdJGC/A+/aUWx0/SIw7kFEZHs91ppMzfEezFQ5VWqFmZGYNlQ +SRjzMs8fVwNldc/VPrBC2OG9VetWUZ0jBFSPV0zunewigBho9lyyQ2w+H3MPylkB +Dmnt+12tI0YUCfJE5Q2wsOvkxQz6uQINBGRr4rMBEADFnLw/+gZQqZlu6A2FIEf3 +8H2RpNfdxXV2uDHNCZSMfgRtq+kAwfkqfiVMsTOA8TcLFS26gCoeREOfmmcCxu0C +nLTUZGPh+LSIzrvjfjaq9DF625lWp/dI9MKy1JmAO4E1BQgsYMKKE+CW4edAU/uy +wxxWC23Lh9s0ZLXotGl0lBtOxrh1UmukC3oIJeAVkrMvZlnAPYFOOm1uflA9JwGJ ++ZPGYtgUFLAVkByM9cHh8Co5BW2aigF+tfrgllFSn/FvQEwXi4aO6Kik3hRTsWLm +8cA7K0EihMkgj1CH/wZCVEVN8Gs0TXV9n4PD3C3oP+abBr4e3G3VnzABcrYarl+e +Hy1icbybgNwGOXowKkVHxR7Y+8El8L8nCWnoU1fLnJngsbw1TC8vmVnhUoGADJS/ +C2TWUOXhR9OuOSsKj6tEvEv9XLs0PAiiPK4Bh0dQxqSYsN4uCYJZka+mqg+5Hf2p +glnta8aC4/VdQkIVHVyDTvIMOm7mfqZsM+roVPyKdbXfXmyjfffgFt7/FBxk57kS +qS7PbbalK2C9AfU+hqmZ3dJJT+g87c5+PMQbGEbSbNktWG8btQYFw5/7B2EZSYZr +e7Ry7ySLQkFD3wFjAbSy9LeJdzdEz58odQkD6e1Q+MIPnh6eRBKPe4izrjBxLWZt +yRV/i4QIjjfix6qq26yy5wARAQABiQI8BBgBCAAmAhsMFiEEi2XloQe77+O6mcWX +UcC1uxn5LWAFAmZGI4YFCQeHFc8ACgkQUcC1uxn5LWB+XxAAtGJ+L8qq3Df3MLte +G+Zg2Rwf7NtPOBgEpw5b/jTclrDwvynC4/o1KQ44sMwPSn0xoyDdWrX30FTBywcV +HrYCh0hBa++HgQiXY2rNM2d0UX+LiZ0B5T6WVTd8wvIdkNlfi9MDHX97CwliFp/7 +7o9Plb2afRuB/XgX08NIoskTprzFnEYrKFjfTbfiLGKtU2/F2S28iHVCeJ3NPJfp +TNH45TtWmTKEengL4PDMi3ZAcnrb9cwiQC6JMZRAPcNBEY/8fNFqogL24NrlSbdD +nlmk8Zbk3yqnKOT1fQx7VwaCoje9kLzTtgTnh6o7tu3X/ONvIkpIER6m8rVf4/IS +c6F66iSVbPLFSewZrPXKbtWCgxuOt4qFxOjsoNpGac1XUKvEHfL0W0s7IQQjvH95 +1hyUQ17q1655FmKcOxPC+7IquU0KDTx/Q27Iziv2CY+sTlC/ARwG42/Lm9smHemJ +dFM0lx3lrKREM01BTRV7lkFNp3kRgYLq0X/gpdqW5tRn6wxyollKtLxi9LUYIRg6 +4HMnD6OkgMVmeQ57ISgCB+PY0vrjZpp6PKZe2pSDZroALjab/3BT/+BcR+BlsUIc +mIg6BzpPfiFCbDYhefNcufi4WW1qmSGBLjahst4qGn4H8zC/moywEPgN2QiZm1+0 +HorlOEi7/Z1VvkaLX3zrDqoGTLm5Ag0EZGvi5gEQAM1rC8ivV42u9F0/V/AhbDwa +CJNP9rCP6XeRZj6sAjyuTAQ2GkVbyRCFhDqB8w+opEoY1JTT26jN5BRHrfFvWbWX +GNPD4DUY2HrTy7ct3JYRYrVLrUlm8CrVn34CtwOH1trcm498NASZH3nrF3YFpaP1 +yQAqkBEKSOU3LLk2Q7Uv4qGQIO4YRF0WNYxmzRhx/aSJi2UL7n4lTaQ8p1U25xb4 +OIS966ly82wO4KsK8xUVjKDERoEj+t0cjRY5q/OgYVyHU0pvtN/9ifT3e4KWUSaV +bqNFzoUHmoy6KEZWPVEzCn87fXkMZs7Z2LBIotvfkHfIVGeBqyleY1TFZgYh6Bqo +Xb+m+PzF1LlgN/2ghrn04d1HcTwmpH06AVdueyt7nTLCTHGhZFVAm6XQ4hKZT14D +rV52bC+2ID/9z3/pRxIJcSW9rP5PREGG3XjbSMl+GUKIB5ouXpf+FZayEBZJ3Tiz +JZtNbHWsi9smTJ2ZTkUl+xLq/7iCP+XgrM7x0z/Fh89w+hQpgWzYfKP9ODNJnLkD +HrtFbn81jook/6XinNNb5TIkAiXS7TZMgPPtyMvP2zu/d/rkmV2TfjCJ+i4LmQmy +PETKas7bSUZvRIRazx1ogqgXy0f52gRXgFQud0yBvhjlGkDcW/JgtE1zlYx5AbwG +RDz6zZ9b8DCczrVL6WhdABEBAAGJAjwEGAEIACYCGwwWIQSLZeWhB7vv47qZxZdR +wLW7GfktYAUCZkYjmQUJB4cVrwAKCRBRwLW7GfktYLh9EACVprLbDAqyiftySBhE +mGRkcMAjGJK+unJQnpuU6kFYsrJAqNIoKmEcsiBCyPnqK7eZomNc4F/EZtYimCrO +FRohJjtcbhC7p8OCzqEq8zE2uJ2lWNT/pOnMpspuneEJayA6+Y2w8Pklc9MjlEfT +ZlQ5pGCzK4ZsssBhGustLrXws2lQKkIfJP+/45yJZwba+FGvhmB8SnXEUce7WKrw +tN9ZkEew4khdnYr5GvZjo6jDl6ES8rpwY5/ITE5+LMzVPTUDSG9Tht1waXJy++3T +2PYEarjjp2QX6Q/3iSjlt1uE/3amxpzgHhKH+EtldsqfZuD9lGXy/eaHBcclchsa +TDz7j9IGXkZX3psAWwSk7oeBPTvgP1ZchicWtXI2Xl6ENT+1MmOS56IkiHHcrPBw +aLL9UfUPVYd61kpqHefham4WCAWEM22mVLrjoHkpZ8WjYa4DC/85fpf6nAxsmFKR +D2xbMG3yOYosOfPjf8UUjFzOywTcSPQRupRv3Nu6nHGblKlyBccIgrasd1F9rozo +ziSS2L/BfezkDbzjgaiNjy7396gD2+lYxgfxSx7RcUAn09nRnANfyuMw7AuBOhBo +C/rutFDx4WeQ+WWxEluT+KOxWS+gllE5RfzVkBH5b+gbH3SibG0Wn0CqI5rZ5axa +HBV++eW9rb0bbC/wKzIIRxXkKrkCDQRka+puARAAtcTNQgcMyLW9EVrwsaNSLb4f +I91HjTeLETRN9xxIS/XXNsIddQk2fC8c9LTryQ5+euPG5SE/q6H/5DhpmVX54C9t +p0uKJ2QrQF25/hB1lQz+7jQ6Sq68NT79iKgp0354OpoCIfVz0bS+LIXNV6SkiF36 +8RdwZ7VXbG27ECizqb2dM4X25xfvbeTcAjBiR7YKWwurZSpTKXbDaBs5UepIZuOR +ia5TGQy8ZnVxNvA7Wa89pFXbmXLJOR8b5yfcmNqv5AfNpwrlNMeYf2yT/zroEFn2 +0Tt4hPz8c7KZabr586PCUyeW4L7SHD+3A6xvlXJ0ygzr+r0j27Yv4DZMyN5S8mcq +puauh6cAtE1tMsWijwZF7R48NLEex6/9LrRwAosJpSPStVJMBwIXOk7VJxOcQRtp +AwbPq8SQPqf08jdgpvZF/ZuJUS323dHLvulF4mNPQ55hNtb/d+Oqh414/mzto0O1 ++1nuG07Wf2BeI5Q5onPis0EM0ru+ye8T8+eapqQ2ySeX8fEBMSiJvC2qadWWlDdN +wJkTbjr6pQgbnTwI+QBdllHxGLHjg9lmR/1X0DchhZtdziCUWTdpEy68h27ab7dN +BSm90y7AvF9QtxiNcJ1+pJE3d6w2D7Oe7ySEnCnWFfM4ON5e+hdrhsG8w1+0orye +euzwiRK1wvFlBrHTDXEAEQEAAYkCPAQYAQgAJgIbDBYhBItl5aEHu+/jupnFl1HA +tbsZ+S1gBQJmRiOoBQkHhw42AAoJEFHAtbsZ+S1guQoP/2wnafMzQ4roJssb4o6r +183Jd42A1sXr5533eBU9TRB6Zuj+zqPww2IxQEUqnUaL3uaUA7SjpyL9B0v5Da1b +DjQmuNhBSXvgwlRo9XP4mWkmLPw9hYtMRTnTVgctAHm/Zx1Y+k5XN8e3qAnZHwmD +HhVHDb7P9tiuQJAysmkzuF8RskhrYDd4feTlU65tSF6xN5GWEtY2wavtVnU9Pq3T +jm+L+upjyf8pc4eznWgDNyWbHy2WZTM21GwRwO4VJojvVAE08qmkLqqabU2MaNs0 +6O3+8GJKmXnJNL1aQwV4D0BaB87uejyYqCxbASi4UapcSNrpf83cZc6FBSMaDDLr +5IWdRdMMBrSwU8Y5oZToI7Hr2dwpxvQb2g9Lek732HOmpRfkBokqajGdm571eWGs +I/nsuYhkBnv3f1JyQi9glPE0DZ9559MnJYf0tLubEtKWG25YorAOysWiq35I4Qjy +P5FsWr8F4NVxk1If6Xfd8aWMaWXWqhBpKtsW/quPN9YxuuMCbVKkEPkb1ZwZYNEf +MigrNo/my2zzY8SAHH5yCUgHOnMEWHPJjn4Ov/mL3W9VmFrFCeLya0oaVjMKMwyR +ES1evDHgpJQqbtfix3Ju0/3Xt9Vtp8qmAHwwvkMgkyiBi+iJJ2iWc0ibs5/SnWXB +U2EuISkCgcTxT1ekiArexBXc +=fY3G +-----END PGP PUBLIC KEY BLOCK----- diff --git a/roles/rstudio_server/handlers/main.yml b/roles/rstudio_server/handlers/main.yml new file mode 100644 index 00000000..3c96f5f8 --- /dev/null +++ b/roles/rstudio_server/handlers/main.yml @@ -0,0 +1,29 @@ +# rserver re-reads the logging and the env-vars configuration on SIGHUP, and nothing else, so a +# change to rserver.conf takes a restart. The chain validates the configuration first, where a +# restart with an unreadable one would leave the service down. The check reports options rserver +# does not know; it does not validate their values. +# +# --check-config is the current name, --test-config the deprecated alias it replaced. Which one +# to use depends on the pinned version: 2023.12.1-402 has --test-config only, 2026.08.2-200 warns +# when it is used, so the installed binary is asked rather than guessed. +- name: 'rstudio_server: rserver --check-config' + ansible.builtin.shell: + cmd: | + set -o pipefail + if /usr/lib/rstudio-server/bin/rserver --help 2>&1 | grep --quiet -- '--check-config'; then + /usr/lib/rstudio-server/bin/rserver --check-config + else + /usr/lib/rstudio-server/bin/rserver --test-config + fi + executable: '/bin/bash' + changed_when: false + notify: 'rstudio_server: restart rstudio-server' + +- name: 'rstudio_server: restart rstudio-server' + ansible.builtin.service: + name: 'rstudio-server' + state: 'restarted' + when: + - 'not (lfops__skip_restart_handlers | d(false) | bool)' + - '__rstudio_server__service_state_result is not defined or __rstudio_server__service_state_result is not changed' + - 'rstudio_server__service_state != "stopped"' diff --git a/roles/rstudio_server/meta/argument_specs.yml b/roles/rstudio_server/meta/argument_specs.yml new file mode 100644 index 00000000..5a7ad976 --- /dev/null +++ b/roles/rstudio_server/meta/argument_specs.yml @@ -0,0 +1,163 @@ +argument_specs: + main: + options: + + rstudio_server__conf_auth_cookies_force_secure: + type: 'bool' + required: false + default: true + description: 'Mark the authentication cookies as secure even though rserver itself speaks plain HTTP.' + + rstudio_server__conf_auth_encrypt_password: + type: 'bool' + required: false + default: true + description: 'Encrypt the password in the browser before it is posted to the sign-in form.' + + rstudio_server__conf_auth_minimum_user_id: + type: 'raw' + required: false + default: 'auto' + description: 'Lowest uid allowed to sign in. "auto" reads UID_MIN from /etc/login.defs.' + + rstudio_server__conf_auth_required_user_group: + type: 'str' + required: false + default: 'rstudio-users' + description: 'Group a user must be a member of to sign in. Comma-separated for several groups, empty for none.' + + rstudio_server__conf_auth_sign_in_throttle_seconds: + type: 'int' + required: false + default: 5 + description: 'Seconds a user has to wait before signing in again after signing out.' + + rstudio_server__conf_auth_timeout_minutes: + type: 'int' + required: false + default: 60 + description: 'Minutes an idle user stays signed in. 0 disables the timeout.' + + rstudio_server__conf_limit_file_upload_size_mb: + type: 'int' + required: false + default: 0 + description: 'Maximum size of a file uploaded through the web interface, in MB. 0 lifts the limit.' + + rstudio_server__conf_rsession_which_r: + type: 'str' + required: false + default: '' + description: 'Path to the R program the sessions run. Empty lets rserver find R itself.' + + rstudio_server__conf_session_timeout_minutes: + type: 'int' + required: false + default: 120 + description: 'Minutes an idle session is kept before it is suspended or ended. 0 disables the timeout.' + + rstudio_server__conf_session_timeout_suspend: + type: 'bool' + required: false + default: true + description: 'Suspend the session when the timeout is reached, instead of ending it.' + + rstudio_server__conf_www_address: + type: 'str' + required: false + default: '0.0.0.0' + description: 'Address rserver listens on.' + + rstudio_server__conf_www_enable_origin_check: + type: 'bool' + required: false + default: true + description: 'Reject requests whose Origin names a host other than the one they were sent to.' + + rstudio_server__conf_www_frame_origin: + type: 'str' + required: false + default: 'none' + description: 'Origin allowed to embed RStudio in a frame.' + + rstudio_server__conf_www_port: + type: 'int' + required: false + default: 8787 + description: 'Port rserver listens on.' + + rstudio_server__conf_www_root_path: + type: 'str' + required: false + default: '' + description: 'Path prefix a proxy in front adds to the URL. Empty means the server is reached at "/".' + + rstudio_server__conf_www_thread_pool_size: + type: 'int' + required: false + default: 6 + description: 'Threads serving incoming requests.' + + rstudio_server__download_url: + type: 'str' + required: false + default: '' + description: 'Full URL of the RStudio Server package. Empty derives it from the version and the platform.' + + rstudio_server__memory_max: + type: 'str' + required: false + default: '' + description: 'MemoryMax of the service unit, for example "8G". Empty leaves the setting out.' + + rstudio_server__pam_include: + type: 'str' + required: false + default: 'password-auth' + description: 'PAM stack /etc/pam.d/rstudio includes for authentication and account checks.' + + rstudio_server__pam_minimum_uid: + type: 'int' + required: false + default: 1000 + description: 'Lowest uid PAM lets through, checked before the password is.' + + rstudio_server__rserver_conf_raw: + type: 'str' + required: false + default: '' + description: 'Verbatim content appended to rserver.conf.' + + rstudio_server__rsession_conf_raw: + type: 'str' + required: false + default: '' + description: 'Verbatim content appended to rsession.conf.' + + rstudio_server__service_enabled: + type: 'bool' + required: false + default: true + description: 'Start rstudio-server.service at boot.' + + rstudio_server__service_state: + type: 'str' + required: false + default: 'started' + choices: + - 'reloaded' + - 'restarted' + - 'started' + - 'stopped' + description: 'Runtime state of rstudio-server.service.' + + rstudio_server__tasks_max: + type: 'str' + required: false + default: '' + description: 'TasksMax of the service unit. Empty leaves the setting out.' + + rstudio_server__version: + type: 'str' + required: true + description: 'Version of RStudio Server to install, as it appears in the package file name.' diff --git a/roles/rstudio_server/tasks/main.yml b/roles/rstudio_server/tasks/main.yml new file mode 100644 index 00000000..61fe44d5 --- /dev/null +++ b/roles/rstudio_server/tasks/main.yml @@ -0,0 +1,268 @@ +- block: + + - name: 'Set platform/version specific variables' + ansible.builtin.import_role: + name: 'shared' + tasks_from: 'platform-variables.yml' + + tags: + - 'always' + + +- block: + + # Posit publishes an x86_64 build only, so this is not a matter of adding another vars file. + - name: 'Assert that the platform has a Posit build' + ansible.builtin.assert: + that: + - 'ansible_facts["architecture"] == "x86_64"' + - '__rstudio_server__package_dist is defined' + quiet: true + fail_msg: >- + RStudio Server is available for x86_64 on RHEL 8, 9 and 10 only. This host is + {{ ansible_facts["distribution"] }} {{ ansible_facts["distribution_major_version"] }} + on {{ ansible_facts["architecture"] }}. + + tags: + - 'always' + + +- block: + + # rserver looks up the group at every sign-in, so it does not have to exist before the service + # starts. It is created here regardless, because an auth-required-user-group naming a group + # that does not exist refuses every user without saying why. + - name: 'groupadd {{ rstudio_server__conf_auth_required_user_group }}' + ansible.builtin.group: + name: '{{ rstudio_server__conf_auth_required_user_group }}' + state: 'present' + system: true + when: + - 'rstudio_server__conf_auth_required_user_group | length > 0' + + tags: + - 'rstudio_server' + - 'rstudio_server:users' + + +# Deployed before the package on purpose. The package writes its own rserver.conf, rsession.conf +# and /etc/pam.d/rstudio only when none exists, and then enables and starts the service straight +# away, so without this the service would come up once on every interface, with every account on +# the host allowed to sign in. +- block: + + - name: 'mkdir -p /etc/rstudio' + ansible.builtin.file: + path: '/etc/rstudio' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + + - name: 'Deploy /etc/rstudio/rserver.conf' + ansible.builtin.template: + backup: true + src: 'etc/rstudio/rserver.conf.j2' + dest: '/etc/rstudio/rserver.conf' + owner: 'root' + group: 'root' + mode: 0o644 + notify: 'rstudio_server: rserver --check-config' + + # A running session keeps the settings it started with, so this needs no handler: the file is + # read by each rsession as it starts. + - name: 'Deploy /etc/rstudio/rsession.conf' + ansible.builtin.template: + backup: true + src: 'etc/rstudio/rsession.conf.j2' + dest: '/etc/rstudio/rsession.conf' + owner: 'root' + group: 'root' + mode: 0o644 + + # Read by the PAM helper on every sign-in, so this needs no handler either. + - name: 'Deploy /etc/pam.d/rstudio' + ansible.builtin.template: + backup: true + src: 'etc/pam.d/rstudio.j2' + dest: '/etc/pam.d/rstudio' + owner: 'root' + group: 'root' + mode: 0o644 + + tags: + - 'rstudio_server' + - 'rstudio_server:configure' + + +- block: + + # rserver refuses to start without a working R installation, and the package starts the + # service as part of its own installation, so an installation without R fails at the package + # rather than at the service task. Both halves are asked for: R itself, and its documentation + # directory, which rserver validates and which a host with `tsflags=nodocs` in its dnf.conf + # does not have even though R is installed. -s keeps R from echoing the expression, so that + # stdout is the path and nothing else. + - name: 'R -e R.home("doc")' + ansible.builtin.command: >- + {{ rstudio_server__conf_rsession_which_r | d("R", true) }} + --vanilla -s --no-save --no-restore -e 'cat(R.home("doc"))' + register: '__rstudio_server__r_doc_dir_result' + changed_when: false + failed_when: false + check_mode: false + + - name: 'stat the R documentation directory' + ansible.builtin.stat: + path: '{{ __rstudio_server__r_doc_dir_result["stdout"] | trim }}' + register: '__rstudio_server__r_doc_dir_stat_result' + when: + - '__rstudio_server__r_doc_dir_result["rc"] == 0' + + - name: 'Assert that R is installed and complete' + ansible.builtin.assert: + that: + - '__rstudio_server__r_doc_dir_result["rc"] == 0' + - '__rstudio_server__r_doc_dir_stat_result["stat"]["exists"] | d(false)' + quiet: true + fail_msg: >- + rserver aborts at startup without R and its documentation directory. Run the `r` role + first. A host that installs packages with `tsflags=nodocs` has R without the directory; + reinstall R with `dnf reinstall --setopt=tsflags= R` there. + + - name: 'Gather package facts' + ansible.builtin.package_facts: # yamllint disable-line rule:empty-values + check_mode: false + + - name: 'Set installed version fact' + ansible.builtin.set_fact: + __rstudio_server__installed_version: '{{ ansible_facts["packages"]["rstudio-server"][0]["version"] | d("") }}' + + - block: + + # Posit signs the RPM, so the package is installed with the GPG check on. The key is shipped + # with the role because Posit publishes it on a web page and on the keyservers, but at no + # stable URL a task could fetch. + - name: 'Deploy {{ __rstudio_server__gpg_key_path }}' + ansible.builtin.copy: + src: 'etc/pki/rpm-gpg/RPM-GPG-KEY-posit' + dest: '{{ __rstudio_server__gpg_key_path }}' + owner: 'root' + group: 'root' + mode: 0o644 + + - name: 'rpm --import {{ __rstudio_server__gpg_key_path }}' + ansible.builtin.rpm_key: + key: '{{ __rstudio_server__gpg_key_path }}' + state: 'present' + + - name: 'curl --output /tmp/ansible.{{ __rstudio_server__local_package_filename }} {{ __rstudio_server__download_url }}' # noqa risky-file-permissions (temporary file) + ansible.builtin.get_url: + url: '{{ __rstudio_server__download_url }}' + dest: '/tmp/ansible.{{ __rstudio_server__local_package_filename }}' + mode: 0o644 + delegate_to: 'localhost' + become: false + changed_when: false # not an actual config change on the target + check_mode: false # run task even if `--check` is specified + + - name: 'Copy /tmp/ansible.{{ __rstudio_server__local_package_filename }} to the target' # noqa risky-file-permissions (temporary file) + ansible.builtin.copy: + src: '/tmp/ansible.{{ __rstudio_server__local_package_filename }}' + dest: '/tmp/{{ __rstudio_server__local_package_filename }}' + changed_when: false # the install task below reports the change + + # ansible.builtin.package cannot install a local file, and the dnf module is what verifies + # the signature of one: it enables localpkg_gpgcheck unless disable_gpg_check is set. + # + # This is also the update path: dnf resolves an install of a newer file as an upgrade of the + # installed package, and of an older one as a downgrade, which is all Posit documents. The + # package restarts the service as part of its own installation. + - name: 'dnf install /tmp/{{ __rstudio_server__local_package_filename }}' + ansible.builtin.dnf: + name: '/tmp/{{ __rstudio_server__local_package_filename }}' + state: 'present' + + - name: 'rm -f /tmp/{{ __rstudio_server__local_package_filename }}' + ansible.builtin.file: + path: '/tmp/{{ __rstudio_server__local_package_filename }}' + state: 'absent' + changed_when: false + + when: + - '__rstudio_server__installed_version != __rstudio_server__package_version' + + # The file context itself comes from the selinux role, which runs earlier in the play, so a + # package installed just now is labelled correctly already. This catches the host that had + # RStudio Server before the context existed, and it notifies a restart, because a service that + # is running keeps the domain it started in. restorecon prints only what it changes. + - name: 'restorecon -v /usr/lib/rstudio-server/bin/rserver' + ansible.builtin.command: 'restorecon -v /usr/lib/rstudio-server/bin/rserver' + register: '__rstudio_server__restorecon_result' + changed_when: '__rstudio_server__restorecon_result["stdout"] | length > 0' + notify: 'rstudio_server: restart rstudio-server' + when: + - 'ansible_facts["selinux"]["status"] != "disabled"' + + tags: + - 'rstudio_server' + + +- block: + + - name: 'mkdir -p /etc/systemd/system/rstudio-server.service.d/' + ansible.builtin.file: + path: '/etc/systemd/system/rstudio-server.service.d/' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + + - name: 'Deploy /etc/systemd/system/rstudio-server.service.d/z00-linuxfabrik.conf' + ansible.builtin.template: + backup: true + src: 'etc/systemd/system/rstudio-server.service.d/z00-linuxfabrik.conf.j2' + dest: '/etc/systemd/system/rstudio-server.service.d/z00-linuxfabrik.conf' + owner: 'root' + group: 'root' + mode: 0o644 + register: '__rstudio_server__z00_linuxfabrik_result' + notify: 'rstudio_server: restart rstudio-server' + + - name: 'systemctl daemon-reload' # noqa no-handler would require flush_handlers here anyway + ansible.builtin.systemd: + daemon_reload: true + when: '__rstudio_server__z00_linuxfabrik_result is changed' + + tags: + - 'rstudio_server' + - 'rstudio_server:configure' + + +- block: + + - name: 'systemctl {{ rstudio_server__service_enabled | bool | ternary("enable", "disable") }} rstudio-server.service' + ansible.builtin.service: + name: 'rstudio-server.service' + enabled: '{{ rstudio_server__service_enabled | bool }}' + + - name: 'systemctl {{ rstudio_server__service_state | regex_replace("p?ed$", "") }} rstudio-server.service' + ansible.builtin.service: + name: 'rstudio-server.service' + state: '{{ rstudio_server__service_state }}' + register: '__rstudio_server__service_state_result' + + tags: + - 'rstudio_server' + - 'rstudio_server:state' + + +- block: + + - name: 'Flush handlers so that the service can be used by other roles later' + ansible.builtin.meta: 'flush_handlers' + + tags: + - 'rstudio_server' + - 'rstudio_server:configure' + - 'rstudio_server:state' diff --git a/roles/rstudio_server/templates/etc/pam.d/rstudio.j2 b/roles/rstudio_server/templates/etc/pam.d/rstudio.j2 new file mode 100644 index 00000000..c331607f --- /dev/null +++ b/roles/rstudio_server/templates/etc/pam.d/rstudio.j2 @@ -0,0 +1,20 @@ +#%PAM-1.0 +# {{ ansible_managed }} +# 2026090901 +# +# The package writes this file only when none exists, so this one survives an update. +# +# RStudio Server Open Source authenticates with pam_authenticate() and pam_acct_mgmt() and never +# opens a PAM session, so only the `auth` and `account` stacks are consulted. Nothing here +# creates a home directory (pam_mkhomedir would sit in the session stack), and a user without one +# cannot work: the home directory has to exist before the first sign-in. + +# Refuses system accounts before their password is checked at all. rserver rejects them too +# (auth-minimum-user-id), but only after PAM has run, so without this line a stream of failed +# root logins through the web interface would trip pam_faillock on root and lock it out +# everywhere else on the host. +auth requisite pam_succeed_if.so uid >= {{ rstudio_server__pam_minimum_uid }} quiet + +# The vendor file authenticates against pam_unix alone, which leaves out SSSD and pam_faillock. +auth include {{ rstudio_server__pam_include }} +account include {{ rstudio_server__pam_include }} diff --git a/roles/rstudio_server/templates/etc/rstudio/rserver.conf.j2 b/roles/rstudio_server/templates/etc/rstudio/rserver.conf.j2 new file mode 100644 index 00000000..30d422d4 --- /dev/null +++ b/roles/rstudio_server/templates/etc/rstudio/rserver.conf.j2 @@ -0,0 +1,39 @@ +# {{ ansible_managed }} +# 2026090902 +# +# Options of the rserver process. `rserver --help` lists them all; anything without a role +# variable goes into rstudio_server__rserver_conf_raw. +# +# rserver reads this file at startup only. `systemctl reload` (SIGHUP) re-reads the logging and +# the env-vars configuration, not this file, so a change here needs a restart. + +# Cookies are marked secure whenever the connection is HTTPS. Behind a proxy that terminates +# TLS, rserver only ever sees plain HTTP and needs to be told. +auth-cookies-force-secure={{ rstudio_server__conf_auth_cookies_force_secure | bool | ternary(1, 0) }} +# The browser encrypts the password with the server's public key before it is posted, so it does +# not travel in the clear even where the connection does. Turn it off only if a proxy in front +# mangles the form, and then only on a connection that carries TLS. +auth-encrypt-password={{ rstudio_server__conf_auth_encrypt_password | bool | ternary(1, 0) }} +auth-minimum-user-id={{ rstudio_server__conf_auth_minimum_user_id }} +{% if rstudio_server__conf_auth_required_user_group | length %} +auth-required-user-group={{ rstudio_server__conf_auth_required_user_group }} +{% endif %} +auth-sign-in-throttle-seconds={{ rstudio_server__conf_auth_sign_in_throttle_seconds }} +auth-timeout-minutes={{ rstudio_server__conf_auth_timeout_minutes }} +{% if rstudio_server__conf_rsession_which_r | length %} +rsession-which-r={{ rstudio_server__conf_rsession_which_r }} +{% endif %} +www-address={{ rstudio_server__conf_www_address }} +# Rejects a request whose Origin or Referer names a host other than the one it was sent to, +# which is what keeps another site from driving a signed-in browser session. +www-enable-origin-check={{ rstudio_server__conf_www_enable_origin_check | bool | ternary(1, 0) }} +www-frame-origin={{ rstudio_server__conf_www_frame_origin }} +www-port={{ rstudio_server__conf_www_port }} +{% if rstudio_server__conf_www_root_path | length %} +www-root-path={{ rstudio_server__conf_www_root_path }} +{% endif %} +www-thread-pool-size={{ rstudio_server__conf_www_thread_pool_size }} +{% if rstudio_server__rserver_conf_raw | length %} + +{{ rstudio_server__rserver_conf_raw }} +{% endif %} diff --git a/roles/rstudio_server/templates/etc/rstudio/rsession.conf.j2 b/roles/rstudio_server/templates/etc/rstudio/rsession.conf.j2 new file mode 100644 index 00000000..a0ab5b30 --- /dev/null +++ b/roles/rstudio_server/templates/etc/rstudio/rsession.conf.j2 @@ -0,0 +1,21 @@ +# {{ ansible_managed }} +# 2026090901 +# +# Options of the rsession processes, one per signed-in user. `rsession --help` lists them all; +# anything without a role variable goes into rstudio_server__rsession_conf_raw. +# +# Read by every session as it starts, so a change reaches a running session only after it is +# suspended or ends. + +# 0 lifts the limit. Uploads are written to the user's home directory, so this is the guard +# against one user filling the file system through the web interface. +limit-file-upload-size-mb={{ rstudio_server__conf_limit_file_upload_size_mb }} +# Minutes an idle session is kept before it is suspended or ended, together with +# session-timeout-suspend. Suspending writes the workspace to disk and frees the memory; the +# user finds their variables again on the next sign-in. +session-timeout-minutes={{ rstudio_server__conf_session_timeout_minutes }} +session-timeout-suspend={{ rstudio_server__conf_session_timeout_suspend | bool | ternary(1, 0) }} +{% if rstudio_server__rsession_conf_raw | length %} + +{{ rstudio_server__rsession_conf_raw }} +{% endif %} diff --git a/roles/rstudio_server/templates/etc/systemd/system/rstudio-server.service.d/z00-linuxfabrik.conf.j2 b/roles/rstudio_server/templates/etc/systemd/system/rstudio-server.service.d/z00-linuxfabrik.conf.j2 new file mode 100644 index 00000000..acf6074e --- /dev/null +++ b/roles/rstudio_server/templates/etc/systemd/system/rstudio-server.service.d/z00-linuxfabrik.conf.j2 @@ -0,0 +1,24 @@ +# {{ ansible_managed }} +# 2026090901 +# +# The package writes its unit to /usr/lib/systemd/system/rstudio-server.service and overwrites it +# on every install and update, so every override lives here instead. + +[Service] +{% if rstudio_server__memory_max | string | length %} +# Every signed-in user gets an R process of their own, and R holds its data in memory, so a +# single careless `read.csv()` can take the host down. The sessions are forked by rserver and +# stay in this control group, so the cap reaches them. +# +# It protects the host, not the individual session: all sessions draw on the same budget, and on +# overrun the OOM killer picks a process inside the group, which may well belong to a user other +# than the one that caused it. +MemoryMax={{ rstudio_server__memory_max }} +{% endif %} +{% if rstudio_server__tasks_max | string | length %} +# Caps processes and threads in the control group. R forks for parallel work +# (parallel::mclapply and anything a session shells out to, the RStudio terminal included), so +# without this the host is bounded only by systemd's DefaultTasksMax, which is 15% of +# kernel.pid_max and therefore commonly in the tens of thousands. +TasksMax={{ rstudio_server__tasks_max }} +{% endif %} diff --git a/roles/rstudio_server/vars/RedHat.yml b/roles/rstudio_server/vars/RedHat.yml new file mode 100644 index 00000000..4196a6b6 --- /dev/null +++ b/roles/rstudio_server/vars/RedHat.yml @@ -0,0 +1,28 @@ +# Posit builds one RPM per RHEL generation and publishes x86_64 only. The package pulls in +# nothing but `psmisc`, `sqlite` and `/bin/sh` (`rpm --query --requires`) and carries its own +# Node.js and Boost below /usr/lib/rstudio-server, so the RHEL 9 build also runs on RHEL 10, +# for which Posit publishes nothing (verified 2026-09 on Rocky 8, 9 and 10 with 2026.08.2-200). +__rstudio_server__package_arch: 'x86_64' +__rstudio_server__package_extension: 'rpm' + +__rstudio_server__package_filename: 'rstudio-server-rhel-{{ rstudio_server__version }}-{{ __rstudio_server__package_arch }}.{{ __rstudio_server__package_extension }}' + +# Posit gives the build of every generation the same file name, so the copy on the controller and +# on the target carries the generation in its own. Without that, a play against RHEL 8 and RHEL 9 +# hosts has the second host reuse the file the first one downloaded, and the RHEL 9 build does +# not even start on RHEL 8, where it misses `libssl.so.3`. +__rstudio_server__local_package_filename: 'rstudio-server-{{ __rstudio_server__package_dist }}-{{ rstudio_server__version }}-{{ __rstudio_server__package_arch }}.{{ __rstudio_server__package_extension }}' + +__rstudio_server__download_url: '{{ + rstudio_server__download_url if rstudio_server__download_url | length + else "https://download2.rstudio.org/server/" ~ __rstudio_server__package_dist ~ "/" ~ __rstudio_server__package_arch ~ "/" ~ __rstudio_server__package_filename + }}' + +# The RPM carries the build number after a "+", where the file name and the download page write +# it after a "-" (file "2026.08.2-200" is package version "2026.08.2+200"). +__rstudio_server__package_version: '{{ rstudio_server__version | regex_replace("-(?=[^-]*$)", "+") }}' + +# Posit signs the RPMs with this key (`Posit Software, PBC `, fingerprint +# 8B65 E5A1 07BB EFE3 BA99 C597 51C0 B5BB 19F9 2D60). Published on +# https://rstudio.com/code-signing/ and on the public keyservers. +__rstudio_server__gpg_key_path: '/etc/pki/rpm-gpg/RPM-GPG-KEY-posit' diff --git a/roles/rstudio_server/vars/RedHat10.yml b/roles/rstudio_server/vars/RedHat10.yml new file mode 100644 index 00000000..4dd766de --- /dev/null +++ b/roles/rstudio_server/vars/RedHat10.yml @@ -0,0 +1,2 @@ +# Posit publishes no RHEL 10 build; the RHEL 9 one runs there unchanged (see vars/RedHat.yml). +__rstudio_server__package_dist: 'rhel9' diff --git a/roles/rstudio_server/vars/RedHat8.yml b/roles/rstudio_server/vars/RedHat8.yml new file mode 100644 index 00000000..5054323a --- /dev/null +++ b/roles/rstudio_server/vars/RedHat8.yml @@ -0,0 +1 @@ +__rstudio_server__package_dist: 'rhel8' diff --git a/roles/rstudio_server/vars/RedHat9.yml b/roles/rstudio_server/vars/RedHat9.yml new file mode 100644 index 00000000..39ac3ba0 --- /dev/null +++ b/roles/rstudio_server/vars/RedHat9.yml @@ -0,0 +1 @@ +__rstudio_server__package_dist: 'rhel9' diff --git a/roles/rstudio_server/vars/main.yml b/roles/rstudio_server/vars/main.yml new file mode 100644 index 00000000..9363fb76 --- /dev/null +++ b/roles/rstudio_server/vars/main.yml @@ -0,0 +1,21 @@ +# Published for roles that run earlier in the play than rstudio_server. vars/main.yml is loaded +# at play parse and visible to every role in the play, which the selinux role needs: it applies +# the file context before this role installs the package, so the files are labelled as they are +# written. + +# Posit installs rserver below /usr/lib, where it carries lib_t. systemd transitions a service +# into unconfined_service_t only when its binary is bin_t, so with the vendor label the service +# keeps systemd's own domain, init_t. From there the fork of a session is denied, so nobody gets +# an R session at all while SELinux is enforcing: the sign-in works, the browser waits, and +# rserver logs "exited with status 256 before a connection was made", while the audit log holds +# `avc: denied { setpgid } for comm="rserver" scontext=system_u:system_r:init_t:s0`. +# +# Labelling the binary bin_t moves the service into unconfined_service_t, the domain RHEL keeps +# for services that bring no policy of their own. Only rserver needs it; rsession keeps lib_t, +# because the domain rserver then runs in may execute it. +# +# Verified on Rocky 8, 9 and 10 with rstudio-server 2026.08.2-200. +rstudio_server__selinux__fcontexts__dependent_var: + - setype: 'bin_t' + target: '/usr/lib/rstudio-server/bin/rserver' + state: 'present' From 264e01ddd902e6c687a9aa5e7e365a33879e2ba0 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 9 Sep 2026 17:14:29 +0200 Subject: [PATCH 7/9] test(roles/rstudio_server): add a molecule scenario Runs the playbook against Rocky 8, 9 and 10. The verification asks the running system what only it can answer: that SELinux is enforcing and rserver reached unconfined_service_t, that a member of the login group signs in while a wrong password and a non-member are refused, that the session runs as the signed-in user inside the control group of the service, and that the resource limits of the drop-in reached the unit. The scenario turns auth-encrypt-password and auth-cookies-force-secure off: it signs in with an ordinary form post over plain HTTP, which is the deployment without a TLS terminator the README documents. --- .../molecule/rstudio_server/converge.yml | 32 +++ .../group_vars/systems_under_test.yml | 21 ++ .../rstudio_server/inventory/hosts.yml | 18 ++ .../molecule/rstudio_server/molecule.yml | 1 + extensions/molecule/rstudio_server/verify.yml | 215 ++++++++++++++++++ 5 files changed, 287 insertions(+) create mode 100644 extensions/molecule/rstudio_server/converge.yml create mode 100644 extensions/molecule/rstudio_server/inventory/group_vars/systems_under_test.yml create mode 100644 extensions/molecule/rstudio_server/inventory/hosts.yml create mode 100644 extensions/molecule/rstudio_server/molecule.yml create mode 100644 extensions/molecule/rstudio_server/verify.yml diff --git a/extensions/molecule/rstudio_server/converge.yml b/extensions/molecule/rstudio_server/converge.yml new file mode 100644 index 00000000..c5107c46 --- /dev/null +++ b/extensions/molecule/rstudio_server/converge.yml @@ -0,0 +1,32 @@ +- name: 'Converge rstudio_server playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.rstudio_server' + + +# The role installs the service but does not manage accounts, so the two the +# verification signs in with are created here. This runs after the playbook, +# because the group one of them joins is created by the role. +# +# The password hash is seeded with the inventory hostname so that it stays the +# same across runs, otherwise the idempotence step would fail on a fresh salt. +- name: 'Create the accounts the sign-in test uses' + hosts: 'systems_under_test' + gather_facts: false + tasks: + + - name: 'useradd the test accounts' + ansible.builtin.user: + name: '{{ item["name"] }}' + create_home: true + groups: '{{ item["groups"] }}' + append: true + password: '{{ "linuxfabrik" | ansible.builtin.password_hash("sha512", (65534 | random(seed=inventory_hostname) | string)) }}' + shell: '/bin/bash' + state: 'present' + loop: + - name: 'molecule-member' + groups: + - 'rstudio-users' + - name: 'molecule-outsider' + groups: [] + loop_control: + label: '{{ item["name"] }}' diff --git a/extensions/molecule/rstudio_server/inventory/group_vars/systems_under_test.yml b/extensions/molecule/rstudio_server/inventory/group_vars/systems_under_test.yml new file mode 100644 index 00000000..b1295461 --- /dev/null +++ b/extensions/molecule/rstudio_server/inventory/group_vars/systems_under_test.yml @@ -0,0 +1,21 @@ +rstudio_server__version: '2026.08.2-200' + +# Neither is the default, so the verification asking the running service for them +# proves the configuration arrives rather than merely being written to a file. +rstudio_server__conf_www_address: '127.0.0.1' +rstudio_server__conf_www_port: 8788 + +# The verification signs in with an ordinary form post. With the default, the +# browser encrypts the password with the server's public key first, which a test +# would have to reimplement; everything behind the form (PAM, the group check, +# the session cookie, the session launch) is the same either way. +rstudio_server__conf_auth_encrypt_password: false + +# The role assumes a TLS-terminating proxy in front and marks every cookie +# secure, which a client on plain HTTP never sends back: the sign-in would fail +# at the CSRF check before reaching PAM. The scenario has no proxy, so it is the +# deployment that has to turn the flag off, exactly as the README says. +rstudio_server__conf_auth_cookies_force_secure: false + +rstudio_server__memory_max: '2G' +rstudio_server__tasks_max: 500 diff --git a/extensions/molecule/rstudio_server/inventory/hosts.yml b/extensions/molecule/rstudio_server/inventory/hosts.yml new file mode 100644 index 00000000..0da71c15 --- /dev/null +++ b/extensions/molecule/rstudio_server/inventory/hosts.yml @@ -0,0 +1,18 @@ +# yamllint disable rule:empty-values + +# rstudio_server targets 'lfops_rstudio_server' (see playbooks/rstudio_server.yml: +# hosts). +# +# Debian and Ubuntu are deliberately absent: the role supports the RHEL family +# (see COMPATIBILITY.md), where the package differs per generation. VMs rather than +# containers, because signing in exercises the host's PAM stack and because the +# resource limits the role deploys are only meaningful in a real control group. +lfops_rstudio_server: + children: + systems_under_test: + +systems_under_test: + hosts: + rocky8-vm: + rocky9-vm: + rocky10-vm: diff --git a/extensions/molecule/rstudio_server/molecule.yml b/extensions/molecule/rstudio_server/molecule.yml new file mode 100644 index 00000000..1e47cbff --- /dev/null +++ b/extensions/molecule/rstudio_server/molecule.yml @@ -0,0 +1 @@ +# Molecule scenario marker diff --git a/extensions/molecule/rstudio_server/verify.yml b/extensions/molecule/rstudio_server/verify.yml new file mode 100644 index 00000000..64476fe1 --- /dev/null +++ b/extensions/molecule/rstudio_server/verify.yml @@ -0,0 +1,215 @@ +# What only the running system can confirm is whether a user can actually sign +# in and get an R session of their own: the PAM profile the role deploys, the +# group gate, the session cookie and the launch of rsession under the user's own +# account are one chain, and every link of it lives outside the files the role +# writes. The configuration files themselves are not checked; the running +# service is asked for the values instead. +- name: 'Verify rstudio_server' + hosts: 'systems_under_test' + gather_facts: false + vars: + + __molecule__rstudio_server_url: 'http://127.0.0.1:8788' + + # www-verify-user-agent is on by default and sends anything that does not + # look like a browser to /unsupported_browser.htm. + __molecule__rstudio_server_user_agent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36' + + tasks: + + # The session launch below is what proves the SELinux file context of the + # role: without it the service stays in init_t and starts no session at all. + # A host that happens to run permissive would pass that assertion without + # proving anything, so the mode is asserted first. + - name: 'getenforce' + ansible.builtin.command: 'getenforce' + changed_when: false + register: '__molecule__rstudio_server_getenforce_result' + + - name: 'Assert that SELinux is enforcing' + ansible.builtin.assert: + that: '__molecule__rstudio_server_getenforce_result["stdout"] == "Enforcing"' + fail_msg: 'SELinux is {{ __molecule__rstudio_server_getenforce_result["stdout"] }}, so the session test proves nothing.' + + - name: 'ps -eZ' # noqa command-instead-of-module (read-only process query) + ansible.builtin.command: 'ps -eZ' + changed_when: false + register: '__molecule__rstudio_server_ps_result' + + - name: 'Assert that rserver runs in the unconfined_service_t domain' + ansible.builtin.assert: + that: '__molecule__rstudio_server_ps_result["stdout_lines"] + | select("search", "unconfined_service_t.*rserver") | list | length > 0' + fail_msg: >- + rserver does not run in unconfined_service_t, so the bin_t file context of the role did not + reach the binary and no user will get a session. + + - name: 'Gather the service facts' + ansible.builtin.service_facts: # yamllint disable-line rule:empty-values + + - name: 'Assert that rstudio-server.service is running' + ansible.builtin.assert: + that: 'ansible_facts["services"]["rstudio-server.service"]["state"] == "running"' + + - name: 'systemctl show rstudio-server' # noqa command-instead-of-module (read-only property query) + ansible.builtin.command: 'systemctl show rstudio-server --property=MemoryMax --property=TasksMax' + changed_when: false + register: '__molecule__rstudio_server_unit_result' + + # Proves that the drop-in reaches the running unit, which the package would + # have overwritten had the role templated the unit file itself. + - name: 'Assert that the resource limits of the drop-in are in effect' + ansible.builtin.assert: + that: + - '"MemoryMax=2147483648" in __molecule__rstudio_server_unit_result["stdout"]' + - '"TasksMax=500" in __molecule__rstudio_server_unit_result["stdout"]' + + # The address and the port are not the defaults, so a service answering here + # is one that read the configuration the role deployed. + - name: 'curl {{ __molecule__rstudio_server_url }}/auth-sign-in' + ansible.builtin.uri: + url: '{{ __molecule__rstudio_server_url }}/auth-sign-in' + status_code: 200 + register: '__molecule__rstudio_server_sign_in_page_result' + + # A failed sign-in answers 302 as well, back to the sign-in page with an + # error in the query string, so the redirect target is what tells the two + # apart. The cookies cannot be used for it: ansible.builtin.uri returns none + # on a redirect it does not follow, and following it would need the session + # cookie the redirect is carrying. + - name: 'Sign in as a member of the login group' + ansible.builtin.uri: + url: '{{ __molecule__rstudio_server_url }}/auth-do-sign-in' + method: 'POST' + body_format: 'form-urlencoded' + body: + appUri: '/' + password: 'linuxfabrik' + rs-csrf-token: '{{ __molecule__rstudio_server_sign_in_page_result["cookies"]["rs-csrf-token"] }}' + staySignedIn: '1' + username: 'molecule-member' + headers: + Cookie: '{{ __molecule__rstudio_server_sign_in_page_result["cookies_string"] }}' + follow_redirects: 'none' + status_code: 302 + register: '__molecule__rstudio_server_sign_in_result' + + - name: 'Assert that the sign-in was accepted' + ansible.builtin.assert: + that: '"error=" not in __molecule__rstudio_server_sign_in_result["location"]' + fail_msg: 'Sign-in as molecule-member was redirected to {{ __molecule__rstudio_server_sign_in_result["location"] }}.' + + - name: 'Sign in with a wrong password' + ansible.builtin.uri: + url: '{{ __molecule__rstudio_server_url }}/auth-do-sign-in' + method: 'POST' + body_format: 'form-urlencoded' + body: + appUri: '/' + password: 'wrong-password' + rs-csrf-token: '{{ __molecule__rstudio_server_sign_in_page_result["cookies"]["rs-csrf-token"] }}' + staySignedIn: '1' + username: 'molecule-member' + headers: + Cookie: '{{ __molecule__rstudio_server_sign_in_page_result["cookies_string"] }}' + follow_redirects: 'none' + status_code: 302 + register: '__molecule__rstudio_server_wrong_password_result' + + - name: 'Assert that a wrong password is refused' + ansible.builtin.assert: + that: '"error=" in __molecule__rstudio_server_wrong_password_result["location"]' + fail_msg: 'A wrong password was accepted.' + + # The account exists and its password is right; only the membership in + # rstudio_server__conf_auth_required_user_group is missing. Without the group + # every account on the host could sign in, which is what the role's deviation + # from the upstream default is about. + - name: 'Sign in as a user outside the login group' + ansible.builtin.uri: + url: '{{ __molecule__rstudio_server_url }}/auth-do-sign-in' + method: 'POST' + body_format: 'form-urlencoded' + body: + appUri: '/' + password: 'linuxfabrik' + rs-csrf-token: '{{ __molecule__rstudio_server_sign_in_page_result["cookies"]["rs-csrf-token"] }}' + staySignedIn: '1' + username: 'molecule-outsider' + headers: + Cookie: '{{ __molecule__rstudio_server_sign_in_page_result["cookies_string"] }}' + follow_redirects: 'none' + status_code: 302 + register: '__molecule__rstudio_server_outsider_result' + + - name: 'Assert that a user outside the login group is refused' + ansible.builtin.assert: + that: '"error=" in __molecule__rstudio_server_outsider_result["location"]' + fail_msg: 'molecule-outsider signed in although it is not a member of rstudio-users.' + + # The IDE asks for a session with this call, so it is what starts rsession; + # requesting the page alone does not. curl rather than ansible.builtin.uri, + # because the call needs the session cookie of the sign-in above and a cookie + # jar is the only way to carry it from one request to the next. + - name: 'Sign in and request an R session' + ansible.builtin.shell: + cmd: | + set -o pipefail + cookies="$(mktemp)" + trap 'rm --force "${cookies}"' EXIT + curl --silent --show-error --cookie-jar "${cookies}" --user-agent "${user_agent}" \ + --output /dev/null "${url}/auth-sign-in" + token="$(awk '/rs-csrf-token/ {print $7}' "${cookies}")" + curl --silent --show-error --cookie "${cookies}" --cookie-jar "${cookies}" --user-agent "${user_agent}" \ + --data-urlencode 'username=molecule-member' \ + --data-urlencode 'password=linuxfabrik' \ + --data-urlencode 'staySignedIn=1' \ + --data-urlencode 'appUri=/' \ + --data-urlencode "rs-csrf-token=${token}" \ + --output /dev/null "${url}/auth-do-sign-in" + # The token is reissued with the session, so it has to be read again, + # and the RPC endpoint wants it in a header on top of the cookie. + token="$(awk '/rs-csrf-token/ {print $7}' "${cookies}")" + curl --silent --show-error --fail --cookie "${cookies}" --user-agent "${user_agent}" \ + --header 'Content-Type: application/json' \ + --header "X-RS-CSRF-Token: ${token}" \ + --data '{"method":"client_init","params":[{"scope":{}}],"clientId":null,"clientVersion":""}' \ + "${url}/rpc/client_init" + executable: '/bin/bash' + environment: + url: '{{ __molecule__rstudio_server_url }}' + user_agent: '{{ __molecule__rstudio_server_user_agent }}' + changed_when: false + register: '__molecule__rstudio_server_client_init_result' + + - name: 'Assert that the session belongs to the signed-in user' + ansible.builtin.assert: + that: '(__molecule__rstudio_server_client_init_result["stdout"] | from_json).get("result", {}).get("systemUsername", "") == "molecule-member"' + fail_msg: 'client_init answered: {{ __molecule__rstudio_server_client_init_result["stdout"][:200] }}' + + - name: 'Wait for the R session to start' + ansible.builtin.command: 'pgrep --uid molecule-member --exact rsession' + changed_when: false + register: '__molecule__rstudio_server_rsession_result' + retries: 15 + delay: 2 + until: '__molecule__rstudio_server_rsession_result["rc"] == 0' + + # R runs as the user, not as a service account, which is what makes the file + # permissions of the host apply to what a user does in RStudio. + - name: 'Assert that R runs under the account of the signed-in user' + ansible.builtin.assert: + that: '__molecule__rstudio_server_rsession_result["stdout_lines"] | length > 0' + + # rsession is forked by rserver and is not moved into a user slice, so the + # limits of the drop-in above apply to the R sessions too. That is the whole + # point of setting them. + - name: 'cat /proc//cgroup' + ansible.builtin.command: 'cat /proc/{{ __molecule__rstudio_server_rsession_result["stdout_lines"][0] }}/cgroup' + changed_when: false + register: '__molecule__rstudio_server_cgroup_result' + + - name: 'Assert that the R session runs in the control group of the service' + ansible.builtin.assert: + that: '"rstudio-server.service" in __molecule__rstudio_server_cgroup_result["stdout"]' + fail_msg: 'The R session runs in {{ __molecule__rstudio_server_cgroup_result["stdout"] }}.' From c24c2fc30377137a7e6c9217e239025e4c263d5b Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 9 Sep 2026 17:14:37 +0200 Subject: [PATCH 8/9] fix(roles/shiny_server): deploy the configuration before the package Only /etc/shiny-server was created before the package; shiny-server.conf was written after it. The package writes its own configuration when none exists and then enables and starts the service, so on a fresh host the service came up once listening on every interface and serving /srv/shiny-server without access control, until Ansible replaced the file seconds later. The comment in the task file and the README already claimed the order this commit makes true. --- roles/shiny_server/tasks/main.yml | 51 ++++++++++++++++++------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/roles/shiny_server/tasks/main.yml b/roles/shiny_server/tasks/main.yml index 8f17e428..9a7b9ec3 100644 --- a/roles/shiny_server/tasks/main.yml +++ b/roles/shiny_server/tasks/main.yml @@ -153,20 +153,12 @@ - 'shiny_server:users' +# Deployed before the package on purpose. The package copies its own configuration only when +# none exists and then enables and starts the service straight away, so without this the service +# would come up once listening on every interface and serving /srv/shiny-server without access +# control. - block: - - name: 'Gather package facts' - ansible.builtin.package_facts: # yamllint disable-line rule:empty-values - check_mode: false - - - name: 'Set installed version fact' - ansible.builtin.set_fact: - __shiny_server__installed_version: '{{ ansible_facts["packages"]["shiny-server"][0]["version"] | d("") }}' - - # Deploy the configuration before the package, not after. The package copies its own - # configuration only when none exists and then enables and starts the service straight away, - # so without this the service would come up once listening on every interface and serving - # /srv/shiny-server without access control. - name: 'mkdir -p /etc/shiny-server' ansible.builtin.file: path: '/etc/shiny-server' @@ -175,6 +167,31 @@ group: 'root' mode: 0o755 + - name: 'Deploy /etc/shiny-server/shiny-server.conf' + ansible.builtin.template: + backup: true + src: 'etc/shiny-server/shiny-server.conf.j2' + dest: '/etc/shiny-server/shiny-server.conf' + owner: 'root' + group: 'root' + mode: 0o644 + notify: 'shiny_server: reload shiny-server' + + tags: + - 'shiny_server' + - 'shiny_server:configure' + + +- block: + + - name: 'Gather package facts' + ansible.builtin.package_facts: # yamllint disable-line rule:empty-values + check_mode: false + + - name: 'Set installed version fact' + ansible.builtin.set_fact: + __shiny_server__installed_version: '{{ ansible_facts["packages"]["shiny-server"][0]["version"] | d("") }}' + - block: # Posit publishes one RPM, built against CentOS 8, for the whole RHEL family. It declares no @@ -306,16 +323,6 @@ - block: - - name: 'Deploy /etc/shiny-server/shiny-server.conf' - ansible.builtin.template: - backup: true - src: 'etc/shiny-server/shiny-server.conf.j2' - dest: '/etc/shiny-server/shiny-server.conf' - owner: 'root' - group: 'root' - mode: 0o644 - notify: 'shiny_server: reload shiny-server' - - name: 'mkdir -p /etc/systemd/system/shiny-server.service.d/' ansible.builtin.file: path: '/etc/systemd/system/shiny-server.service.d/' From 326e4d455f4385eec49e5479d98f029a944816b4 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 9 Sep 2026 17:14:38 +0200 Subject: [PATCH 9/9] style(roles/shiny_server): drop user-facing comments from the defaults Comments in a role are for developers; what an administrator needs belongs in the README, which carries all of it. The developer notes stay. --- roles/shiny_server/defaults/main.yml | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/roles/shiny_server/defaults/main.yml b/roles/shiny_server/defaults/main.yml index fd729930..30dcd921 100644 --- a/roles/shiny_server/defaults/main.yml +++ b/roles/shiny_server/defaults/main.yml @@ -9,40 +9,26 @@ shiny_server__conf_preserve_logs: false shiny_server__conf_run_as: 'shiny' shiny_server__conf_sanitize_errors: true shiny_server__conf_simple_scheduler: 100 - # Derived from shiny_server__version and the platform in vars/RedHat.yml when empty. shiny_server__download_url: '' - shiny_server__htpasswd_dir: '/etc/httpd' shiny_server__log_dir: '/var/log/shiny-server' shiny_server__logrotate: '{{ logrotate__rotate | d(14) }}' - -# Resource limit for the systemd drop-in. Empty leaves the setting out of it. shiny_server__memory_max: '' - shiny_server__service_enabled: true shiny_server__service_state: 'started' shiny_server__shared_dir: '/srv/shiny-server/shared' - # Must stay distinct from shiny_server__conf_run_as: that account's group owns the # application directory of every location left at the default run_as, so merging the # two would make those directories readable by all tenants. shiny_server__shared_group: 'shiny-shared' - -# Root the role creates; shiny_server__shared_dir and shiny_server__tenants_dir live below it. shiny_server__site_dir: '/srv/shiny-server' - -# Resource limit for the systemd drop-in. Empty leaves the setting out of it. shiny_server__tasks_max: '' - shiny_server__tenants_dir: '/srv/shiny-server/tenants' shiny_server__tmp_dir: '/var/lib/shiny-server/tmp' - # Shiny Server aborts the launch of a worker whose run_as user has no home directory, # so these have to be real directories (lib/worker/app-worker.ts:127). shiny_server__users_home_dir: '/var/lib/shiny-server/home' - -# --- Shiny Server locations --- shiny_server__locations__combined_var: '{{ ( shiny_server__locations__role_var + shiny_server__locations__dependent_var + @@ -50,13 +36,10 @@ shiny_server__locations__combined_var: '{{ ( shiny_server__locations__host_var ) | linuxfabrik.lfops.combine_lod(unique_key="path") }}' - shiny_server__locations__dependent_var: [] shiny_server__locations__group_var: [] shiny_server__locations__host_var: [] shiny_server__locations__role_var: [] - -# --- Apache httpd vHosts --- shiny_server__vhosts__combined_var: '{{ ( shiny_server__vhosts__role_var + shiny_server__vhosts__dependent_var + @@ -64,17 +47,11 @@ shiny_server__vhosts__combined_var: '{{ ( shiny_server__vhosts__host_var ) | linuxfabrik.lfops.combine_lod(unique_key="conf_server_name") }}' - shiny_server__vhosts__dependent_var: [] shiny_server__vhosts__group_var: [] shiny_server__vhosts__host_var: [] shiny_server__vhosts__role_var: [] - -# Applied to every generated vHost, overridable per vHost with the key of the same -# name without the `vhost_default_` prefix. shiny_server__vhost_default_allowed_http_methods: - # POST carries the uploads and the SockJS fallback transports, HEAD the monitoring - # checks. - 'GET' - 'HEAD' - 'OPTIONS'