From 1250e1c03b41c167e8ce619c827d8f412261353a Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Sun, 13 Sep 2026 23:51:37 +0200 Subject: [PATCH 01/12] style(roles/postgresql_server): fix typos and a duplicated config heading The restart handler was called "restart posgresql", and the README misspelled the service and "version". z00-linuxfabrik.conf repeated the "CONNECTIONS AND AUTHENTICATION" section heading; it now uses the subsection comments of upstream's postgresql.conf.sample. --- roles/postgresql_server/README.md | 4 ++-- roles/postgresql_server/handlers/main.yml | 2 +- roles/postgresql_server/tasks/main.yml | 6 +++--- .../var/lib/pgsql/data/conf.d/z00-linuxfabrik.conf.j2 | 9 ++++----- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/roles/postgresql_server/README.md b/roles/postgresql_server/README.md index 9cec62940..9b15d2c87 100644 --- a/roles/postgresql_server/README.md +++ b/roles/postgresql_server/README.md @@ -19,7 +19,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `postgresql_server` * Installs and configures PostgreSQL. -* Triggers: posgresql.service restart. +* Triggers: postgresql.service restart. `postgresql_server:state` @@ -267,7 +267,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `postgresql_server__version` -* Specifies the PostgreSQL verison to install (use only the major version number like `'14'`. The latest minor version is used). Set this when using the official PostgreSQL Repo. +* Specifies the PostgreSQL version to install (use only the major version number like `'14'`. The latest minor version is used). Set this when using the official PostgreSQL Repo. * Type: String. * Default: `''` diff --git a/roles/postgresql_server/handlers/main.yml b/roles/postgresql_server/handlers/main.yml index ed754f667..c144d5a98 100644 --- a/roles/postgresql_server/handlers/main.yml +++ b/roles/postgresql_server/handlers/main.yml @@ -4,7 +4,7 @@ # enabled task), so that enabled-only changes don't prevent the restart. # `is not defined` covers a run that skips the state block (`--skip-tags postgresql_server:state`), # where the result is never registered. -- name: 'postgresql_server: restart posgresql' +- name: 'postgresql_server: restart postgresql' ansible.builtin.service: name: 'postgresql-{{ postgresql_server__version }}' state: 'restarted' diff --git a/roles/postgresql_server/tasks/main.yml b/roles/postgresql_server/tasks/main.yml index 7e49a9304..210156e44 100644 --- a/roles/postgresql_server/tasks/main.yml +++ b/roles/postgresql_server/tasks/main.yml @@ -61,7 +61,7 @@ line: "include_dir = 'conf.d'" regexp: '^include_dir(\s+|\s*=\s*)?''conf.d''\s*(#.*)?$' path: '{{ postgresql_server__data_dir }}/postgresql.conf' - notify: 'postgresql_server: restart posgresql' + notify: 'postgresql_server: restart postgresql' - name: 'mkdir -p {{ postgresql_server__data_dir }}/conf.d' ansible.builtin.file: @@ -79,7 +79,7 @@ owner: 'postgres' group: 'postgres' mode: 0o600 - notify: 'postgresql_server: restart posgresql' + notify: 'postgresql_server: restart postgresql' - name: 'Deploy {{ postgresql_server__data_dir }}/pg_hba.conf' ansible.builtin.template: @@ -89,7 +89,7 @@ owner: 'postgres' group: 'postgres' mode: 0o600 - notify: 'postgresql_server: restart posgresql' + notify: 'postgresql_server: restart postgresql' - name: 'Remove rpmnew / rpmsave (and Debian equivalents)' ansible.builtin.include_role: diff --git a/roles/postgresql_server/templates/var/lib/pgsql/data/conf.d/z00-linuxfabrik.conf.j2 b/roles/postgresql_server/templates/var/lib/pgsql/data/conf.d/z00-linuxfabrik.conf.j2 index 54006a88b..8957ea338 100644 --- a/roles/postgresql_server/templates/var/lib/pgsql/data/conf.d/z00-linuxfabrik.conf.j2 +++ b/roles/postgresql_server/templates/var/lib/pgsql/data/conf.d/z00-linuxfabrik.conf.j2 @@ -1,17 +1,16 @@ # {{ ansible_managed }} -# 2024061001 +# 2026091301 #------------------------------------------------------------------------------ # CONNECTIONS AND AUTHENTICATION #------------------------------------------------------------------------------ +# - Connection Settings - + listen_addresses = '{{ postgresql_server__conf_listen_addresses | d(['localhost']) | join(',') }}' port = {{ postgresql_server__conf_port | d(5432) }} max_connections = {{ postgresql_server__conf_max_connections | d(100) }} - -#------------------------------------------------------------------------------ -# CONNECTIONS AND AUTHENTICATION -#------------------------------------------------------------------------------ +# - Authentication - password_encryption = {{ postgresql_server__conf_password_encryption | d('scram-sha-256') }} From cc9bcedd98de7507ba2acb63ff2981b0957af720 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Sun, 13 Sep 2026 23:51:38 +0200 Subject: [PATCH 02/12] fix(roles/postgresql_server): restart the distribution package service under its real name The handler hardcoded `postgresql-{{ postgresql_server__version }}`, which is `postgresql-` without a version, so every configuration change on a distribution package installation failed in the handler. Use the service name the tasks already determine. --- CHANGELOG.md | 1 + roles/postgresql_server/handlers/main.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7787906a..234cc4a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:postgresql_server**: A configuration change on a PostgreSQL installed from the distribution packages restarts `postgresql.service` instead of aborting the run on a service called `postgresql-`. * **role:grafana**: With `grafana__auth_jwt: true`, the run no longer aborts at `generate JWT RSA private key` with `Cannot detect the required Python library cryptography` on hosts that lack it, because the playbooks deploying Grafana install `python3-cryptography` first. * **role:clamav, role:sshd**: `--tags clamav:configure`, `--tags clamav:state` and `--tags sshd:state` no longer abort on an undefined variable, so a restart skipped with `lfops__skip_restart_handlers` can be caught up with `--tags :state` as the README describes. * **role:php**: A playbook that includes PHP, such as `setup_nextcloud`, no longer aborts at `Get PHP version` when it is run with another role's tags, for example `--tags apache_httpd`, against a host that has no PHP installed yet. diff --git a/roles/postgresql_server/handlers/main.yml b/roles/postgresql_server/handlers/main.yml index c144d5a98..ea11c24ac 100644 --- a/roles/postgresql_server/handlers/main.yml +++ b/roles/postgresql_server/handlers/main.yml @@ -6,7 +6,7 @@ # where the result is never registered. - name: 'postgresql_server: restart postgresql' ansible.builtin.service: - name: 'postgresql-{{ postgresql_server__version }}' + name: '{{ postgresql_server__service_name }}' state: 'restarted' when: - 'not (lfops__skip_restart_handlers | d(false) | bool)' From a39cf39bc251bceba0b1c8146c0c3be216efe7f1 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Sun, 13 Sep 2026 23:51:39 +0200 Subject: [PATCH 03/12] feat(roles/postgresql_server)!: manage the pg_hba.conf entries as combined variables postgresql_server__pg_hba_entries was a plain list: an inventory replaced the defaults wholesale and could not remove a single one. It is split into postgresql_server__pg_hba_local_entries__*_var (key: database, user) and postgresql_server__pg_hba_host_entries__*_var (key: type, database, user, address), both with a state subkey. The split exists because a `local` record has no address, and combine_lod requires every key to be present. Rendering all local records before all host records is safe: check_hba() in src/backend/libpq/hba.c matches `local` records against Unix-socket connections only and `host*` records against TCP connections only. `type` stays in the host key because `host` matches the same TCP connection as `hostssl` or `hostnossl`. PostgreSQL uses the first matching record, and the role defaults are catch-all records, so the template writes the entries that do not replace a default first. combine_lod keeps an entry where it was first seen, so the combined variable alone would put every inventory entry behind the catch-alls, where it can never match. The run aborts while the old variable is still set, since Ansible would otherwise ignore it silently, and on a host entry with an unknown type or without an address, which keeps the postmaster from starting at all ("could not load pg_hba.conf"). --- CHANGELOG.md | 1 + roles/postgresql_server/README.md | 95 ++++++++++++++++--- roles/postgresql_server/defaults/main.yml | 43 +++++++-- roles/postgresql_server/tasks/main.yml | 28 ++++++ .../var/lib/pgsql/data/pg_hba.conf.j2 | 39 +++++++- 5 files changed, 181 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 234cc4a9b..6ac758355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +* **role:postgresql_server**: `postgresql_server__pg_hba_entries` is replaced by `postgresql_server__pg_hba_local_entries__host_var` / `__group_var` for `local` records and `postgresql_server__pg_hba_host_entries__host_var` / `__group_var` for all `host*` records. Both are merged with the role defaults instead of replacing them, and an entry with `state: 'absent'` removes a default. Move your `local` entries to the first variable without their `type` key, and all other entries to the second, where `address` is mandatory. Entries from the inventory are written before the role defaults. The run aborts as long as the old variable is set. * **role:icingaweb2**: The session and remember-me cookies always carry the `Secure` flag, also behind a reverse proxy that terminates TLS and talks plain HTTP to IcingaWeb2, where IcingaWeb2 left the flag off. An IcingaWeb2 that browsers reach over plain HTTP no longer logs anyone in until `icingaweb2__cookie_secure: false` is set. * **role:apache_httpd**: Every entry in `apache_httpd__htpasswd__*_var` needs the `path` subkey, because the username and the path together identify an entry. Until now, entries with the same username but a different `path` were collapsed into one, so only the last file got the user; a user listed with different paths in group and host variables is now written to both files. On entries that relied on the default, set `path: '/etc/httpd/.htpasswd'` (RedHat) or `path: '/etc/apache2/.htpasswd'` (Debian and Ubuntu), otherwise the play fails with an error naming the entry. * **role:postfix**: On RHEL 10 the role deploys the compatibility level the distribution ships (`3.8`) instead of the RHEL 8 / 9 value it applied everywhere. Postfix now matches TLS fingerprints with SHA-256 instead of MD5, evaluates the relay restrictions before the recipient restrictions, and uses the neutral wording in its postscreen log lines. Re-generate any peer fingerprint pinned as MD5 in a TLS policy table, and check log parsers keyed on the old postscreen wording. Set `postfix__compatibility_level: '2'` to restore the previous behaviour. RHEL 8, RHEL 9 and Debian are unaffected. diff --git a/roles/postgresql_server/README.md b/roles/postgresql_server/README.md index 9b15d2c87..541d11fe0 100644 --- a/roles/postgresql_server/README.md +++ b/roles/postgresql_server/README.md @@ -146,37 +146,94 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * Type: String. * Default: unset -`postgresql_server__pg_hba_entries` +`postgresql_server__pg_hba_host_entries__host_var` / `postgresql_server__pg_hba_host_entries__group_var` -* List of [host based authentication](https://www.postgresql.org/docs/current/static/auth-pg-hba-conf.html) entries. +* [Client authentication](https://www.postgresql.org/docs/current/auth-pg-hba-conf.html) records for TCP/IP connections (`host`, `hostssl`, `hostnossl`, `hostgssenc`, `hostnogssenc`). Entries are identified by `type`, `database`, `user` and `address` together. +* PostgreSQL uses the first record that matches a connection. The role renders the entries from the inventory in their inventory order, followed by the role defaults, which are catch-all records. An entry that uses the same identifying keys as a default replaces that default in place. +* For the usage in `host_vars` / `group_vars` (can only be used in one group at a time). * Type: List of dictionaries. -* Default: Allow `scram-sha-256` for all `local` and `host` +* Default: + + ```yaml + - type: 'host' + database: 'all' + user: 'all' + address: '127.0.0.1/32' + auth_method: 'scram-sha-256' + ``` + * Subkeys: * `type`: - * Mandatory. Record type. + * Mandatory. Record type. One of `host`, `hostgssenc`, `hostnogssenc`, `hostnossl`, `hostssl`. * Type: String. * `database`: - * Mandatory. Specifies which database name(s) this record matches. + * Mandatory. Database name(s) this record matches, for example `all` or `db1,db2`. * Type: String. * `user`: - * Mandatory. Specifies which database user name(s) this record matches. + * Mandatory. Database user name(s) this record matches, for example `all` or `user1`. * Type: String. * `address`: - * Optional. Specifies the client machine address(es) that this record matches. + * Mandatory. Client address(es) this record matches, for example `192.0.2.0/24`, `all`, `samenet` or a host name. + * Type: String. + + * `auth_method`: + + * Optional. Authentication method for a connection that matches this record. + * Type: String. + * Default: `'scram-sha-256'` + + * `auth_options`: + + * Optional. Options for the `auth_method`. * Type: String. * Default: `''` + * `state`: + + * Optional. `present` or `absent`. + * Type: String. + * Default: `'present'` + +`postgresql_server__pg_hba_local_entries__host_var` / `postgresql_server__pg_hba_local_entries__group_var` + +* [Client authentication](https://www.postgresql.org/docs/current/auth-pg-hba-conf.html) records of type `local`, for connections over the Unix-domain socket. Entries are identified by `database` and `user` together. +* PostgreSQL uses the first record that matches a connection. The role renders the entries from the inventory in their inventory order, followed by the role defaults, which are catch-all records. An entry that uses the same identifying keys as a default replaces that default in place. +* For the usage in `host_vars` / `group_vars` (can only be used in one group at a time). +* Type: List of dictionaries. +* Default: + + ```yaml + - database: 'all' + user: 'postgres' + auth_method: 'peer' + - database: 'all' + user: 'all' + auth_method: 'scram-sha-256' + ``` + +* Subkeys: + + * `database`: + + * Mandatory. Database name(s) this record matches, for example `all` or `db1,db2`. + * Type: String. + + * `user`: + + * Mandatory. Database user name(s) this record matches, for example `all` or `user1`. + * Type: String. + * `auth_method`: - * Optional. Specifies the authentication method to use when a connection matches this record. + * Optional. Authentication method for a connection that matches this record. * Type: String. * Default: `'scram-sha-256'` @@ -186,6 +243,12 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * Type: String. * Default: `''` + * `state`: + + * Optional. `present` or `absent`. + * Type: String. + * Default: `'present'` + `postgresql_server__privs__host_var` / `postgresql_server__privs__group_var` * List of dictionaries containing PostgreSQL privileges to apply. @@ -290,13 +353,17 @@ postgresql_server__dump_directory: '/backup/postgresql-dump' postgresql_server__dump_on_calendar: '*-*-* 21:30:00' postgresql_server__enabled: true postgresql_server__login_password: 'linuxfabrik' -postgresql_server__pg_hba_entries: - - type: 'local' - database: 'all' - user: 'all' - - type: 'host' - database: 'all' +postgresql_server__pg_hba_host_entries__host_var: + - type: 'hostssl' + database: 'database1' + user: 'user1' + address: '192.0.2.0/24' +postgresql_server__pg_hba_local_entries__host_var: + - database: 'all' user: 'all' + auth_method: 'reject' + - database: 'database1' + user: 'user1' postgresql_server__privs__host_var: - privs: - 'CONNECT' diff --git a/roles/postgresql_server/defaults/main.yml b/roles/postgresql_server/defaults/main.yml index f44c4e3c2..c2546ca06 100644 --- a/roles/postgresql_server/defaults/main.yml +++ b/roles/postgresql_server/defaults/main.yml @@ -18,18 +18,45 @@ postgresql_server__databases__role_var: [] postgresql_server__dump_directory: '/backup/postgresql-dump' postgresql_server__dump_on_calendar: '*-*-* 21:{{ 59 | random(start=0, seed=inventory_hostname) }}:00' postgresql_server__enabled: true -postgresql_server__pg_hba_entries: - - type: 'local' - database: 'all' - user: 'postgres' - auth_method: 'peer' - - type: 'local' - database: 'all' - user: 'all' +# pg_hba.conf is split by connection kind because a `local` record has no address. A `local` +# record only matches Unix-socket connections and a `host*` record only TCP connections +# (check_hba() in src/backend/libpq/hba.c), so rendering all local records before all host +# records does not change which record a connection matches. +# `type` is part of the host key because `host` matches the same TCP connection as `hostssl` or +# `hostnossl`. +postgresql_server__pg_hba_host_entries__combined_var: '{{ ( + postgresql_server__pg_hba_host_entries__role_var + + postgresql_server__pg_hba_host_entries__dependent_var + + postgresql_server__pg_hba_host_entries__group_var + + postgresql_server__pg_hba_host_entries__host_var + ) | linuxfabrik.lfops.combine_lod(unique_key=["type", "database", "user", "address"]) + }}' +postgresql_server__pg_hba_host_entries__dependent_var: [] +postgresql_server__pg_hba_host_entries__group_var: [] +postgresql_server__pg_hba_host_entries__host_var: [] +postgresql_server__pg_hba_host_entries__role_var: - type: 'host' database: 'all' user: 'all' address: '127.0.0.1/32' + auth_method: 'scram-sha-256' +postgresql_server__pg_hba_local_entries__combined_var: '{{ ( + postgresql_server__pg_hba_local_entries__role_var + + postgresql_server__pg_hba_local_entries__dependent_var + + postgresql_server__pg_hba_local_entries__group_var + + postgresql_server__pg_hba_local_entries__host_var + ) | linuxfabrik.lfops.combine_lod(unique_key=["database", "user"]) + }}' +postgresql_server__pg_hba_local_entries__dependent_var: [] +postgresql_server__pg_hba_local_entries__group_var: [] +postgresql_server__pg_hba_local_entries__host_var: [] +postgresql_server__pg_hba_local_entries__role_var: + - database: 'all' + user: 'postgres' + auth_method: 'peer' + - database: 'all' + user: 'all' + auth_method: 'scram-sha-256' postgresql_server__privs__combined_var: '{{ ( postgresql_server__privs__role_var + postgresql_server__privs__dependent_var + diff --git a/roles/postgresql_server/tasks/main.yml b/roles/postgresql_server/tasks/main.yml index 210156e44..e59a7f323 100644 --- a/roles/postgresql_server/tasks/main.yml +++ b/roles/postgresql_server/tasks/main.yml @@ -1,3 +1,31 @@ +- block: + + # Ansible ignores an unknown inventory variable without a word, so an inventory that still sets + # the replaced variable would silently deploy the role defaults instead. + - name: 'Assert that postgresql_server__pg_hba_entries is not set' + ansible.builtin.assert: + that: + - 'postgresql_server__pg_hba_entries is not defined' + quiet: true + fail_msg: 'postgresql_server__pg_hba_entries is replaced by postgresql_server__pg_hba_local_entries__*_var and postgresql_server__pg_hba_host_entries__*_var. Move the entries there, see the role README.' + + # A host record without an address, or with a type PostgreSQL does not know, keeps + # PostgreSQL from starting at all: postmaster aborts with "could not load pg_hba.conf". + - name: 'Assert that every pg_hba host entry has a valid type and an address' + ansible.builtin.assert: + that: + - 'item["type"] in ["host", "hostgssenc", "hostnogssenc", "hostnossl", "hostssl"]' + - 'item["address"] | string | length > 0' + quiet: true + fail_msg: 'postgresql_server__pg_hba_host_entries: "type" must be one of host, hostgssenc, hostnogssenc, hostnossl or hostssl, and "address" must not be empty.' + loop: '{{ postgresql_server__pg_hba_host_entries__combined_var }}' + loop_control: + label: '{{ item["type"] }} {{ item["database"] }} {{ item["user"] }} {{ item["address"] }}' + + tags: + - 'always' + + - block: - name: 'Install postgresql{{ postgresql_server__version }}-server' diff --git a/roles/postgresql_server/templates/var/lib/pgsql/data/pg_hba.conf.j2 b/roles/postgresql_server/templates/var/lib/pgsql/data/pg_hba.conf.j2 index fd6580c63..4b11829ca 100644 --- a/roles/postgresql_server/templates/var/lib/pgsql/data/pg_hba.conf.j2 +++ b/roles/postgresql_server/templates/var/lib/pgsql/data/pg_hba.conf.j2 @@ -1,7 +1,40 @@ # {{ ansible_managed }} -# 2022111701 +# 2026091301 +{# +PostgreSQL uses the first record that matches a connection. The role defaults are catch-all +records, so the entries that do not replace a default are rendered first, otherwise a more +specific entry from the inventory could never match. combine_lod keeps an entry at the position +it was first seen, which is why the order cannot be left to the combined variable. +#} +{% set __local = namespace(defaults=[], own=[]) %} +{% for item in postgresql_server__pg_hba_local_entries__combined_var if item['state'] | d('present') != 'absent' %} +{% if postgresql_server__pg_hba_local_entries__role_var + | selectattr('database', 'equalto', item['database']) + | selectattr('user', 'equalto', item['user']) + | list | length > 0 %} +{% set __local.defaults = __local.defaults + [item] %} +{% else %} +{% set __local.own = __local.own + [item] %} +{% endif %} +{% endfor %} +{% set __host = namespace(defaults=[], own=[]) %} +{% for item in postgresql_server__pg_hba_host_entries__combined_var if item['state'] | d('present') != 'absent' %} +{% if postgresql_server__pg_hba_host_entries__role_var + | selectattr('type', 'equalto', item['type']) + | selectattr('database', 'equalto', item['database']) + | selectattr('user', 'equalto', item['user']) + | selectattr('address', 'equalto', item['address']) + | list | length > 0 %} +{% set __host.defaults = __host.defaults + [item] %} +{% else %} +{% set __host.own = __host.own + [item] %} +{% endif %} +{% endfor %} # TYPE DATABASE USER ADDRESS METHOD -{% for item in postgresql_server__pg_hba_entries %} -{{ item['type'] }} {{ item['database'] }} {{ item['user'] }} {{ item['address'] | d('') }} {{ item['auth_method'] | d('scram-sha-256') }} {{ item['auth_options'] | d('') }} +{% for item in __local.own + __local.defaults %} +local {{ item['database'] }} {{ item['user'] }} {{ item['auth_method'] | d('scram-sha-256') }} {{ item['auth_options'] | d('') }} +{% endfor %} +{% for item in __host.own + __host.defaults %} +{{ item['type'] }} {{ item['database'] }} {{ item['user'] }} {{ item['address'] }} {{ item['auth_method'] | d('scram-sha-256') }} {{ item['auth_options'] | d('') }} {% endfor %} From fb496468a5b9e977b93ed5784c40bda719c69461 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Sun, 13 Sep 2026 23:51:41 +0200 Subject: [PATCH 04/12] fix(roles/postgresql_server): accept localhost connections over IPv6 listen_addresses = 'localhost' binds ::1 as well, and a client resolving `localhost` tries ::1 first. The default pg_hba.conf only allowed 127.0.0.1/32, so the connection failed with `no pg_hba.conf entry for host "::1"`. libpq does not try the next address after the server rejects the authentication, connection_failed() in src/interfaces/libpq/fe-connect.c only retries with another encryption method. Verified with PostgreSQL 16 on Rocky 9. --- CHANGELOG.md | 1 + roles/postgresql_server/README.md | 5 +++++ roles/postgresql_server/defaults/main.yml | 7 +++++++ 3 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac758355..64cd87016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:postgresql_server**: Clients connecting to `localhost` are accepted over IPv6 as well, where they were rejected with `no pg_hba.conf entry for host "::1"`. * **role:postgresql_server**: A configuration change on a PostgreSQL installed from the distribution packages restarts `postgresql.service` instead of aborting the run on a service called `postgresql-`. * **role:grafana**: With `grafana__auth_jwt: true`, the run no longer aborts at `generate JWT RSA private key` with `Cannot detect the required Python library cryptography` on hosts that lack it, because the playbooks deploying Grafana install `python3-cryptography` first. * **role:clamav, role:sshd**: `--tags clamav:configure`, `--tags clamav:state` and `--tags sshd:state` no longer abort on an undefined variable, so a restart skipped with `lfops__skip_restart_handlers` can be caught up with `--tags :state` as the README describes. diff --git a/roles/postgresql_server/README.md b/roles/postgresql_server/README.md index 541d11fe0..ed68ee17e 100644 --- a/roles/postgresql_server/README.md +++ b/roles/postgresql_server/README.md @@ -160,6 +160,11 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE user: 'all' address: '127.0.0.1/32' auth_method: 'scram-sha-256' + - type: 'host' + database: 'all' + user: 'all' + address: '::1/128' + auth_method: 'scram-sha-256' ``` * Subkeys: diff --git a/roles/postgresql_server/defaults/main.yml b/roles/postgresql_server/defaults/main.yml index c2546ca06..0235cf499 100644 --- a/roles/postgresql_server/defaults/main.yml +++ b/roles/postgresql_server/defaults/main.yml @@ -40,6 +40,13 @@ postgresql_server__pg_hba_host_entries__role_var: user: 'all' address: '127.0.0.1/32' auth_method: 'scram-sha-256' + # `localhost` also resolves to ::1, and libpq does not fall back to 127.0.0.1 when the server + # rejects the connection on ::1 (connection_failed() in src/interfaces/libpq/fe-connect.c). + - type: 'host' + database: 'all' + user: 'all' + address: '::1/128' + auth_method: 'scram-sha-256' postgresql_server__pg_hba_local_entries__combined_var: '{{ ( postgresql_server__pg_hba_local_entries__role_var + postgresql_server__pg_hba_local_entries__dependent_var + From af93955756f720424edc7776d0a19616692f2841 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Sun, 13 Sep 2026 23:51:43 +0200 Subject: [PATCH 05/12] fix(roles/postgresql_server): check the configuration before the restart A broken postgresql.conf or pg_hba.conf is fatal when the postmaster starts (load_hba() and ProcessConfigFileInternal()), so the restart handler turned every typo into an outage. The handler chain now checks first and aborts the run while the server still runs its old configuration: * `postgres -D -C data_directory` reads postgresql.conf, conf.d and postgresql.auto.conf like the postmaster at startup and exits before it takes the data directory lock, so it runs next to a live server. * pg_hba.conf has no offline check. pg_hba_file_rules parses the file on disk on every query, on the port from line 4 of postmaster.pid, since a changed port only applies after the restart. The names and paths now only depend on postgresql_server__version and are set under the always tag, which the handler and the dump script need. Verified on Rocky 8/9 (PostgreSQL 18, PGDG) and Rocky 10 (PostgreSQL 16): an invalid password_encryption and an invalid auth method abort the run, the server keeps its start time and settings. --- CHANGELOG.md | 1 + roles/postgresql_server/README.md | 7 ++- roles/postgresql_server/handlers/main.yml | 60 +++++++++++++++++++++++ roles/postgresql_server/tasks/main.yml | 41 +++++++++++----- 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64cd87016..a5b23e23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* **role:postgresql_server**: The configuration is checked before PostgreSQL is restarted, so a broken setting in `postgresql.conf` or `pg_hba.conf` aborts the run with the error message instead of leaving PostgreSQL down after the restart. * **playbook:setup_basic**: The mail and reboot roles run before the security roles, so a first run against a fresh host files the reboot request that a changed crypto policy, SELinux state or kernel module blocklist needs. Until now the reboot mechanism was deployed further down the playbook and such a change could only be reported to the operator. * **role:network**: The reminder that NetworkManager may have to be restarted by hand is printed only when a connection profile actually changed, instead of on every run. diff --git a/roles/postgresql_server/README.md b/roles/postgresql_server/README.md index ed68ee17e..61f324ec6 100644 --- a/roles/postgresql_server/README.md +++ b/roles/postgresql_server/README.md @@ -6,6 +6,11 @@ This role installs and configures a [PostgreSQL](https://www.postgresql.org/) se *Available since LFOps `2.0.0`.* +## How the Role Behaves + +* A changed `postgresql.conf`, `conf.d/z00-linuxfabrik.conf` or `pg_hba.conf` restarts PostgreSQL. Before the restart, the role checks the configuration files with `postgres -C` and asks the running server for errors in `pg_hba.conf` through the `pg_hba_file_rules` view. A broken setting aborts the run with the error message, and the running server keeps its current configuration. The file with the error is already deployed at that point: fix the inventory and run the role again before PostgreSQL is restarted for any other reason. + + ## 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. @@ -19,7 +24,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `postgresql_server` * Installs and configures PostgreSQL. -* Triggers: postgresql.service restart. +* Triggers: postgresql.service restart, after the configuration check. `postgresql_server:state` diff --git a/roles/postgresql_server/handlers/main.yml b/roles/postgresql_server/handlers/main.yml index ea11c24ac..c00b8e610 100644 --- a/roles/postgresql_server/handlers/main.yml +++ b/roles/postgresql_server/handlers/main.yml @@ -1,3 +1,62 @@ +# The config tasks notify 'postgresql_server: validate config; restart postgresql'. Handlers that +# listen to the same topic run in the order they are defined in this file, so the restart at the +# end only runs once every check before it passed. A failing check aborts the run while the +# running server keeps its current configuration. + +# `postgres -C` reads postgresql.conf, conf.d and postgresql.auto.conf the way the postmaster does +# at startup, where every error is fatal, prints the value and exits before it touches the data +# directory lock, so it is safe next to a running server (PostmasterMain() in +# src/backend/postmaster/postmaster.c). It does not read pg_hba.conf. +- name: 'postgresql_server: postgres -C data_directory' + ansible.builtin.command: '{{ __postgresql_server__bin_dir }}/postgres -D {{ postgresql_server__data_dir | quote }} -C data_directory' + become: true + become_user: 'postgres' + changed_when: false # read-only check + check_mode: false # run task even if `--check` is specified + listen: 'postgresql_server: validate config; restart postgresql' + +# pg_hba.conf has no offline check. The pg_hba_file_rules view parses the file on disk on every +# query and reports each broken line (fill_hba_view() in src/backend/utils/adt/hbafuncs.c), so it +# needs the running server. Its port is taken from line 4 of postmaster.pid +# (LOCK_FILE_LINE_PORT in src/include/utils/pidfile.h) instead of postgresql_server__conf_port, +# because a changed port only takes effect with the restart below. +# Skipped where the restart is skipped as well: the service was just started with this +# configuration, or it is supposed to be stopped. +- name: 'postgresql_server: cat postmaster.pid' + ansible.builtin.slurp: + src: '{{ postgresql_server__data_dir }}/postmaster.pid' + register: '__postgresql_server__postmaster_pid_result' + failed_when: false # the server is not running, nothing to check against + listen: 'postgresql_server: validate config; restart postgresql' + when: + - '__postgresql_server__service_state_result is not defined or __postgresql_server__service_state_result is not changed' + - 'postgresql_server__state != "stopped"' + +- name: 'postgresql_server: SELECT line_number, error FROM pg_hba_file_rules WHERE error IS NOT NULL' + community.postgresql.postgresql_query: + port: '{{ (__postgresql_server__postmaster_pid_result["content"] | b64decode).splitlines()[3] }}' + login_password: '{{ postgresql_server__login_password | default(omit) }}' + query: 'SELECT line_number, error FROM pg_hba_file_rules WHERE error IS NOT NULL' + become: true + become_user: 'postgres' + register: '__postgresql_server__pg_hba_errors_result' + check_mode: false # read-only query, run it even if `--check` is specified + listen: 'postgresql_server: validate config; restart postgresql' + when: + - '__postgresql_server__postmaster_pid_result["content"] is defined' + +- name: 'postgresql_server: Assert that pg_hba.conf has no errors' + ansible.builtin.assert: + that: + - '__postgresql_server__pg_hba_errors_result["query_result"] | length == 0' + quiet: true + fail_msg: >- + {{ postgresql_server__data_dir }}/pg_hba.conf contains errors, PostgreSQL was not restarted: + {% for row in __postgresql_server__pg_hba_errors_result["query_result"] %}line {{ row["line_number"] }}: {{ row["error"] }}; {% endfor %} + listen: 'postgresql_server: validate config; restart postgresql' + when: + - '__postgresql_server__pg_hba_errors_result is not skipped' + # skip restart if the operator deferred restarts LFOps-wide (lfops__skip_restart_handlers), if # the service was just started (redundant), or if the user wants it stopped. # __postgresql_server__service_state_result is registered on a dedicated state-only task (not the @@ -8,6 +67,7 @@ ansible.builtin.service: name: '{{ postgresql_server__service_name }}' state: 'restarted' + listen: 'postgresql_server: validate config; restart postgresql' when: - 'not (lfops__skip_restart_handlers | d(false) | bool)' - '__postgresql_server__service_state_result is not defined or __postgresql_server__service_state_result is not changed' diff --git a/roles/postgresql_server/tasks/main.yml b/roles/postgresql_server/tasks/main.yml index e59a7f323..c6d77d40f 100644 --- a/roles/postgresql_server/tasks/main.yml +++ b/roles/postgresql_server/tasks/main.yml @@ -38,14 +38,37 @@ - 'postgresql_server' +# The names and paths only depend on postgresql_server__version, not on the state of the host, so +# they are safe to set under every tag. - block: - - name: 'set internal vars for role' + - name: 'set internal vars for role (PostgreSQL Yum Repository)' ansible.builtin.set_fact: - postgresql_server__installed_version: '{{ postgresql_server__version }}' - postgresql_server__service_name: 'postgresql-{{ postgresql_server__version }}' + __postgresql_server__bin_dir: '/usr/pgsql-{{ postgresql_server__version }}/bin' postgresql_server__data_dir: '/var/lib/pgsql/{{ postgresql_server__version }}/data' postgresql_server__initdb_cmd: '/usr/pgsql-{{ postgresql_server__version }}/bin/postgresql-{{ postgresql_server__version }}-setup initdb' + postgresql_server__service_name: 'postgresql-{{ postgresql_server__version }}' + when: + - 'postgresql_server__version | string | length > 0' + + - name: 'set internal vars for role (distribution packages)' + ansible.builtin.set_fact: + __postgresql_server__bin_dir: '/usr/bin' + postgresql_server__data_dir: '/var/lib/pgsql/data' + postgresql_server__initdb_cmd: '/usr/bin/postgresql-setup --initdb' + postgresql_server__service_name: 'postgresql' + when: + - 'not postgresql_server__version | string | length > 0' + + tags: + - 'always' + + +- block: + + - name: 'set internal vars for role' + ansible.builtin.set_fact: + postgresql_server__installed_version: '{{ postgresql_server__version }}' when: - 'postgresql_server__version | string | length > 0' @@ -64,12 +87,6 @@ ansible.builtin.set_fact: postgresql_server__installed_version: '{{ (ansible_facts["packages"]["postgresql-server"][0]["version"] | d(ansible_facts["packages"]["postgresql-server"][0]["version"])) | regex_replace("\.\d+$", "") }}' # strip the patch version - - name: 'set internal vars for role' - ansible.builtin.set_fact: - postgresql_server__service_name: 'postgresql' - postgresql_server__data_dir: '/var/lib/pgsql/data' - postgresql_server__initdb_cmd: '/usr/bin/postgresql-setup --initdb' - when: - 'not postgresql_server__version | string | length > 0' tags: @@ -89,7 +106,7 @@ line: "include_dir = 'conf.d'" regexp: '^include_dir(\s+|\s*=\s*)?''conf.d''\s*(#.*)?$' path: '{{ postgresql_server__data_dir }}/postgresql.conf' - notify: 'postgresql_server: restart postgresql' + notify: 'postgresql_server: validate config; restart postgresql' - name: 'mkdir -p {{ postgresql_server__data_dir }}/conf.d' ansible.builtin.file: @@ -107,7 +124,7 @@ owner: 'postgres' group: 'postgres' mode: 0o600 - notify: 'postgresql_server: restart postgresql' + notify: 'postgresql_server: validate config; restart postgresql' - name: 'Deploy {{ postgresql_server__data_dir }}/pg_hba.conf' ansible.builtin.template: @@ -117,7 +134,7 @@ owner: 'postgres' group: 'postgres' mode: 0o600 - notify: 'postgresql_server: restart postgresql' + notify: 'postgresql_server: validate config; restart postgresql' - name: 'Remove rpmnew / rpmsave (and Debian equivalents)' ansible.builtin.include_role: From dd5d2b2671b6218213d16973f0213256524bae2e Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Sun, 13 Sep 2026 23:51:44 +0200 Subject: [PATCH 06/12] fix(roles/postgresql_server): dump the roles and every database * Dump roles and tablespaces with `pg_dumpall --globals-only`. pg_dump never includes them, so a restore failed on every `OWNER TO`. * Select the databases like pg_dumpall does (datallowconn and not left invalid by an interrupted DROP DATABASE). The old name filter skipped the postgres database and every database starting with "template". * Read the database names line by line, so names with spaces survive. * Call the client tools of the installed version: pg_dump refuses to dump a newer server, and /usr/bin may point to another version. The rm -rf of the dump directory at the start stays as it is. --- CHANGELOG.md | 1 + roles/postgresql_server/README.md | 2 +- .../usr/local/sbin/postgresql-dump.j2 | 57 +++++++++++-------- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5b23e23c..949ae1795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:postgresql_server**: `postgresql-dump` also saves the roles and tablespaces (`globals.sql.gz`), without which a restored database fails on every object owner, no longer skips the `postgres` database and databases whose name starts with `template`, and uses the client tools of the installed PostgreSQL version. * **role:postgresql_server**: Clients connecting to `localhost` are accepted over IPv6 as well, where they were rejected with `no pg_hba.conf entry for host "::1"`. * **role:postgresql_server**: A configuration change on a PostgreSQL installed from the distribution packages restarts `postgresql.service` instead of aborting the run on a service called `postgresql-`. * **role:grafana**: With `grafana__auth_jwt: true`, the run no longer aborts at `generate JWT RSA private key` with `Cannot detect the required Python library cryptography` on hosts that lack it, because the playbooks deploying Grafana install `python3-cryptography` first. diff --git a/roles/postgresql_server/README.md b/roles/postgresql_server/README.md index 61f324ec6..e9fe5fd4b 100644 --- a/roles/postgresql_server/README.md +++ b/roles/postgresql_server/README.md @@ -129,7 +129,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `postgresql_server__dump_directory` -* The directory where `postgresql-dump` stores its database dumps. +* The directory where `postgresql-dump` stores its dumps: one file `dump-.sql.gz` per database, and `globals.sql.gz` with the roles and tablespaces, which pg_dump does not include in a database dump. Restore `globals.sql.gz` first. The directory is emptied at the start of every dump. * Type: String. * Default: `'/backup/postgresql-dump'` diff --git a/roles/postgresql_server/templates/usr/local/sbin/postgresql-dump.j2 b/roles/postgresql_server/templates/usr/local/sbin/postgresql-dump.j2 index d835ecdc9..fa229367b 100755 --- a/roles/postgresql_server/templates/usr/local/sbin/postgresql-dump.j2 +++ b/roles/postgresql_server/templates/usr/local/sbin/postgresql-dump.j2 @@ -6,7 +6,7 @@ # License: The Unlicense, see LICENSE file. # {{ ansible_managed }} -# 2022111701 +# 2026091301 # make sure the backup is not readable by others old_umask=$(umask) @@ -14,10 +14,30 @@ umask 027 source /etc/postgresql-dump.conf +# the client tools of the running server: pg_dump refuses to dump a server that is newer than +# itself, and /usr/bin may hold the tools of another installed PostgreSQL version +BIN_DIR='{{ __postgresql_server__bin_dir }}' + rm -rf "$BACKUP_DIR" mkdir -p "$BACKUP_DIR" -DBS=$(sudo --user "$USERNAME" --login psql --tuples-only --command='SELECT datname FROM pg_database;') +final_rc=0 + +# roles and tablespaces belong to the cluster, not to a database, so pg_dump never includes them. +# restore this file first, otherwise every `ALTER ... OWNER TO` of a database dump fails. +sudo --user "$USERNAME" --login "$BIN_DIR/pg_dumpall" --globals-only > "$BACKUP_DIR/globals.sql" +rc=$? +if [ $rc -ne 0 ]; then + echo "Failed to dump the roles and tablespaces (Return Code $rc)." + final_rc=$rc +else + echo 'Roles and tablespaces dumped.' +fi +gzip -9fn "$BACKUP_DIR/globals.sql" + +# the same selection as pg_dumpall: every database that accepts connections and is not left +# invalid by an interrupted DROP DATABASE (datconnlimit -2). template0 does not accept connections. +DBS=$(sudo --user "$USERNAME" --login "$BIN_DIR/psql" --no-align --tuples-only --command='SELECT datname FROM pg_database WHERE datallowconn AND datconnlimit != -2 ORDER BY datname;') if [ $? -ne 0 ]; then echo "Cannot connect to PostgreSQL with user '$USERNAME'." exit 1 @@ -28,27 +48,18 @@ if [ "$DBS" == '' ]; then exit 0 fi -final_rc=0 -for DB in $DBS; do - case $DB in - template* ) - # do nothing - ;; - postgres ) - # do nothing - ;; - *) - sudo --user "$USERNAME" --login pg_dump --clean "$DB" > "$BACKUP_DIR/dump-$DB.sql" - rc=$? - if [ $rc -ne 0 ]; then - echo "Failed to dump $DB (Return Code $rc)." - final_rc=$rc - else - echo "Database $DB dumped." - fi - gzip -9fn "$BACKUP_DIR/dump-$DB.sql" - esac -done +# one database name per line, so names containing spaces stay intact +while IFS= read -r DB; do + sudo --user "$USERNAME" --login "$BIN_DIR/pg_dump" --clean "$DB" > "$BACKUP_DIR/dump-$DB.sql" + rc=$? + if [ $rc -ne 0 ]; then + echo "Failed to dump $DB (Return Code $rc)." + final_rc=$rc + else + echo "Database $DB dumped." + fi + gzip -9fn "$BACKUP_DIR/dump-$DB.sql" +done <<< "$DBS" umask "$old_umask" From 66440e9041b2ff526bfdc59d57c4a8e8d5e18af8 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Sun, 13 Sep 2026 23:51:45 +0200 Subject: [PATCH 07/12] docs(roles/postgresql_server): correct the README Describe what the postgresql_server tag manages and which unit it restarts, that password_encryption also applies to changed passwords and that PostgreSQL deprecates MD5, and when postgresql_server__version is needed. --- roles/postgresql_server/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/roles/postgresql_server/README.md b/roles/postgresql_server/README.md index e9fe5fd4b..314a421e3 100644 --- a/roles/postgresql_server/README.md +++ b/roles/postgresql_server/README.md @@ -23,8 +23,8 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `postgresql_server` -* Installs and configures PostgreSQL. -* Triggers: postgresql.service restart, after the configuration check. +* Installs and configures PostgreSQL, and manages its users, databases, privileges and the dump timer. +* Triggers: PostgreSQL restart (`postgresql.service`, or `postgresql-.service` with `postgresql_server__version`), after the configuration check. `postgresql_server:state` @@ -68,7 +68,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `postgresql_server__conf_password_encryption` -* Determines the algorithm to use to encrypt passwords when creating new users / roles. Possible options: `'scram-sha-256'`, `'md5'`. +* Determines the algorithm to use to encrypt passwords when creating new users / roles or changing their passwords. Possible options: `'scram-sha-256'`, `'md5'`. PostgreSQL deprecates MD5 passwords and logs a warning for them. * Type: String. * Default: `'scram-sha-256'` @@ -340,7 +340,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `postgresql_server__version` -* Specifies the PostgreSQL version to install (use only the major version number like `'14'`. The latest minor version is used). Set this when using the official PostgreSQL Repo. +* Specifies the PostgreSQL version to install from the official PostgreSQL Yum Repository (use only the major version number like `'17'`. The latest minor version is used). Leave it empty to install the `postgresql-server` package of the distribution instead. * Type: String. * Default: `''` @@ -388,7 +388,7 @@ postgresql_server__users__host_var: - username: 'user1' password: 'linuxfabrik' state: 'present' -postgresql_server__version: '14' +postgresql_server__version: '17' ``` From 424b188263255fa85c4f066fc96b1b69df69ed20 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Sun, 13 Sep 2026 23:51:47 +0200 Subject: [PATCH 08/12] test(extensions/molecule/postgresql_server): add the scenario The role had no scenario. verify.yml checks the running server: listen_addresses and max_connections as applied, pg_hba.conf without errors, TCP connections over 127.0.0.1, localhost and ::1, the order of the local pg_hba entries (an added entry works, a replaced default rejects), and a dump that contains the roles, was taken with the matching pg_dump and restores. Rocky 8 and 9 install PostgreSQL 18 from PGDG, Rocky 10 the distribution package. All three pass, so RHEL 9 and 10 are marked as verified. --- COMPATIBILITY.md | 2 +- .../molecule/postgresql_server/converge.yml | 2 + .../group_vars/systems_under_test.yml | 29 +++ .../postgresql_server/inventory/hosts.yml | 24 +++ .../molecule/postgresql_server/molecule.yml | 1 + .../molecule/postgresql_server/verify.yml | 192 ++++++++++++++++++ 6 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 extensions/molecule/postgresql_server/converge.yml create mode 100644 extensions/molecule/postgresql_server/inventory/group_vars/systems_under_test.yml create mode 100644 extensions/molecule/postgresql_server/inventory/hosts.yml create mode 100644 extensions/molecule/postgresql_server/molecule.yml create mode 100644 extensions/molecule/postgresql_server/verify.yml diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 772ba312f..ce2cc2456 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -122,7 +122,7 @@ Which Ansible role is proven to run on which OS? | podman_containers | | | (x) | x | (x) | | | | | | policycoreutils | | | x | x | x | | | | Fedora 35 | | postfix | x | x | x | x | x | (x) | (x) | (x) | Fedora 35 | -| postgresql_server | | | x | (x) | (x) | | | | | +| postgresql_server | | | x | x | x | | | | | | proxysql | | | x | (x) | (x) | | | | | | python | x | x | x | x | x | (x) | (x) | (x) | Windows | | python_venv | x | x | x | x | x | (x) | (x) | (x) | Fedora 35 | diff --git a/extensions/molecule/postgresql_server/converge.yml b/extensions/molecule/postgresql_server/converge.yml new file mode 100644 index 000000000..ddc14d158 --- /dev/null +++ b/extensions/molecule/postgresql_server/converge.yml @@ -0,0 +1,2 @@ +- name: 'Converge postgresql_server playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.postgresql_server' diff --git a/extensions/molecule/postgresql_server/inventory/group_vars/systems_under_test.yml b/extensions/molecule/postgresql_server/inventory/group_vars/systems_under_test.yml new file mode 100644 index 000000000..1f9a0a08d --- /dev/null +++ b/extensions/molecule/postgresql_server/inventory/group_vars/systems_under_test.yml @@ -0,0 +1,29 @@ +# Variables the playbook under test needs, applied to every system under test. +# +# Where a value is compared in verify.yml, it deliberately differs from the role default, so the +# check can tell "the running server uses what the inventory asked for" apart from "the running +# server happens to use the packaged default". +postgresql_server__conf_listen_addresses: + - '*' +postgresql_server__conf_max_connections: 150 + +postgresql_server__databases__group_var: + - name: 'app' + owner: 'app' + # the dump used to skip every database whose name starts with "template" + - name: 'template_app' + owner: 'app' + +# Replaces the catch-all default for Unix-socket connections in place, and adds a more specific +# entry that only works if the role renders it before the defaults. +postgresql_server__pg_hba_local_entries__group_var: + - database: 'all' + user: 'all' + auth_method: 'reject' + - database: 'app' + user: 'app' + auth_method: 'scram-sha-256' + +postgresql_server__users__group_var: + - username: 'app' + password: 'linuxfabrik' diff --git a/extensions/molecule/postgresql_server/inventory/hosts.yml b/extensions/molecule/postgresql_server/inventory/hosts.yml new file mode 100644 index 000000000..c833cf484 --- /dev/null +++ b/extensions/molecule/postgresql_server/inventory/hosts.yml @@ -0,0 +1,24 @@ +# yamllint disable rule:empty-values + +# postgresql_server targets 'lfops_postgresql_server' (see playbooks/postgresql_server.yml: hosts). +# +# Only Red Hat-family hosts run: the role hardcodes the RHEL paths and has no Debian support (see +# COMPATIBILITY.md). +# +# The hosts cover both ways the role installs PostgreSQL. Rocky 8 and 9 install it from the +# PostgreSQL Yum Repository (`postgresql_server__version`), Rocky 10 installs the `postgresql-server` +# package of the distribution, which uses other service names, paths and client tools. +lfops_postgresql_server: + children: + systems_under_test: + +systems_under_test: + hosts: + rocky8-vm: + postgresql_server__version: '18' + rocky9-vm: + postgresql_server__version: '18' + rocky10-vm: + # repo_postgresql runs `dnf module disable postgresql`, which fails on RHEL 10 because it + # has no module streams anymore. + postgresql_server__skip_repo_postgresql: true diff --git a/extensions/molecule/postgresql_server/molecule.yml b/extensions/molecule/postgresql_server/molecule.yml new file mode 100644 index 000000000..1e47cbff8 --- /dev/null +++ b/extensions/molecule/postgresql_server/molecule.yml @@ -0,0 +1 @@ +# Molecule scenario marker diff --git a/extensions/molecule/postgresql_server/verify.yml b/extensions/molecule/postgresql_server/verify.yml new file mode 100644 index 000000000..b05082875 --- /dev/null +++ b/extensions/molecule/postgresql_server/verify.yml @@ -0,0 +1,192 @@ +# verify.yml runs after converge and again after the idempotence step. It asserts the observable +# end state, not that individual tasks ran: every setting is read back from the running server, +# and pg_hba.conf is proven by connections that have to succeed or be rejected. +- name: 'Verify postgresql_server is running and uses the configured values' + hosts: 'systems_under_test' + gather_facts: true + become: true + + vars: + + # The PostgreSQL Yum Repository and the distribution package use different names and paths. + __molecule__postgresql_bin_dir: '{{ + (postgresql_server__version | d("") | length > 0) + | ternary("/usr/pgsql-" ~ postgresql_server__version | d("") ~ "/bin", "/usr/bin") + }}' + __molecule__postgresql_service_name: '{{ + (postgresql_server__version | d("") | length > 0) + | ternary("postgresql-" ~ postgresql_server__version | d("") ~ ".service", "postgresql.service") + }}' + __molecule__psql: '{{ __molecule__postgresql_bin_dir }}/psql --no-align --tuples-only --no-psqlrc' + __molecule__dump_directory: '/backup/postgresql-dump' + + tasks: + + - name: 'Get the service facts' + ansible.builtin.service_facts: # yamllint disable-line rule:empty-values + + - name: 'Assert the PostgreSQL service is enabled and running' + ansible.builtin.assert: + that: + - 'ansible_facts["services"][__molecule__postgresql_service_name]["state"] == "running"' + - 'ansible_facts["services"][__molecule__postgresql_service_name]["status"] == "enabled"' + fail_msg: '{{ __molecule__postgresql_service_name }} is {{ ansible_facts["services"][__molecule__postgresql_service_name] | d("absent") }}' + + # Ask the running server what it applied, which proves the chain from group_vars through + # conf.d/z00-linuxfabrik.conf and the include_dir line to the server. + - name: 'psql --command="SHOW ..."' + ansible.builtin.command: '{{ __molecule__psql }} --command="SHOW {{ item["setting"] }}"' + become: true + become_user: 'postgres' + register: '__molecule__postgresql_show_result' + changed_when: false + loop: + - setting: 'listen_addresses' + expected: '*' + - setting: 'max_connections' + expected: '{{ postgresql_server__conf_max_connections | string }}' + loop_control: + label: '{{ item["setting"] }}' + + - name: 'Assert the running server applied the configured values' + ansible.builtin.assert: + that: 'item["stdout"] == item["item"]["expected"]' + fail_msg: '{{ item["item"]["setting"] }} is {{ item["stdout"] }}, expected {{ item["item"]["expected"] }}' + loop: '{{ __molecule__postgresql_show_result["results"] }}' + loop_control: + label: '{{ item["item"]["setting"] }}' + + - name: 'psql --command="SELECT ... FROM pg_hba_file_rules WHERE error IS NOT NULL"' + ansible.builtin.command: '{{ __molecule__psql }} --command="SELECT line_number, error FROM pg_hba_file_rules WHERE error IS NOT NULL"' + become: true + become_user: 'postgres' + register: '__molecule__postgresql_hba_errors_result' + changed_when: false + + - name: 'Assert pg_hba.conf has no errors' + ansible.builtin.assert: + that: '__molecule__postgresql_hba_errors_result["stdout"] | length == 0' + fail_msg: 'pg_hba.conf contains errors: {{ __molecule__postgresql_hba_errors_result["stdout"] }}' + + # The IPv4 and IPv6 loopback defaults. `localhost` resolves to ::1 first, and libpq does not + # fall back to 127.0.0.1 when the server rejects the connection on ::1. + - name: 'psql over TCP as app' + ansible.builtin.command: '{{ __molecule__psql }} "host={{ item }} user=app dbname=app" --command="SELECT inet_client_addr()"' + environment: + PGPASSWORD: 'linuxfabrik' + register: '__molecule__postgresql_tcp_result' + changed_when: false + loop: '{{ ["127.0.0.1", "localhost"] + (__molecule__has_ipv6_loopback | ternary(["::1"], [])) }}' + vars: + # Gate on the loopback interface, not on ansible_facts['all_ipv6_addresses']: that fact + # lists the real addresses only and never contains ::1. + __molecule__has_ipv6_loopback: '{{ "::1" in (ansible_facts["lo"]["ipv6"] | d([]) | map(attribute="address") | list) }}' + + - name: 'Assert app connects over TCP from the expected address' + ansible.builtin.assert: + that: 'item["stdout"] in (item["item"] == "localhost") | ternary(["127.0.0.1", "::1"], [item["item"]])' + fail_msg: 'connecting to {{ item["item"] }} came from {{ item["stdout"] }}' + loop: '{{ __molecule__postgresql_tcp_result["results"] }}' + loop_control: + label: '{{ item["item"] }}' + + # The inventory replaces the `local all all` default with `reject` and adds `local app app`. + # The connection to app only works if the added entry is rendered before the defaults, the + # one to postgres is only rejected if the default was replaced. + - name: 'psql over the Unix socket as app to the database app' + ansible.builtin.command: '{{ __molecule__psql }} "user=app dbname=app" --command="SELECT current_user"' + environment: + PGPASSWORD: 'linuxfabrik' + register: '__molecule__postgresql_socket_app_result' + changed_when: false + + - name: 'psql over the Unix socket as app to the database postgres' + ansible.builtin.command: '{{ __molecule__psql }} "user=app dbname=postgres" --command="SELECT current_user"' + environment: + PGPASSWORD: 'linuxfabrik' + register: '__molecule__postgresql_socket_postgres_result' + changed_when: false + failed_when: false + + - name: 'Assert the local pg_hba entries are applied in the expected order' + ansible.builtin.assert: + that: + - '__molecule__postgresql_socket_app_result["stdout"] == "app"' + - '__molecule__postgresql_socket_postgres_result["rc"] != 0' + - '"rejects connection" in __molecule__postgresql_socket_postgres_result["stderr"]' + fail_msg: 'socket to postgres as app: rc={{ __molecule__postgresql_socket_postgres_result["rc"] }} {{ __molecule__postgresql_socket_postgres_result["stderr"] }}' + + # postgresql-dump: dump a database with content and restore it, with the tools the script is + # expected to use. + - name: 'Create a table with a row in the database app' + ansible.builtin.command: >- + {{ __molecule__psql }} "user=app dbname=app" + --command="CREATE TABLE IF NOT EXISTS molecule_marker (id int)" + --command="INSERT INTO molecule_marker SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM molecule_marker)" + environment: + PGPASSWORD: 'linuxfabrik' + changed_when: false + + - name: 'systemctl start postgresql-dump.service' + ansible.builtin.systemd: + name: 'postgresql-dump.service' + state: 'started' + changed_when: false + + - name: 'stat {{ __molecule__dump_directory }}/*.sql.gz' + ansible.builtin.stat: + path: '{{ __molecule__dump_directory }}/{{ item }}' + register: '__molecule__postgresql_dump_stat_result' + loop: + - 'dump-app.sql.gz' + - 'dump-postgres.sql.gz' + - 'dump-template1.sql.gz' + - 'dump-template_app.sql.gz' + - 'globals.sql.gz' + + - name: 'Assert every database and the globals were dumped' + ansible.builtin.assert: + that: 'item["stat"]["exists"]' + fail_msg: '{{ item["item"] }} is missing' + loop: '{{ __molecule__postgresql_dump_stat_result["results"] }}' + loop_control: + label: '{{ item["item"] }}' + + - name: 'zcat globals.sql.gz; zcat dump-app.sql.gz' + ansible.builtin.command: 'zcat {{ __molecule__dump_directory }}/{{ item }}' + register: '__molecule__postgresql_dump_content_result' + changed_when: false + loop: + - 'globals.sql.gz' + - 'dump-app.sql.gz' + + - name: 'Assert the dumps contain the role and were made with the matching pg_dump' + ansible.builtin.assert: + that: + - '__molecule__postgresql_dump_content_result["results"][0]["stdout"] is search("CREATE ROLE app;")' + - '__molecule__postgresql_dump_content_result["results"][1]["stdout"] is search("Dumped from database version " ~ __molecule__server_major ~ "\.")' + - '__molecule__postgresql_dump_content_result["results"][1]["stdout"] is search("Dumped by pg_dump version " ~ __molecule__server_major ~ "\.")' + fail_msg: 'unexpected dump content, expected PostgreSQL {{ __molecule__server_major }}' + vars: + __molecule__server_major: '{{ + __molecule__postgresql_dump_content_result["results"][1]["stdout"] + | regex_search("Dumped from database version (\d+)", "\1") | first + }}' + + # The dumps are readable by root only, so zcat runs as root and only psql as postgres. + - name: 'Restore dump-app.sql.gz into the database app_restore' + ansible.builtin.shell: + cmd: >- + set -o pipefail; + runuser --user=postgres -- {{ __molecule__psql }} --command="DROP DATABASE IF EXISTS app_restore" && + runuser --user=postgres -- {{ __molecule__psql }} --command="CREATE DATABASE app_restore OWNER app" && + zcat {{ __molecule__dump_directory }}/dump-app.sql.gz | runuser --user=postgres -- {{ __molecule__psql }} --quiet --dbname=app_restore && + runuser --user=postgres -- {{ __molecule__psql }} --dbname=app_restore --command="SELECT tableowner || ':' || (SELECT count(*) FROM molecule_marker) FROM pg_tables WHERE tablename = 'molecule_marker'" + executable: '/bin/bash' + register: '__molecule__postgresql_restore_result' + changed_when: false + + - name: 'Assert the restored table has its owner and its row' + ansible.builtin.assert: + that: '__molecule__postgresql_restore_result["stdout_lines"][-1] == "app:1"' + fail_msg: 'restore returned {{ __molecule__postgresql_restore_result["stdout"] }} {{ __molecule__postgresql_restore_result["stderr"] }}' From a65236b23489180a5ccbc47ee9e770e1d74a1982 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Tue, 15 Sep 2026 09:22:50 +0200 Subject: [PATCH 09/12] fix(roles/repo_postgresql): skip the module disable on RHEL 10 RHEL 10 ships no module streams, so `dnf module disable -y postgresql` fails with "missing groups or modules: postgresql" and aborts every playbook that runs the role. Verified on Rocky 10. --- CHANGELOG.md | 1 + roles/repo_postgresql/tasks/main.yml | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 949ae1795..712084593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:repo_postgresql**: The role no longer aborts on RHEL 10 at `dnf module disable -y postgresql`. * **role:postgresql_server**: `postgresql-dump` also saves the roles and tablespaces (`globals.sql.gz`), without which a restored database fails on every object owner, no longer skips the `postgres` database and databases whose name starts with `template`, and uses the client tools of the installed PostgreSQL version. * **role:postgresql_server**: Clients connecting to `localhost` are accepted over IPv6 as well, where they were rejected with `no pg_hba.conf entry for host "::1"`. * **role:postgresql_server**: A configuration change on a PostgreSQL installed from the distribution packages restarts `postgresql.service` instead of aborting the run on a service called `postgresql-`. diff --git a/roles/repo_postgresql/tasks/main.yml b/roles/repo_postgresql/tasks/main.yml index 51f119407..52d4da9e6 100644 --- a/roles/repo_postgresql/tasks/main.yml +++ b/roles/repo_postgresql/tasks/main.yml @@ -25,10 +25,14 @@ vars: shared__remove_rpmnew_rpmsave_config_file: '/etc/yum.repos.d/pgdg-redhat-all.repo' + # RHEL 10 ships no module streams, where `dnf module disable` fails with "missing groups or + # modules: postgresql". Verified on Rocky 10. - name: 'dnf module disable -y postgresql # prevent the default module stream from being used' ansible.builtin.command: 'dnf module disable -y postgresql' register: 'repo_postgresql__module_disable_result' changed_when: "'Nothing to do' not in repo_postgresql__module_disable_result['stdout']" + when: + - 'ansible_facts["distribution_major_version"] | int < 10' tags: - 'repo_postgresql' From 7d7b10997ebc33aac097537551afc017f2acfdb2 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Tue, 15 Sep 2026 09:22:51 +0200 Subject: [PATCH 10/12] feat(roles/postgresql_server)!: install PostgreSQL only from the PostgreSQL Yum Repository Without postgresql_server__version the role installed the postgresql-server package of the distribution. That path was broken or outdated: on RHEL 8, repo_postgresql disables the postgresql module, so the package is filtered out and the install fails; RHEL 9 installs PostgreSQL 13 from AppStream. CONTRIBUTING also requires software versions to be mandatory. postgresql_server__version is now mandatory and asserted to be a major version. The distribution package facts and the unused postgresql_server__installed_version discovery are gone. A host that already runs the distribution package keeps its cluster in /var/lib/pgsql/data and listens on the same port. Installing the PGDG release next to it would initialize a second, empty cluster that cannot start, so the role aborts before the install when /var/lib/pgsql/data/PG_VERSION exists and points to the migration steps in the README. The scenario runs PGDG PostgreSQL 18 on Rocky 8, 9 and 10. Verified by hand that a host with /var/lib/pgsql/data/PG_VERSION aborts before anything is installed. --- CHANGELOG.md | 2 +- .../group_vars/systems_under_test.yml | 2 + .../postgresql_server/inventory/hosts.yml | 13 +--- .../molecule/postgresql_server/verify.yml | 11 +--- roles/postgresql_server/README.md | 33 +++++++--- roles/postgresql_server/defaults/main.yml | 1 - roles/postgresql_server/tasks/main.yml | 66 +++++++------------ 7 files changed, 55 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 712084593..04e01a468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +* **role:postgresql_server, playbook:setup_mastodon**: `postgresql_server__version` is mandatory, and PostgreSQL is only installed from the PostgreSQL Yum Repository instead of optionally from the distribution packages. Set the major version in your inventory, for example `postgresql_server__version: '18'`. On a host that already runs PostgreSQL from the distribution packages, the run aborts before it installs anything; move the data to the PostgreSQL Yum Repository release first, as described in the role README. * **role:postgresql_server**: `postgresql_server__pg_hba_entries` is replaced by `postgresql_server__pg_hba_local_entries__host_var` / `__group_var` for `local` records and `postgresql_server__pg_hba_host_entries__host_var` / `__group_var` for all `host*` records. Both are merged with the role defaults instead of replacing them, and an entry with `state: 'absent'` removes a default. Move your `local` entries to the first variable without their `type` key, and all other entries to the second, where `address` is mandatory. Entries from the inventory are written before the role defaults. The run aborts as long as the old variable is set. * **role:icingaweb2**: The session and remember-me cookies always carry the `Secure` flag, also behind a reverse proxy that terminates TLS and talks plain HTTP to IcingaWeb2, where IcingaWeb2 left the flag off. An IcingaWeb2 that browsers reach over plain HTTP no longer logs anyone in until `icingaweb2__cookie_secure: false` is set. * **role:apache_httpd**: Every entry in `apache_httpd__htpasswd__*_var` needs the `path` subkey, because the username and the path together identify an entry. Until now, entries with the same username but a different `path` were collapsed into one, so only the last file got the user; a user listed with different paths in group and host variables is now written to both files. On entries that relied on the default, set `path: '/etc/httpd/.htpasswd'` (RedHat) or `path: '/etc/apache2/.htpasswd'` (Debian and Ubuntu), otherwise the play fails with an error naming the entry. @@ -39,7 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **role:repo_postgresql**: The role no longer aborts on RHEL 10 at `dnf module disable -y postgresql`. * **role:postgresql_server**: `postgresql-dump` also saves the roles and tablespaces (`globals.sql.gz`), without which a restored database fails on every object owner, no longer skips the `postgres` database and databases whose name starts with `template`, and uses the client tools of the installed PostgreSQL version. * **role:postgresql_server**: Clients connecting to `localhost` are accepted over IPv6 as well, where they were rejected with `no pg_hba.conf entry for host "::1"`. -* **role:postgresql_server**: A configuration change on a PostgreSQL installed from the distribution packages restarts `postgresql.service` instead of aborting the run on a service called `postgresql-`. * **role:grafana**: With `grafana__auth_jwt: true`, the run no longer aborts at `generate JWT RSA private key` with `Cannot detect the required Python library cryptography` on hosts that lack it, because the playbooks deploying Grafana install `python3-cryptography` first. * **role:clamav, role:sshd**: `--tags clamav:configure`, `--tags clamav:state` and `--tags sshd:state` no longer abort on an undefined variable, so a restart skipped with `lfops__skip_restart_handlers` can be caught up with `--tags :state` as the README describes. * **role:php**: A playbook that includes PHP, such as `setup_nextcloud`, no longer aborts at `Get PHP version` when it is run with another role's tags, for example `--tags apache_httpd`, against a host that has no PHP installed yet. diff --git a/extensions/molecule/postgresql_server/inventory/group_vars/systems_under_test.yml b/extensions/molecule/postgresql_server/inventory/group_vars/systems_under_test.yml index 1f9a0a08d..c211ca853 100644 --- a/extensions/molecule/postgresql_server/inventory/group_vars/systems_under_test.yml +++ b/extensions/molecule/postgresql_server/inventory/group_vars/systems_under_test.yml @@ -3,6 +3,8 @@ # Where a value is compared in verify.yml, it deliberately differs from the role default, so the # check can tell "the running server uses what the inventory asked for" apart from "the running # server happens to use the packaged default". +postgresql_server__version: '18' + postgresql_server__conf_listen_addresses: - '*' postgresql_server__conf_max_connections: 150 diff --git a/extensions/molecule/postgresql_server/inventory/hosts.yml b/extensions/molecule/postgresql_server/inventory/hosts.yml index c833cf484..1e0f4bc01 100644 --- a/extensions/molecule/postgresql_server/inventory/hosts.yml +++ b/extensions/molecule/postgresql_server/inventory/hosts.yml @@ -2,12 +2,8 @@ # postgresql_server targets 'lfops_postgresql_server' (see playbooks/postgresql_server.yml: hosts). # -# Only Red Hat-family hosts run: the role hardcodes the RHEL paths and has no Debian support (see -# COMPATIBILITY.md). -# -# The hosts cover both ways the role installs PostgreSQL. Rocky 8 and 9 install it from the -# PostgreSQL Yum Repository (`postgresql_server__version`), Rocky 10 installs the `postgresql-server` -# package of the distribution, which uses other service names, paths and client tools. +# Only Red Hat-family hosts run: the role installs PostgreSQL from the PostgreSQL Yum Repository +# and has no Debian support (see COMPATIBILITY.md). lfops_postgresql_server: children: systems_under_test: @@ -15,10 +11,5 @@ lfops_postgresql_server: systems_under_test: hosts: rocky8-vm: - postgresql_server__version: '18' rocky9-vm: - postgresql_server__version: '18' rocky10-vm: - # repo_postgresql runs `dnf module disable postgresql`, which fails on RHEL 10 because it - # has no module streams anymore. - postgresql_server__skip_repo_postgresql: true diff --git a/extensions/molecule/postgresql_server/verify.yml b/extensions/molecule/postgresql_server/verify.yml index b05082875..05b235e31 100644 --- a/extensions/molecule/postgresql_server/verify.yml +++ b/extensions/molecule/postgresql_server/verify.yml @@ -8,15 +8,8 @@ vars: - # The PostgreSQL Yum Repository and the distribution package use different names and paths. - __molecule__postgresql_bin_dir: '{{ - (postgresql_server__version | d("") | length > 0) - | ternary("/usr/pgsql-" ~ postgresql_server__version | d("") ~ "/bin", "/usr/bin") - }}' - __molecule__postgresql_service_name: '{{ - (postgresql_server__version | d("") | length > 0) - | ternary("postgresql-" ~ postgresql_server__version | d("") ~ ".service", "postgresql.service") - }}' + __molecule__postgresql_bin_dir: '/usr/pgsql-{{ postgresql_server__version }}/bin' + __molecule__postgresql_service_name: 'postgresql-{{ postgresql_server__version }}.service' __molecule__psql: '{{ __molecule__postgresql_bin_dir }}/psql --no-align --tuples-only --no-psqlrc' __molecule__dump_directory: '/backup/postgresql-dump' diff --git a/roles/postgresql_server/README.md b/roles/postgresql_server/README.md index 314a421e3..c2728c88c 100644 --- a/roles/postgresql_server/README.md +++ b/roles/postgresql_server/README.md @@ -8,6 +8,7 @@ This role installs and configures a [PostgreSQL](https://www.postgresql.org/) se ## How the Role Behaves +* PostgreSQL is installed from the PostgreSQL Yum Repository, not from the distribution packages. The cluster lives in `/var/lib/pgsql//data` and runs as `postgresql-.service`. The role does not upgrade the data: changing `postgresql_server__version` on an existing host installs and initializes a second, empty cluster next to the old one, which fails to start while the old one listens on the same port. * A changed `postgresql.conf`, `conf.d/z00-linuxfabrik.conf` or `pg_hba.conf` restarts PostgreSQL. Before the restart, the role checks the configuration files with `postgres -C` and asks the running server for errors in `pg_hba.conf` through the `pg_hba_file_rules` view. A broken setting aborts the run with the error message, and the running server keeps its current configuration. The file with the error is already deployed at that point: fix the inventory and run the role again before PostgreSQL is restarted for any other reason. @@ -16,7 +17,7 @@ This role installs and configures a [PostgreSQL](https://www.postgresql.org/) se 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 `python3-psycopg2` library must be installed (role: [linuxfabrik.lfops.python](https://github.com/Linuxfabrik/lfops/tree/main/roles/python)). -* Optional: the official [PostgreSQL Yum Repository](https://yum.postgresql.org/) enabled (role: [linuxfabrik.lfops.repo_postgresql](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_postgresql)). +* The [PostgreSQL Yum Repository](https://yum.postgresql.org/) must be enabled (role: [linuxfabrik.lfops.repo_postgresql](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_postgresql)). ## Tags @@ -24,7 +25,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `postgresql_server` * Installs and configures PostgreSQL, and manages its users, databases, privileges and the dump timer. -* Triggers: PostgreSQL restart (`postgresql.service`, or `postgresql-.service` with `postgresql_server__version`), after the configuration check. +* Triggers: postgresql-.service restart, after the configuration check. `postgresql_server:state` @@ -52,6 +53,20 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * Triggers: none. +## Mandatory Role Variables + +`postgresql_server__version` + +* The major version of PostgreSQL to install from the PostgreSQL Yum Repository, for example `'18'`. The latest minor release of that version is installed. +* Type: String. + +Example: +```yaml +# mandatory +postgresql_server__version: '18' +``` + + ## Optional Role Variables `postgresql_server__conf_listen_addresses` @@ -338,12 +353,6 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * Type: String. * Default: `'present'` -`postgresql_server__version` - -* Specifies the PostgreSQL version to install from the official PostgreSQL Yum Repository (use only the major version number like `'17'`. The latest minor version is used). Leave it empty to install the `postgresql-server` package of the distribution instead. -* Type: String. -* Default: `''` - Example: ```yaml @@ -388,10 +397,16 @@ postgresql_server__users__host_var: - username: 'user1' password: 'linuxfabrik' state: 'present' -postgresql_server__version: '17' ``` +## Troubleshooting + +**The run aborts with `This host has a PostgreSQL cluster of the distribution package in /var/lib/pgsql/data`** + +* The host runs PostgreSQL from the distribution packages, which the role does not manage. Move the data to the PostgreSQL Yum Repository release before running the role, for example: dump the cluster with `pg_dumpall`, stop and disable `postgresql.service`, rename `/var/lib/pgsql/data`, run the role, and restore the dump into the new cluster. + + ## License [The Unlicense](https://unlicense.org/) diff --git a/roles/postgresql_server/defaults/main.yml b/roles/postgresql_server/defaults/main.yml index 0235cf499..4659d9441 100644 --- a/roles/postgresql_server/defaults/main.yml +++ b/roles/postgresql_server/defaults/main.yml @@ -89,7 +89,6 @@ postgresql_server__users__dependent_var: [] postgresql_server__users__group_var: [] postgresql_server__users__host_var: [] postgresql_server__users__role_var: [] -postgresql_server__version: '' # ----------------------------------------------------------------------------- postgresql_server__python__modules__dependent_var: diff --git a/roles/postgresql_server/tasks/main.yml b/roles/postgresql_server/tasks/main.yml index c6d77d40f..5a48d00f5 100644 --- a/roles/postgresql_server/tasks/main.yml +++ b/roles/postgresql_server/tasks/main.yml @@ -1,5 +1,13 @@ - block: + - name: 'Assert that postgresql_server__version is a major version' + ansible.builtin.assert: + that: + - 'postgresql_server__version is defined' + - 'postgresql_server__version | string is match("^\\d+$")' + quiet: true + fail_msg: 'postgresql_server__version is mandatory and takes the major version of PostgreSQL, for example "18".' + # Ansible ignores an unknown inventory variable without a word, so an inventory that still sets # the replaced variable would silently deploy the role defaults instead. - name: 'Assert that postgresql_server__pg_hba_entries is not set' @@ -28,6 +36,21 @@ - block: + # The distribution package keeps its cluster in /var/lib/pgsql/data and listens on the same port. + # Installing the PostgreSQL Yum Repository release next to it would initialize a second, empty + # cluster that cannot start while the first one runs. + - name: 'stat /var/lib/pgsql/data/PG_VERSION' + ansible.builtin.stat: + path: '/var/lib/pgsql/data/PG_VERSION' + register: '__postgresql_server__distribution_cluster_result' + + - name: 'Assert that there is no cluster of the distribution package' + ansible.builtin.assert: + that: + - 'not __postgresql_server__distribution_cluster_result["stat"]["exists"]' + quiet: true + fail_msg: 'This host has a PostgreSQL cluster of the distribution package in /var/lib/pgsql/data. The role only installs PostgreSQL from the PostgreSQL Yum Repository. Migrate the data first, see the Troubleshooting section of the role README.' + - name: 'Install postgresql{{ postgresql_server__version }}-server' ansible.builtin.package: name: @@ -42,58 +65,17 @@ # they are safe to set under every tag. - block: - - name: 'set internal vars for role (PostgreSQL Yum Repository)' + - name: 'set internal vars for role' ansible.builtin.set_fact: __postgresql_server__bin_dir: '/usr/pgsql-{{ postgresql_server__version }}/bin' postgresql_server__data_dir: '/var/lib/pgsql/{{ postgresql_server__version }}/data' postgresql_server__initdb_cmd: '/usr/pgsql-{{ postgresql_server__version }}/bin/postgresql-{{ postgresql_server__version }}-setup initdb' postgresql_server__service_name: 'postgresql-{{ postgresql_server__version }}' - when: - - 'postgresql_server__version | string | length > 0' - - - name: 'set internal vars for role (distribution packages)' - ansible.builtin.set_fact: - __postgresql_server__bin_dir: '/usr/bin' - postgresql_server__data_dir: '/var/lib/pgsql/data' - postgresql_server__initdb_cmd: '/usr/bin/postgresql-setup --initdb' - postgresql_server__service_name: 'postgresql' - when: - - 'not postgresql_server__version | string | length > 0' tags: - 'always' -- block: - - - name: 'set internal vars for role' - ansible.builtin.set_fact: - postgresql_server__installed_version: '{{ postgresql_server__version }}' - - when: - - 'postgresql_server__version | string | length > 0' - tags: - - 'postgresql_server' - - 'postgresql_server:state' - - -- block: - - - name: 'Get the list of installed packages' - ansible.builtin.package_facts: # yamllint disable-line rule:empty-values - check_mode: false # run task even if `--check` is specified - - - name: 'Get postgresql-server version' - ansible.builtin.set_fact: - postgresql_server__installed_version: '{{ (ansible_facts["packages"]["postgresql-server"][0]["version"] | d(ansible_facts["packages"]["postgresql-server"][0]["version"])) | regex_replace("\.\d+$", "") }}' # strip the patch version - - when: - - 'not postgresql_server__version | string | length > 0' - tags: - - 'postgresql_server' - - 'postgresql_server:state' - - - block: - name: '{{ postgresql_server__initdb_cmd }}' From 86dc531c7a2c79d2d3110f883c63308f56e3cb95 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Tue, 15 Sep 2026 09:22:52 +0200 Subject: [PATCH 11/12] feat(roles/postgresql_server): reload instead of restart where a reload applies the change Every configuration change restarted PostgreSQL and dropped all connections, although pg_hba.conf is re-read on SIGHUP (process_pm_reload_request() in src/backend/postmaster/postmaster.c) and password_encryption is PGC_USERSET. The settings are split by their context in guc_parameters.dat: listen_addresses, port and max_connections (PGC_POSTMASTER) stay in conf.d/z00-linuxfabrik.conf and notify the restart chain; password_encryption moves to conf.d/z00-linuxfabrik-reload.conf, which notifies the reload chain together with pg_hba.conf. The checks listen to both topics and run once. The reload is not skipped by lfops__skip_restart_handlers, like in the other roles. Moving password_encryption changes z00-linuxfabrik.conf, so the first run after the update restarts once. Verified on the scenario VMs: a pg_hba.conf entry and password_encryption=md5 only reload (pg_conf_load_time() moves, pg_postmaster_start_time() stays), max_connections restarts. --- CHANGELOG.md | 1 + roles/postgresql_server/README.md | 5 ++- roles/postgresql_server/handlers/main.yml | 40 ++++++++++++++----- roles/postgresql_server/tasks/main.yml | 12 +++++- .../conf.d/z00-linuxfabrik-reload.conf.j2 | 13 ++++++ .../pgsql/data/conf.d/z00-linuxfabrik.conf.j2 | 10 ++--- 6 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 roles/postgresql_server/templates/var/lib/pgsql/data/conf.d/z00-linuxfabrik-reload.conf.j2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e01a468..321bb581b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* **role:postgresql_server**: A changed `pg_hba.conf` or `postgresql_server__conf_password_encryption` reloads PostgreSQL instead of restarting it, so open connections are kept; the first run after the update still restarts it once. * **role:postgresql_server**: The configuration is checked before PostgreSQL is restarted, so a broken setting in `postgresql.conf` or `pg_hba.conf` aborts the run with the error message instead of leaving PostgreSQL down after the restart. * **playbook:setup_basic**: The mail and reboot roles run before the security roles, so a first run against a fresh host files the reboot request that a changed crypto policy, SELinux state or kernel module blocklist needs. Until now the reboot mechanism was deployed further down the playbook and such a change could only be reported to the operator. * **role:network**: The reminder that NetworkManager may have to be restarted by hand is printed only when a connection profile actually changed, instead of on every run. diff --git a/roles/postgresql_server/README.md b/roles/postgresql_server/README.md index c2728c88c..64f7810ef 100644 --- a/roles/postgresql_server/README.md +++ b/roles/postgresql_server/README.md @@ -9,7 +9,8 @@ This role installs and configures a [PostgreSQL](https://www.postgresql.org/) se ## How the Role Behaves * PostgreSQL is installed from the PostgreSQL Yum Repository, not from the distribution packages. The cluster lives in `/var/lib/pgsql//data` and runs as `postgresql-.service`. The role does not upgrade the data: changing `postgresql_server__version` on an existing host installs and initializes a second, empty cluster next to the old one, which fails to start while the old one listens on the same port. -* A changed `postgresql.conf`, `conf.d/z00-linuxfabrik.conf` or `pg_hba.conf` restarts PostgreSQL. Before the restart, the role checks the configuration files with `postgres -C` and asks the running server for errors in `pg_hba.conf` through the `pg_hba_file_rules` view. A broken setting aborts the run with the error message, and the running server keeps its current configuration. The file with the error is already deployed at that point: fix the inventory and run the role again before PostgreSQL is restarted for any other reason. +* Settings that only take effect after a restart (`postgresql_server__conf_listen_addresses`, `__conf_max_connections`, `__conf_port`) are written to `conf.d/z00-linuxfabrik.conf`, and changing them restarts PostgreSQL. `postgresql_server__conf_password_encryption` is written to `conf.d/z00-linuxfabrik-reload.conf`, and a change there or in `pg_hba.conf` only reloads PostgreSQL, which keeps open connections. +* Before the reload or restart, the role checks the configuration files with `postgres -C` and asks the running server for errors in `pg_hba.conf` through the `pg_hba_file_rules` view. A broken setting aborts the run with the error message, and the running server keeps its current configuration. The file with the error is already deployed at that point: fix the inventory and run the role again before PostgreSQL is restarted for any other reason. ## Dependent Roles @@ -25,7 +26,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `postgresql_server` * Installs and configures PostgreSQL, and manages its users, databases, privileges and the dump timer. -* Triggers: postgresql-.service restart, after the configuration check. +* Triggers: postgresql-.service reload or restart, after the configuration check. `postgresql_server:state` diff --git a/roles/postgresql_server/handlers/main.yml b/roles/postgresql_server/handlers/main.yml index c00b8e610..91bf0da9c 100644 --- a/roles/postgresql_server/handlers/main.yml +++ b/roles/postgresql_server/handlers/main.yml @@ -1,7 +1,10 @@ -# The config tasks notify 'postgresql_server: validate config; restart postgresql'. Handlers that -# listen to the same topic run in the order they are defined in this file, so the restart at the -# end only runs once every check before it passed. A failing check aborts the run while the -# running server keeps its current configuration. +# The config tasks notify 'postgresql_server: validate config; restart postgresql' for settings +# that need a restart, and 'postgresql_server: validate config; reload postgresql' for pg_hba.conf +# and settings that a reload applies. The checks listen to both topics, and handlers run in the +# order they are defined in this file, so the reload and the restart at the end only run once +# every check passed. A failing check aborts the run while the running server keeps its current +# configuration. With both topics notified, the checks run once, followed by the reload and the +# restart. # `postgres -C` reads postgresql.conf, conf.d and postgresql.auto.conf the way the postmaster does # at startup, where every error is fatal, prints the value and exits before it touches the data @@ -13,7 +16,9 @@ become_user: 'postgres' changed_when: false # read-only check check_mode: false # run task even if `--check` is specified - listen: 'postgresql_server: validate config; restart postgresql' + listen: + - 'postgresql_server: validate config; reload postgresql' + - 'postgresql_server: validate config; restart postgresql' # pg_hba.conf has no offline check. The pg_hba_file_rules view parses the file on disk on every # query and reports each broken line (fill_hba_view() in src/backend/utils/adt/hbafuncs.c), so it @@ -27,7 +32,9 @@ src: '{{ postgresql_server__data_dir }}/postmaster.pid' register: '__postgresql_server__postmaster_pid_result' failed_when: false # the server is not running, nothing to check against - listen: 'postgresql_server: validate config; restart postgresql' + listen: + - 'postgresql_server: validate config; reload postgresql' + - 'postgresql_server: validate config; restart postgresql' when: - '__postgresql_server__service_state_result is not defined or __postgresql_server__service_state_result is not changed' - 'postgresql_server__state != "stopped"' @@ -41,7 +48,9 @@ become_user: 'postgres' register: '__postgresql_server__pg_hba_errors_result' check_mode: false # read-only query, run it even if `--check` is specified - listen: 'postgresql_server: validate config; restart postgresql' + listen: + - 'postgresql_server: validate config; reload postgresql' + - 'postgresql_server: validate config; restart postgresql' when: - '__postgresql_server__postmaster_pid_result["content"] is defined' @@ -51,12 +60,25 @@ - '__postgresql_server__pg_hba_errors_result["query_result"] | length == 0' quiet: true fail_msg: >- - {{ postgresql_server__data_dir }}/pg_hba.conf contains errors, PostgreSQL was not restarted: + {{ postgresql_server__data_dir }}/pg_hba.conf contains errors, PostgreSQL was neither reloaded nor restarted: {% for row in __postgresql_server__pg_hba_errors_result["query_result"] %}line {{ row["line_number"] }}: {{ row["error"] }}; {% endfor %} - listen: 'postgresql_server: validate config; restart postgresql' + listen: + - 'postgresql_server: validate config; reload postgresql' + - 'postgresql_server: validate config; restart postgresql' when: - '__postgresql_server__pg_hba_errors_result is not skipped' +# A reload applies the configuration without an outage, so it is not skipped by +# lfops__skip_restart_handlers. The remaining guards are the same as for the restart below. +- name: 'postgresql_server: reload postgresql' + ansible.builtin.service: + name: '{{ postgresql_server__service_name }}' + state: 'reloaded' + listen: 'postgresql_server: validate config; reload postgresql' + when: + - '__postgresql_server__service_state_result is not defined or __postgresql_server__service_state_result is not changed' + - 'postgresql_server__state != "stopped"' + # skip restart if the operator deferred restarts LFOps-wide (lfops__skip_restart_handlers), if # the service was just started (redundant), or if the user wants it stopped. # __postgresql_server__service_state_result is registered on a dedicated state-only task (not the diff --git a/roles/postgresql_server/tasks/main.yml b/roles/postgresql_server/tasks/main.yml index 5a48d00f5..a28a806f7 100644 --- a/roles/postgresql_server/tasks/main.yml +++ b/roles/postgresql_server/tasks/main.yml @@ -98,6 +98,16 @@ mode: 0o700 state: 'directory' + - name: 'Deploy {{ postgresql_server__data_dir }}/conf.d/z00-linuxfabrik-reload.conf' + ansible.builtin.template: + backup: true + src: 'var/lib/pgsql/data/conf.d/z00-linuxfabrik-reload.conf.j2' + dest: '{{ postgresql_server__data_dir }}/conf.d/z00-linuxfabrik-reload.conf' + owner: 'postgres' + group: 'postgres' + mode: 0o600 + notify: 'postgresql_server: validate config; reload postgresql' + - name: 'Deploy {{ postgresql_server__data_dir }}/conf.d/z00-linuxfabrik.conf' ansible.builtin.template: backup: true @@ -116,7 +126,7 @@ owner: 'postgres' group: 'postgres' mode: 0o600 - notify: 'postgresql_server: validate config; restart postgresql' + notify: 'postgresql_server: validate config; reload postgresql' - name: 'Remove rpmnew / rpmsave (and Debian equivalents)' ansible.builtin.include_role: diff --git a/roles/postgresql_server/templates/var/lib/pgsql/data/conf.d/z00-linuxfabrik-reload.conf.j2 b/roles/postgresql_server/templates/var/lib/pgsql/data/conf.d/z00-linuxfabrik-reload.conf.j2 new file mode 100644 index 000000000..fad1f85db --- /dev/null +++ b/roles/postgresql_server/templates/var/lib/pgsql/data/conf.d/z00-linuxfabrik-reload.conf.j2 @@ -0,0 +1,13 @@ +# {{ ansible_managed }} +# 2026091302 + +# Settings that PostgreSQL applies on a reload (every context other than "postmaster" and +# "internal" in pg_settings). Settings that need a restart belong in z00-linuxfabrik.conf. + +#------------------------------------------------------------------------------ +# CONNECTIONS AND AUTHENTICATION +#------------------------------------------------------------------------------ + +# - Authentication - + +password_encryption = {{ postgresql_server__conf_password_encryption | d('scram-sha-256') }} diff --git a/roles/postgresql_server/templates/var/lib/pgsql/data/conf.d/z00-linuxfabrik.conf.j2 b/roles/postgresql_server/templates/var/lib/pgsql/data/conf.d/z00-linuxfabrik.conf.j2 index 8957ea338..b191d6868 100644 --- a/roles/postgresql_server/templates/var/lib/pgsql/data/conf.d/z00-linuxfabrik.conf.j2 +++ b/roles/postgresql_server/templates/var/lib/pgsql/data/conf.d/z00-linuxfabrik.conf.j2 @@ -1,5 +1,9 @@ # {{ ansible_managed }} -# 2026091301 +# 2026091302 + +# Settings that only take effect when PostgreSQL is restarted (context "postmaster" in +# pg_settings). Settings that a reload applies belong in z00-linuxfabrik-reload.conf, so that +# changing them does not restart the server. #------------------------------------------------------------------------------ # CONNECTIONS AND AUTHENTICATION @@ -10,7 +14,3 @@ listen_addresses = '{{ postgresql_server__conf_listen_addresses | d(['localhost']) | join(',') }}' port = {{ postgresql_server__conf_port | d(5432) }} max_connections = {{ postgresql_server__conf_max_connections | d(100) }} - -# - Authentication - - -password_encryption = {{ postgresql_server__conf_password_encryption | d('scram-sha-256') }} From 7862520c4b1c62cd398b84922da71f4d4be7cc91 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Tue, 15 Sep 2026 09:22:53 +0200 Subject: [PATCH 12/12] feat(roles/postgresql_server)!: create databases with the C.UTF-8 locale by default The default lc_collate and lc_ctype en_US.UTF-8 only exist with glibc-langpack-en. Where only glibc-minimal-langpack is installed, as in the Rocky 8, 9 and 10 container images, CREATE DATABASE fails with "invalid LC_COLLATE locale name" (verified with PGDG PostgreSQL 18 on the rockylinux:9 image, where C.UTF-8 works). C.utf8 is part of the minimal langpack and sorts by code point on all three releases (`LC_ALL=C.utf8 sort` compared on Rocky 8, 9 and 10), so an OS upgrade does not change the order of an index. postgresql_db raises "Changing LC_COLLATE is not supported" when it is given a locale that differs from an existing database, so the new default would have broken every database created with the old one. The default is therefore only passed for databases that do not exist yet. Verified on the scenario VMs: an existing en_US.UTF-8 database without explicit locale keys stays untouched, a new one gets C.UTF-8. --- CHANGELOG.md | 1 + .../molecule/postgresql_server/verify.yml | 13 +++++++++++ roles/postgresql_server/README.md | 10 +++++---- roles/postgresql_server/tasks/main.yml | 22 +++++++++++++++++-- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 321bb581b..2c8e07021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +* **role:postgresql_server**: Databases are created with the `C.UTF-8` locale instead of `en_US.UTF-8`, which failed on hosts with only the minimal glibc language pack. `C.UTF-8` sorts text by code point instead of by language rules. Existing databases are unaffected. To create new databases as before, set `lc_collate: 'en_US.UTF-8'` and `lc_ctype: 'en_US.UTF-8'` on their entries in `postgresql_server__databases__*_var`. * **role:postgresql_server, playbook:setup_mastodon**: `postgresql_server__version` is mandatory, and PostgreSQL is only installed from the PostgreSQL Yum Repository instead of optionally from the distribution packages. Set the major version in your inventory, for example `postgresql_server__version: '18'`. On a host that already runs PostgreSQL from the distribution packages, the run aborts before it installs anything; move the data to the PostgreSQL Yum Repository release first, as described in the role README. * **role:postgresql_server**: `postgresql_server__pg_hba_entries` is replaced by `postgresql_server__pg_hba_local_entries__host_var` / `__group_var` for `local` records and `postgresql_server__pg_hba_host_entries__host_var` / `__group_var` for all `host*` records. Both are merged with the role defaults instead of replacing them, and an entry with `state: 'absent'` removes a default. Move your `local` entries to the first variable without their `type` key, and all other entries to the second, where `address` is mandatory. Entries from the inventory are written before the role defaults. The run aborts as long as the old variable is set. * **role:icingaweb2**: The session and remember-me cookies always carry the `Secure` flag, also behind a reverse proxy that terminates TLS and talks plain HTTP to IcingaWeb2, where IcingaWeb2 left the flag off. An IcingaWeb2 that browsers reach over plain HTTP no longer logs anyone in until `icingaweb2__cookie_secure: false` is set. diff --git a/extensions/molecule/postgresql_server/verify.yml b/extensions/molecule/postgresql_server/verify.yml index 05b235e31..8f7cc6744 100644 --- a/extensions/molecule/postgresql_server/verify.yml +++ b/extensions/molecule/postgresql_server/verify.yml @@ -49,6 +49,19 @@ loop_control: label: '{{ item["item"]["setting"] }}' + # The databases in the inventory set no locale, so they get the role default. + - name: 'psql --command="SELECT datcollate, datctype FROM pg_database WHERE datname = ''app''"' + ansible.builtin.command: '{{ __molecule__psql }} --field-separator=" " --command="SELECT datcollate, datctype FROM pg_database WHERE datname = ''app''"' + become: true + become_user: 'postgres' + register: '__molecule__postgresql_locale_result' + changed_when: false + + - name: 'Assert the database was created with the C.UTF-8 locale' + ansible.builtin.assert: + that: '__molecule__postgresql_locale_result["stdout"] == "C.UTF-8 C.UTF-8"' + fail_msg: 'datcollate and datctype of app are {{ __molecule__postgresql_locale_result["stdout"] }}' + - name: 'psql --command="SELECT ... FROM pg_hba_file_rules WHERE error IS NOT NULL"' ansible.builtin.command: '{{ __molecule__psql }} --command="SELECT line_number, error FROM pg_hba_file_rules WHERE error IS NOT NULL"' become: true diff --git a/roles/postgresql_server/README.md b/roles/postgresql_server/README.md index 64f7810ef..345d203be 100644 --- a/roles/postgresql_server/README.md +++ b/roles/postgresql_server/README.md @@ -109,15 +109,17 @@ postgresql_server__version: '18' * `lc_collate`: - * Optional. DB Collation order. + * Optional. Collation order of the database. Only used when the database is created, PostgreSQL cannot change it afterwards. * Type: String. - * Default: `'en_US.UTF-8'` + * Default: `'C.UTF-8'` + * Deviates from the upstream default, the locale of the template database, which `initdb` takes from the environment of the host: `C.UTF-8` exists on every host, also where only the minimal glibc language pack is installed, and does not change its sort order with a glibc update. It sorts by code point, not by language rules (`B` before `a`); set `'en_US.UTF-8'` or another installed locale for linguistic sorting. * `lc_ctype`: - * Optional. DB Character classification. + * Optional. Character classification of the database. Only used when the database is created, PostgreSQL cannot change it afterwards. * Type: String. - * Default: `'en_US.UTF-8'` + * Default: `'C.UTF-8'` + * Deviates from the upstream default for the same reason as `lc_collate`. * `encoding`: diff --git a/roles/postgresql_server/tasks/main.yml b/roles/postgresql_server/tasks/main.yml index a28a806f7..d4d1203b8 100644 --- a/roles/postgresql_server/tasks/main.yml +++ b/roles/postgresql_server/tasks/main.yml @@ -180,12 +180,28 @@ - block: + - name: 'SELECT datname FROM pg_database' + community.postgresql.postgresql_query: + port: '{{ postgresql_server__conf_port }}' + login_password: '{{ postgresql_server__login_password | default(omit) }}' + query: 'SELECT datname FROM pg_database' + become: true + become_user: 'postgres' + register: '__postgresql_server__existing_databases_result' + changed_when: false # read-only query + check_mode: false # run task even if `--check` is specified + + # The locale default only applies when a database is created. postgresql_db aborts with + # "Changing LC_COLLATE is not supported" when it is given a locale that differs from the one of + # an existing database, which would break every database created with an earlier default. + # C.UTF-8 sorts by code point on RHEL 8, 9 and 10 alike (`LC_ALL=C.utf8 sort` verified on Rocky + # 8, 9 and 10), so the order does not change with an OS upgrade. - name: 'Create or delete PostgreSQL databases' community.postgresql.postgresql_db: port: '{{ postgresql_server__conf_port }}' name: '{{ item["name"] }}' - lc_collate: '{{ item["lc_collate"] | default("en_US.UTF-8") }}' - lc_ctype: '{{ item["lc_ctype"] | default("en_US.UTF-8") }}' + lc_collate: '{{ item["lc_collate"] | default(omit if item["name"] in __postgresql_server__existing_databases else "C.UTF-8") }}' + lc_ctype: '{{ item["lc_ctype"] | default(omit if item["name"] in __postgresql_server__existing_databases else "C.UTF-8") }}' login_password: '{{ postgresql_server__login_password | default(omit) }}' encoding: '{{ item["encoding"] | default("UTF-8") }}' template: '{{ item["template"] | default("template0") }}' @@ -194,6 +210,8 @@ loop: '{{ postgresql_server__databases__combined_var }}' become: true become_user: 'postgres' + vars: + __postgresql_server__existing_databases: '{{ __postgresql_server__existing_databases_result["query_result"] | map(attribute="datname") | list }}' tags: - 'postgresql_server'