From 1dd4d6993c96078ff8248b1d11d959c539a17b80 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 2 Jul 2026 01:23:19 +1200 Subject: [PATCH 1/2] feat(postgres): bundle top-10 extensions in the appwrite/postgres image Dedicated Postgres databases (and VectorsDB) need the most-used extensions available out of the box. Compile pgvector, PostGIS, and pg_cron into the image (the seven contrib extensions already ship in the base image) and set shared_preload_libraries = 'pg_stat_statements,pg_cron' in the cluster config template, since preload libraries must be set before the server starts and edge sets no Postgres config at runtime. Build for every major version Cloud advertises (Postgres 17 and 18) via a PG_MAJOR build arg and a CI matrix, tagging per major so the version -> image mapping keeps working. A build-and-verify workflow boots each image on PRs and asserts all ten extensions are available, install via CREATE EXTENSION, and that the two preload extensions actually load. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 38 +++++++++++ .github/workflows/publish.yml | 15 +++++ Dockerfile | 19 ++++-- README.md | 58 +++++++++++++++++ docker-compose.yml | 4 +- tests/verify.sh | 115 ++++++++++++++++++++++++++++++++++ 6 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 README.md create mode 100755 tests/verify.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..0a5a03a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,38 @@ +name: Build and Verify + +on: + pull_request: + push: + branches: + - main + +jobs: + build: + name: Build and verify (PG ${{ matrix.pg_major }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + pg_major: ['17', '18'] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + load: true + platforms: linux/amd64 + build-args: | + PG_MAJOR=${{ matrix.pg_major }} + tags: appwrite/postgres:ci-${{ matrix.pg_major }} + + - name: Verify extensions and preload libraries + env: + IMAGE: appwrite/postgres:ci-${{ matrix.pg_major }} + PG_MAJOR: ${{ matrix.pg_major }} + run: ./tests/verify.sh "$IMAGE" "$PG_MAJOR" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8fbdc79..9ebc31e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,9 +5,16 @@ on: tags: - '*' +env: + DEFAULT_PG_MAJOR: '18' + jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + pg_major: ['17', '18'] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -29,9 +36,16 @@ jobs: uses: docker/metadata-action@v5 with: images: appwrite/postgres + flavor: | + latest=false + prefix=${{ matrix.pg_major }}-,onlatest=false tags: | + type=raw,value=${{ matrix.pg_major }},prefix= type=semver,pattern={{major}}.{{minor}}.{{patch}} type=match,pattern=.*RC.*,group=0 + type=semver,pattern={{major}}.{{minor}}.{{patch}},prefix=,enable=${{ matrix.pg_major == env.DEFAULT_PG_MAJOR }} + type=match,pattern=.*RC.*,group=0,prefix=,enable=${{ matrix.pg_major == env.DEFAULT_PG_MAJOR }} + type=raw,value=latest,prefix=,enable=${{ matrix.pg_major == env.DEFAULT_PG_MAJOR }} - name: Build and push uses: docker/build-push-action@v6 @@ -39,6 +53,7 @@ jobs: context: . platforms: linux/amd64,linux/arm64 build-args: | + PG_MAJOR=${{ matrix.pg_major }} VERSION=${{ steps.meta.outputs.version }} push: true tags: ${{ steps.meta.outputs.tags }} diff --git a/Dockerfile b/Dockerfile index 2a4ea90..869ecb8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,18 @@ -FROM postgres:18 +ARG PG_MAJOR=18 +FROM postgres:${PG_MAJOR} + +ARG PG_MAJOR + +# hadolint ignore=DL3008 RUN apt-get update && \ apt-get upgrade -y && \ apt-get install -y --no-install-recommends \ - postgresql-18-postgis-3 \ - postgresql-18-postgis-3-scripts \ - postgresql-18-pgvector && \ - rm -rf /var/lib/apt/lists/* \ No newline at end of file + postgresql-${PG_MAJOR}-pgvector \ + postgresql-${PG_MAJOR}-postgis-3 \ + postgresql-${PG_MAJOR}-postgis-3-scripts \ + postgresql-${PG_MAJOR}-cron && \ + rm -rf /var/lib/apt/lists/* + +RUN printf '\n%s\n' "shared_preload_libraries = 'pg_stat_statements,pg_cron'" \ + >> "/usr/share/postgresql/${PG_MAJOR}/postgresql.conf.sample" diff --git a/README.md b/README.md new file mode 100644 index 0000000..68b74f7 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# appwrite/postgres + +Custom PostgreSQL image for Appwrite Cloud dedicated databases (and the VectorsDB product). It is the official `postgres` image plus the ten most-used PostgreSQL extensions, bundled so they are available out of the box. + +## Bundled extensions + +| Extension | `CREATE EXTENSION` name | Purpose | Preloaded | +| --------- | ----------------------- | ------- | --------- | +| pgvector | `vector` | Vector similarity search / embeddings | No | +| pg_stat_statements | `pg_stat_statements` | Per-query execution statistics | **Yes** | +| uuid-ossp | `uuid-ossp` | UUID generation | No | +| pgcrypto | `pgcrypto` | Hashing / encryption functions | No | +| pg_trgm | `pg_trgm` | Trigram fuzzy / similarity text search | No | +| PostGIS | `postgis` | Geospatial types, indexing, functions | No | +| citext | `citext` | Case-insensitive text | No | +| unaccent | `unaccent` | Accent-insensitive text search | No | +| hstore | `hstore` | Key/value pairs in a single column | No | +| pg_cron | `pg_cron` | In-database job scheduling | **Yes** | + +Every extension is compiled into the image, so it appears in `pg_available_extensions` and installs with `CREATE EXTENSION IF NOT EXISTS`. + +## Preloaded libraries + +`pg_stat_statements` and `pg_cron` require `shared_preload_libraries`, which must be set before the server starts. The image sets it in the cluster config template, so it applies to every initialized cluster with no runtime configuration: + +``` +shared_preload_libraries = 'pg_stat_statements,pg_cron' +``` + +`pg_cron` runs its scheduler in the default `postgres` database. + +## Supported major versions and tags + +Built for each PostgreSQL major version Appwrite Cloud advertises (`Engine::getSupportedVersions()`): **17** and **18**. Publishing a release tag (e.g. `0.2.0`) produces, for each major: + +| Tag | Meaning | +| --- | ------- | +| `appwrite/postgres:18`, `appwrite/postgres:17` | Floating tag for the major version | +| `appwrite/postgres:18-0.2.0`, `appwrite/postgres:17-0.2.0` | Immutable major + release | +| `appwrite/postgres:0.2.0`, `appwrite/postgres:latest` | Default major (18), for backward compatibility | + +The default-major bare-semver tags keep the existing `version` -> image mapping working until the per-version wiring lands. + +## Build + +```bash +docker build --build-arg PG_MAJOR=18 -t appwrite/postgres:18 . +docker build --build-arg PG_MAJOR=17 -t appwrite/postgres:17 . +``` + +## Verify + +`tests/verify.sh` boots the image and asserts every extension is available, installs, and that both preload extensions actually load: + +```bash +docker build --build-arg PG_MAJOR=18 -t appwrite/postgres:ci-18 . +./tests/verify.sh appwrite/postgres:ci-18 18 +``` diff --git a/docker-compose.yml b/docker-compose.yml index bdfb8e8..f14bb6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,9 @@ services: postgresql: - build: + build: context: . + args: + PG_MAJOR: ${PG_MAJOR:-18} restart: unless-stopped volumes: - appwrite-postgresql:/var/lib/postgresql:rw diff --git a/tests/verify.sh b/tests/verify.sh new file mode 100755 index 0000000..22a169b --- /dev/null +++ b/tests/verify.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# +# Boots the built image and asserts every bundled extension is available, +# installable via CREATE EXTENSION, and that the two preload extensions +# (pg_stat_statements, pg_cron) actually load at startup. +# +# Usage: tests/verify.sh +set -uo pipefail + +IMAGE="${1:?image required}" +PG_MAJOR="${2:?pg major required}" +CONTAINER="verify-pg-${PG_MAJOR}-$$" +FAILED=0 + +EXTENSIONS=( + vector + pg_stat_statements + uuid-ossp + pgcrypto + pg_trgm + postgis + citext + unaccent + hstore + pg_cron +) + +# shellcheck disable=SC2329 # invoked by trap +cleanup() { docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +fail() { echo "FAIL: $1"; FAILED=1; } + +psql() { docker exec "$CONTAINER" psql -U postgres -d postgres -tAX -c "$1" 2>&1; } + +docker run -d --name "$CONTAINER" \ + -e POSTGRES_PASSWORD=verify \ + -e POSTGRES_DB=postgres \ + "$IMAGE" >/dev/null + +for _ in $(seq 1 60); do + docker exec "$CONTAINER" pg_isready -U postgres -d postgres >/dev/null 2>&1 && break + sleep 1 +done +if ! docker exec "$CONTAINER" pg_isready -U postgres -d postgres >/dev/null 2>&1; then + echo "FAIL: PostgreSQL never became ready" + docker logs "$CONTAINER" 2>&1 | tail -40 + exit 1 +fi +sleep 2 + +echo "===== PG ${PG_MAJOR}: $(psql 'SHOW server_version;') =====" + +echo "----- shared_preload_libraries -----" +spl="$(psql 'SHOW shared_preload_libraries;')" +echo "$spl" +if [[ "$spl" == *pg_stat_statements* && "$spl" == *pg_cron* ]]; then + echo "PASS: preload libraries set" +else + fail "shared_preload_libraries missing pg_stat_statements and/or pg_cron" +fi + +echo "----- pg_available_extensions -----" +for ext in "${EXTENSIONS[@]}"; do + if [[ "$(psql "SELECT 1 FROM pg_available_extensions WHERE name = '$ext' LIMIT 1;")" == "1" ]]; then + echo "AVAILABLE: $ext" + else + fail "$ext not in pg_available_extensions" + fi +done + +echo "----- CREATE EXTENSION IF NOT EXISTS -----" +for ext in "${EXTENSIONS[@]}"; do + result="$(psql "CREATE EXTENSION IF NOT EXISTS \"$ext\";")" + if [[ "$result" == "CREATE EXTENSION" || -z "$result" ]]; then + echo "CREATED: $ext" + else + fail "CREATE EXTENSION $ext -> $result" + fi +done + +echo "----- pg_stat_statements loaded -----" +if [[ "$(psql 'SELECT count(*) >= 0 FROM pg_stat_statements;')" == "t" ]]; then + echo "PASS: pg_stat_statements view queryable" +else + fail "pg_stat_statements view not queryable" +fi + +echo "----- pg_cron loaded -----" +jobid="$(psql "SELECT cron.schedule('verify-job','* * * * *','SELECT 1');")" +if [[ "$jobid" =~ ^[0-9]+$ ]]; then + echo "PASS: pg_cron.schedule returned job $jobid" + psql "SELECT cron.unschedule('verify-job');" >/dev/null +else + fail "pg_cron.schedule -> $jobid" +fi + +echo "----- pgvector usable -----" +distance="$(psql "SELECT '[1,2,3]'::vector <-> '[3,2,1]'::vector;")" +if [[ -n "$distance" && "$distance" != *ERROR* ]]; then + echo "PASS: vector distance = $distance" +else + fail "vector operation -> $distance" +fi + +echo "----- postgis usable -----" +echo "postgis_version: $(psql 'SELECT postgis_version();')" + +echo +if [[ "$FAILED" == "0" ]]; then + echo "########## PG ${PG_MAJOR}: ALL CHECKS PASSED ##########" +else + echo "########## PG ${PG_MAJOR}: CHECKS FAILED ##########" +fi +exit "$FAILED" From fe64a298c02096a6a03c41cced35a050c1aae289 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 2 Jul 2026 01:31:20 +1200 Subject: [PATCH 2/2] test(postgres): harden verify.sh and drop dead build-arg Address review feedback on the verification script and workflows: - CREATE EXTENSION now asserts the exact "CREATE EXTENSION" success string, so empty output from a crashed container no longer counts as success. - Unschedule the pg_cron job by its numeric id rather than name, and report on empty results consistently. - Assert on postgis_version() like the other runtime usability checks instead of only printing it. - Remove the unused VERSION build-arg from publish.yml (the Dockerfile never declared ARG VERSION). - Cache the apt layer across build-verify runs with the GitHub Actions cache. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 2 ++ .github/workflows/publish.yml | 1 - tests/verify.sh | 17 ++++++++++++----- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a5a03a..39be4a5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,6 +30,8 @@ jobs: build-args: | PG_MAJOR=${{ matrix.pg_major }} tags: appwrite/postgres:ci-${{ matrix.pg_major }} + cache-from: type=gha,scope=pg-${{ matrix.pg_major }} + cache-to: type=gha,mode=max,scope=pg-${{ matrix.pg_major }} - name: Verify extensions and preload libraries env: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9ebc31e..b2d6560 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -54,7 +54,6 @@ jobs: platforms: linux/amd64,linux/arm64 build-args: | PG_MAJOR=${{ matrix.pg_major }} - VERSION=${{ steps.meta.outputs.version }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/tests/verify.sh b/tests/verify.sh index 22a169b..236e189 100755 --- a/tests/verify.sh +++ b/tests/verify.sh @@ -72,10 +72,12 @@ done echo "----- CREATE EXTENSION IF NOT EXISTS -----" for ext in "${EXTENSIONS[@]}"; do result="$(psql "CREATE EXTENSION IF NOT EXISTS \"$ext\";")" - if [[ "$result" == "CREATE EXTENSION" || -z "$result" ]]; then + # psql -tA prints exactly "CREATE EXTENSION" on success. Anything else + # (an error, or empty output from a dead container) is a failure. + if [[ "$result" == "CREATE EXTENSION" ]]; then echo "CREATED: $ext" else - fail "CREATE EXTENSION $ext -> $result" + fail "CREATE EXTENSION $ext -> ${result:-}" fi done @@ -90,9 +92,9 @@ echo "----- pg_cron loaded -----" jobid="$(psql "SELECT cron.schedule('verify-job','* * * * *','SELECT 1');")" if [[ "$jobid" =~ ^[0-9]+$ ]]; then echo "PASS: pg_cron.schedule returned job $jobid" - psql "SELECT cron.unschedule('verify-job');" >/dev/null + psql "SELECT cron.unschedule($jobid);" >/dev/null else - fail "pg_cron.schedule -> $jobid" + fail "pg_cron.schedule -> ${jobid:-}" fi echo "----- pgvector usable -----" @@ -104,7 +106,12 @@ else fi echo "----- postgis usable -----" -echo "postgis_version: $(psql 'SELECT postgis_version();')" +postgis_version="$(psql 'SELECT postgis_version();')" +if [[ -n "$postgis_version" && "$postgis_version" != *ERROR* ]]; then + echo "PASS: postgis_version = $postgis_version" +else + fail "postgis_version() -> ${postgis_version:-}" +fi echo if [[ "$FAILED" == "0" ]]; then