diff --git a/.env.example b/.env.example index d64e525..73adc85 100644 --- a/.env.example +++ b/.env.example @@ -19,11 +19,12 @@ RENEWCMS_CONTAINER=false DB_DRIVER=sqlite DATA_DIR= DB_HOST= -DB_PORT=3306 +DB_PORT= DB_USER= DB_PASSWORD= DB_NAME= DB_TLS=false +DB_SSLMODE=disable DB_MAX_OPEN_CONNS=10 DB_MAX_IDLE_CONNS=5 DB_CONN_MAX_LIFETIME_SECONDS=300 diff --git a/Taskfile.yml b/Taskfile.yml index f10bef7..0d8b2c2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -87,6 +87,46 @@ tasks: exit $STATUS silent: true + test:postgres: + desc: Run the PostgreSQL tests against real servers (postgres:16 then postgres:17 in Docker, removed afterwards) + cmds: + - cmd: | + PASSWORD=renewcms-test-postgres + STATUS=0 + run_engine() { + NAME="renewcms-test-postgres-$1" + IMAGE="postgres:$1" + docker rm -f "$NAME" >/dev/null 2>&1 + docker run -d --name "$NAME" -e POSTGRES_PASSWORD="$PASSWORD" -p 127.0.0.1::5432 "$IMAGE" >/dev/null || return 1 + MAPPING="$(docker port "$NAME" 5432/tcp | head -n 1)" + PORT="${MAPPING##*:}" + READY=no + TRIES=0 + while [ "$TRIES" -lt 90 ]; do + if docker exec "$NAME" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && docker exec "$NAME" psql -h 127.0.0.1 -U postgres -c 'SELECT 1' >/dev/null 2>&1; then + READY=yes + break + fi + TRIES=$((TRIES + 1)) + sleep 2 + done + if [ "$READY" != yes ]; then + echo "$IMAGE did not become ready" >&2 + docker logs "$NAME" 2>&1 | tail -n 20 >&2 + docker rm -f "$NAME" >/dev/null 2>&1 + return 1 + fi + echo "== $IMAGE on 127.0.0.1:$PORT" + RENEWCMS_TEST_POSTGRES_DSN="postgres://postgres:$PASSWORD@127.0.0.1:$PORT/postgres?sslmode=disable" go test -tags nofrontend ./test/... -run Postgres -count=1 {{.CLI_ARGS}} + RESULT=$? + docker rm -f "$NAME" >/dev/null 2>&1 + return $RESULT + } + run_engine 16 || STATUS=1 + run_engine 17 || STATUS=1 + exit $STATUS + silent: true + test:s3: desc: Run the S3 storage integration test against a throwaway SeaweedFS in Docker (skipped when Docker is unavailable) cmds: diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index 041fb4c..3c9c9bd 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -141,6 +141,8 @@ func databaseConfig(settings config.Settings) database.Config { switch settings.Database.Driver { case config.DatabaseDriverMySQL: return mysqlDatabaseConfig(settings.Database) + case config.DatabaseDriverPostgres: + return postgresDatabaseConfig(settings.Database) case "", config.DatabaseDriverSQLite: return database.Config{Driver: database.DriverSQLite, Source: settings.DBFile} default: @@ -167,6 +169,25 @@ func mysqlDatabaseConfig(settings config.DatabaseSettings) database.Config { } } +func postgresDatabaseConfig(settings config.DatabaseSettings) database.Config { + return database.Config{ + Driver: database.DriverPostgres, + Source: database.PostgresDSN(database.PostgresConfig{ + Host: settings.Host, + Port: settings.Port, + User: settings.User, + Password: settings.Password, + Name: settings.Name, + SSLMode: settings.SSLMode, + }), + Pool: &database.PoolSettings{ + MaxOpenConns: settings.MaxOpenConns, + MaxIdleConns: settings.MaxIdleConns, + ConnMaxLifetime: settings.ConnMaxLifetime, + }, + } +} + func openDatabase(settings config.Settings) *database.Connection { connection, err := database.Connect(databaseConfig(settings)) if err != nil { diff --git a/cmd/server/main.go b/cmd/server/main.go index 689d2d5..de73b3c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -46,8 +46,8 @@ var envDefaults = map[string]string{ "PORT": "8080", "DB_FILE": "./data/renewcms.db", config.EnvDatabaseDriver: config.DefaultDatabaseDriver, - "DB_PORT": config.DefaultDatabasePort, "DB_TLS": config.DefaultDatabaseTLS, + "DB_SSLMODE": config.DefaultDatabaseSSLMode, "DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns, "DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns, "DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds, diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml new file mode 100644 index 0000000..405dc73 --- /dev/null +++ b/docker-compose.postgres.yml @@ -0,0 +1,51 @@ +services: + postgres: + image: postgres:17 + restart: unless-stopped + environment: + POSTGRES_DB: renewcms + POSTGRES_USER: renewcms + POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U renewcms -d renewcms"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + renewcms: + image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + env_file: + - docker.env + environment: + DB_DRIVER: postgres + DB_HOST: postgres + DB_PORT: "5432" + DB_NAME: renewcms + DB_USER: renewcms + DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD} + DB_SSLMODE: disable + DATA_DIR: /data + UPLOAD_DIR: /data/uploads + RENEWCMS_CONTAINER: "true" + ports: + - "8080:8080" + volumes: + - renewcms-data:/data + stop_grace_period: 15s + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + renewcms-data: + postgres-data: diff --git a/docker.env.example b/docker.env.example index 90f94f3..ac945f6 100644 --- a/docker.env.example +++ b/docker.env.example @@ -14,11 +14,12 @@ TRUSTED_PROXIES= DB_DRIVER=sqlite DATA_DIR= DB_HOST= -DB_PORT=3306 +DB_PORT= DB_USER= DB_PASSWORD= DB_NAME= DB_TLS=false +DB_SSLMODE=disable DB_MAX_OPEN_CONNS=10 DB_MAX_IDLE_CONNS=5 DB_CONN_MAX_LIFETIME_SECONDS=300 diff --git a/docs/architecture.md b/docs/architecture.md index 32fa1e3..dced11b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,7 +82,7 @@ Uploaded images are addressed by a stored name (`.`). `CreateImageUse SQL in repositories must run on SQLite, MySQL/MariaDB and PostgreSQL: substring searches use `LIKE ... ESCAPE '!'` through `containsPattern` instead of `instr`. `Settings.DataDir()` is where update state, staging and backups live; it is `DATA_DIR` when set and the directory of `DB_FILE` otherwise, so it does not depend on where the database is. -Two drivers are registered in `database.drivers`: `sqlite` (default) and `mysql` (MySQL 8 and MariaDB 11, through `gorm.io/driver/mysql`, pure Go so `CGO_ENABLED=0` and the six release targets still build). `DB_DRIVER` picks one in `databaseConfig` (composition root); an unknown name fails in `database.Connect` with `unsupported database driver ""`. `database.MySQLDSN` builds the DSN from separate variables, and the driver redacts the password from every error and names only host and port. `Connect` applies the optional `PoolSettings` to any driver. What keeps the repositories identical on both engines, all without touching the domain, use cases or handlers: +Three drivers are registered in `database.drivers`: `sqlite` (default), `mysql` (MySQL 8 and MariaDB 11, through `gorm.io/driver/mysql`) and `postgres` (PostgreSQL 16 and 17, alias `postgresql`, through `gorm.io/driver/postgres` on pgx v5); all are pure Go so `CGO_ENABLED=0` and the six release targets still build. `DB_DRIVER` picks one in `databaseConfig` (composition root); an unknown name fails in `database.Connect` with `unsupported database driver ""`. `database.MySQLDSN` and `database.PostgresDSN` build the DSN from separate variables (the PostgreSQL one as an escaped URL with `sslmode`, `timezone=UTC` and a connect timeout), and the driver redacts the password from every error and names only host and port. `Connect` applies the optional `PoolSettings` to any driver. What keeps the repositories identical on both engines, all without touching the domain, use cases or handlers: - Indexed and unique strings carry `size:` tags in the models (a MySQL index needs a bounded `varchar`). SQLite maps them to the same `text` column, so an existing SQLite file migrates with no schema or row change (`test/sqlite_schema_unchanged_test.go` builds a file from the old model definitions and compares `sqlite_master` and every row). - `Migrate` creates MySQL tables with `utf8mb4_bin` (`gorm:table_options`), so `Bob` and `bob` are distinct like in SQLite. The DSN also sets `collation=utf8mb4_bin`. @@ -90,9 +90,11 @@ Two drivers are registered in `database.drivers`: `sqlite` (default) and `mysql` - The Go driver sends a zero `time.Time` as `0000-00-00`, which MySQL's default `NO_ZERO_DATE` rejects, so each session sets `sql_mode` to the server default minus `NO_ZERO_DATE`. Reading `0000-00-00` gives the zero time back. - `UserRepository.Create` returns the filled model instead of re-reading it, because MySQL has no `RETURNING`. -Backups are a `domain/update.DatabaseBackup` per driver. SQLite uses `VACUUM INTO`. MySQL uses `DumpBackup`, a pure Go logical dump (`SHOW CREATE TABLE` plus every row, strings escaped and binary values as hex, NULL and zero dates kept) written inside one repeatable-read transaction to `DATA_DIR/backups/renewcms-