Serve R Shiny applications multi-tenant: new r and shiny_server roles - #349
Draft
NavidSassan wants to merge 9 commits into
Draft
Serve R Shiny applications multi-tenant: new r and shiny_server roles#349NavidSassan wants to merge 9 commits into
NavidSassan wants to merge 9 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/randplaybooks/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 usescentos8, there is norhel8path).Rprofile.siteandRenviron.siteare not shipped by the R packages, so the role creates both.roles/shiny_serverandplaybooks/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 onelocation, one R worker and one system account per tenant application.randsetup_shiny_server(installandremove),CHANGELOG.md,COMPATIBILITY.md,playbooks/README.mdandplaybooks/all.yml.Points worth a close look
Shiny-Server-Credentials, which the application reads assession$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 to127.0.0.1, and the role aborts when a vHost passes the credentials header while the listener is reachable from the network.shiny-server.confonly 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-serverwithout access control.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 theremovesub-scenario asserts.tasks/apache-httpd-vhosts.yml, imported by the playbook'spre_tasks, becauseapache_httpdruns beforeshiny_serverand an arbitrary number of tenants needs a Jinja loop that a variable definition cannot carry.ansible.builtin.dnfanddisable_gpg_check, asroles/glpi_agentalready does, becauseansible.builtin.packagecannot 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.mdlists the RHEL family for both roles.The
setup_shiny_serververification 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 rejectPOST, 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.