PostgreSQL ansible role

2024-10-09 - The ansible role I use to manage my PostgreSQL databases
Tags: ansible PostgreSQL

Introduction

Before succumbing to nixos, I had been using an ansible role to manage my PostgreSQL databases. Now that I am in need of it again I refined it a bit: here is the result.

The role

Tasks

My main.yaml relies on OS specific tasks:

---
- name: 'Generate postgres user password'
  include_tasks: 'generate_password.yaml'
  vars:
    name: 'postgres'
  when: '(ansible_local["postgresql_postgres"]|default({})).password is undefined'

- name: 'Run OS tasks'
  include_tasks: '{{ ansible_distribution }}.yaml'

- name: 'Start postgresql and activate it on boot'
  service:
    name: 'postgresql'
    enabled: true
    state: 'started'

Here is an example in Debian.yaml:

---
- name: 'Install postgresql'
  package:
    name:
      - 'postgresql'
      - 'python3-psycopg2'  # necessary for the ansible postgresql modules

- name: 'Configure postgresql'
  template:
    src: 'pg_hba.conf'
    dest: '/etc/postgresql/15/main/'
    owner: 'root'
    group: 'postgres'
    mode: '0440'
  notify: 'reload postgresql'

- name: 'Configure postgresql (file that require a restart when modified)'
  template:
    src: 'postgresql.conf'
    dest: '/etc/postgresql/15/main/'
    owner: 'root'
    group: 'postgres'
    mode: '0440'
  notify: 'restart postgresql'

- meta: 'flush_handlers'

- name: 'Set postgres admin password'
  shell:
    cmd: "printf \"ALTER USER postgres WITH PASSWORD '%s';\" \"{{ ansible_local.postgresql_postgres.password }}\" | su -c psql - postgres"
  when: 'postgresql_password_postgres is defined'

My generate_password.yaml will persist a password with a custom fact:

---
# Inputs:
#   name: string
# Outputs:
#   ansible_local["postgresql_" + postgresql.name].password
- name: 'Generate a password'
  set_fact: { "postgresql_password_{{ name }}": "{{ lookup('password', '/dev/null length=32 chars=ascii_letters') }}" }

- name: 'Deploy ansible fact to persist the password'
  template:
    src: 'postgresql.fact'
    dest: '/etc/ansible/facts.d/postgresql_{{ name }}.fact'
    owner: 'root'
    mode: '0500'
  vars:
    password: "{{ lookup('vars', 'postgresql_password_' + name) }}"

- name: 'reload ansible_local'
  setup: 'filter=ansible_local'

The main entry point of the role is the database.yaml task:

---
# Inputs:
#   postgresql:
#     name: string
#     extension: list
# Outputs:
#   ansible_local["postgresql_" + postgresql.name].password
- name: 'Generate {{ postgresql.name }} password'
  include_tasks: 'generate_password.yaml'
  vars:
    name: '{{ postgresql.name }}'
  when: '(ansible_local["postgresql_" + postgresql.name]|default({})).password is undefined'

- name: 'Create {{ postgresql.name }} user'
  community.postgresql.postgresql_user:
    login_host: 'localhost'
    login_password: '{{ ansible_local.postgresql_postgres.password }}'
    name: '{{ postgresql.name }}'
    password: '{{ ansible_local["postgresql_" + postgresql.name].password }}'

- name: 'Create {{ postgresql.name }} database'
  community.postgresql.postgresql_db:
    login_host: 'localhost'
    login_password: '{{ ansible_local.postgresql_postgres.password }}'
    name: '{{ postgresql.name }}'
    owner: '{{ postgresql.name }}'

- name: 'Activate {{ postgres.name }} extensions'
  community.postgresql.postgresql_ext:
    db: '{{ postgresql.name }}'
    login_host: 'localhost'
    login_password: '{{ ansible_local.postgresql_postgres.password }}'
    name: '{{ item }}'
  loop: '{{ postgresql.extensions | default([]) }}'

Handlers

Here are the two handlers:

---
- name: 'reload postgresql'
  service:
    name: 'postgresql'
    state: 'reloaded'

- name: 'restart postgresql'
  service:
    name: 'postgresql'
    state: 'restarted'

Templates

Here is my usual pg_hba.conf:

###############################################################################
#     \_o<     WARNING : This file is being managed by ansible!      >o_/     #
#     ~~~~                                                           ~~~~     #
###############################################################################

local  all  all  peer #unix socket

host   all  all  127.0.0.0/8  scram-sha-256
host   all  all  ::1/128      scram-sha-256
host   all  all  10.88.0.0/16 scram-sha-256  # podman

Here is my postgresql.conf for Debian:

###############################################################################
#     \_o<     WARNING : This file is being managed by ansible!      >o_/     #
#     ~~~~                                                           ~~~~     #
###############################################################################

data_directory = '/var/lib/postgresql/15/main'          # use data in another directory
hba_file = '/etc/postgresql/15/main/pg_hba.conf'        # host-based authentication file
ident_file = '/etc/postgresql/15/main/pg_ident.conf'    # ident configuration file
external_pid_file = '/var/run/postgresql/15-main.pid'   # write an extra PID file

port = 5432            # (change requires restart)
max_connections = 100  # (change requires restart)

unix_socket_directories = '/var/run/postgresql' # comma-separated list of directories
listen_addresses = 'localhost,10.88.0.1'

shared_buffers = 128MB                  # min 128kB
dynamic_shared_memory_type = posix      # the default is usually the first option
max_wal_size = 1GB
min_wal_size = 80MB
log_line_prefix = '%m [%p] %q%u@%d '            # special values:
log_timezone = 'Europe/Paris'
cluster_name = '15/main'                        # added to process titles if nonempty
datestyle = 'iso, mdy'
timezone = 'Europe/Paris'
lc_messages = 'en_US.UTF-8'                     # locale for system error message
lc_monetary = 'en_US.UTF-8'                     # locale for monetary formatting
lc_numeric = 'en_US.UTF-8'                      # locale for number formatting
lc_time = 'en_US.UTF-8'                         # locale for time formatting
default_text_search_config = 'pg_catalog.english'
include_dir = 'conf.d'                  # include files ending in '.conf' from

And here is the simple fact script:

#!/bin/sh
###############################################################################
#     \_o<     WARNING : This file is being managed by ansible!      >o_/     #
#     ~~~~                                                           ~~~~     #
###############################################################################
set -eu

printf '{"password": "%s"}' "{{ password }}"

Usage example

I do not call the role from a playbook, I prefer running the setup from an application’s role that relies on postgresql using a meta/main.yaml containing something like:

---
dependencies:
  - role: 'borg
  - role: 'postgresql'

Then from a tasks file:

- include_role:
    name: 'postgresql'
    tasks_from: 'database'
  vars:
    postgresql:
      extensions:
        - 'pgcrypto'
      name: 'eventline'

Backup jobs can be setup with:

- include_role:
    name: 'borg'
    tasks_from: 'client'
  vars:
    client:
      jobs:
        - name: 'postgres'
          command_to_pipe: "su - postgres -c '/usr/bin/pg_dump -b -c -C -d eventline'"
      name: 'eventline'
      server: '{{ eventline_adyxax_org.borg }}'

Conclusion

I enjoy this design, it has served me well.