diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml deleted file mode 100644 index a2738d4..0000000 --- a/.gitea/workflows/docker-build.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: 🚀 Docker Build and Push - -on: - push: - branches: - - main - -jobs: - build-and-push: - runs-on: ubuntu-latest - strategy: - matrix: - pg_version: [15, 16, 17] - steps: - - name: 📥 Checkout code - uses: https://github.com/actions/checkout@v4 - - - name: 🛠️ Set up Docker Buildx - uses: https://github.com/docker/setup-buildx-action@v3 - - - name: 🔐 Login to Gitea Registry - uses: https://github.com/docker/login-action@v3 - with: - registry: gitea.killinger.fr - username: maxime.killinger - password: ${{ secrets.DOCKER_TOKEN }} - - - name: 📦 Build Docker image - uses: https://github.com/docker/build-push-action@v5 - with: - context: . - load: true - build-args: | - PG_VERSION=${{ matrix.pg_version }} - tags: | - postgres-ts-vectors:pg${{ matrix.pg_version }}-test - - - name: 🧪 Test image - Start container - run: | - docker run -d --name test-pg${{ matrix.pg_version }} \ - -e POSTGRES_PASSWORD=testpass \ - postgres-ts-vectors:pg${{ matrix.pg_version }}-test - - # Wait for PostgreSQL to be ready - echo "Waiting for PostgreSQL to start..." - for i in {1..30}; do - if docker exec test-pg${{ matrix.pg_version }} pg_isready -U postgres > /dev/null 2>&1; then - echo "PostgreSQL is ready!" - break - fi - sleep 2 - done - - - name: ⚙️ Setup Tests - run: | - # Wait for valid startup - sleep 10 - docker cp tests/ test-pg${{ matrix.pg_version }}:/tmp/tests/ - - - name: "🧪 Test 1: Configuration" - run: docker exec test-pg${{ matrix.pg_version }} psql -U postgres -f /tmp/tests/test-1-config.sql - - - name: "🧪 Test 2: TimescaleDB" - run: docker exec test-pg${{ matrix.pg_version }} psql -U postgres -f /tmp/tests/test-2-timescale.sql - - - name: "🧪 Test 3: VectorChord" - run: docker exec test-pg${{ matrix.pg_version }} psql -U postgres -f /tmp/tests/test-3-vector.sql - - - name: "🧪 Test 4: Hybrid Scenarios" - run: docker exec test-pg${{ matrix.pg_version }} psql -U postgres -f /tmp/tests/test-4-hybrid.sql - - - name: "🧪 Test 5: PostGIS" - run: docker exec test-pg${{ matrix.pg_version }} psql -U postgres -f /tmp/tests/test-5-postgis.sql - - - name: 🧹 Cleanup test container - if: always() - run: docker rm -f test-pg${{ matrix.pg_version }} || true - - - name: 🚀 Push Docker image - uses: https://github.com/docker/build-push-action@v5 - with: - context: . - push: true - build-args: | - PG_VERSION=${{ matrix.pg_version }} - tags: | - gitea.killinger.fr/maxime.killinger/postgres-ts-vectors:pg${{ matrix.pg_version }} - - - name: 🏷️ Tag latest (PG17 only) - if: matrix.pg_version == 17 - uses: https://github.com/docker/build-push-action@v5 - with: - context: . - push: true - build-args: | - PG_VERSION=17 - tags: | - gitea.killinger.fr/maxime.killinger/postgres-ts-vectors:latest - - - name: 🧹 Cleanup Docker images - if: always() - run: | - docker rmi postgres-ts-vectors:pg${{ matrix.pg_version }}-test || true - docker image prune -f - - diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 1853012..96c542b 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -4,26 +4,32 @@ on: push: branches: - main + # Un push qui ne touche que la doc ne reconstruit rien : aucun Markdown n'entre dans + # l'image. Un push mêlant code et doc lance toujours la CI. + paths-ignore: + - '**.md' + - 'docs/**' + # Chaque nuit (heure UTC) : les images publiées sont testées et, si apt leur propose des mises + # à jour, reconstruites et republiées. + # Voir docs/decisions/0005-mises-a-jour-nocturnes-pilotees-par-apt.md. + schedule: + - cron: '30 3 * * *' + workflow_dispatch: jobs: build-and-push: runs-on: ubuntu-latest strategy: matrix: - pg_version: [15, 16, 17] - # Accélération (2026-09-27) : - # - aucune action externe : checkout, login et build-push étaient téléchargés depuis GitHub à - # chaque job, ~20 s par action ; - # - construction par le démon Docker du runner, sans Buildx : « Set up Docker Buildx » créait - # un constructeur neuf, sans cache, et TimescaleDB était recompilé à chaque job (7 à 9 min). - # Le cache du démon persiste d'une CI à l'autre : la compilation n'est rejouée que si la - # version de TimescaleDB, de PostgreSQL ou l'image de base change ; - # - une seule construction : l'image testée est celle qui est poussée (étiquetage), au lieu de - # la reconstruire pour le push puis pour `latest`. + pg_version: [15, 16, 17, 18] + # Aucune action externe, construction par le démon Docker du runner (sans Buildx) dont le + # cache persiste d'une CI à l'autre, et une seule construction par version : l'image testée + # est celle qui est poussée. Voir docs/decisions/0004-ci-sans-action-externe.md. env: IMAGE: gitea.killinger.fr/${{ github.repository }} - TEST: pg${{ matrix.pg_version }}-test-${{ github.run_id }} + PREVIOUS: postgres-ts-vectors-previous:pg${{ matrix.pg_version }} steps: + # Le seul commit poussé, sans historique : c'est tout ce que la construction lit. - name: 📥 Checkout code env: TOKEN: ${{ github.token }} @@ -37,67 +43,70 @@ jobs: DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} run: echo "$DOCKER_TOKEN" | docker login gitea.killinger.fr -u maxime.killinger --password-stdin + # On demande à apt, dans l'image publiée, s'il lui reste des mises à jour : + # - oui, ou image jamais publiée : nouvelle clé APT_REFRESH, les couches apt sont rejouées ; + # - non : la clé de l'image publiée (label apt-refresh) est reprise et le cache sert. + # L'exécution planifiée ne reconstruit ni ne pousse alors rien : elle teste l'image publiée. + # L'image publiée est gardée sous $PREVIOUS : les tests y créent une base, que l'image + # reconstruite doit savoir reprendre. + - name: 🔎 Check for updates + id: updates + run: | + refresh=""; previous="" + if docker pull -q "$IMAGE:pg${{ matrix.pg_version }}" >/dev/null 2>&1; then + docker tag "$IMAGE:pg${{ matrix.pg_version }}" "$PREVIOUS"; previous="$PREVIOUS" + upgrade=$(docker run --rm --entrypoint sh "$PREVIOUS" -c \ + 'apt-get update -qq -o APT::Update::Error-Mode=any && apt-get -s upgrade --with-new-pkgs') + if pending=$(echo "$upgrade" | grep '^Inst'); then + echo "Mises à jour disponibles :"; echo "$pending" + else + refresh=$(docker image inspect -f '{{ index .Config.Labels "fr.killinger.apt-refresh" }}' "$PREVIOUS") + fi + fi + build=true + if [ -n "$refresh" ] && [ "${{ github.event_name }}" = schedule ]; then + echo "Image publiée à jour : rien à reconstruire"; build=false; previous="" + fi + echo "refresh=${refresh:-$(date -u +%Y%m%dT%H%M%SZ)}" >> "$GITHUB_OUTPUT" + echo "build=$build" >> "$GITHUB_OUTPUT" + echo "previous=$previous" >> "$GITHUB_OUTPUT" + # `--pull` : l'image de base (tensorchord/vchord-postgres) est vérifiée à chaque construction, - # sinon ses correctifs de sécurité ne seraient jamais repris avec le cache persistant. + # pour recevoir une republication sous la même étiquette malgré le cache persistant. + # Labels OCI propres à la construction ; les labels fixes sont dans le Dockerfile. - name: 📦 Build Docker image + if: steps.updates.outputs.build == 'true' run: | docker build --pull \ --build-arg PG_VERSION=${{ matrix.pg_version }} \ + --build-arg APT_REFRESH=${{ steps.updates.outputs.refresh }} \ + --label fr.killinger.apt-refresh=${{ steps.updates.outputs.refresh }} \ + --label org.opencontainers.image.url=https://gitea.killinger.fr/${{ github.repository }} \ --label org.opencontainers.image.revision=${{ github.sha }} \ + --label org.opencontainers.image.version=pg${{ matrix.pg_version }} \ --label org.opencontainers.image.created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ -t "$IMAGE:pg${{ matrix.pg_version }}" . - # Prête quand l'initialisation est terminée (extensions créées, serveur temporaire arrêté) ET - # que le serveur définitif accepte les connexions — au lieu d'un `sleep 10` fixe. - - name: 🧪 Test image - Start container - run: | - docker run -d --name "$TEST" -e POSTGRES_PASSWORD=testpass "$IMAGE:pg${{ matrix.pg_version }}" >/dev/null - for i in $(seq 1 90); do - if docker logs "$TEST" 2>&1 | grep -q "PostgreSQL init process complete" \ - && docker exec "$TEST" pg_isready -U postgres -q; then - echo "PostgreSQL prêt en ${i} s"; exit 0 - fi - sleep 1 - done - docker logs "$TEST" 2>&1 | tail -30 - exit 1 + # La même commande qu'en local. Sans reconstruction, c'est l'image publiée qui est testée. + - name: 🧪 Tests + run: bash tests.sh "$IMAGE:pg${{ matrix.pg_version }}" ${{ steps.updates.outputs.previous }} - - name: ⚙️ Setup Tests - run: docker cp tests/ "$TEST":/tmp/tests/ - - - name: "🧪 Test 1: Configuration" - run: docker exec "$TEST" psql -U postgres -f /tmp/tests/test-1-config.sql - - - name: "🧪 Test 2: TimescaleDB" - run: docker exec "$TEST" psql -U postgres -f /tmp/tests/test-2-timescale.sql - - - name: "🧪 Test 3: VectorChord" - run: docker exec "$TEST" psql -U postgres -f /tmp/tests/test-3-vector.sql - - - name: "🧪 Test 4: Hybrid Scenarios" - run: docker exec "$TEST" psql -U postgres -f /tmp/tests/test-4-hybrid.sql - - - name: "🧪 Test 5: PostGIS" - run: docker exec "$TEST" psql -U postgres -f /tmp/tests/test-5-postgis.sql - - - name: 🧹 Cleanup test container - if: always() - run: docker rm -f "$TEST" >/dev/null 2>&1 || true - - # Poussée seulement si tous les tests sont passés (étapes précédentes sans `if: always()`). + # Poussée seulement si les tests sont passés. - name: 🚀 Push Docker image + if: steps.updates.outputs.build == 'true' run: docker push -q "$IMAGE:pg${{ matrix.pg_version }}" + # Voir docs/decisions/0006-latest-reste-sur-postgresql-17.md. - name: 🏷️ Tag latest (PG17 only) - if: matrix.pg_version == 17 + if: steps.updates.outputs.build == 'true' && matrix.pg_version == 17 run: | docker tag "$IMAGE:pg17" "$IMAGE:latest" docker push -q "$IMAGE:latest" - # Cache borné : il ne doit pas remplir le disque de Tower (la compilation de TimescaleDB - # pour trois versions de PostgreSQL y tient largement). + # Cache borné : il ne doit pas remplir le disque de Tower. - name: 🧹 Cleanup Docker images if: always() run: | + docker rmi "$PREVIOUS" >/dev/null 2>&1 || true docker builder prune -f --keep-storage 8GB >/dev/null docker image prune -f >/dev/null diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9684f11 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,139 @@ +# postgres-ts-vectors — consignes + +Image Docker de PostgreSQL avec TimescaleDB, VectorChord, pgvector et PostGIS, publiée pour +PostgreSQL 15 à 18 dans le registre Gitea. Le dépôt ne contient que la construction de +l'image, ses scripts de démarrage et ses tests. +Architecture : [docs/architecture.md](docs/architecture.md). Choix de conception : +[docs/decisions/](docs/decisions/). Points ouverts : [docs/a-faire.md](docs/a-faire.md). + +## Commandes + +```bash +# Construire une version (PG_VERSION : 15, 16, 17 ou 18 ; 18 par défaut) +docker build --build-arg PG_VERSION=17 -t postgres-ts-vectors:pg17 . + +# Rejouer la mise à niveau des paquets, que le cache garderait sinon telle quelle +docker build --build-arg PG_VERSION=17 --build-arg APT_REFRESH=$(date +%F) -t postgres-ts-vectors:pg17 . + +# Essayer une autre version de TimescaleDB sans toucher au Dockerfile +docker build --build-arg PG_VERSION=17 --build-arg TIMESCALEDB_VERSION=2.30.2 -t postgres-ts-vectors:essai . + +# Tests de l'image +./tests.sh postgres-ts-vectors:pg17 + +# Tests, plus reprise d'une base créée par l'image publiée +docker pull gitea.killinger.fr/maxime.killinger/postgres-ts-vectors:pg17 +./tests.sh postgres-ts-vectors:pg17 gitea.killinger.fr/maxime.killinger/postgres-ts-vectors:pg17 + +# Mises à jour qu'apt propose à une image publiée (ce que la CI regarde chaque nuit) +docker run --rm --entrypoint sh gitea.killinger.fr/maxime.killinger/postgres-ts-vectors:pg17 -c \ + 'apt-get update -qq && apt-get -s upgrade --with-new-pkgs | grep ^Inst' +``` + +- **Tests** : `tests.sh IMAGE [IMAGE_PRECEDENTE]`, en local comme en CI. Il démarre l'image + avec un utilisateur et une base autres que `postgres`, vérifie que le serveur a la version + majeure de l'étiquette, joue `tests/test-*.sql` dans l'ordre, redémarre le serveur, puis, + si une image précédente est donnée, crée une base avec elle (`tests/reprise-1-semis.sql`) + et la reprend avec l'image testée (`tests/reprise-2-controle.sql`). Conteneurs et volume + sont jetables. +- **Lint** : aucun outil configuré. +- **CI** (`.gitea/workflows/docker.yml`) : construit, teste et publie les quatre versions à + chaque push sur `main`, sauf si tous les fichiers modifiés sont des `.md` ou sous `docs/` + (`paths-ignore`). Chaque nuit, elle teste les images publiées et reconstruit celles qui ont + des mises à jour. Détail : [docs/exploitation/publication.md](docs/exploitation/publication.md). + +## Conventions + +- **Une image par version majeure**, construite par le même Dockerfile avec `PG_VERSION`. + Rien n'y est propre à une version : ce qui en dépend se lit dans `PG_MAJOR`, fourni par + l'image de base. +- **Versions épinglées dans le Dockerfile** + ([0003](docs/decisions/0003-versions-de-timescaledb-et-vectorchord-epinglees.md)) : + `TIMESCALEDB_VERSION` et `VCHORD_VERSION`. Les changer suit la procédure de + [docs/exploitation/publication.md](docs/exploitation/publication.md), et met à jour le + tableau et les badges du `README.md`. +- **Le reste vient d'apt, à jour** + ([0002](docs/decisions/0002-paquets-a-jour-et-en-tetes-du-serveur.md)) : PostgreSQL, + pgvector, PostGIS, Debian. Aucune version de paquet n'est écrite dans le Dockerfile, sauf + les en-têtes du serveur, épinglés sur la version du serveur installé. +- **Nouvelle extension** : l'installer dans l'étape finale du Dockerfile, l'ajouter à + `init-extensions.sh`, à la liste contrôlée par `tests/test-1-config.sql`, lui écrire un + `tests/test-N-nom.sql`, et l'ajouter aux deux fichiers `tests/reprise-*.sql`. +- **Nouvelle version majeure de PostgreSQL** : l'ajouter à la matrice de la CI, au tableau + et au badge du `README.md`, et vérifier le point de montage du volume + ([docs/architecture.md](docs/architecture.md)). +- **Tests SQL** : chaque fichier commence par `\set ON_ERROR_STOP on` et lève une exception + quand un contrôle échoue. Ils ne supposent ni l'utilisateur ni la base `postgres`. +- **CI sans action externe** ([0004](docs/decisions/0004-ci-sans-action-externe.md)) : des + commandes `git` et `docker`, jamais de `uses:`. +- **Commentaires** : en français. Le « pourquoi » local reste à côté du code. Les tests SQL + `tests/test-*.sql` et les scripts de démarrage sont encore commentés en anglais : on les + traduit quand on les modifie. + +## Pièges connus + +- **Changer la version de TimescaleDB rend une base existante inutilisable jusqu'à sa mise + à niveau.** L'image ne contient que la bibliothèque de sa version : toute requête échoue + avec `could not access file "$libdir/timescaledb-2.28.3"` tant que + `ALTER EXTENSION timescaledb UPDATE` n'a pas été exécuté, en première instruction d'une + session `psql -X`. `pg_isready` répond pourtant, et le conteneur paraît sain. +- **Des en-têtes plus récents que le serveur cassent TimescaleDB au chargement** + (`undefined symbol: palloc0_mul`), sans erreur à la compilation. C'est ce qui arrive si + l'on installe `postgresql-server-dev` sans mettre le serveur à niveau. +- **La couche `apt-get upgrade` est mise en cache comme les autres.** Une construction locale + ne reprend les mises à jour qu'avec un nouvel `APT_REFRESH`. +- **`PG_VERSION` a deux sens.** Avant le premier `FROM`, c'est l'argument de construction + (`18`). Dans les étapes, c'est la variable d'environnement de l'image de base, la version + du paquet au moment où TensorChord l'a construite (`18.3-1.pgdg12+1`). Elle reste telle + quelle dans l'image publiée, alors que le serveur est mis à niveau : la version du serveur + se lit par `postgres --version`. +- **Le point de montage du volume change avec PostgreSQL 18** + ([docs/architecture.md](docs/architecture.md)). Jusqu'à 17, un volume monté sur + `/var/lib/postgresql` reste vide : les données partent dans un volume anonyme. +- **`init-extensions.sh` ne s'exécute qu'à l'initialisation d'un volume vide.** Une extension + ajoutée à l'image n'apparaît pas dans une base existante : il faut l'y créer à la main. +- **Remplacer la commande du conteneur remplace `shared_preload_libraries`.** Sans + `timescaledb,vchord`, les deux extensions ne se chargent pas. +- **L'enveloppe du point d'entrée parcourt tout le volume à chaque démarrage** + (`chown -R`) : le démarrage s'allonge avec le nombre de fichiers de la base. +- **Le serveur temporaire de l'initialisation répond déjà aux requêtes**, par le socket, + avant que les extensions soient créées. Un test qui attend seulement une réponse démarre + trop tôt : `tests.sh` attend aussi `PostgreSQL init process complete` dans le journal. +- **`docker logs … | grep -q` échoue sous `pipefail`** alors que le motif est trouvé : + `grep -q` ferme le tube à la première correspondance. `tests.sh` lit le journal en entier + avant de chercher. +- **Une exécution nocturne qui échoue aux tests ne publie rien et recompile la nuit + suivante** : la clé `APT_REFRESH` n'est gardée que par l'image publiée. +- **Chaque reconstruction déclenchée par apt recompile TimescaleDB** : l'étape de compilation + part de l'étape mise à niveau. + +## Git + +- Messages de commit en français. **Jamais de trailer `Co-Authored-By`.** +- **Ne pas pousser sans demande explicite** : un push sur `main` republie les quatre images, + dont `latest`, que d'autres projets utilisent comme base de données. +- En cas d'échec de push : retenter. Après deux échecs rapprochés, parler d'indisponibilité + probablement temporaire de Gitea, sans mettre en cause les identifiants. + +## Documentation + +- `README.md` : la vitrine du dépôt. Il résume et renvoie, le détail vit ici et dans `docs/`. +- `CLAUDE.md` : commandes, conventions, pièges connus. +- `docs/architecture.md` : étapes de construction, démarrage, données. +- `docs/exploitation/` : CI, mises à jour, changement de version. +- `docs/decisions/` : décisions numérotées et figées. +- `docs/a-faire.md` : points ouverts. + +Règles : + +1. **Un fait, un seul endroit.** Ailleurs, un lien, jamais une copie. +2. **La doc décrit le présent.** Pas de dates, de « corrigé le », ni de récit. L'historique + va dans les messages de commit, le « pourquoi » d'un choix dans une décision. +3. **Une décision est numérotée et figée** (`0001-titre.md`). On ne la modifie pas : une + nouvelle décision la remplace et le dit. +4. **`a-faire.md` ne contient que l'ouvert.** Un point terminé est supprimé, pas coché. +5. **La doc est mise à jour dans le même commit que le code qu'elle décrit.** Sinon, la + tâche n'est pas terminée. +6. **Un commentaire cite un document par son nom de fichier**, jamais par un numéro de + section. +7. **Un fichier au-delà de ~300 lignes se découpe.** diff --git a/Dockerfile b/Dockerfile index 6f22635..79f59d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,43 @@ # syntax=docker/dockerfile:1 +# Versions épinglées : docs/decisions/0003-versions-de-timescaledb-et-vectorchord-epinglees.md ARG PG_VERSION=18 +ARG VCHORD_VERSION=1.1.1 # ============================================================================= -# Builder stage - Compile TimescaleDB +# Étape base — image VectorChord, paquets mis à niveau # ============================================================================= -FROM tensorchord/vchord-postgres:pg${PG_VERSION}-v1.1.1 AS builder +FROM tensorchord/vchord-postgres:pg${PG_VERSION}-v${VCHORD_VERSION} AS base USER root -# Install build dependencies +# L'image de base n'est republiée qu'à chaque version de VectorChord : les versions mineures +# de PostgreSQL, pgvector et les correctifs Debian viennent d'apt. +# APT_REFRESH est une clé de cache : la changer rejoue cette couche, ce que fait la CI quand +# des mises à jour sont en attente. Voir docs/decisions/0002-paquets-a-jour-et-en-tetes-du-serveur.md. +ARG APT_REFRESH= +RUN echo "apt refresh: ${APT_REFRESH:-none}" \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --with-new-pkgs \ + && rm -rf /var/lib/apt/lists/* + +# ============================================================================= +# Étape builder — compilation de TimescaleDB +# ============================================================================= +FROM base AS builder + +# Les en-têtes du serveur sont épinglés sur la version du serveur installé : des en-têtes plus +# récents font référencer à TimescaleDB des symboles que le serveur n'exporte pas +# (`palloc0_mul` avec PostgreSQL 18). RUN apt-get update && apt-get install -y --no-install-recommends \ git \ build-essential \ cmake \ libssl-dev \ libkrb5-dev \ - postgresql-server-dev-$PG_MAJOR \ + postgresql-server-dev-$PG_MAJOR="$(dpkg-query -W -f='${Version}' postgresql-$PG_MAJOR)" \ && rm -rf /var/lib/apt/lists/* -# Build TimescaleDB from source (stable release) +# TimescaleDB compilé depuis l'étiquette de la version stable ARG TIMESCALEDB_VERSION=2.28.3 WORKDIR /build/timescaledb RUN git clone --branch ${TIMESCALEDB_VERSION} --depth 1 https://github.com/timescale/timescaledb.git . \ @@ -26,45 +45,50 @@ RUN git clone --branch ${TIMESCALEDB_VERSION} --depth 1 https://github.com/times && cd build && make -j$(nproc) && make install DESTDIR=/tmp/timescaledb # ============================================================================= -# Final stage - Clean runtime image +# Étape finale — image publiée, sans outils de compilation # ============================================================================= -FROM tensorchord/vchord-postgres:pg${PG_VERSION}-v1.1.1 AS final +FROM base AS final -# OCI Labels +# Labels OCI fixes. Ceux qui dépendent de la construction (révision, version, date) sont posés +# par la CI. LABEL org.opencontainers.image.title="TimescaleDB + VectorChord + pgvector + PostGIS" LABEL org.opencontainers.image.description="PostgreSQL with TimescaleDB, VectorChord, pgvector, and PostGIS extensions pre-installed" LABEL org.opencontainers.image.source="https://gitea.killinger.fr/maxime.killinger/postgres-ts-vectors" LABEL org.opencontainers.image.vendor="Maxime Killinger" LABEL org.opencontainers.image.licenses="Apache-2.0" -LABEL org.opencontainers.image.base.name="tensorchord/vchord-postgres:pg${PG_VERSION}-v1.1.1" +# PG_MAJOR et non PG_VERSION : dans cette étape, PG_VERSION est la variable d'environnement de +# l'image de base (la version complète du paquet), pas l'argument de construction. +ARG VCHORD_VERSION +LABEL org.opencontainers.image.base.name="tensorchord/vchord-postgres:pg${PG_MAJOR}-v${VCHORD_VERSION}" -# Copy TimescaleDB from builder +# TimescaleDB, depuis l'étape de compilation COPY --from=builder /tmp/timescaledb/usr/lib/postgresql/ /usr/lib/postgresql/ COPY --from=builder /tmp/timescaledb/usr/share/postgresql/ /usr/share/postgresql/ -USER root - -# Install PostGIS +# PostGIS RUN apt-get update && apt-get install -y --no-install-recommends \ postgresql-$PG_MAJOR-postgis-3 \ postgresql-$PG_MAJOR-postgis-3-scripts \ postgis \ && rm -rf /var/lib/apt/lists/* -# Add init script for auto-extension creation +# Création des extensions à l'initialisation de la base COPY init-extensions.sh /docker-entrypoint-initdb.d/ RUN sed -i 's/\r$//' /docker-entrypoint-initdb.d/init-extensions.sh && \ chmod +x /docker-entrypoint-initdb.d/init-extensions.sh -# Add entrypoint wrapper to fix volume permissions at runtime +# Enveloppe du point d'entrée : droits du volume, puis point d'entrée d'origine COPY docker-entrypoint-wrapper.sh /usr/local/bin/ RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint-wrapper.sh && \ chmod +x /usr/local/bin/docker-entrypoint-wrapper.sh -# Healthcheck +# Sonde avec l'utilisateur et la base du conteneur : sonder en `postgres` écrit +# `FATAL: role "postgres" does not exist` au journal toutes les 30 s dès que POSTGRES_USER +# est défini. HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ - CMD pg_isready -U postgres || exit 1 + CMD pg_isready -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-${POSTGRES_USER:-postgres}}" || exit 1 -# Run as root initially, wrapper will fix permissions then switch to postgres +# Démarre en root : l'enveloppe corrige les droits, le point d'entrée d'origine passe ensuite +# à l'utilisateur postgres. ENTRYPOINT ["docker-entrypoint-wrapper.sh"] CMD ["postgres", "-c", "shared_preload_libraries=timescaledb,vchord"] diff --git a/README.md b/README.md index 966ea3a..88265f9 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,134 @@ -# TimescaleDB + VectorChord + pgvector + PostGIS +

postgres-ts-vectors

+

Image Docker de PostgreSQL avec TimescaleDB, VectorChord, pgvector et PostGIS déjà installés.

-[![Build Status](https://gitea.killinger.fr/maxime.killinger/postgres-ts-vectors/actions/workflows/docker-build.yml/badge.svg)](https://gitea.killinger.fr/maxime.killinger/postgres-ts-vectors/actions) +

+ CI + PostgreSQL 15, 16, 17 et 18 + TimescaleDB 2.28.3 + VectorChord 1.1.1 + pgvector 0.8 + PostGIS 3 + Docker, une image par version de PostgreSQL +

-Docker image combining **PostgreSQL** with four powerful extensions: +## Présentation -- **[TimescaleDB](https://www.timescale.com/)** - Time-series database extension -- **[VectorChord](https://vectorchord.ai/)** - High-performance vector similarity search -- **[pgvector](https://github.com/pgvector/pgvector)** - Open-source vector similarity search -- **[PostGIS](https://postgis.net/)** - Geographic objects support +Une image par version majeure de PostgreSQL, prête à servir des séries temporelles, de la +recherche vectorielle et des données géographiques dans la même base : -## Supported PostgreSQL Versions +- **[TimescaleDB](https://www.timescale.com/)** : séries temporelles (hypertables) ; +- **[VectorChord](https://vectorchord.ai/)** et **[pgvector](https://github.com/pgvector/pgvector)** : + type `vector` et index de recherche par similarité ; +- **[PostGIS](https://postgis.net/)** : objets géographiques. -| Tag | PostgreSQL | TimescaleDB | VectorChord | pgvector | PostGIS | -|-----|------------|-------------|-------------|----------|---------| -| `pg15` | 15 | 2.28.3 | 1.1.1 | 0.8.1 | 3.x | -| `pg16` | 16 | 2.28.3 | 1.1.1 | 0.8.1 | 3.x | -| `pg17`, `latest` | 17 | 2.28.3 | 1.1.1 | 0.8.1 | 3.x | +Les extensions sont créées au premier démarrage, dans la base du conteneur. L'image part de +celle de VectorChord, mise à niveau à la construction : [docs/architecture.md](docs/architecture.md). -## Quick Start +## Images + +Publiées sous `gitea.killinger.fr/maxime.killinger/postgres-ts-vectors`. + +| Étiquette | PostgreSQL | TimescaleDB | VectorChord | pgvector | PostGIS | +|---|---|---|---|---|---| +| `pg15` | 15 | 2.28.3 | 1.1.1 | 0.8 | 3 | +| `pg16` | 16 | 2.28.3 | 1.1.1 | 0.8 | 3 | +| `pg17`, `latest` | 17 | 2.28.3 | 1.1.1 | 0.8 | 3 | +| `pg18` | 18 | 2.28.3 | 1.1.1 | 0.8 | 3 | + +TimescaleDB et VectorChord sont épinglés. PostgreSQL, pgvector et PostGIS suivent leurs +versions mineures : les images sont reconstruites dès qu'apt en propose +([Mises à jour](#mises-à-jour)). Les versions exactes se lisent dans un conteneur : + +```bash +docker exec ma-base psql -U postgres -Atc "select version()" \ + -c "select extname, extversion from pg_extension" +``` + +## Démarrage rapide ```bash docker run -d \ - --name my-postgres \ - -e POSTGRES_PASSWORD=mysecretpassword \ - -v postgres_data:/var/lib/postgresql \ + --name ma-base \ + -e POSTGRES_PASSWORD=motdepasse \ + -v donnees_pg:/var/lib/postgresql \ -p 5432:5432 \ - gitea.killinger.fr/maxime.killinger/postgres-ts-vectors:latest + gitea.killinger.fr/maxime.killinger/postgres-ts-vectors:pg18 ``` -All extensions are **automatically created** on first startup. +Le point de montage du volume dépend de la version de PostgreSQL : -## Data Persistence +| Étiquette | Monter le volume sur | +|---|---| +| `pg15`, `pg16`, `pg17`, `latest` | `/var/lib/postgresql/data` | +| `pg18` | `/var/lib/postgresql` | -> [!IMPORTANT] -> Starting with PostgreSQL 18, mount volumes at `/var/lib/postgresql` (not `/var/lib/postgresql/data`). -> Data will be stored in a versioned subdirectory (e.g., `/var/lib/postgresql/18/main`), which facilitates future upgrades with `pg_upgrade`. +Sur l'autre chemin, les données d'une image jusqu'à PostgreSQL 17 partent dans un volume +anonyme, perdu quand le conteneur est recréé, et l'image `pg18` refuse de démarrer. -## Environment Variables +## Configuration -| Variable | Description | Default | -|----------|-------------|---------| -| `POSTGRES_PASSWORD` | PostgreSQL password (required) | - | -| `POSTGRES_USER` | PostgreSQL user | `postgres` | -| `POSTGRES_DB` | Default database | `postgres` | +| Variable | Rôle | Défaut | +|---|---|---| +| `POSTGRES_PASSWORD` | Mot de passe, obligatoire | aucun | +| `POSTGRES_USER` | Utilisateur | `postgres` | +| `POSTGRES_DB` | Base, où les extensions sont créées | le nom de l'utilisateur | -## Extensions Usage +Ce sont les variables de l'image officielle `postgres`. Une commande qui remplace celle de +l'image doit garder `-c shared_preload_libraries=timescaledb,vchord`, sans quoi TimescaleDB +et VectorChord ne sont pas chargés. -### TimescaleDB +## Utilisation ```sql --- Create a hypertable -CREATE TABLE conditions ( - time TIMESTAMPTZ NOT NULL, - device_id INTEGER, - temperature DOUBLE PRECISION -); +-- Série temporelle +CREATE TABLE mesures (time TIMESTAMPTZ NOT NULL, capteur INTEGER, temperature DOUBLE PRECISION); +SELECT create_hypertable('mesures', 'time'); -SELECT create_hypertable('conditions', 'time'); -``` - -### VectorChord / pgvector - -```sql --- Create a table with vector embeddings -CREATE TABLE documents ( - id SERIAL PRIMARY KEY, - content TEXT, - embedding vector(1536) -); - --- Create an index for fast similarity search +-- Recherche vectorielle +CREATE TABLE documents (id SERIAL PRIMARY KEY, contenu TEXT, embedding vector(1536)); CREATE INDEX ON documents USING vchordrq (embedding vector_l2_ops); +SELECT * FROM documents ORDER BY embedding <-> '[0.1, 0.2, ...]' LIMIT 10; --- Query similar documents -SELECT * FROM documents -ORDER BY embedding <-> '[0.1, 0.2, ...]' -LIMIT 10; +-- Géographie +CREATE TABLE lieux (id SERIAL PRIMARY KEY, geom geometry(Point, 4326)); +SELECT ST_Distance(a.geom, b.geom) FROM lieux a, lieux b WHERE a.id = 1 AND b.id = 2; ``` -## Building Locally +## Mises à jour + +Chaque nuit, la CI teste les images publiées et reconstruit celles pour lesquelles apt +propose des mises à jour : versions mineures de PostgreSQL, pgvector, PostGIS, correctifs +Debian. Une version de TimescaleDB ou de VectorChord se change à la main, car une base +existante doit alors être mise à niveau : +[docs/exploitation/publication.md](docs/exploitation/publication.md). + +## Tests ```bash -# Build for PG18 -docker build --build-arg PG_VERSION=18 -t my-image:pg18 . - -# Build for PG16 -docker build --build-arg PG_VERSION=16 -t my-image:pg16 . +docker build --build-arg PG_VERSION=18 -t postgres-ts-vectors:pg18 . +./tests.sh postgres-ts-vectors:pg18 ``` -## License +`tests.sh` démarre l'image, vérifie les extensions, les exerce, redémarre le serveur et, +avec une seconde image en argument, reprend une base créée par celle-ci. La CI lance la même +commande avant de publier. Détail dans [CLAUDE.md](CLAUDE.md). -This project is licensed under the Apache 2.0 License. +## Documentation -- TimescaleDB: [Timescale License](https://github.com/timescale/timescaledb/blob/main/LICENSE) -- VectorChord: [Apache 2.0](https://github.com/tensorchord/VectorChord/blob/main/LICENSE) -- pgvector: [PostgreSQL License](https://github.com/pgvector/pgvector/blob/master/LICENSE) +- [CLAUDE.md](CLAUDE.md) : commandes, conventions, pièges connus. +- [docs/architecture.md](docs/architecture.md) : étapes de construction, démarrage du + conteneur, emplacement des données. +- [docs/exploitation/publication.md](docs/exploitation/publication.md) : CI, mises à jour, + changement de version. +- [docs/decisions/](docs/decisions/) : choix de conception. +- [docs/a-faire.md](docs/a-faire.md) : points ouverts. + +## Licences + +Le dépôt est sous licence Apache 2.0. Les extensions gardent la leur : +[TimescaleDB](https://github.com/timescale/timescaledb/blob/main/LICENSE), +[VectorChord](https://github.com/tensorchord/VectorChord/blob/main/LICENSE), +[pgvector](https://github.com/pgvector/pgvector/blob/master/LICENSE), +[PostGIS](https://postgis.net/) (GPL v2 ou ultérieure). + +Image réalisée par [Maxime Killinger](https://www.linkedin.com/in/maxime-killinger-8504b7b6). diff --git a/docs/a-faire.md b/docs/a-faire.md new file mode 100644 index 0000000..772ed80 --- /dev/null +++ b/docs/a-faire.md @@ -0,0 +1,28 @@ +# À faire + +Ce fichier ne contient que l'ouvert. Un point terminé est supprimé. + +## Versions + +- **TimescaleDB 2.30.2 est disponible**, l'image épingle 2.28.3. Vérifié sur PostgreSQL 17 : + la 2.30.2 compile, passe les tests et reprend une base créée en 2.28.3 après + `ALTER EXTENSION timescaledb UPDATE`. Reste à le vérifier pour 15, 16 et 18, puis à suivre + la procédure de [exploitation/publication.md](exploitation/publication.md), base de Health + comprise. + +## CI + +- **Le cache de construction est borné à 8 Go, et les couches des quatre versions en pèsent + environ 7,3** (1,8 Go par version, dont 1,1 pour les outils de compilation), sur un cache + partagé avec les autres dépôts du runner. Une couche évincée est reconstruite au push + suivant. À mesurer sur Tower avant de relever la borne. +- **Vérifier qu'un échec de l'exécution nocturne est notifié** par Gitea. Sinon, seuls la page + Actions et le badge du README le montrent. + +## Dépôt + +- **`init-timescale.sh` n'est copié dans aucune image.** À supprimer s'il ne sert plus. +- **`build.sh` nomme l'image `timescaledb-vectorchord`**, un nom que rien d'autre n'utilise. +- **`docker-entrypoint-wrapper.sh` refait `chown -R` sur tout le volume à chaque + démarrage.** Ne le faire que si le propriétaire n'est pas `postgres` éviterait le parcours + d'une grosse base. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0e4ec67 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,99 @@ +# Architecture + +``` +tensorchord/vchord-postgres:pgN-v1.1.1 PostgreSQL N, VectorChord, pgvector (Debian 12) + │ + ▼ + base ........ apt-get upgrade : PostgreSQL (mineure), pgvector, Debian + ├──▶ builder ... en-têtes du serveur + compilation de TimescaleDB + │ │ bibliothèques et scripts SQL + ▼ ▼ + final ....... TimescaleDB copié, PostGIS (apt), scripts de démarrage + │ + ▼ +gitea.killinger.fr/maxime.killinger/postgres-ts-vectors:pgN +``` + +Le même Dockerfile produit une image par version majeure de PostgreSQL, choisie par +l'argument `PG_VERSION`. Les trois étapes s'enchaînent ainsi parce que le serveur et ses +en-têtes doivent avoir la même version +([0002](decisions/0002-paquets-a-jour-et-en-tetes-du-serveur.md)). + +## Fichiers + +| Fichier | Rôle | +|---|---| +| `Dockerfile` | Construction de l'image, en trois étapes | +| `init-extensions.sh` | Création des extensions à l'initialisation de la base | +| `docker-entrypoint-wrapper.sh` | Point d'entrée : droits du volume, puis point d'entrée de l'image `postgres` | +| `tests.sh` | Tests de l'image, en local et en CI | +| `tests/test-*.sql` | Contrôles joués sur une base neuve, un fichier par extension ou combinaison | +| `tests/reprise-*.sql` | Données créées par l'image précédente, puis relues par la nouvelle | +| `build.sh` | Construction locale d'une version | +| `.gitea/workflows/docker.yml` | CI : construction, tests, publication, mises à jour nocturnes | + +## D'où vient chaque composant + +| Composant | Source | Version | +|---|---|---| +| PostgreSQL | image de base, puis `apt-get upgrade` (dépôt PGDG) | dernière mineure de la majeure | +| VectorChord | image de base | `VCHORD_VERSION` du Dockerfile | +| pgvector | image de base, puis `apt-get upgrade` (dépôt PGDG) | dernière disponible | +| TimescaleDB | compilé depuis l'étiquette Git de la version | `TIMESCALEDB_VERSION` du Dockerfile | +| PostGIS | paquet `postgresql-N-postgis-3` (dépôt PGDG) | dernière disponible | + +Pourquoi cet assemblage : +[0001](decisions/0001-image-vectorchord-et-timescaledb-compile.md). Pourquoi deux versions +sont épinglées et pas les autres : +[0003](decisions/0003-versions-de-timescaledb-et-vectorchord-epinglees.md). + +## Étapes de construction + +- **`base`** part de l'image de VectorChord et met tous ses paquets à niveau. L'argument + `APT_REFRESH` ne sert qu'à invalider le cache de cette couche : sa valeur est affichée, + rien d'autre ne la lit. +- **`builder`** part de `base`, installe les outils de compilation et les en-têtes du + serveur dans la version exacte du serveur installé, puis compile TimescaleDB et l'installe + dans `/tmp/timescaledb`. +- **`final`** part de `base`, reçoit de `builder` les bibliothèques (`/usr/lib/postgresql/`) + et les scripts SQL (`/usr/share/postgresql/`) de TimescaleDB, installe PostGIS, copie les + deux scripts de démarrage et déclare la sonde de santé. Aucun outil de compilation n'y + entre. + +Labels de l'image : titre, description, source, auteur, licence et image de base sont dans +le Dockerfile ; révision, version, date et clé `fr.killinger.apt-refresh` sont posés par la +CI ([exploitation/publication.md](exploitation/publication.md)). + +## Démarrage du conteneur + +1. `docker-entrypoint-wrapper.sh` démarre en root, crée `/var/lib/postgresql` s'il manque, + en donne la propriété à `postgres` et le ferme aux autres utilisateurs. Un volume monté + arrive en effet avec les droits de son hôte. +2. Il passe la main à `docker-entrypoint.sh`, le point d'entrée de l'image officielle + `postgres`, qui prend l'identité `postgres`. +3. Sur un volume vide, ce point d'entrée initialise la base, démarre un serveur temporaire + et exécute `init-extensions.sh`, qui crée `timescaledb`, `vector`, `vchord`, `postgis` et + `postgis_topology` dans la base `POSTGRES_DB`. Sur un volume déjà initialisé, rien de + cela n'a lieu. +4. Le serveur définitif démarre avec la commande de l'image : + `postgres -c shared_preload_libraries=timescaledb,vchord`. Les deux bibliothèques doivent + être préchargées pour que leurs extensions fonctionnent. + +La sonde de santé interroge `pg_isready` toutes les 30 secondes, avec l'utilisateur et la +base du conteneur. + +## Données + +| Étiquette | Volume déclaré par l'image | Répertoire des données (`PGDATA`) | +|---|---|---| +| `pg15`, `pg16`, `pg17`, `latest` | `/var/lib/postgresql/data` | `/var/lib/postgresql/data` | +| `pg18` | `/var/lib/postgresql` | `/var/lib/postgresql/18/docker` | + +Ces emplacements sont ceux de l'image officielle `postgres`, que l'image de base reprend. +À partir de PostgreSQL 18, le répertoire porte le numéro de la version majeure, pour que +deux versions cohabitent dans le même volume pendant un `pg_upgrade`. Une image `pg18` qui +trouve un volume monté sur `/var/lib/postgresql/data` refuse de démarrer. + +Passer d'une version majeure à la suivante n'est pas un changement d'étiquette : les données +d'une version ne sont pas lisibles par une autre sans `pg_upgrade` ou sauvegarde et +restauration ([0006](decisions/0006-latest-reste-sur-postgresql-17.md)). diff --git a/docs/decisions/0001-image-vectorchord-et-timescaledb-compile.md b/docs/decisions/0001-image-vectorchord-et-timescaledb-compile.md new file mode 100644 index 0000000..edb890a --- /dev/null +++ b/docs/decisions/0001-image-vectorchord-et-timescaledb-compile.md @@ -0,0 +1,32 @@ +# 0001 — Image de VectorChord en base, TimescaleDB compilé, PostGIS par apt + +## Contexte + +L'image doit réunir PostgreSQL, TimescaleDB, VectorChord, pgvector et PostGIS, pour plusieurs +versions majeures de PostgreSQL. TensorChord publie `tensorchord/vchord-postgres`, qui +contient déjà PostgreSQL, VectorChord et pgvector, construite sur l'image officielle +`postgres` (Debian 12, dépôt de paquets PGDG). + +## Décision + +- L'image part de `tensorchord/vchord-postgres:pgN-vX.Y.Z`. +- TimescaleDB est compilé depuis l'étiquette Git de sa version, dans une étape séparée. Seuls + ses bibliothèques et ses scripts SQL passent dans l'image finale. +- PostGIS est installé par le paquet `postgresql-N-postgis-3` du dépôt PGDG. +- Les extensions sont créées à l'initialisation de la base par un script de + `/docker-entrypoint-initdb.d/`, et la commande de l'image précharge `timescaledb` et + `vchord`. + +## Conséquences + +- Le même Dockerfile sert toutes les versions de PostgreSQL pour lesquelles TensorChord + publie une image. +- Une construction sans cache compile TimescaleDB, ce qui prend plusieurs minutes par + version. +- Les outils de compilation ne sont pas dans l'image publiée. +- Variables d'environnement, point d'entrée et emplacement des données sont ceux de l'image + officielle `postgres`. + +## Attestée par + +`Dockerfile` ; `init-extensions.sh` ; `tests/test-*.sql`. diff --git a/docs/decisions/0002-paquets-a-jour-et-en-tetes-du-serveur.md b/docs/decisions/0002-paquets-a-jour-et-en-tetes-du-serveur.md new file mode 100644 index 0000000..efbab9c --- /dev/null +++ b/docs/decisions/0002-paquets-a-jour-et-en-tetes-du-serveur.md @@ -0,0 +1,38 @@ +# 0002 — Paquets mis à niveau à la construction, en-têtes épinglés sur le serveur + +## Contexte + +L'image de base n'est republiée qu'à chaque version de VectorChord. Entre deux, son +PostgreSQL prend du retard sur les versions mineures et les correctifs de sécurité : une +image de base contenait PostgreSQL 18.3 quand le dépôt PGDG en était à 18.6, avec une +trentaine d'autres paquets en retard. + +La compilation de TimescaleDB installe les en-têtes du serveur par apt, donc dans la +dernière version. Compilé avec des en-têtes 18.6 puis chargé par un serveur 18.3, +TimescaleDB échouait à la création de l'extension (`undefined symbol: palloc0_mul`), sans +erreur à la compilation. PostgreSQL 18 avait été retiré de la CI pour cette raison. + +## Décision + +- Une étape `base`, commune à la compilation et à l'image finale, met tous les paquets à + niveau (`apt-get upgrade --with-new-pkgs`). Le serveur publié est donc à la dernière + version mineure. +- L'étape de compilation installe les en-têtes dans la version exacte du serveur installé, + lue par `dpkg-query`, et non dans la dernière disponible. +- La couche de mise à niveau porte une clé de cache, l'argument `APT_REFRESH`. + +## Conséquences + +- PostgreSQL 18 est de nouveau construit et publié. +- Serveur et en-têtes ne peuvent plus diverger : si la version voulue des en-têtes n'est + plus dans le dépôt, la construction échoue au lieu de produire une image qui ne charge + pas TimescaleDB. +- La variable d'environnement `PG_VERSION` héritée de l'image de base n'est plus la version + du serveur. +- Sans changement de `APT_REFRESH`, le cache garde la mise à niveau telle qu'elle a été + faite : c'est la CI qui décide quand la rejouer + ([0005](0005-mises-a-jour-nocturnes-pilotees-par-apt.md)). + +## Attestée par + +`Dockerfile` (étapes `base` et `builder`) ; commit `dcf4169`, qui avait retiré PostgreSQL 18. diff --git a/docs/decisions/0003-versions-de-timescaledb-et-vectorchord-epinglees.md b/docs/decisions/0003-versions-de-timescaledb-et-vectorchord-epinglees.md new file mode 100644 index 0000000..09ab626 --- /dev/null +++ b/docs/decisions/0003-versions-de-timescaledb-et-vectorchord-epinglees.md @@ -0,0 +1,32 @@ +# 0003 — Versions de TimescaleDB et de VectorChord épinglées + +## Contexte + +TimescaleDB nomme sa bibliothèque d'après sa version (`timescaledb-2.28.3.so`), et une base +retient la version de l'extension qu'elle a installée. L'image ne contient que la +bibliothèque de la version compilée. Démarrée sur une base créée avec une autre version, +elle refuse toute requête (`could not access file "$libdir/timescaledb-2.28.3"`) jusqu'à +l'exécution de `ALTER EXTENSION timescaledb UPDATE`. Le serveur, lui, démarre et répond à +`pg_isready`. + +Les versions mineures de PostgreSQL, pgvector et PostGIS n'ont pas cet effet : une base +existante redémarre sur la nouvelle image sans intervention. + +## Décision + +- `TIMESCALEDB_VERSION` et `VCHORD_VERSION` sont écrits dans le Dockerfile. La CI ne les fait + jamais avancer d'elle-même. +- Les changer est un commit, précédé du test de reprise et d'un avertissement aux + exploitants ([exploitation/publication.md](../exploitation/publication.md)). +- Tout le reste suit apt ([0002](0002-paquets-a-jour-et-en-tetes-du-serveur.md)). + +## Conséquences + +- Aucune republication automatique ne peut rendre une base inutilisable. +- TimescaleDB et VectorChord prennent du retard tant que personne ne change leur version. +- Le test de reprise de `tests.sh` commence par mettre les extensions à niveau : il vérifie + que la procédure fonctionne, pas que la base repart sans elle. + +## Attestée par + +`Dockerfile` ; `tests/reprise-2-controle.sql`. diff --git a/docs/decisions/0004-ci-sans-action-externe.md b/docs/decisions/0004-ci-sans-action-externe.md new file mode 100644 index 0000000..651a8c9 --- /dev/null +++ b/docs/decisions/0004-ci-sans-action-externe.md @@ -0,0 +1,36 @@ +# 0004 — CI sans action externe, cache du démon, une construction par version + +## Contexte + +La CI téléchargeait depuis GitHub, à chaque job, quatre actions (`checkout`, +`setup-buildx`, `login`, `build-push`), une vingtaine de secondes chacune, pour des +opérations que l'image du runner sait faire avec `git` et `docker`. `setup-buildx` créait un +constructeur neuf, sans cache : TimescaleDB était recompilé à chaque job, 7 à 9 minutes par +version. L'image était construite jusqu'à trois fois : pour les tests, pour la publication, +puis pour `latest`. Tout push relançait la CI, même quand il ne touchait que la documentation. + +## Décision + +- Récupération du seul commit concerné par `git fetch --depth 1`, connexion au registre par + `docker login`, construction par `docker build --pull` avec le démon Docker du runner, + dont le cache persiste d'un job à l'autre. +- Une seule construction par version : l'image testée est celle qui est poussée, et `latest` + est une seconde étiquette de la même image. +- Les tests sont une commande du dépôt, `tests.sh`, la même en local. +- `paths-ignore` sur `**.md` et `docs/**` : un push de documentation seule ne lance pas la CI. +- Cache de construction borné à 8 Go, images sans étiquette supprimées en fin de job. + +## Conséquences + +- La CI ne dépend plus de la disponibilité de GitHub pour ses actions. Elle en dépend encore + pour le code source de TimescaleDB, quand il faut le recompiler. +- La compilation n'est rejouée que si l'image de base, les paquets ou la version de + TimescaleDB changent. +- Le runner doit donner accès à un démon Docker, ce qui est le cas du runner commun aux + dépôts de Health et aux sites. +- Le cache est partagé avec les autres dépôts du runner, chacun le bornant à 8 Go. + +## Attestée par + +`.gitea/workflows/docker.yml` ; commit `2a0f84e` ; la même décision dans nk-environnement +(`0011-ci-sans-action-externe.md`) et dans les dépôts de Health. diff --git a/docs/decisions/0005-mises-a-jour-nocturnes-pilotees-par-apt.md b/docs/decisions/0005-mises-a-jour-nocturnes-pilotees-par-apt.md new file mode 100644 index 0000000..ecdf9cd --- /dev/null +++ b/docs/decisions/0005-mises-a-jour-nocturnes-pilotees-par-apt.md @@ -0,0 +1,42 @@ +# 0005 — Mises à jour nocturnes, déclenchées par ce qu'apt propose + +Complète [0002](0002-paquets-a-jour-et-en-tetes-du-serveur.md) et +[0004](0004-ci-sans-action-externe.md). + +## Contexte + +Les images ne changeaient qu'à un push. Entre deux, elles ne recevaient ni version mineure de +PostgreSQL ni correctif de sécurité. Le cache persistant du runner aggrave le problème : une +couche `apt-get upgrade` déjà construite n'est jamais rejouée. Reconstruire sans cache chaque +nuit recompilerait TimescaleDB quatre fois et republierait quatre images identiques aux +précédentes. + +## Décision + +- La CI tourne chaque nuit. Pour chaque version, elle tire l'image publiée et y simule + `apt-get upgrade --with-new-pkgs`. +- S'il reste des paquets à mettre à niveau, ou si l'image n'existe pas, elle construit avec + une nouvelle clé `APT_REFRESH`, teste et publie. +- Sinon elle ne construit ni ne publie : elle joue les tests sur l'image publiée. +- La clé d'une image est gardée dans son label `fr.killinger.apt-refresh`. Un push la + reprend quand l'image publiée est à jour, pour que le cache serve. +- Les versions de TimescaleDB et de VectorChord ne sont pas concernées + ([0003](0003-versions-de-timescaledb-et-vectorchord-epinglees.md)). + +## Conséquences + +- Une version mineure de PostgreSQL ou un correctif Debian est publié la nuit qui suit son + arrivée dans apt, sans intervention. +- Une nuit sans mise à jour coûte une simulation apt et les tests, par version. +- L'état de référence est l'image publiée, pas le cache du runner : un cache perdu ne + provoque ni reconstruction ni publication inutiles. +- Tant qu'une reconstruction échoue aux tests, rien n'est publié et elle est retentée la nuit + suivante. +- La simulation et la construction utilisent la même commande apt : après une publication, + l'image n'a plus rien en attente et la nuit suivante ne reconstruit pas. +- Une republication de l'image de base sous la même étiquette n'est reprise qu'à la + prochaine reconstruction, par `--pull`. + +## Attestée par + +`.gitea/workflows/docker.yml` (étape « Check for updates ») ; `Dockerfile` (`APT_REFRESH`). diff --git a/docs/decisions/0006-latest-reste-sur-postgresql-17.md b/docs/decisions/0006-latest-reste-sur-postgresql-17.md new file mode 100644 index 0000000..128ba64 --- /dev/null +++ b/docs/decisions/0006-latest-reste-sur-postgresql-17.md @@ -0,0 +1,26 @@ +# 0006 — `latest` reste sur PostgreSQL 17 + +## Contexte + +L'image est publiée pour PostgreSQL 15 à 18. `latest` désigne PostgreSQL 17, et la base de +Health l'utilise, avec un volume monté sur `/var/lib/postgresql/data`. Les données d'une +version majeure ne sont pas lisibles par la suivante, et l'image `pg18` attend son volume sur +`/var/lib/postgresql`. + +## Décision + +- `latest` reste une seconde étiquette de `pg17`. L'arrivée de `pg18` ne la déplace pas. +- Un nouveau projet choisit une étiquette `pgN` explicite. + +## Conséquences + +- Aucun exploitant ne change de version majeure en tirant `latest`. +- `latest` ne désigne pas la version la plus récente de PostgreSQL. +- Déplacer `latest` demandera une nouvelle décision, après migration des bases qui + l'utilisent (`pg_upgrade`, ou sauvegarde et restauration) et changement de leur point de + montage. + +## Attestée par + +`.gitea/workflows/docker.yml` (étape « Tag latest ») ; `docker-compose.yml` de +health-workspace. diff --git a/docs/exploitation/publication.md b/docs/exploitation/publication.md new file mode 100644 index 0000000..47f5344 --- /dev/null +++ b/docs/exploitation/publication.md @@ -0,0 +1,110 @@ +# Publication et mises à jour + +Les images sont construites, testées et publiées par la CI (`.gitea/workflows/docker.yml`), +jamais à la main. Un push sur `main` **est une publication** : il remplace les quatre images, +dont `latest`. Personne ne pousse sans demande explicite de l'utilisateur. + +## Déclencheurs + +| Déclencheur | Effet | +|---|---| +| Push sur `main` | Construction, tests et publication des quatre versions. Sans effet si tous les fichiers modifiés sont des `.md` ou sous `docs/` | +| Chaque nuit, 3 h 30 UTC | Tests des images publiées ; reconstruction et publication de celles qui ont des mises à jour | +| Lancement manuel (`workflow_dispatch`) | Comme un push | + +## Chaîne de publication + +Un job par version de PostgreSQL (15, 16, 17, 18), sans action externe +([0004](../decisions/0004-ci-sans-action-externe.md)) : + +1. Récupération du seul commit concerné et connexion au registre. +2. **Recherche des mises à jour.** L'image publiée est tirée, et apt y simule une mise à + niveau. S'il reste des paquets à mettre à niveau, ou si l'image n'a jamais été publiée, + une nouvelle clé `APT_REFRESH` est tirée de l'heure. Sinon la clé de l'image publiée, + lue dans son label `fr.killinger.apt-refresh`, est reprise + ([0005](../decisions/0005-mises-a-jour-nocturnes-pilotees-par-apt.md)). +3. **Construction** avec `docker build --pull`, par le démon Docker du runner. Avec une clé + reprise, tout sort du cache. Avec une clé nouvelle, les paquets sont mis à niveau et + TimescaleDB est recompilé. La nuit, cette étape est sautée quand l'image est à jour. +4. **Tests** par `tests.sh`, avec l'image publiée comme image précédente : la nouvelle image + doit reprendre une base créée par celle qu'elle remplace. La nuit, sans reconstruction, + c'est l'image publiée elle-même qui est testée. +5. **Publication** de `pgN`, seulement si les tests passent, puis de `latest` pour + PostgreSQL 17 ([0006](../decisions/0006-latest-reste-sur-postgresql-17.md)). +6. Nettoyage : cache de construction borné à 8 Go, images sans étiquette supprimées. + +La CI ne déclenche aucun déploiement. Un conteneur passe à la nouvelle image quand son +exploitant la tire et le recrée. + +| Secret du dépôt | Usage | +|---|---| +| `secrets.DOCKER_TOKEN` | Connexion au registre Gitea (compte `maxime.killinger`) | + +## Ce qui se met à jour seul, et ce qui ne le fait pas + +| Composant | Mise à jour | +|---|---| +| PostgreSQL (version mineure), pgvector, PostGIS, paquets Debian | Automatique, la nuit qui suit leur arrivée dans apt | +| TimescaleDB, VectorChord | À la main, dans le Dockerfile ([0003](../decisions/0003-versions-de-timescaledb-et-vectorchord-epinglees.md)) | +| Nouvelle version majeure de PostgreSQL | À la main, dans la matrice de la CI | + +Une mise à jour automatique ne demande rien à l'exploitant d'une base : le conteneur recréé +repart sur ses données. Les extensions installées gardent la version de leurs objets SQL +tant qu'on ne les met pas à niveau, ce qui reste facultatif pour pgvector et PostGIS : + +```sql +ALTER EXTENSION vector UPDATE; +SELECT postgis_extensions_upgrade(); +``` + +## Changer la version de TimescaleDB + +1. Construire et tester en local contre l'image publiée, pour chaque version de PostgreSQL : + ```bash + docker build --build-arg PG_VERSION=17 --build-arg TIMESCALEDB_VERSION=2.30.2 -t postgres-ts-vectors:essai . + docker pull gitea.killinger.fr/maxime.killinger/postgres-ts-vectors:pg17 + ./tests.sh postgres-ts-vectors:essai gitea.killinger.fr/maxime.killinger/postgres-ts-vectors:pg17 + ``` + Une version de TimescaleDB qui abandonne une version de PostgreSQL échoue à la + compilation : cette version de PostgreSQL sort alors de la matrice, ou garde l'ancienne + version de TimescaleDB. +2. Changer `TIMESCALEDB_VERSION` dans le `Dockerfile`, le tableau et le badge du `README.md`. +3. Prévenir les exploitants **avant** de pousser. Dès qu'un conteneur est recréé sur la + nouvelle image, sa base refuse toute requête jusqu'à l'exécution de : + ```bash + docker exec -it psql -X -U -d -c 'ALTER EXTENSION timescaledb UPDATE' + ``` + La commande est à passer dans chaque base qui a l'extension. `-X` et une session neuve + sont nécessaires : l'instruction doit être la première de la session. + +## Changer la version de VectorChord + +Elle fait partie de l'étiquette de l'image de base. Vérifier que TensorChord publie +`tensorchord/vchord-postgres:pgN-vX.Y.Z` pour les quatre versions de PostgreSQL, changer +`VCHORD_VERSION` dans le `Dockerfile`, le tableau et le badge du `README.md`, puis tester +comme pour TimescaleDB. Côté base : `ALTER EXTENSION vchord UPDATE`. + +## Vérifier une image publiée + +```bash +IMAGE=gitea.killinger.fr/maxime.killinger/postgres-ts-vectors:pg17 +docker pull "$IMAGE" + +# Commit, date de construction, clé de mise à jour, image de base +docker image inspect "$IMAGE" -f '{{json .Config.Labels}}' + +# Version du serveur et des paquets d'extensions +docker run --rm --entrypoint sh "$IMAGE" -c \ + 'postgres --version; dpkg-query -W -f "\${Package} \${Version}\n" "postgresql-$PG_MAJOR-*" | grep " ."' + +# La suite de tests, sur l'image publiée +./tests.sh "$IMAGE" +``` + +| Label | Contenu | +|---|---| +| `org.opencontainers.image.revision` | Commit du dépôt qui a construit l'image | +| `org.opencontainers.image.created` | Date de construction | +| `org.opencontainers.image.version` | Étiquette `pgN` | +| `org.opencontainers.image.base.name` | Image de VectorChord dont elle part | +| `fr.killinger.apt-refresh` | Clé de la dernière mise à niveau des paquets, à l'heure UTC | diff --git a/tests.sh b/tests.sh new file mode 100755 index 0000000..447b6d0 --- /dev/null +++ b/tests.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Tests de l'image, en local comme en CI. +# +# ./tests.sh IMAGE démarrage, extensions, tests SQL, redémarrage +# ./tests.sh IMAGE PRECEDENTE en plus : reprise d'une base créée par l'image PRECEDENTE +# +# Tout tourne dans des conteneurs et un volume jetables, sans port publié, supprimés à la fin. +set -euo pipefail +cd "$(dirname "$0")" + +IMAGE="${1:?usage : tests.sh IMAGE [IMAGE_PRECEDENTE]}" +PRECEDENTE="${2:-}" +ID="ptvtest$$" +# Utilisateur et base autres que `postgres`, comme chez les consommateurs de l'image : les +# extensions doivent être créées dans POSTGRES_DB, par POSTGRES_USER. +OPTIONS=(-e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=test + --memory=768m --memory-swap=768m) # le runner de CI est limité en mémoire + +nettoyer() { + docker rm -f -v "$ID" "$ID-reprise" >/dev/null 2>&1 || true + docker volume rm "$ID-donnees" >/dev/null 2>&1 || true +} +trap nettoyer EXIT + +sql() { docker exec -i "$1" psql -X -q -v ON_ERROR_STOP=1 -U test -d test "${@:2}"; } + +# Trois sondes, de la plus exigeante à la moins exigeante. +# Premier démarrage : le serveur temporaire de l'initialisation répond déjà aux requêtes, avant +# que les extensions soient créées ; il faut aussi la fin de l'initialisation dans le journal. +# Le journal est lu en entier avant la recherche : `grep -q` branché sur `docker logs` le coupe +# dès la première correspondance, ce que `pipefail` compte comme un échec. +initialise() { + local journal + journal=$(docker logs "$1" 2>&1) + grep -q "PostgreSQL init process complete" <<<"$journal" && repond "$1" +} +repond() { sql "$1" -Atc 'select 1' >/dev/null 2>&1; } +# Sans requête : après un changement de version de TimescaleDB, toute requête échoue tant que +# l'extension n'est pas mise à niveau (tests/reprise-2-controle.sql). +accepte() { docker exec "$1" pg_isready -q -U test -d test; } + +attendre() { # attendre SONDE CONTENEUR + for _ in $(seq 1 90); do + "$1" "$2" && return 0 + sleep 1 + done + docker logs "$2" 2>&1 | tail -30 >&2 + echo "ÉCHEC : $2 ne démarre pas" >&2 + return 1 +} + +echo "== Démarrage de $IMAGE" +docker run -d --name "$ID" "${OPTIONS[@]}" "$IMAGE" >/dev/null +attendre initialise "$ID" + +# L'étiquette pgN doit contenir un serveur N : PG_MAJOR vient de l'image de base. +attendue=$(docker exec "$ID" printenv PG_MAJOR) +servie=$(sql "$ID" -Atc "select current_setting('server_version_num')::int / 10000") +[[ "$servie" == "$attendue" ]] || { echo "ÉCHEC : serveur $servie, image pg$attendue" >&2; exit 1; } + +for fichier in tests/test-*.sql; do + echo "== $fichier" + sql "$ID" < "$fichier" +done + +echo "== Redémarrage sur des données existantes" +docker restart "$ID" >/dev/null +attendre repond "$ID" +sql "$ID" < tests/test-1-config.sql + +if [[ -n "$PRECEDENTE" ]]; then + echo "== Reprise d'une base créée par $PRECEDENTE" + # Le volume se monte là où l'image déclare le sien : /var/lib/postgresql/data jusqu'à + # PostgreSQL 17, /var/lib/postgresql à partir de 18. + montage=$(docker image inspect -f '{{range $chemin, $_ := .Config.Volumes}}{{$chemin}}{{end}}' "$IMAGE") + docker volume create "$ID-donnees" >/dev/null + docker run -d --name "$ID-reprise" "${OPTIONS[@]}" -v "$ID-donnees:$montage" "$PRECEDENTE" >/dev/null + attendre initialise "$ID-reprise" + sql "$ID-reprise" < tests/reprise-1-semis.sql + docker stop "$ID-reprise" >/dev/null + docker rm "$ID-reprise" >/dev/null + + docker run -d --name "$ID-reprise" "${OPTIONS[@]}" -v "$ID-donnees:$montage" "$IMAGE" >/dev/null + attendre accepte "$ID-reprise" + sql "$ID-reprise" < tests/reprise-2-controle.sql +fi + +echo "== Versions" +sql "$ID" -At -c "select version()" \ + -c "select string_agg(extname || ' ' || extversion, ', ' order by extname) from pg_extension" +echo "Tests réussis : $IMAGE" diff --git a/tests/reprise-1-semis.sql b/tests/reprise-1-semis.sql new file mode 100644 index 0000000..a692c41 --- /dev/null +++ b/tests/reprise-1-semis.sql @@ -0,0 +1,25 @@ +-- Reprise 1 : données créées par l'image précédente, relues ensuite par la nouvelle +-- (reprise-2-controle.sql). Une table par extension. +\set ON_ERROR_STOP on + +CREATE TABLE reprise_mesures ( + time TIMESTAMPTZ NOT NULL, + capteur INTEGER, + valeur DOUBLE PRECISION +); +SELECT create_hypertable('reprise_mesures', 'time'); +INSERT INTO reprise_mesures +SELECT TIMESTAMPTZ '2026-01-01' + n * INTERVAL '1 day', 1, n FROM generate_series(1, 30) AS n; + +CREATE TABLE reprise_vecteurs ( + id bigserial PRIMARY KEY, + embedding vector(3) +); +INSERT INTO reprise_vecteurs (embedding) VALUES ('[1,2,3]'), ('[4,5,6]'), ('[1.1, 2.1, 3.1]'); +CREATE INDEX ON reprise_vecteurs USING vchordrq (embedding vector_l2_ops); + +CREATE TABLE reprise_lieux ( + id serial PRIMARY KEY, + geom geometry(Point, 4326) +); +INSERT INTO reprise_lieux (geom) VALUES (ST_GeomFromText('POINT(7.75 48.58)', 4326)); diff --git a/tests/reprise-2-controle.sql b/tests/reprise-2-controle.sql new file mode 100644 index 0000000..85fa7b8 --- /dev/null +++ b/tests/reprise-2-controle.sql @@ -0,0 +1,39 @@ +-- Reprise 2 : la nouvelle image démarre sur les données de la précédente +-- (reprise-1-semis.sql). Les extensions sont d'abord mises à niveau, comme le fait +-- l'exploitant ; la première instruction de la session doit être celle de TimescaleDB. +\set ON_ERROR_STOP on + +ALTER EXTENSION timescaledb UPDATE; +ALTER EXTENSION vector UPDATE; +ALTER EXTENSION vchord UPDATE; +SELECT postgis_extensions_upgrade(); + +INSERT INTO reprise_mesures VALUES (TIMESTAMPTZ '2026-03-01', 1, 99); +INSERT INTO reprise_vecteurs (embedding) VALUES ('[7,8,9]'); + +DO $$ +DECLARE + mesures int; + plus_proche int; + lieux int; +BEGIN + SELECT count(*) INTO mesures FROM reprise_mesures; + IF mesures != 31 THEN + RAISE EXCEPTION 'Hypertable : 31 lignes attendues, % lues', mesures; + END IF; + + SET LOCAL enable_seqscan = off; + SELECT id INTO plus_proche FROM reprise_vecteurs ORDER BY embedding <-> '[1,2,3]' LIMIT 1; + IF plus_proche != 1 THEN + RAISE EXCEPTION 'Index vchordrq : identifiant 1 attendu, % lu', plus_proche; + END IF; + + SELECT count(*) INTO lieux FROM reprise_lieux + WHERE ST_DWithin(geom, ST_GeomFromText('POINT(7.75 48.58)', 4326), 0.01); + IF lieux != 1 THEN + RAISE EXCEPTION 'PostGIS : 1 lieu attendu, % lus', lieux; + END IF; + + RAISE NOTICE 'Reprise : OK'; +END +$$; diff --git a/tests/test-1-config.sql b/tests/test-1-config.sql index cc2caca..82f2824 100644 --- a/tests/test-1-config.sql +++ b/tests/test-1-config.sql @@ -4,6 +4,19 @@ SELECT version(); SELECT extname, extversion FROM pg_extension; +DO $$ +DECLARE + missing text; +BEGIN + SELECT string_agg(name, ', ') INTO missing + FROM unnest(ARRAY['timescaledb', 'vector', 'vchord', 'postgis', 'postgis_topology']) AS name + WHERE name NOT IN (SELECT extname FROM pg_extension); + IF missing IS NOT NULL THEN + RAISE EXCEPTION 'Missing extensions: %', missing; + END IF; +END +$$; + DO $$ DECLARE preload_libs text;