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
235 lines
9.8 KiB
YAML
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
|