diff --git a/ansible/arcodange/factory/inventory/group_vars/postgres/postgres.yml b/ansible/arcodange/factory/inventory/group_vars/postgres/postgres.yml index 5874e31..ba2999d 100644 --- a/ansible/arcodange/factory/inventory/group_vars/postgres/postgres.yml +++ b/ansible/arcodange/factory/inventory/group_vars/postgres/postgres.yml @@ -22,4 +22,45 @@ postgres: pgbouncer: auth_user: &pgbouncer_auth pgbouncer_auth - auth_user_password: *pgbouncer_auth \ No newline at end of file + 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 \ No newline at end of file diff --git a/ansible/arcodange/factory/playbooks/setup/postgres.yml b/ansible/arcodange/factory/playbooks/setup/postgres.yml index a066c63..dce7d32 100644 --- a/ansible/arcodange/factory/playbooks/setup/postgres.yml +++ b/ansible/arcodange/factory/playbooks/setup/postgres.yml @@ -25,6 +25,63 @@ 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('$','\$') }}"