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
This commit is contained in:
2026-08-08 09:46:08 +02:00
co-authored by Claude Opus 5
parent b7f7a47a5d
commit f944fe4bbd
2 changed files with 99 additions and 1 deletions
@@ -23,3 +23,44 @@ postgres:
pgbouncer: pgbouncer:
auth_user: &pgbouncer_auth pgbouncer_auth auth_user: &pgbouncer_auth pgbouncer_auth
auth_user_password: *pgbouncer_auth auth_user_password: *pgbouncer_auth
# PostGIS — spatial types for the databases that ask for it.
#
# Kadans stores neighbourhood ("zone") outlines as real geometry, so it needs
# geometry(MultiPolygon,4326), ST_Contains and a GiST index. The image stays
# `postgres:16.3-alpine`: the extension is installed INTO the running container
# by setup/postgres.yml, exactly like the pgbouncer role and the app databases
# are created there. No custom image (founder's call, 2026-08-08).
#
# WARNING — this is a per-CONTAINER install, not a per-VOLUME one. `apk add`
# writes to the container's writable layer, so recreating the container (image
# change, `docker compose up --force-recreate`) REMOVES PostGIS while the data
# keeps its geometry columns — every spatial query then fails until this
# playbook runs again. The install task is therefore idempotent and runs after
# every compose deploy, and it is the reason `postgis_verifier` exists below:
# a silent absence would look like an application bug.
postgis:
# Only these databases get the extension. Adding one here is the whole change.
databases:
- kadans
# The Alpine package. Pinned to a MAJOR line, not a patch: postgis 3.x
# upgrades within a major are ABI-compatible with a given PostgreSQL major.
paquet: postgis
# ⚠ WHY THE COPY STEP EXISTS — measured on pi2 (arm64), 2026-08-08.
# `apk add postgis` alone SUCCEEDS and `CREATE EXTENSION postgis` still fails:
#
# ERROR: extension "postgis" is not available
# DETAIL: Could not open extension control file
# "/usr/local/share/postgresql/extension/postgis.control"
#
# Alpine's package targets Alpine's own PostgreSQL layout
# (/usr/share/postgresql16, /usr/lib/postgresql16), while the official
# `postgres:16-alpine` image builds the server into /usr/local. The files are
# on disk, the server looks elsewhere. Relocating them makes it work — proven
# in a throwaway container: PostGIS 3.4 USE_GEOS=1 USE_PROJ=1, and a Lyon
# polygon round-tripping through ST_GeomFromText/ST_AsGeoJSON.
#
# Do NOT "simplify" this to a bare `apk add`. An `apk add --simulate` reports
# OK, the install reports OK, and the extension is still unusable.
source_partagee: /usr/share/postgresql16/extension
source_lib: /usr/lib/postgresql16
@@ -25,6 +25,63 @@
applications_databases: applications_databases:
gitea: "{{ gitea_database }}" 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) - name: Create auth_user for pgbouncer (connection pool component)
ansible.builtin.shell: | ansible.builtin.shell: |
docker exec -it {{ postgres_container_name }} psql -U postgres -d {{ database }} -tc "{{ pg_instruction.replace('$','\$') }}" docker exec -it {{ postgres_container_name }} psql -U postgres -d {{ database }} -tc "{{ pg_instruction.replace('$','\$') }}"