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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

* **role:keycloak**: The role no longer leaves the bootstrap admin credentials lying around in `/etc/sysconfig/keycloak` after the first run. It now writes the credentials, waits for Keycloak to consume them on startup (provisioning the bootstrap admin in the `master` realm), re-renders the sysconfig file with the credentials removed, and stores a state marker at `/etc/ansible/facts.d/keycloak__admin_login_bootstrapped.state` so subsequent runs skip the credential render entirely. After the first run, `keycloak__admin_login` can be removed from the inventory. Disaster recovery: delete the marker file, re-add the variable, re-run. Also recommend a `-temp` suffix for the initial admin username (example: `keycloak-admin-temp`) so it is visually obvious in the Keycloak UI which account must be deleted once a permanent admin exists.
* **role:monitoring_plugins**: `install_method: 'source'` now reads the per-Python-LTS lockfile under `lockfiles/pyXX/requirements.txt` (`py39` ... `py314`) from both the `monitoring-plugins` and `lib` repos, picking the directory that matches the target host's Python. The previous root-level `requirements.txt` no longer exists upstream. No variable changes; rsync sources updated.
* **CONTRIBUTING**: `meta/argument_specs.yml` must declare the `__dependent_var` slot for any variable that `setup_*` playbooks inject into the role via `vars:`. Dict variables fed by external lookups like `linuxfabrik.lfops.bitwarden_item` should use `type: 'dict'` without strict sub-options, since the lookup returns the full item with additional keys.
* **role:example**: Demonstrate the `delegate_to: 'localhost'` + `become: false` pattern (download on the controller, copy to the target) so role authors can copy it consistently.
Expand Down Expand Up @@ -64,10 +65,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

* **role:keycloak**: Fix ownership under `/opt/keycloak/data/`. Previously the post-install build step ran as `root` and left `/opt/keycloak/data/` and `/opt/keycloak/data/tmp/` owned by `root:root`, which the `keycloak` service user could not write into (no `data/cache/` was ever created). The build now runs as the `keycloak` service user, and existing installations get the ownership corrected on the next role run.
* **role:nodejs**: Fix `@nodejs:<stream>` install failing with `broken groups or modules: nodejs:<stream>`. Two issues compounded: DNF refuses to silently switch an already-enabled module stream, and some modules ship without a `[d]efault` profile, so `@nodejs:<stream>` (no profile specified) cannot be resolved. The role now runs `dnf -y module reset nodejs` first when `nodejs__dnf_module_stream` is set, and installs the explicit `/common` profile.
* **role:blocky**: The handler `blocky: validate config & restart blocky.service` is now notified if the blocky binary is changed on the host to ensure that the blocky service is restarted after an update (as it was already documented for the `blocky` tag)
* **role:nextcloud**: The `nextcloud-update` script now owns the maintenance mode lifecycle itself instead of expecting callers to enable it beforehand. Previously, callers enabled maintenance mode before invoking the script (to protect the DB dump), which disables the LDAP user provider and causes the `before-update` export (`occ user:list`, `config:list`, `app:list`) to silently omit LDAP users. The script now assumes maintenance mode is **off** at start, runs the `before-update` export with apps loaded, lets `updater.phar` manage maintenance mode itself, and explicitly disables it again before `occ upgrade` and `occ app:update` (since `occ upgrade` does not turn it off on its own) — so all post-upgrade commands (`app:update`, `db:add-missing-*`, `db:convert-filecache-bigint`, the `after-update` export) also run with apps loaded. Callers must drop the manual `maintenance:mode --on` step from their pre-script workflow; the DB dump should rely on `--single-transaction` instead.

* **roles**: Set `become: false` on tasks delegated to localhost across the collection. Previously these tasks inherited `become: true` from the playbook level and tried to call `sudo` on the Ansible controller, which fails on controllers without a passwordless sudo setup with `sudo: a password is required`. Affected are all `repo_*` roles, the `*_vm` cloud roles (`exoscale_vm`, `hetzner_vm`, `infomaniak_vm`), all `icingaweb2_module_*` roles that download artefacts, `monitoring_plugins`, `shared`, plus several others. Existing playbooks that were working without playbook-level `become: true` are unaffected ([#242](https://github.com/Linuxfabrik/lfops/issues/242)).
* **role:repo_monitoring_plugins**: Add the missing `run_once: true` on the local repo-key download task on Red Hat platforms, matching the Debian variant. The key is now downloaded once per run instead of once per host.
* **role:network**: README still claimed the role disables zeroconf, but the corresponding `NOZEROCONF=yes` task was removed in 2024 (NetworkManager no longer adds the zeroconf route by default). Bring the README in line with what the role actually does and call out the Hetzner-specific `hc-utils` cleanup explicitly.
Expand Down
15 changes: 10 additions & 5 deletions roles/keycloak/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,17 @@ All Keycloak config settings are described here: https://www.keycloak.org/server

`keycloak__admin_login`

* The *temporary* Keycloak Admin login credentials. To harden security, create a permanent admin account after logging in as a temporary admin user, and delete the temporary one.
* The *temporary* Keycloak bootstrap admin login credentials. Keycloak only honors `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` on the very first start, when no admin user exists in the `master` realm yet. Subsequent restarts ignore these variables.
* Mandatory only on the first role run. The role writes the credentials to `/etc/sysconfig/keycloak`, restarts Keycloak so it consumes them and provisions the bootstrap admin in the `master` realm, then immediately re-renders the sysconfig file with the credentials removed and marks the bootstrap as done via `/etc/ansible/facts.d/keycloak__admin_login_bootstrapped.state`. The cleartext password no longer lingers on disk after the role finishes.
* On subsequent runs the role detects the marker file and renders the sysconfig file without credentials right away. `keycloak__admin_login` can be removed from the inventory at that point.
* Use a username that visibly marks the account as throwaway (suffix `-temp`), so it is obvious in the Keycloak UI which account must be deleted once a permanent admin has been created.
* For disaster recovery (e.g. lost database, need to re-bootstrap an admin): remove `/etc/ansible/facts.d/keycloak__admin_login_bootstrapped.state`, re-add `keycloak__admin_login` to the inventory, and re-run the role.
* Type: Dictionary.
* Subkeys:

* `username`:

* Mandatory. Username.
* Mandatory. Username. By convention, end with `-temp` (e.g. `keycloak-admin-temp`) to flag the account as the bootstrap user that must be deleted after the permanent admin is in place.
* Type: String.

* `password`:
Expand Down Expand Up @@ -92,17 +96,18 @@ All Keycloak config settings are described here: https://www.keycloak.org/server
`keycloak__version`

* The version of Keycloak that should be installed.
* Possible options: <https://github.com/keycloak/keycloak/releases>.
* Type: String.

Example:
```yaml
# mandatory
keycloak__admin_login:
password: 'password'
username: 'keycloak-admin'
username: 'keycloak-admin-temp'
password: 'linuxfabrik'
keycloak__db_login:
password: 'password'
username: 'keycloak'
password: 'linuxfabrik'
keycloak__hostname: 'keycloak.local'
keycloak__version: '26.1.2'
```
Expand Down
152 changes: 129 additions & 23 deletions roles/keycloak/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -1,28 +1,42 @@
- block:

- name: 'Debug Variables:'
- name: 'stat /etc/ansible/facts.d/keycloak__admin_login_bootstrapped.state'
ansible.builtin.stat:
path: '/etc/ansible/facts.d/keycloak__admin_login_bootstrapped.state'
register: '__keycloak__admin_login_bootstrapped_stat'

# Read-only fact used in the debug output and as the default value of the per-render
# `__keycloak__include_bootstrap_creds` flag below. Do NOT reuse this fact directly in the
# sysconfig template: set_fact has higher Ansible variable precedence than task-level `vars:`,
# so a task trying to override this fact would be silently ignored.
- name: 'Set __keycloak__admin_login_bootstrapped'
ansible.builtin.set_fact:
__keycloak__admin_login_bootstrapped: '{{ __keycloak__admin_login_bootstrapped_stat["stat"]["exists"] }}'

- name: 'Debug Variables'
ansible.builtin.debug:
msg: |
keycloak__admin_login.username: {{ keycloak__admin_login.username }}
keycloak__db_login.username: {{ keycloak__db_login.username }}
keycloak__db_url_database: {{ keycloak__db_url_database }}
keycloak__db_url_host: {{ keycloak__db_url_host }}
keycloak__db_vendor: {{ keycloak__db_vendor }}
keycloak__expose_healthcheck_endpoints: {{ keycloak__expose_healthcheck_endpoints }}
keycloak__expose_metrics_endpoints: {{ keycloak__expose_metrics_endpoints }}
keycloak__hostname: {{ keycloak__hostname }}
keycloak__hostname_backchannel_dynamic: {{ keycloak__hostname_backchannel_dynamic }}
keycloak__https_certificate_file: {{ keycloak__https_certificate_file }}
keycloak__https_certificate_key_file: {{ keycloak__https_certificate_key_file }}
keycloak__https_protocols: {{ keycloak__https_protocols }}
keycloak__log: {{ keycloak__log }}
keycloak__log_file: {{ keycloak__log_file }}
keycloak__mode: {{ keycloak__mode }}
keycloak__proxy_headers: {{ keycloak__proxy_headers }}
keycloak__service_enabled: {{ keycloak__service_enabled }}
keycloak__spi_sticky_session_encoder_infinispan_should_attach_route: {{ keycloak__spi_sticky_session_encoder_infinispan_should_attach_route }}
keycloak__state: {{ keycloak__state }}
keycloak__version: {{ keycloak__version }}
msg:
- 'keycloak__admin_login.username: {{ (keycloak__admin_login | d({}))["username"] | d("(unset)") }}'
- '/etc/ansible/facts.d/keycloak__admin_login_bootstrapped.state exists: {{ __keycloak__admin_login_bootstrapped }}'
- 'keycloak__db_login.username: {{ keycloak__db_login["username"] }}'
- 'keycloak__db_url_database: {{ keycloak__db_url_database }}'
- 'keycloak__db_url_host: {{ keycloak__db_url_host }}'
- 'keycloak__db_vendor: {{ keycloak__db_vendor }}'
- 'keycloak__expose_healthcheck_endpoints: {{ keycloak__expose_healthcheck_endpoints }}'
- 'keycloak__expose_metrics_endpoints: {{ keycloak__expose_metrics_endpoints }}'
- 'keycloak__hostname: {{ keycloak__hostname }}'
- 'keycloak__hostname_backchannel_dynamic: {{ keycloak__hostname_backchannel_dynamic }}'
- 'keycloak__https_certificate_file: {{ keycloak__https_certificate_file }}'
- 'keycloak__https_certificate_key_file: {{ keycloak__https_certificate_key_file }}'
- 'keycloak__https_protocols: {{ keycloak__https_protocols }}'
- 'keycloak__log: {{ keycloak__log }}'
- 'keycloak__log_file: {{ keycloak__log_file }}'
- 'keycloak__mode: {{ keycloak__mode }}'
- 'keycloak__proxy_headers: {{ keycloak__proxy_headers }}'
- 'keycloak__service_enabled: {{ keycloak__service_enabled }}'
- 'keycloak__spi_sticky_session_encoder_infinispan_should_attach_route: {{ keycloak__spi_sticky_session_encoder_infinispan_should_attach_route }}'
- 'keycloak__state: {{ keycloak__state }}'
- 'keycloak__version: {{ keycloak__version }}'

tags:
- 'always'
Expand Down Expand Up @@ -95,6 +109,8 @@
owner: 'root'
group: 'root'
mode: 0o640
vars:
__keycloak__include_bootstrap_creds: '{{ not __keycloak__admin_login_bootstrapped }}'
notify: 'keycloak: systemctl restart keycloak'

- name: 'Create keycloak.service'
Expand All @@ -118,10 +134,14 @@

- block:

- name: 'Change the working directory to /opt/keycloak and bin/kc.sh build --db {{ keycloak__db_vendor }}'
# kc.sh build always rebuilds and gives no reliable idempotency signal in its stdout, hence changed_when: true.
- name: 'cd /opt/keycloak && bin/kc.sh build --db {{ keycloak__db_vendor }}'
ansible.builtin.command: 'bin/kc.sh build --db {{ keycloak__db_vendor }}'
args:
chdir: '/opt/keycloak'
become: true
become_user: 'keycloak'
changed_when: true
when: 'keycloak__mode == "production"'

tags:
Expand All @@ -144,3 +164,89 @@
- 'keycloak'
- 'keycloak:configure'
- 'keycloak:state'


- block:

- name: 'Flush handlers so Keycloak restarts with the bootstrap credentials loaded'
ansible.builtin.meta: 'flush_handlers'

# Port-up only proves Quarkus is listening, not that the bootstrap admin was provisioned.
# The admin-cli token request below is the actual readiness check; this wait_for just
# avoids spamming "connection refused" before Keycloak has bound the socket.
- name: 'Wait for Keycloak listener on port {{ __keycloak__listen_port }}'
ansible.builtin.wait_for:
port: '{{ __keycloak__listen_port }}'
host: '127.0.0.1'
delay: 5
sleep: 2
timeout: 300
vars:
__keycloak__listen_port: '{{ (keycloak__https_certificate_file | length > 0) | ternary(8443, 8080) }}'

# Verify the bootstrap admin actually exists with the configured password before touching
# the marker. If this fails, the marker stays absent and a re-run with corrected variables
# will re-render the credentials and retry.
- name: 'curl --data grant_type=password --data client_id=admin-cli {{ __keycloak__base_url }}/realms/master/protocol/openid-connect/token'
ansible.builtin.uri:
url: '{{ __keycloak__base_url }}/realms/master/protocol/openid-connect/token'
method: 'POST'
body_format: 'form-urlencoded'
body:
client_id: 'admin-cli'
grant_type: 'password'
password: '{{ keycloak__admin_login["password"] }}'
username: '{{ keycloak__admin_login["username"] }}'
status_code: 200
validate_certs: false
register: '__keycloak__admin_token_result'
until: '__keycloak__admin_token_result["status"] | d(0) == 200'
retries: 30
delay: 5
no_log: true
changed_when: false
check_mode: false
vars:
__keycloak__listen_port: '{{ (keycloak__https_certificate_file | length > 0) | ternary(8443, 8080) }}'
__keycloak__scheme: '{{ (keycloak__https_certificate_file | length > 0) | ternary("https", "http") }}'
__keycloak__base_url: '{{ __keycloak__scheme }}://127.0.0.1:{{ __keycloak__listen_port }}'

# `backup: false` is on purpose. The previous file contained the cleartext bootstrap password;
# writing a `.<timestamp>~` backup would leave those credentials on disk and defeat the
# whole point of this cleanup pass.
- name: 'Deploy /etc/sysconfig/keycloak (without bootstrap credentials)'
ansible.builtin.template:
backup: false
src: 'etc/sysconfig/keycloak-sysconfig.j2'
dest: '/etc/sysconfig/keycloak'
owner: 'root'
group: 'root'
mode: 0o640
vars:
__keycloak__include_bootstrap_creds: false

- name: 'mkdir -p /etc/ansible/facts.d'
ansible.builtin.file:
path: '/etc/ansible/facts.d'
state: 'directory'
owner: 'root'
group: 'root'
mode: 0o755

# `copy` with `content: ''` instead of `file` with `state: touch` so the task does not
# report changed on every run after the bootstrap is complete.
- name: 'Create state marker /etc/ansible/facts.d/keycloak__admin_login_bootstrapped.state'
ansible.builtin.copy:
content: ''
dest: '/etc/ansible/facts.d/keycloak__admin_login_bootstrapped.state'
owner: 'root'
group: 'root'
mode: 0o600

# block
when:
- 'not (__keycloak__admin_login_bootstrapped | bool)'
- 'keycloak__state == "started"'
tags:
- 'keycloak'
- 'keycloak:configure'
20 changes: 15 additions & 5 deletions roles/keycloak/templates/etc/sysconfig/keycloak-sysconfig.j2
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
# {{ ansible_managed }}
# 2025022701
# 2026051808

# WARN: Environment variable 'KEYCLOAK_ADMIN' is deprecated, use 'KC_BOOTSTRAP_ADMIN_USERNAME' instead
# WARN: Environment variable 'KEYCLOAK_ADMIN_PASSWORD' is deprecated, use 'KC_BOOTSTRAP_ADMIN_PASSWORD' instead
KC_BOOTSTRAP_ADMIN_USERNAME={{ keycloak__admin_login.username }}
KC_BOOTSTRAP_ADMIN_PASSWORD={{ keycloak__admin_login.password }}
{% if __keycloak__include_bootstrap_creds | d(false) | bool %}
{# WARN: Environment variable 'KEYCLOAK_ADMIN' is deprecated, use 'KC_BOOTSTRAP_ADMIN_USERNAME' instead #}
{# WARN: Environment variable 'KEYCLOAK_ADMIN_PASSWORD' is deprecated, use 'KC_BOOTSTRAP_ADMIN_PASSWORD' instead #}
KC_BOOTSTRAP_ADMIN_USERNAME={{ keycloak__admin_login["username"] }}
KC_BOOTSTRAP_ADMIN_PASSWORD={{ keycloak__admin_login["password"] }}
{% else %}
# Bootstrap admin credentials have been removed by the role after they served
# their purpose on the first Keycloak start. Re-bootstrap (e.g. after a DB
# loss) by removing /etc/ansible/facts.d/keycloak__admin_login_bootstrapped.state and
# re-running the role with `keycloak__admin_login` defined.
#
# This file is intentionally kept on disk (empty of credentials) because
# keycloak.service references it as a mandatory `EnvironmentFile=`.
{% endif %}
Loading