Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .ansible-lint-ignore
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -137,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]
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ 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.
* **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.
Expand Down
3 changes: 3 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | | | | |
Expand All @@ -153,11 +154,13 @@ 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) | |
| 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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions extensions/molecule/r/converge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- name: 'Converge r playbook'
ansible.builtin.import_playbook: 'linuxfabrik.lfops.r'
11 changes: 11 additions & 0 deletions extensions/molecule/r/inventory/group_vars/systems_under_test.yml
Original file line number Diff line number Diff line change
@@ -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'
18 changes: 18 additions & 0 deletions extensions/molecule/r/inventory/hosts.yml
Original file line number Diff line number Diff line change
@@ -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:
1 change: 1 addition & 0 deletions extensions/molecule/r/molecule.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Molecule scenario marker
86 changes: 86 additions & 0 deletions extensions/molecule/r/verify.yml
Original file line number Diff line number Diff line change
@@ -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<major>.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"] }}'
32 changes: 32 additions & 0 deletions extensions/molecule/rstudio_server/converge.yml
Original file line number Diff line number Diff line change
@@ -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"] }}'
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions extensions/molecule/rstudio_server/inventory/hosts.yml
Original file line number Diff line number Diff line change
@@ -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:
1 change: 1 addition & 0 deletions extensions/molecule/rstudio_server/molecule.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Molecule scenario marker
Loading