Files
factory/ansible/arcodange/factory/playbooks/setup/postgres.yml
T
arcodangeandClaude Opus 5 f944fe4bbd feat(postgres) — PostGIS, posé par le playbook et non par une image custom
Kadans doit ranger le contour d'un quartier en vraie géométrie
(geometry(MultiPolygon,4326), ST_Contains, index GiST). L'extension
n'existait nulle part : mesuré sur pi2, `pg_available_extensions` ne
rendait AUCUNE ligne `postgis%`.

Arbitrage fondateur (2026-08-08) : on garde `postgres:16.3-alpine` et on
pose l'extension par Ansible, comme le playbook pose déjà les bases et le
rôle pgbouncer. Pas d'image custom.

⚠ POURQUOI LE RECALAGE DE CHEMINS N'EST PAS FACULTATIF — mesuré, arm64.
`apk add postgis` SEUL réussit, et `CREATE EXTENSION postgis` échoue quand
même :

    ERROR: extension "postgis" is not available
    DETAIL: Could not open extension control file
            "/usr/local/share/postgresql/extension/postgis.control"

Le paquet Alpine vise la disposition d'Alpine (/usr/share/postgresql16,
/usr/lib/postgresql16) ; l'image officielle compile le serveur dans
/usr/local. Les fichiers sont là, le serveur regarde ailleurs. Après
recalage : PostGIS 3.4 USE_GEOS=1 USE_PROJ=1, et un polygone lyonnais qui
fait l'aller-retour ST_GeomFromText → ST_AsGeoJSON.

Ne pas « simplifier » en un `apk add` nu : la simulation dit OK,
l'installation dit OK, et l'extension reste inutilisable.

⚠ INSTALLATION PAR CONTENEUR, PAS PAR VOLUME. `apk add` écrit dans la
couche inscriptible : recréer le conteneur efface PostGIS pendant que les
données gardent leurs colonnes géométriques — toute requête spatiale casse
jusqu'au prochain passage du playbook. D'où l'ordre (déploiement compose
PUIS installation), l'idempotence, et surtout la tâche de vérification.

La vérification ne se contente pas d'un code de retour : elle exige que la
base rende USE_GEOS=1 ET un vrai Point GeoJSON avec son SRID. Un bouchon
qui répondrait une chaîne vide passerait un simple `rc == 0` et ne
prouverait rien — un playbook vert sur une extension absente ferait
atterrir le symptôme dans Kadans, des jours plus tard, déguisé en bug
applicatif.

Vérifié sur le conteneur RÉEL sans le modifier : `apk add --simulate`
résout postgis 3.4.2-r2, et `pg_config` y rend bien les deux chemins que
les variables supposent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01J4UE4AmX5PAMN6c6Q6Fey9
2026-08-08 09:46:08 +02:00

235 lines
9.8 KiB
YAML

---
- name: Setup Postgres
hosts: postgres
gather_facts: yes
become: false
vars:
app: "{{ postgres }}"
app_name: postgres
postgres_container_name: "{{ postgres.dockercompose.services.postgres.container_name }}"
tasks:
- name: Deploy postgres Docker Compose configuration
include_role:
name: arcodange.factory.deploy_docker_compose
vars:
dockercompose_content: "{{ app.dockercompose }}"
app_owner: "{{ app.owner | default('pi') }}"
app_group: "{{ app.group | default('docker') }}"
- name: Deploy PostgreSQL
include_role:
name: deploy_postgresql
vars:
applications_databases:
gitea: "{{ gitea_database }}"
# ── PostGIS ────────────────────────────────────────────────────────────
# Installed INTO the running container rather than baked into a custom
# image (founder's call, 2026-08-08). See group_vars/postgres/postgres.yml
# for why the copy step is not optional, and for the durability caveat.
#
# Runs after the compose deploy above ON PURPOSE: if that task recreated
# the container, the writable layer is fresh and PostGIS is gone with it.
# This is what makes the pair (deploy, install) safe to replay.
- name: Install PostGIS into the Postgres container
ansible.builtin.shell: |
set -eu
docker exec {{ postgres_container_name }} sh -c '
set -eu
apk add --no-cache {{ postgis.paquet }} >/dev/null
cp -r {{ postgis.source_partagee }}/* "$(pg_config --sharedir)/extension/"
cp -r {{ postgis.source_lib }}/*.so "$(pg_config --pkglibdir)/"
'
# `apk add` is idempotent and the copies overwrite identical files, so a
# replay changes nothing observable. We do not pretend to detect that:
# claiming `changed_when: false` outright would hide a REAL first install.
register: postgis_installation
changed_when: "'Installing' in postgis_installation.stdout"
- name: Enable PostGIS on the databases that need it
ansible.builtin.shell: |
docker exec {{ postgres_container_name }} \
psql -U postgres -d {{ item }} -tAc 'CREATE EXTENSION IF NOT EXISTS postgis;'
loop: "{{ postgis.databases }}"
register: postgis_activation
changed_when: "'CREATE EXTENSION' in postgis_activation.stdout"
# ⚠ THE HALF THAT MATTERS. Without it, a botched install leaves a green
# playbook and an application that fails at its first spatial query — the
# symptom would land in Kadans, days later, looking like an app bug.
# We ask the database itself, and we FAIL on anything unexpected.
- name: Verify PostGIS answers on every database
ansible.builtin.shell: |
docker exec {{ postgres_container_name }} psql -U postgres -d {{ item }} -tAc \
"SELECT postgis_version() || ' | ' || ST_AsGeoJSON(ST_SetSRID(ST_Point(4.83, 45.76), 4326));"
loop: "{{ postgis.databases }}"
register: postgis_verifier
changed_when: false
# Not just "the command exited 0": a real geometry must come back with
# the SRID applied. A stub that answered an empty string would pass a
# bare rc check — and prove nothing.
failed_when: >-
postgis_verifier.rc != 0
or 'USE_GEOS=1' not in postgis_verifier.stdout
or '"type":"Point"' not in postgis_verifier.stdout
- name: Report the PostGIS version in use
ansible.builtin.debug:
msg: "PostGIS on {{ item.item }} → {{ item.stdout | trim }}"
loop: "{{ postgis_verifier.results }}"
loop_control:
label: "{{ item.item }}"
- name: Create auth_user for pgbouncer (connection pool component)
ansible.builtin.shell: |
docker exec -it {{ postgres_container_name }} psql -U postgres -d {{ database }} -tc "{{ pg_instruction.replace('$','\$') }}"
vars:
pg_instructions:
- >-
DO $$
BEGIN
CREATE ROLE {{ pgbouncer.auth_user }}
WITH LOGIN PASSWORD '{{ pgbouncer.auth_user_password }}';
EXCEPTION WHEN duplicate_object THEN RAISE NOTICE '%, skipping', SQLERRM USING ERRCODE = SQLSTATE;
END
$$;
- >-
CREATE OR REPLACE FUNCTION user_lookup(in i_username text, out uname text, out phash text)
RETURNS record AS $$
BEGIN
SELECT usename, passwd FROM pg_catalog.pg_shadow
WHERE usename = i_username INTO uname, phash;
RETURN;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
REVOKE ALL ON FUNCTION user_lookup FROM public;
GRANT EXECUTE ON FUNCTION user_lookup TO {{ pgbouncer.auth_user }};
database: "{{ database__pg_instruction[0] }}"
pg_instruction: "{{ database__pg_instruction[1] }}"
loop_control:
loop_var: database__pg_instruction
loop:
"{{ ['postgres', 'gitea'] | product(pg_instructions) }}"
# ---
- name: Change table owner (CronJob with dynamic roles and auto DB naming)
hosts: localhost
connection: local
gather_facts: false
collections:
- kubernetes.core
vars:
namespace: kube-system
cronjob_name: pg-fix-table-ownership
pg_conf: >-
{{ hostvars[groups.postgres[0]].postgres.dockercompose.services.postgres.environment }}
postgres_admin_credentials:
username: '{{ pg_conf.POSTGRES_USER }}'
password: '{{ pg_conf.POSTGRES_PASSWORD }}'
pg_host: "{{ hostvars[groups.postgres[0]]['preferred_ip'] }}"
tasks:
- name: Create Kubernetes Secret for PostgreSQL admin credentials
kubernetes.core.k8s:
state: present
definition:
apiVersion: v1
kind: Secret
metadata:
name: postgres-admin-credentials
namespace: "{{ namespace }}"
type: Opaque
data:
username: "{{ postgres_admin_credentials.username | b64encode }}"
password: "{{ postgres_admin_credentials.password | b64encode }}"
- name: Create cronjob to change table owners (dynamic roles, auto DB)
kubernetes.core.k8s:
state: present
definition:
apiVersion: batch/v1
kind: CronJob
metadata:
name: "{{ cronjob_name }}"
namespace: "{{ namespace }}"
spec:
schedule: "0 3 * * *" # Exécution quotidienne à 3h du matin
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: psql
image: postgres:16.3
envFrom:
- secretRef:
name: postgres-admin-credentials
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: postgres-admin-credentials
key: password
command:
- /bin/sh
- -c
args:
- |
set -eu
# Récupérer dynamiquement les rôles PostgreSQL
echo "Fetching roles from PostgreSQL..."
ROLES=$(psql \
-h {{ pg_host }} \
-U $username \
-d postgres \
-t -A \
-c "SELECT rolname FROM pg_roles WHERE rolname LIKE '%_role';")
echo "Roles found: $ROLES"
# Pour chaque rôle, changer le propriétaire des tables dans sa base associée
for role in $ROLES; do
# Déduire le nom de la base en retirant "_role"
DB_NAME="${role%_role}"
echo "Database for $role: $DB_NAME"
# Vérifier si la base existe
if psql -h {{ pg_host }} -U $username -d postgres -t -A -c "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME';" | grep -q 1; then
echo "Changing owner to $role for all tables in $DB_NAME..."
psql \
-h {{ pg_host }} \
-U $username \
-d "$DB_NAME" \
-c "
DO \$\$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
LOOP
EXECUTE format('ALTER TABLE public.%I OWNER TO %I', r.tablename, '$role');
END LOOP;
END \$\$;
"
echo "Owner changed for $role in $DB_NAME"
else
echo "Database $DB_NAME does not exist, skipping..."
fi
done