Skip to content

Serve R Shiny applications multi-tenant: new r and shiny_server roles - #349

Draft
NavidSassan wants to merge 9 commits into
mainfrom
feat/shiny-server
Draft

Serve R Shiny applications multi-tenant: new r and shiny_server roles#349
NavidSassan wants to merge 9 commits into
mainfrom
feat/shiny-server

Conversation

@NavidSassan

Copy link
Copy Markdown
Member

Adds two roles and their playbooks, so that R Shiny applications can be served to several tenants from one host.

What is in here

  • roles/r and playbooks/r.yml: installs R and pandoc from EPEL and points R at the Posit Public Package Manager, so that CRAN packages arrive as prebuilt binaries instead of being compiled on the host. The path component differs per RHEL generation and is selected automatically (RHEL 8 uses centos8, there is no rhel8 path). Rprofile.site and Renviron.site are not shipped by the R packages, so the role creates both.
  • roles/shiny_server and playbooks/setup_shiny_server.yml: installs Shiny Server Open Source and makes it multi-tenant. One Apache httpd vHost per tenant, HTTP basic authentication per tenant, and one location, one R worker and one system account per tenant application.
  • Molecule scenarios r and setup_shiny_server (install and remove), CHANGELOG.md, COMPATIBILITY.md, playbooks/README.md and playbooks/all.yml.

Points worth a close look

  • Shiny Server Open Source authenticates nobody and passes every client header through to the R worker, including Shiny-Server-Credentials, which the application reads as session$user. The separation therefore happens in front of it (Apache, basic auth, RequestHeader set) and below it (one account per tenant). The listener is bound to 127.0.0.1, and the role aborts when a vHost passes the credentials header while the listener is reachable from the network.
  • The configuration is deployed before the package. The package writes its own shiny-server.conf only when none exists and then starts the service immediately, so the other order would bring the service up once on every interface, serving /srv/shiny-server without access control.
  • Removal semantics. Dropping a location from the inventory only stops it from being served. An explicit state: 'absent' deletes the application directory with the tenant's data in it, the log directory and the account. Anything a still active location shares is kept, which is what the remove sub-scenario asserts.
  • The vHost injections are rendered from a template in tasks/apache-httpd-vhosts.yml, imported by the playbook's pre_tasks, because apache_httpd runs before shiny_server and an arbitrary number of tenants needs a Jinja loop that a variable definition cannot carry.
  • Posit publishes one RPM for the whole RHEL family, built against CentOS 8, unsigned and with no declared dependencies. It is installed with ansible.builtin.dnf and disable_gpg_check, as roles/glpi_agent already does, because ansible.builtin.package cannot install a local file with the GPG check off.

Testing

Both scenarios provision Rocky 8, 9 and 10 VMs. Debian and Ubuntu are deliberately absent: Posit builds its Debian package against Ubuntu 20.04 only, and COMPATIBILITY.md lists the RHEL family for both roles.

molecule test --scenario-name r
molecule test --scenario-name setup_shiny_server/install
molecule test --scenario-name setup_shiny_server/remove

The setup_shiny_server verification asks the running system what only it can answer: that each tenant gets its own R worker under its own account, that one tenant cannot read the other's application files, that the reverse proxy asks for a password and does not reject POST, and that the resource limits of the systemd drop-in reach the running unit.

Draft

Opened as a draft: the Molecule scenarios still have to be run against the full Rocky 8 / 9 / 10 matrix on this branch state before this is ready for review.

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<major>.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`.
…erver

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.
…s 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.
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<major>.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.
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.
…io 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.
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.
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.
Comments in a role are for developers; what an administrator needs belongs in
the README, which carries all of it. The developer notes stay.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant