feat: PostgreSQL driver support
This commit is contained in:
1 parent
eb9bfabed5
commit
dc5d12348c
24 files changed
+1585
-75
No files matched your search
+2
-1
@@ -19,11 +19,12 @@ RENEWCMS_CONTAINER=false
|
|||||||
DB_DRIVER=sqlite
|
DB_DRIVER=sqlite
|
||||||
DATA_DIR=
|
DATA_DIR=
|
||||||
DB_HOST=
|
DB_HOST=
|
||||||
DB_PORT=3306
|
DB_PORT=
|
||||||
DB_USER=
|
DB_USER=
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
DB_NAME=
|
DB_NAME=
|
||||||
DB_TLS=false
|
DB_TLS=false
|
||||||
|
DB_SSLMODE=disable
|
||||||
DB_MAX_OPEN_CONNS=10
|
DB_MAX_OPEN_CONNS=10
|
||||||
DB_MAX_IDLE_CONNS=5
|
DB_MAX_IDLE_CONNS=5
|
||||||
DB_CONN_MAX_LIFETIME_SECONDS=300
|
DB_CONN_MAX_LIFETIME_SECONDS=300
|
||||||
|
|||||||
@@ -87,6 +87,46 @@ tasks:
|
|||||||
exit $STATUS
|
exit $STATUS
|
||||||
silent: true
|
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:
|
test:s3:
|
||||||
desc: Run the S3 storage integration test against a throwaway SeaweedFS in Docker (skipped when Docker is unavailable)
|
desc: Run the S3 storage integration test against a throwaway SeaweedFS in Docker (skipped when Docker is unavailable)
|
||||||
cmds:
|
cmds:
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ func databaseConfig(settings config.Settings) database.Config {
|
|||||||
switch settings.Database.Driver {
|
switch settings.Database.Driver {
|
||||||
case config.DatabaseDriverMySQL:
|
case config.DatabaseDriverMySQL:
|
||||||
return mysqlDatabaseConfig(settings.Database)
|
return mysqlDatabaseConfig(settings.Database)
|
||||||
|
case config.DatabaseDriverPostgres:
|
||||||
|
return postgresDatabaseConfig(settings.Database)
|
||||||
case "", config.DatabaseDriverSQLite:
|
case "", config.DatabaseDriverSQLite:
|
||||||
return database.Config{Driver: database.DriverSQLite, Source: settings.DBFile}
|
return database.Config{Driver: database.DriverSQLite, Source: settings.DBFile}
|
||||||
default:
|
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 {
|
func openDatabase(settings config.Settings) *database.Connection {
|
||||||
connection, err := database.Connect(databaseConfig(settings))
|
connection, err := database.Connect(databaseConfig(settings))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+1
-1
@@ -46,8 +46,8 @@ var envDefaults = map[string]string{
|
|||||||
"PORT": "8080",
|
"PORT": "8080",
|
||||||
"DB_FILE": "./data/renewcms.db",
|
"DB_FILE": "./data/renewcms.db",
|
||||||
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
|
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
|
||||||
"DB_PORT": config.DefaultDatabasePort,
|
|
||||||
"DB_TLS": config.DefaultDatabaseTLS,
|
"DB_TLS": config.DefaultDatabaseTLS,
|
||||||
|
"DB_SSLMODE": config.DefaultDatabaseSSLMode,
|
||||||
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
|
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
|
||||||
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
|
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
|
||||||
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
|
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
|
||||||
|
|||||||
@@ -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:
|
||||||
+2
-1
@@ -14,11 +14,12 @@ TRUSTED_PROXIES=
|
|||||||
DB_DRIVER=sqlite
|
DB_DRIVER=sqlite
|
||||||
DATA_DIR=
|
DATA_DIR=
|
||||||
DB_HOST=
|
DB_HOST=
|
||||||
DB_PORT=3306
|
DB_PORT=
|
||||||
DB_USER=
|
DB_USER=
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
DB_NAME=
|
DB_NAME=
|
||||||
DB_TLS=false
|
DB_TLS=false
|
||||||
|
DB_SSLMODE=disable
|
||||||
DB_MAX_OPEN_CONNS=10
|
DB_MAX_OPEN_CONNS=10
|
||||||
DB_MAX_IDLE_CONNS=5
|
DB_MAX_IDLE_CONNS=5
|
||||||
DB_CONN_MAX_LIFETIME_SECONDS=300
|
DB_CONN_MAX_LIFETIME_SECONDS=300
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ Uploaded images are addressed by a stored name (`<uuid>.<ext>`). `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.
|
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 "<name>"`. `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 "<name>"`. `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).
|
- 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`.
|
- `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.
|
- 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`.
|
- `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-<time>.sql.partial` and renamed once complete. Any failure removes the partial file and returns an error, so the update download fails instead of claiming a backup. Both keep the newest 3 files (`pruneBackups`).
|
On PostgreSQL the MySQL-only table options are not applied, pgx returns `timestamptz` values in the local zone so a query callback in the driver converts them to UTC, and only the `public` schema is used.
|
||||||
|
|
||||||
`task test` runs everything that needs no server. The repository contract tests (`test/repository_contract_test.go`) run on SQLite always, and on MySQL when `RENEWCMS_TEST_MYSQL_DSN` points at a server (admin DSN; each test creates and drops its own database). `task test:mysql` starts `mysql:8.4` and then `mariadb:11` in Docker and runs the `MySQL` tests against each.
|
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-<time>.sql.partial` and renamed once complete. Any failure removes the partial file and returns an error, so the update download fails instead of claiming a backup. PostgreSQL reuses `DumpBackup` with its own `dumpDialect` (double-quoted identifiers, doubled single quotes, `\x` bytea literals): `CREATE TABLE` is rebuilt from `pg_catalog` (serial columns, defaults, primary key, unique and check constraints), followed by the indexes, the rows and a `setval` per `id` sequence, all inside one repeatable-read read-only transaction. All drivers keep the newest 3 files (`pruneBackups`).
|
||||||
|
|
||||||
|
`task test` runs everything that needs no server. The repository contract tests (`test/repository_contract_test.go`) run on SQLite always, and on MySQL when `RENEWCMS_TEST_MYSQL_DSN` points at a server (admin DSN; each test creates and drops its own database). `task test:mysql` starts `mysql:8.4` and then `mariadb:11` in Docker and runs the `MySQL` tests against each. The same contract tests run on PostgreSQL when `RENEWCMS_TEST_POSTGRES_DSN` points at a server (admin DSN, for example `postgres://postgres:pw@127.0.0.1:5432/postgres?sslmode=disable`), and `task test:postgres` starts `postgres:16` then `postgres:17` in Docker and runs the `Postgres` tests against each.
|
||||||
|
|
||||||
Two storage adapters implement `image.Storage`: `local` (default, files in `UPLOAD_DIR`) and `s3` (`storage.S3Storage`, `aws-sdk-go-v2` over plain HTTPS, pure Go so `CGO_ENABLED=0` still holds). `STORAGE_DRIVER` picks one in `imageStorageConfig` (composition root, from `config.StorageSettings`); `storage.Open` fails with `unsupported storage driver "<name>"` for anything else, and `openImageStorage` panics with the error. The S3 adapter works as follows, with no change to the domain, use cases, repositories or the `staticimages` handler:
|
Two storage adapters implement `image.Storage`: `local` (default, files in `UPLOAD_DIR`) and `s3` (`storage.S3Storage`, `aws-sdk-go-v2` over plain HTTPS, pure Go so `CGO_ENABLED=0` still holds). `STORAGE_DRIVER` picks one in `imageStorageConfig` (composition root, from `config.StorageSettings`); `storage.Open` fails with `unsupported storage driver "<name>"` for anything else, and `openImageStorage` panics with the error. The S3 adapter works as follows, with no change to the domain, use cases, repositories or the `staticimages` handler:
|
||||||
|
|
||||||
|
|||||||
+12
-5
@@ -24,9 +24,9 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|
|||||||
|------------------------|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|------------------------|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `ENVIRONMENT` | `production` | `production` or `development`. In `production` the auth cookie is `Secure`, so it is only sent over HTTPS. `development` turns on HTTP request logging. |
|
| `ENVIRONMENT` | `production` | `production` or `development`. In `production` the auth cookie is `Secure`, so it is only sent over HTTPS. `development` turns on HTTP request logging. |
|
||||||
| `PORT` | `8080` | Port the HTTP server listens on (all interfaces). |
|
| `PORT` | `8080` | Port the HTTP server listens on (all interfaces). |
|
||||||
| `DB_DRIVER` | `sqlite` | `sqlite` or `mysql` (MySQL and MariaDB). Any other value stops the server with `unsupported database driver "<value>"`. See [Database](#database). |
|
| `DB_DRIVER` | `sqlite` | `sqlite`, `mysql` (MySQL and MariaDB) or `postgres` (alias `postgresql`). Any other value stops the server with `unsupported database driver "<value>"`. See [Database](#database). |
|
||||||
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. Ignored when `DB_DRIVER=mysql`. |
|
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. Ignored when `DB_DRIVER` is `mysql` or `postgres`. |
|
||||||
| `DATA_DIR` | directory of `DB_FILE` | Directory for update state, staged updates and database backups (`backups/`). Set it with `DB_DRIVER=mysql`, where there is no database file to sit beside (the default still follows `DB_FILE`). With SQLite, leave it unset to keep everything beside the database. |
|
| `DATA_DIR` | directory of `DB_FILE` | Directory for update state, staged updates and database backups (`backups/`). Set it with `DB_DRIVER=mysql` or `postgres`, where there is no database file to sit beside (the default still follows `DB_FILE`). With SQLite, leave it unset to keep everything beside the database. |
|
||||||
| `STORAGE_DRIVER` | `local` | `local` (files in `UPLOAD_DIR`) or `s3` (an S3-compatible bucket). Any other value stops the server with `unsupported storage driver "<value>"`. See [Image storage](#image-storage). |
|
| `STORAGE_DRIVER` | `local` | `local` (files in `UPLOAD_DIR`) or `s3` (an S3-compatible bucket). Any other value stops the server with `unsupported storage driver "<value>"`. See [Image storage](#image-storage). |
|
||||||
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. Ignored, without error, when `STORAGE_DRIVER=s3`. |
|
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. Ignored, without error, when `STORAGE_DRIVER=s3`. |
|
||||||
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
|
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
|
||||||
@@ -72,22 +72,29 @@ The pre-update backup covers the database only (and `DATA_DIR` state). Neither t
|
|||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
SQLite is the default and needs nothing but `DB_FILE`. To run against MySQL 8 or MariaDB 11 instead, set `DB_DRIVER=mysql` and the connection variables below. They are only read (and the four required ones only checked) when `DB_DRIVER=mysql`. A required variable that is not set stops the server with `Environment variable DB_HOST is not set` (or `DB_USER`, `DB_PASSWORD`, `DB_NAME`).
|
SQLite is the default and needs nothing but `DB_FILE`. To run against MySQL 8 or MariaDB 11 instead, set `DB_DRIVER=mysql` and the connection variables below. They are only read (and the four required ones only checked) when `DB_DRIVER` is `mysql` or `postgres`. A required variable that is not set stops the server with `Environment variable DB_HOST is not set` (or `DB_USER`, `DB_PASSWORD`, `DB_NAME`).
|
||||||
|
|
||||||
| Name | Default | Description |
|
| Name | Default | Description |
|
||||||
|--------------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------|
|
|--------------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `DB_HOST` | required | Server host name or IP address. |
|
| `DB_HOST` | required | Server host name or IP address. |
|
||||||
| `DB_PORT` | `3306` | Server port. |
|
| `DB_PORT` | `3306` (`5432` for PostgreSQL) | Server port. |
|
||||||
| `DB_USER` | required | Database user. It needs full rights on `DB_NAME`, because the schema is created and migrated at startup. |
|
| `DB_USER` | required | Database user. It needs full rights on `DB_NAME`, because the schema is created and migrated at startup. |
|
||||||
| `DB_PASSWORD` | required | Database password. It is never logged and never appears in an error message. |
|
| `DB_PASSWORD` | required | Database password. It is never logged and never appears in an error message. |
|
||||||
| `DB_NAME` | required | Name of an existing database. The server does not create it. |
|
| `DB_NAME` | required | Name of an existing database. The server does not create it. |
|
||||||
| `DB_TLS` | `false` | `false`, `true`, `skip-verify` or `preferred`. Any other value stops the server with an error naming `DB_TLS` and these values. `true` verifies the server certificate; `skip-verify` encrypts without verifying it. |
|
| `DB_TLS` | `false` | `false`, `true`, `skip-verify` or `preferred`. Any other value stops the server with an error naming `DB_TLS` and these values. `true` verifies the server certificate; `skip-verify` encrypts without verifying it. |
|
||||||
|
| `DB_SSLMODE` | `disable` | PostgreSQL only: `disable`, `require`, `verify-ca` or `verify-full`. Any other value stops the server with an error naming `DB_SSLMODE` and these values. Ignored for SQLite and MySQL (use `DB_TLS` there). |
|
||||||
| `DB_MAX_OPEN_CONNS` | `10` | Maximum open connections in the pool. `0` means unlimited. |
|
| `DB_MAX_OPEN_CONNS` | `10` | Maximum open connections in the pool. `0` means unlimited. |
|
||||||
| `DB_MAX_IDLE_CONNS` | `5` | Maximum idle connections kept in the pool. |
|
| `DB_MAX_IDLE_CONNS` | `5` | Maximum idle connections kept in the pool. |
|
||||||
| `DB_CONN_MAX_LIFETIME_SECONDS` | `300` | Seconds after which a connection is closed and replaced. `0` keeps connections forever. Keep it below the server's `wait_timeout`. |
|
| `DB_CONN_MAX_LIFETIME_SECONDS` | `300` | Seconds after which a connection is closed and replaced. `0` keeps connections forever. Keep it below the server's `wait_timeout`. |
|
||||||
|
|
||||||
A non numeric (or negative) pool value stops the server with an error naming the variable. There is no DSN variable: the connection string is built from the variables above, with `parseTime=true`, `loc=UTC`, `charset=utf8mb4`, `collation=utf8mb4_bin` and `clientFoundRows=true`. Connecting uses a 5 second timeout, so a wrong host, port, password or database name stops startup quickly with `Unable to open the database`, naming the host and port but never the password.
|
A non numeric (or negative) pool value stops the server with an error naming the variable. There is no DSN variable: the connection string is built from the variables above, with `parseTime=true`, `loc=UTC`, `charset=utf8mb4`, `collation=utf8mb4_bin` and `clientFoundRows=true`. Connecting uses a 5 second timeout, so a wrong host, port, password or database name stops startup quickly with `Unable to open the database`, naming the host and port but never the password.
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
Set `DB_DRIVER=postgres` (or `postgresql`) and the same connection variables, with `DB_PORT` defaulting to `5432` and `DB_SSLMODE` instead of `DB_TLS`. PostgreSQL 16 and 17 are tested. The database must already exist and `DB_USER` needs full rights on the `public` schema, which is the only schema used. The connection string is built with escaping from the variables (a password may contain spaces, quotes, `@` or `/`), sets the session time zone to UTC and a 5 second connect timeout, and the pool variables above apply. A failure to connect stops startup with `Unable to open the database`, naming the host and port but never the password. Usernames and emails compare case-sensitively, as on SQLite. Before an update download RenewCMS writes a logical dump to `DATA_DIR/backups/renewcms-<time>.sql` (the newest 3 are kept); replay it into an empty database with `psql -f`. For routine backups prefer `pg_dump`.
|
||||||
|
|
||||||
|
### MySQL settings
|
||||||
|
|
||||||
Why those settings: tables are created with the `utf8mb4_bin` collation so that usernames and emails compare case-sensitively exactly like in SQLite (`Bob` and `bob` are different users). The collation of `DB_NAME` itself does not matter, but tables that already exist keep the collation they have. `clientFoundRows` makes an update that does not change a row still count as matched, which the invite and purge-settings code relies on. Each session also drops the server's `NO_ZERO_DATE` SQL mode, because the Go driver sends a zero time as `0000-00-00`. The other strict modes stay on.
|
Why those settings: tables are created with the `utf8mb4_bin` collation so that usernames and emails compare case-sensitively exactly like in SQLite (`Bob` and `bob` are different users). The collation of `DB_NAME` itself does not matter, but tables that already exist keep the collation they have. `clientFoundRows` makes an update that does not change a row still count as matched, which the invite and purge-settings code relies on. Each session also drops the server's `NO_ZERO_DATE` SQL mode, because the Go driver sends a zero time as `0000-00-00`. The other strict modes stay on.
|
||||||
|
|
||||||
With MySQL the data directory (`DATA_DIR`) holds update state and backups only. The pre-update backup is a `renewcms-<time>.sql` logical dump (schema and rows, written by RenewCMS itself, no `mysqldump` needed) that keeps the newest 3. If the dump fails, the update download fails with it. The dump holds your data, such as password hashes, but not the connection password.
|
With MySQL the data directory (`DATA_DIR`) holds update state and backups only. The pre-update backup is a `renewcms-<time>.sql` logical dump (schema and rows, written by RenewCMS itself, no `mysqldump` needed) that keeps the newest 3. If the dump fails, the update download fails with it. The dump holds your data, such as password hashes, but not the connection password.
|
||||||
|
|||||||
+18
-2
@@ -8,7 +8,7 @@ RenewCMS ships as **one self-contained executable**. It serves:
|
|||||||
| `/healthz` | Liveness check, returns `200 ok` |
|
| `/healthz` | Liveness check, returns `200 ok` |
|
||||||
| `/*` | The Vue SPA (embedded), with fallback to `index.html` for client-side routes |
|
| `/*` | The Vue SPA (embedded), with fallback to `index.html` for client-side routes |
|
||||||
|
|
||||||
Runtime state includes the SQLite database (`DB_FILE`, or a MySQL/MariaDB server, see [Using MySQL or MariaDB](#using-mysql-or-mariadb)), uploads (`UPLOAD_DIR`, or an S3-compatible bucket with `STORAGE_DRIVER=s3`, see [configuration.md](configuration.md#image-storage)), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below.
|
Runtime state includes the SQLite database (`DB_FILE`, or a MySQL/MariaDB or PostgreSQL server, see [Using MySQL or MariaDB](#using-mysql-or-mariadb) and [Using PostgreSQL](#using-postgresql)), uploads (`UPLOAD_DIR`, or an S3-compatible bucket with `STORAGE_DRIVER=s3`, see [configuration.md](configuration.md#image-storage)), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below.
|
||||||
|
|
||||||
To run it in a container instead, see [docker.md](docker.md). The published image `git.floriansylvain.fr/fsylvain/renewcms` is upgraded with `docker compose pull && docker compose up -d`. Tags are `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release, and `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `<pre-name>` (`beta`, `rc`) for a pre-release, which never moves `latest`. Maintainers publish with `task docker:publish` after `docker login git.floriansylvain.fr`; there is no CI. See [Maintainer: publishing](docker.md#maintainer-publishing).
|
To run it in a container instead, see [docker.md](docker.md). The published image `git.floriansylvain.fr/fsylvain/renewcms` is upgraded with `docker compose pull && docker compose up -d`. Tags are `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release, and `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `<pre-name>` (`beta`, `rc`) for a pre-release, which never moves `latest`. Maintainers publish with `task docker:publish` after `docker login git.floriansylvain.fr`; there is no CI. See [Maintainer: publishing](docker.md#maintainer-publishing).
|
||||||
|
|
||||||
@@ -292,6 +292,22 @@ DATA_DIR=/var/lib/renewcms
|
|||||||
|
|
||||||
`DB_FILE` is then ignored. `DATA_DIR` is where update state, staged updates and backups live, so give it the same value as the helper's `--data-dir` (see [browser updates](#browser-updates)). Use `DB_TLS=true` (or `preferred`) whenever the server is not on the same host. Every variable is described in [configuration.md](configuration.md#database). The schema is created and migrated at startup, so the first start on an empty database needs nothing else.
|
`DB_FILE` is then ignored. `DATA_DIR` is where update state, staged updates and backups live, so give it the same value as the helper's `--data-dir` (see [browser updates](#browser-updates)). Use `DB_TLS=true` (or `preferred`) whenever the server is not on the same host. Every variable is described in [configuration.md](configuration.md#database). The schema is created and migrated at startup, so the first start on an empty database needs nothing else.
|
||||||
|
|
||||||
|
## Using PostgreSQL
|
||||||
|
|
||||||
|
To use an existing PostgreSQL 16 or 17 server, create an empty database and a user with full rights on its `public` schema, then set in `/etc/renewcms/renewcms.env`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DB_DRIVER=postgres
|
||||||
|
DB_HOST=db.example.com
|
||||||
|
DB_USER=renewcms
|
||||||
|
DB_PASSWORD=<generated password>
|
||||||
|
DB_NAME=renewcms
|
||||||
|
DB_SSLMODE=require
|
||||||
|
DATA_DIR=/var/lib/renewcms
|
||||||
|
```
|
||||||
|
|
||||||
|
`DB_FILE` is then ignored, and `DB_PORT` defaults to `5432`. `DATA_DIR` is where update state, staged updates and backups live, so give it the same value as the helper's `--data-dir`. Use `DB_SSLMODE=require` (or `verify-full`) whenever the server is not on the same host. Every variable is described in [configuration.md](configuration.md#database). The schema is created and migrated at startup. Moving an existing SQLite site is a manual procedure like the MySQL one below, and RenewCMS writes a logical `.sql` dump to `DATA_DIR/backups` before each update download.
|
||||||
|
|
||||||
### Moving an existing SQLite site to MySQL
|
### Moving an existing SQLite site to MySQL
|
||||||
|
|
||||||
There is no migration tool. The manual procedure, with the site stopped:
|
There is no migration tool. The manual procedure, with the site stopped:
|
||||||
@@ -305,7 +321,7 @@ Keep the SQLite file until you have checked the site.
|
|||||||
|
|
||||||
## Backups
|
## Backups
|
||||||
|
|
||||||
With MySQL or MariaDB, back the database up with your server's own tools (for example `mysqldump --single-transaction`), plus the uploads directory. RenewCMS also writes a logical dump to `DATA_DIR/backups` before each update download. The rest of this section is about SQLite.
|
With MySQL, MariaDB or PostgreSQL, back the database up with your server's own tools (for example `mysqldump --single-transaction` or `pg_dump`), plus the uploads directory. RenewCMS also writes a logical dump to `DATA_DIR/backups` before each update download. The rest of this section is about SQLite.
|
||||||
|
|
||||||
Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running:
|
Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running:
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ If your editor reports `pattern all:dist: no matching files found`, either run `
|
|||||||
task --list # everything available
|
task --list # everything available
|
||||||
task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v
|
task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v
|
||||||
task test:mysql # MySQL tests against real servers in Docker (mysql:8.4, then mariadb:11)
|
task test:mysql # MySQL tests against real servers in Docker (mysql:8.4, then mariadb:11)
|
||||||
|
task test:postgres # PostgreSQL tests against real servers in Docker (postgres:16, then postgres:17)
|
||||||
task cover # coverage of the use cases → tmp/coverage.out
|
task cover # coverage of the use cases → tmp/coverage.out
|
||||||
task mutate # gremlins mutation testing → tmp/gremlins.json
|
task mutate # gremlins mutation testing → tmp/gremlins.json
|
||||||
task test:web # SPA component tests (Vitest)
|
task test:web # SPA component tests (Vitest)
|
||||||
|
|||||||
@@ -102,6 +102,17 @@ To use MariaDB, change the `mysql` service image to `mariadb:11`, its `MYSQL_*`
|
|||||||
|
|
||||||
Back the database up with `docker compose -f docker-compose.mysql.yml exec mysql mysqldump ...` (a `tar` copy of the volume is not consistent while it runs), and the `renewcms-data` volume as described above. Moving an existing SQLite volume is a manual procedure, see [deployment.md](deployment.md#moving-an-existing-sqlite-site-to-mysql).
|
Back the database up with `docker compose -f docker-compose.mysql.yml exec mysql mysqldump ...` (a `tar` copy of the volume is not consistent while it runs), and the `renewcms-data` volume as described above. Moving an existing SQLite volume is a manual procedure, see [deployment.md](deployment.md#moving-an-existing-sqlite-site-to-mysql).
|
||||||
|
|
||||||
|
## PostgreSQL
|
||||||
|
|
||||||
|
[`docker-compose.postgres.yml`](../docker-compose.postgres.yml) runs RenewCMS next to a PostgreSQL 17 container:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export DB_PASSWORD=<generated>
|
||||||
|
docker compose -f docker-compose.postgres.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
It starts the `postgres` service with a named volume `postgres-data` and a `pg_isready` healthcheck, and starts `renewcms` only once the database is healthy (`depends_on` with `service_healthy`). RenewCMS gets `DB_DRIVER=postgres`, `DB_HOST=postgres`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` and `DB_SSLMODE=disable` (the traffic stays on the Compose network) from the compose file and the rest from `docker.env`. `DATA_DIR=/data` keeps update state and the `.sql` pre-update backups on the `renewcms-data` volume. For a server on another machine drop the `postgres` service, point `DB_HOST` at it and set `DB_SSLMODE=require`. Back the database up with `docker compose -f docker-compose.postgres.yml exec postgres pg_dump -U renewcms renewcms`. Replace the placeholders; never commit real passwords.
|
||||||
|
|
||||||
## S3-compatible storage (SeaweedFS)
|
## S3-compatible storage (SeaweedFS)
|
||||||
|
|
||||||
[`docker-compose.s3.yml`](../docker-compose.s3.yml) runs RenewCMS with `STORAGE_DRIVER=s3` next to a [SeaweedFS](https://github.com/seaweedfs/seaweedfs) server (Apache 2.0, image `chrislusf/seaweedfs`, override with `SEAWEEDFS_IMAGE`) with its S3 gateway enabled, and a one-shot `seaweedfs-init` step that creates the bucket with `weed shell`:
|
[`docker-compose.s3.yml`](../docker-compose.s3.yml) runs RenewCMS with `STORAGE_DRIVER=s3` next to a [SeaweedFS](https://github.com/seaweedfs/seaweedfs) server (Apache 2.0, image `chrislusf/seaweedfs`, override with `SEAWEEDFS_IMAGE`) with its S3 gateway enabled, and a one-shot `seaweedfs-init` step that creates the bucket with `weed shell`:
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
Feature: Optional PostgreSQL database driver
|
||||||
|
An operator who prefers PostgreSQL runs RenewCMS against it by setting
|
||||||
|
environment variables. SQLite stays the default, and MySQL/MariaDB behaves
|
||||||
|
exactly as before.
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given the database drivers are registered by name in database.drivers
|
||||||
|
And the database is chosen by the DB_DRIVER environment variable
|
||||||
|
And repositories, use cases and handlers are unchanged
|
||||||
|
|
||||||
|
Scenario Outline: Driver selection
|
||||||
|
Given DB_DRIVER is "<value>"
|
||||||
|
When the server starts
|
||||||
|
Then the outcome is "<outcome>"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| value | outcome |
|
||||||
|
| unset | the sqlite driver opens DB_FILE |
|
||||||
|
| sqlite | the sqlite driver opens DB_FILE |
|
||||||
|
| mysql | the mysql driver opens the DB_HOST connection |
|
||||||
|
| postgres | the postgres driver opens the DB_HOST connection |
|
||||||
|
| postgresql | the postgres driver opens the DB_HOST connection |
|
||||||
|
| oracle | startup fails with: unsupported database driver "oracle" |
|
||||||
|
|
||||||
|
Scenario Outline: Missing PostgreSQL variable
|
||||||
|
Given DB_DRIVER is "postgres"
|
||||||
|
And "<missing>" is not set
|
||||||
|
When the environment is validated
|
||||||
|
Then startup fails with "Environment variable <missing> is not set"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| missing |
|
||||||
|
| DB_HOST |
|
||||||
|
| DB_USER |
|
||||||
|
| DB_PASSWORD |
|
||||||
|
| DB_NAME |
|
||||||
|
|
||||||
|
Scenario: SQLite and MySQL do not require PostgreSQL-only variables
|
||||||
|
Given DB_DRIVER is "sqlite" or "mysql"
|
||||||
|
And DB_SSLMODE is "maybe"
|
||||||
|
When the settings are read
|
||||||
|
Then DB_SSLMODE is ignored and validation succeeds
|
||||||
|
|
||||||
|
Scenario Outline: Connection string built from variables
|
||||||
|
Given DB_HOST "<host>", DB_PORT "<port>", DB_USER "app", DB_NAME "cms", DB_SSLMODE "<sslmode>"
|
||||||
|
When the PostgreSQL DSN is built
|
||||||
|
Then the DSN targets "<host>:<expectedPort>" and database "cms"
|
||||||
|
And it sets sslmode "<expectedSslmode>"
|
||||||
|
And it sets the session time zone to UTC
|
||||||
|
And a password containing spaces, quotes, "@" or "/" is escaped so that parsing the DSN returns it unchanged
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| host | port | expectedPort | sslmode | expectedSslmode |
|
||||||
|
| db.local | | 5432 | | disable |
|
||||||
|
| db.local | 5433 | 5433 | require | require |
|
||||||
|
| pg | 5432 | 5432 | verify-full | verify-full |
|
||||||
|
|
||||||
|
Scenario: Invalid SSL mode is rejected
|
||||||
|
Given DB_DRIVER is "postgres" and DB_SSLMODE is "maybe"
|
||||||
|
When the settings are read
|
||||||
|
Then startup fails naming DB_SSLMODE and the accepted values
|
||||||
|
|
||||||
|
Scenario Outline: Fail fast when the server cannot be used
|
||||||
|
Given DB_DRIVER is "postgres" and "<fault>"
|
||||||
|
When the server starts
|
||||||
|
Then startup stops with "Unable to open the database"
|
||||||
|
And the message names the host and port
|
||||||
|
And the message does not contain the DB_PASSWORD value
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| fault |
|
||||||
|
| nothing listens on DB_PORT |
|
||||||
|
| the password is wrong |
|
||||||
|
| the database does not exist |
|
||||||
|
| the server requires TLS and DB_SSLMODE is disable |
|
||||||
|
|
||||||
|
Scenario: Data directory with PostgreSQL
|
||||||
|
Given DB_DRIVER is "postgres" and DATA_DIR is "/data"
|
||||||
|
Then update state, staging and backups live in "/data"
|
||||||
|
And DB_FILE is ignored
|
||||||
|
|
||||||
|
Scenario: Connection pool
|
||||||
|
Given DB_MAX_OPEN_CONNS 4, DB_MAX_IDLE_CONNS 2 and DB_CONN_MAX_LIFETIME_SECONDS 60
|
||||||
|
When the PostgreSQL connection is opened
|
||||||
|
Then the pool limits are 4 open, 2 idle and 60 seconds
|
||||||
|
|
||||||
|
Scenario: Migration on PostgreSQL
|
||||||
|
Given an empty PostgreSQL database
|
||||||
|
When database.Migrate runs
|
||||||
|
Then the tables articles, images, users, invites, api_keys and purge_settings exist in schema "public"
|
||||||
|
And the unique indexes on users and api_keys exist
|
||||||
|
And the first user lacking a role becomes "owner" and the others "admin"
|
||||||
|
And running database.Migrate a second time changes nothing
|
||||||
|
|
||||||
|
Scenario: Existing drivers are untouched by the PostgreSQL addition
|
||||||
|
Given a SQLite file holding rows, and a MySQL database holding rows
|
||||||
|
When database.Migrate runs on each
|
||||||
|
Then the schema and every row are unchanged on both
|
||||||
|
|
||||||
|
Scenario Outline: Repository behaviour is the same on every driver
|
||||||
|
Given the "<driver>" driver
|
||||||
|
Then the repository contract "<case>" holds
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| driver | case |
|
||||||
|
| postgres | create user and read it back with its id and timestamps |
|
||||||
|
| postgres | username "Bob" and "bob" are distinct users |
|
||||||
|
| postgres | email "A@x.io" and "a@x.io" follow the same rule as on sqlite |
|
||||||
|
| postgres | duplicate email returns an error and stores nothing |
|
||||||
|
| postgres | the failed duplicate insert leaves the connection usable |
|
||||||
|
| postgres | unknown id returns domain ErrUserNotFound |
|
||||||
|
| postgres | accept invite twice: the second returns ErrInviteNotFound |
|
||||||
|
| postgres | accept invite with a taken username leaves the invite unaccepted |
|
||||||
|
| postgres | renew invite with unchanged values still succeeds |
|
||||||
|
| postgres | duplicate image hashes are grouped and counted |
|
||||||
|
| postgres | media search treats % and _ literally |
|
||||||
|
| postgres | media search is case-sensitive exactly as on sqlite |
|
||||||
|
| postgres | article body of 1 MB round-trips |
|
||||||
|
| postgres | article title with emoji and accents round-trips |
|
||||||
|
| postgres | zero reset expiration round-trips as a zero time |
|
||||||
|
| postgres | stored times read back in UTC |
|
||||||
|
| postgres | purge settings save, then SaveLastRun with identical values succeeds |
|
||||||
|
| postgres | api key unique hashed_key, revoke and last_used |
|
||||||
|
| postgres | offline article reads as not found publicly, online article is listed |
|
||||||
|
| postgres | listing order and pagination match sqlite for the same data |
|
||||||
|
|
||||||
|
Scenario Outline: Backup before an update
|
||||||
|
Given the "<driver>" driver with a populated database
|
||||||
|
When the pre-update backup runs
|
||||||
|
Then a file "<pattern>" exists in DATA_DIR/backups
|
||||||
|
And only the 3 newest backups are kept
|
||||||
|
And a failure returns an error, so the update download fails instead of claiming a backup
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| driver | pattern |
|
||||||
|
| sqlite | renewcms-<time>.db |
|
||||||
|
| mysql | renewcms-<time>.sql |
|
||||||
|
| postgres | renewcms-<time>.sql |
|
||||||
|
|
||||||
|
Scenario: PostgreSQL backup content
|
||||||
|
Given a PostgreSQL database with 2 users and 1 article
|
||||||
|
When the pre-update backup runs
|
||||||
|
Then the dump contains the schema, indexes and the rows of every table
|
||||||
|
And it does not contain the DB_PASSWORD value
|
||||||
|
And replaying it into an empty database restores the same rows
|
||||||
|
And sequences are reset so the next insert gets an unused id
|
||||||
|
|
||||||
|
Scenario: Secrets are never exposed
|
||||||
|
Given DB_DRIVER is "postgres"
|
||||||
|
Then no log line, error message or file in the repository contains the DB_PASSWORD value
|
||||||
|
|
||||||
|
Scenario: Static builds
|
||||||
|
Then the module builds with CGO_ENABLED=0 for the 6 task release targets
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# QA: PostgreSQL database driver
|
||||||
|
|
||||||
|
Backend only. Go tests in test/, black-box, no live server by default.
|
||||||
|
|
||||||
|
Decisions: DB_DRIVER=postgres (alias postgresql), gorm.io/driver/postgres (pgx, pure Go), shared DB_* vars plus DB_SSLMODE, pure-Go logical dump backup, `task test:postgres` against postgres:16 and postgres:17, SQLite or MySQL to PostgreSQL migration is a documented manual export/import only.
|
||||||
|
|
||||||
|
## Automated, no server (task test)
|
||||||
|
1. Registry: contains sqlite, mysql and postgres. Alias "postgresql" resolves to postgres. "oracle" returns `unsupported database driver "oracle"`.
|
||||||
|
2. Env validation: table-driven over Driver selection and Missing PostgreSQL variable. Invalid DB_SSLMODE or a non-numeric pool var returns an error naming the variable. DB_SSLMODE is ignored under sqlite and mysql. MySQL validation unchanged.
|
||||||
|
3. DSN builder: table-driven over the DSN scenario. Parse the output with the pgx config parser and assert host, port, database, sslmode and the UTC time zone. Round-trip a password such as `s3cr3t/Value@42:x 'q'`. The form used in errors never contains the password.
|
||||||
|
4. Settings.DataDir under postgres: DATA_DIR wins, DB_FILE ignored.
|
||||||
|
5. Fail fast without a server: closed 127.0.0.1 port, 1s connect timeout. Error has the "Unable to open the database" prefix, mentions host and port, not the password.
|
||||||
|
6. Pool settings via a stub opener or the real test below.
|
||||||
|
7. Regression: the existing SQLite and MySQL tests pass unchanged. A pre-change SQLite fixture is unchanged after Migrate.
|
||||||
|
8. Dump writer: reuse the existing formatter tests. Add PostgreSQL value formatting with a fake row source: bool as true/false, bytea hex escapes, timestamptz in UTC, NULL, identifier and string quoting.
|
||||||
|
9. Migrate dialect handling: no MySQL table options are applied under postgres (tableOptionsFor).
|
||||||
|
|
||||||
|
## Automated, real server (task test:postgres)
|
||||||
|
Setup: the task runs `docker run` for postgres:16 then postgres:17, waits with pg_isready, exports RENEWCMS_TEST_POSTGRES_DSN, runs `go test -tags nofrontend ./test/... -run Postgres -count=1`, then removes the container. Tests t.Skip when the variable is unset. Repository contract tests are parameterised over sqlite, mysql and postgres with a fresh database each.
|
||||||
|
10. Migrate on an empty database: tables and indexes via information_schema and pg_indexes, role backfill, idempotent second run.
|
||||||
|
11. User create/read, case-sensitive username and email, duplicate email, ErrUserNotFound. After the unique violation the next query on the same pool succeeds.
|
||||||
|
12. Invite accept twice, accept with a taken username (invite stays unaccepted), renew with unchanged values.
|
||||||
|
13. Image duplicates grouping and counts (GROUP BY strictness), search with % and _ and its case rule, filename backfill listing.
|
||||||
|
14. Article body 1 MB, unicode title, title lookup, online filter, listing order and pagination parity with sqlite.
|
||||||
|
15. API key unique hashed_key, revoke, last_used.
|
||||||
|
16. Zero-time round trip, UTC times.
|
||||||
|
17. Purge settings upsert.
|
||||||
|
18. Backup on a populated database: .sql file, 3-file retention, replay into a second empty database gives identical rows, no password in the file, next insert after replay gets a fresh id.
|
||||||
|
19. Wrong password, missing database and TLS-required-but-disabled fail with the "Unable to open the database" prefix and no password.
|
||||||
|
|
||||||
|
## Build and lint
|
||||||
|
20. `task lint:go` and `task test` pass. `task test:postgres` passes on 16 and 17 (record both; NOT RUN if Docker is unavailable).
|
||||||
|
21. `CGO_ENABLED=0 go build -tags nofrontend ./...` passes. Cross-compile the 6 `task release` targets.
|
||||||
|
22. `task mutate` impact: none (useCases only).
|
||||||
|
23. No comment lines in the added Go files.
|
||||||
|
24. Layering: `go list -deps` on useCases and domain shows no pgx or gorm. Only api/dependecyInjection.go builds the postgres config.
|
||||||
|
|
||||||
|
## Manual (needs Compose and real SMTP)
|
||||||
|
25. docker-compose.postgres.yml: renewcms plus a postgres service with a named volume and a pg_isready healthcheck, depends_on service_healthy, DB_DRIVER=postgres, DB_HOST=postgres, DATA_DIR=/data. `docker compose up`: /healthz 200, register, log in, create an article, restart the stack, data persists.
|
||||||
|
26. Stop the postgres container and start renewcms: clear error, no password in the logs.
|
||||||
|
27. In the Docker image (uid 10001), trigger the backup path and confirm the .sql file lands under /data/backups.
|
||||||
|
28. Docs: .env.example, docker.env.example, docs/configuration.md, docs/docker.md, docs/deployment.md, docs/development.md and the docs/architecture.md "Storage and database seams" section list DB_DRIVER=postgres, DB_SSLMODE and the manual migration note. DB_SSLMODE goes into cmd/server/main.go envDefaults.
|
||||||
|
29. SQLite default end to end with no new vars set behaves as before.
|
||||||
@@ -15,6 +15,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.28.0
|
github.com/go-playground/validator/v10 v10.28.0
|
||||||
github.com/go-sql-driver/mysql v1.8.1
|
github.com/go-sql-driver/mysql v1.8.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
@@ -22,7 +23,8 @@ require (
|
|||||||
golang.org/x/net v0.59.0
|
golang.org/x/net v0.59.0
|
||||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||||
gorm.io/driver/mysql v1.6.0
|
gorm.io/driver/mysql v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/driver/postgres v1.6.3
|
||||||
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -50,6 +52,9 @@ require (
|
|||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
@@ -64,6 +69,7 @@ require (
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/segmentio/asm v1.2.1 // indirect
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
|
||||||
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
|
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
|
||||||
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
|
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -105,6 +113,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
|||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -114,10 +124,12 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
|||||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||||
@@ -147,8 +159,12 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
|
||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
|||||||
@@ -13,9 +13,14 @@ const (
|
|||||||
DatabaseDriverSQLite = "sqlite"
|
DatabaseDriverSQLite = "sqlite"
|
||||||
DatabaseDriverMySQL = "mysql"
|
DatabaseDriverMySQL = "mysql"
|
||||||
|
|
||||||
|
DatabaseDriverPostgres = "postgres"
|
||||||
|
databaseDriverPostgresAlias = "postgresql"
|
||||||
|
|
||||||
DefaultDatabaseDriver = DatabaseDriverSQLite
|
DefaultDatabaseDriver = DatabaseDriverSQLite
|
||||||
DefaultDatabasePort = "3306"
|
DefaultDatabasePort = "3306"
|
||||||
|
DefaultPostgresPort = "5432"
|
||||||
DefaultDatabaseTLS = "false"
|
DefaultDatabaseTLS = "false"
|
||||||
|
DefaultDatabaseSSLMode = "disable"
|
||||||
DefaultMaxOpenConns = "10"
|
DefaultMaxOpenConns = "10"
|
||||||
DefaultMaxIdleConns = "5"
|
DefaultMaxIdleConns = "5"
|
||||||
DefaultConnMaxLifetimeSeconds = "300"
|
DefaultConnMaxLifetimeSeconds = "300"
|
||||||
@@ -26,6 +31,7 @@ const (
|
|||||||
envDatabasePassword = "DB_PASSWORD"
|
envDatabasePassword = "DB_PASSWORD"
|
||||||
envDatabaseName = "DB_NAME"
|
envDatabaseName = "DB_NAME"
|
||||||
envDatabaseTLS = "DB_TLS"
|
envDatabaseTLS = "DB_TLS"
|
||||||
|
envDatabaseSSLMode = "DB_SSLMODE"
|
||||||
envDatabaseMaxOpenConns = "DB_MAX_OPEN_CONNS"
|
envDatabaseMaxOpenConns = "DB_MAX_OPEN_CONNS"
|
||||||
envDatabaseMaxIdleConns = "DB_MAX_IDLE_CONNS"
|
envDatabaseMaxIdleConns = "DB_MAX_IDLE_CONNS"
|
||||||
envDatabaseConnMaxLifetimeSecs = "DB_CONN_MAX_LIFETIME_SECONDS"
|
envDatabaseConnMaxLifetimeSecs = "DB_CONN_MAX_LIFETIME_SECONDS"
|
||||||
@@ -35,6 +41,8 @@ var mysqlRequiredEnvVars = []string{envDatabaseHost, envDatabaseUser, envDatabas
|
|||||||
|
|
||||||
var DatabaseTLSModes = []string{"false", "true", "skip-verify", "preferred"}
|
var DatabaseTLSModes = []string{"false", "true", "skip-verify", "preferred"}
|
||||||
|
|
||||||
|
var DatabaseSSLModes = []string{"disable", "require", "verify-ca", "verify-full"}
|
||||||
|
|
||||||
type DatabaseSettings struct {
|
type DatabaseSettings struct {
|
||||||
Driver string
|
Driver string
|
||||||
Host string
|
Host string
|
||||||
@@ -43,13 +51,22 @@ type DatabaseSettings struct {
|
|||||||
Password string
|
Password string
|
||||||
Name string
|
Name string
|
||||||
TLS string
|
TLS string
|
||||||
|
SSLMode string
|
||||||
MaxOpenConns int
|
MaxOpenConns int
|
||||||
MaxIdleConns int
|
MaxIdleConns int
|
||||||
ConnMaxLifetime time.Duration
|
ConnMaxLifetime time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NormalizeDatabaseDriver(driver string) string {
|
||||||
|
if driver == databaseDriverPostgresAlias {
|
||||||
|
return DatabaseDriverPostgres
|
||||||
|
}
|
||||||
|
return driver
|
||||||
|
}
|
||||||
|
|
||||||
func DatabaseRequiredEnvVars(driver string) []string {
|
func DatabaseRequiredEnvVars(driver string) []string {
|
||||||
if driver == DatabaseDriverMySQL {
|
switch NormalizeDatabaseDriver(driver) {
|
||||||
|
case DatabaseDriverMySQL, DatabaseDriverPostgres:
|
||||||
return slices.Clone(mysqlRequiredEnvVars)
|
return slices.Clone(mysqlRequiredEnvVars)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -65,18 +82,27 @@ func MissingEnvVarError(names []string, lookup func(string) (string, bool)) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func databaseDriverFromEnv() string {
|
func databaseDriverFromEnv() string {
|
||||||
return envOrDefault(EnvDatabaseDriver, DefaultDatabaseDriver)
|
return NormalizeDatabaseDriver(envOrDefault(EnvDatabaseDriver, DefaultDatabaseDriver))
|
||||||
}
|
}
|
||||||
|
|
||||||
func databaseSettingsFromEnv() (DatabaseSettings, error) {
|
func databaseSettingsFromEnv() (DatabaseSettings, error) {
|
||||||
driver := databaseDriverFromEnv()
|
driver := databaseDriverFromEnv()
|
||||||
if driver != DatabaseDriverMySQL {
|
if driver != DatabaseDriverMySQL && driver != DatabaseDriverPostgres {
|
||||||
return DatabaseSettings{Driver: driver}, nil
|
return DatabaseSettings{Driver: driver}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tls := envOrDefault(envDatabaseTLS, DefaultDatabaseTLS)
|
defaultPort, tls, sslMode := DefaultDatabasePort, "", ""
|
||||||
if !slices.Contains(DatabaseTLSModes, tls) {
|
if driver == DatabaseDriverPostgres {
|
||||||
return DatabaseSettings{}, fmt.Errorf("%s must be one of %s, got %q", envDatabaseTLS, strings.Join(DatabaseTLSModes, ", "), tls)
|
defaultPort = DefaultPostgresPort
|
||||||
|
sslMode = envOrDefault(envDatabaseSSLMode, DefaultDatabaseSSLMode)
|
||||||
|
if !slices.Contains(DatabaseSSLModes, sslMode) {
|
||||||
|
return DatabaseSettings{}, fmt.Errorf("%s must be one of %s, got %q", envDatabaseSSLMode, strings.Join(DatabaseSSLModes, ", "), sslMode)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tls = envOrDefault(envDatabaseTLS, DefaultDatabaseTLS)
|
||||||
|
if !slices.Contains(DatabaseTLSModes, tls) {
|
||||||
|
return DatabaseSettings{}, fmt.Errorf("%s must be one of %s, got %q", envDatabaseTLS, strings.Join(DatabaseTLSModes, ", "), tls)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
maxOpen, err := nonNegativeIntEnv(envDatabaseMaxOpenConns, DefaultMaxOpenConns)
|
maxOpen, err := nonNegativeIntEnv(envDatabaseMaxOpenConns, DefaultMaxOpenConns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -94,11 +120,12 @@ func databaseSettingsFromEnv() (DatabaseSettings, error) {
|
|||||||
return DatabaseSettings{
|
return DatabaseSettings{
|
||||||
Driver: driver,
|
Driver: driver,
|
||||||
Host: os.Getenv(envDatabaseHost),
|
Host: os.Getenv(envDatabaseHost),
|
||||||
Port: envOrDefault(envDatabasePort, DefaultDatabasePort),
|
Port: envOrDefault(envDatabasePort, defaultPort),
|
||||||
User: os.Getenv(envDatabaseUser),
|
User: os.Getenv(envDatabaseUser),
|
||||||
Password: os.Getenv(envDatabasePassword),
|
Password: os.Getenv(envDatabasePassword),
|
||||||
Name: os.Getenv(envDatabaseName),
|
Name: os.Getenv(envDatabaseName),
|
||||||
TLS: tls,
|
TLS: tls,
|
||||||
|
SSLMode: sslMode,
|
||||||
MaxOpenConns: maxOpen,
|
MaxOpenConns: maxOpen,
|
||||||
MaxIdleConns: maxIdle,
|
MaxIdleConns: maxIdle,
|
||||||
ConnMaxLifetime: time.Duration(lifetimeSeconds) * time.Second,
|
ConnMaxLifetime: time.Duration(lifetimeSeconds) * time.Second,
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ type Driver interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var drivers = map[string]Driver{
|
var drivers = map[string]Driver{
|
||||||
DriverSQLite: sqliteDriver{},
|
DriverSQLite: sqliteDriver{},
|
||||||
DriverMySQL: mysqlDriver{},
|
DriverMySQL: mysqlDriver{},
|
||||||
|
DriverPostgres: postgresDriver{},
|
||||||
}
|
}
|
||||||
|
|
||||||
type Connection struct {
|
type Connection struct {
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ type DumpBackup struct {
|
|||||||
open func() (DumpSource, error)
|
open func() (DumpSource, error)
|
||||||
directory string
|
directory string
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
dialect dumpDialect
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ domain.DatabaseBackup = &DumpBackup{}
|
var _ domain.DatabaseBackup = &DumpBackup{}
|
||||||
|
|
||||||
func NewDumpBackup(open func() (DumpSource, error), directory string, now func() time.Time) *DumpBackup {
|
func NewDumpBackup(open func() (DumpSource, error), directory string, now func() time.Time) *DumpBackup {
|
||||||
return &DumpBackup{open: open, directory: directory, now: now}
|
return &DumpBackup{open: open, directory: directory, now: now, dialect: mysqlDumpDialect}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *DumpBackup) Backup() (string, error) {
|
func (b *DumpBackup) Backup() (string, error) {
|
||||||
@@ -63,7 +64,7 @@ func (b *DumpBackup) writePartial(partial string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
buffered := bufio.NewWriter(file)
|
buffered := bufio.NewWriter(file)
|
||||||
if err := WriteDump(buffered, source); err != nil {
|
if err := writeDump(buffered, source, b.dialect); err != nil {
|
||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,18 +17,44 @@ func NewMySQLBackup(db *gorm.DB, directory string, now func() time.Time) *DumpBa
|
|||||||
return NewDumpBackup(func() (DumpSource, error) { return openMySQLDumpSource(db) }, directory, now)
|
return NewDumpBackup(func() (DumpSource, error) { return openMySQLDumpSource(db) }, directory, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type txDumpSource struct {
|
||||||
|
tx *gorm.DB
|
||||||
|
quote func(name string) string
|
||||||
|
convert func(value any) []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTxDumpSource(db *gorm.DB, quote func(name string) string, convert func(value any) []byte) (*txDumpSource, error) {
|
||||||
|
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
||||||
|
if tx.Error != nil {
|
||||||
|
return nil, tx.Error
|
||||||
|
}
|
||||||
|
return &txDumpSource{tx: tx, quote: quote, convert: convert}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *txDumpSource) Rows(table string) (DumpRows, error) {
|
||||||
|
rows, err := s.tx.Raw("SELECT * FROM " + s.quote(table)).Rows()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &sqlDumpRows{rows: rows, convert: s.convert}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *txDumpSource) Close() error {
|
||||||
|
return s.tx.Rollback().Error
|
||||||
|
}
|
||||||
|
|
||||||
type mysqlDumpSource struct {
|
type mysqlDumpSource struct {
|
||||||
tx *gorm.DB
|
*txDumpSource
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ DumpSource = &mysqlDumpSource{}
|
var _ DumpSource = &mysqlDumpSource{}
|
||||||
|
|
||||||
func openMySQLDumpSource(db *gorm.DB) (*mysqlDumpSource, error) {
|
func openMySQLDumpSource(db *gorm.DB) (*mysqlDumpSource, error) {
|
||||||
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
source, err := openTxDumpSource(db, quoteIdentifier, dumpBytes)
|
||||||
if tx.Error != nil {
|
if err != nil {
|
||||||
return nil, tx.Error
|
return nil, err
|
||||||
}
|
}
|
||||||
return &mysqlDumpSource{tx: tx}, nil
|
return &mysqlDumpSource{source}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *mysqlDumpSource) Tables() ([]string, error) {
|
func (s *mysqlDumpSource) Tables() ([]string, error) {
|
||||||
@@ -46,25 +72,14 @@ func (s *mysqlDumpSource) CreateStatement(table string) (string, error) {
|
|||||||
return statement, nil
|
return statement, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *mysqlDumpSource) Rows(table string) (DumpRows, error) {
|
type sqlDumpRows struct {
|
||||||
rows, err := s.tx.Raw("SELECT * FROM " + quoteIdentifier(table)).Rows()
|
rows *sql.Rows
|
||||||
if err != nil {
|
convert func(value any) []byte
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &mysqlDumpRows{rows: rows}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *mysqlDumpSource) Close() error {
|
var _ DumpRows = &sqlDumpRows{}
|
||||||
return s.tx.Rollback().Error
|
|
||||||
}
|
|
||||||
|
|
||||||
type mysqlDumpRows struct {
|
func (r *sqlDumpRows) Columns() ([]DumpColumn, error) {
|
||||||
rows *sql.Rows
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ DumpRows = &mysqlDumpRows{}
|
|
||||||
|
|
||||||
func (r *mysqlDumpRows) Columns() ([]DumpColumn, error) {
|
|
||||||
types, err := r.rows.ColumnTypes()
|
types, err := r.rows.ColumnTypes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -76,11 +91,11 @@ func (r *mysqlDumpRows) Columns() ([]DumpColumn, error) {
|
|||||||
return columns, nil
|
return columns, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mysqlDumpRows) Next() bool {
|
func (r *sqlDumpRows) Next() bool {
|
||||||
return r.rows.Next()
|
return r.rows.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mysqlDumpRows) Values() ([][]byte, error) {
|
func (r *sqlDumpRows) Values() ([][]byte, error) {
|
||||||
columnCount, err := r.rows.Columns()
|
columnCount, err := r.rows.Columns()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -95,16 +110,16 @@ func (r *mysqlDumpRows) Values() ([][]byte, error) {
|
|||||||
}
|
}
|
||||||
values := make([][]byte, len(scanned))
|
values := make([][]byte, len(scanned))
|
||||||
for index, value := range scanned {
|
for index, value := range scanned {
|
||||||
values[index] = dumpBytes(value)
|
values[index] = r.convert(value)
|
||||||
}
|
}
|
||||||
return values, nil
|
return values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mysqlDumpRows) Err() error {
|
func (r *sqlDumpRows) Err() error {
|
||||||
return r.rows.Err()
|
return r.rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mysqlDumpRows) Close() error {
|
func (r *sqlDumpRows) Close() error {
|
||||||
return r.rows.Close()
|
return r.rows.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
domainUpdate "RenewCMS/internal/domain/update"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DriverPostgres = "postgres"
|
||||||
|
|
||||||
|
postgresTimeZone = "UTC"
|
||||||
|
postgresConnectTimeoutSeconds = 5
|
||||||
|
postgresScheme = "postgres"
|
||||||
|
postgresUTCCallback = "renewcms:postgres_utc"
|
||||||
|
gormQueryCallback = "gorm:query"
|
||||||
|
)
|
||||||
|
|
||||||
|
var timeType = reflect.TypeOf(time.Time{})
|
||||||
|
|
||||||
|
type postgresDriver struct{}
|
||||||
|
|
||||||
|
type PostgresConfig struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Name string
|
||||||
|
SSLMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func PostgresDSN(config PostgresConfig) string {
|
||||||
|
dsn := url.URL{
|
||||||
|
Scheme: postgresScheme,
|
||||||
|
User: url.UserPassword(config.User, config.Password),
|
||||||
|
Host: net.JoinHostPort(config.Host, config.Port),
|
||||||
|
Path: "/" + config.Name,
|
||||||
|
}
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("sslmode", config.SSLMode)
|
||||||
|
query.Set("timezone", postgresTimeZone)
|
||||||
|
query.Set("connect_timeout", fmt.Sprint(postgresConnectTimeoutSeconds))
|
||||||
|
dsn.RawQuery = query.Encode()
|
||||||
|
return dsn.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (postgresDriver) Open(source string) (*gorm.DB, error) {
|
||||||
|
parsed, err := pgconn.ParseConfig(source)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the postgres connection settings are invalid")
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(postgres.Open(source), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
address := net.JoinHostPort(parsed.Host, fmt.Sprint(parsed.Port))
|
||||||
|
return nil, fmt.Errorf("cannot use the postgres server at %s: %s", address, redact(err.Error(), parsed.Password))
|
||||||
|
}
|
||||||
|
if err := db.Callback().Query().After(gormQueryCallback).Register(postgresUTCCallback, readTimesInUTC); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTimesInUTC(db *gorm.DB) {
|
||||||
|
if db.Error != nil || db.Statement.Dest == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
convertTimesToUTC(reflect.ValueOf(db.Statement.Dest))
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertTimesToUTC(value reflect.Value) {
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Pointer, reflect.Interface:
|
||||||
|
if !value.IsNil() {
|
||||||
|
convertTimesToUTC(value.Elem())
|
||||||
|
}
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
for index := 0; index < value.Len(); index++ {
|
||||||
|
convertTimesToUTC(value.Index(index))
|
||||||
|
}
|
||||||
|
case reflect.Struct:
|
||||||
|
convertStructTimesToUTC(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertStructTimesToUTC(value reflect.Value) {
|
||||||
|
if value.Type() == timeType {
|
||||||
|
if value.CanSet() {
|
||||||
|
value.Set(reflect.ValueOf(value.Interface().(time.Time).UTC()))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for index := 0; index < value.NumField(); index++ {
|
||||||
|
if value.Type().Field(index).IsExported() {
|
||||||
|
convertTimesToUTC(value.Field(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (postgresDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
|
||||||
|
return NewPostgresBackup(db, directory, now)
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
postgresSchema = "public"
|
||||||
|
|
||||||
|
postgresTablesQuery = "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE' ORDER BY table_name"
|
||||||
|
|
||||||
|
postgresColumnsQuery = `SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS data_type, a.attnotnull AS not_null, COALESCE(pg_get_expr(d.adbin, d.adrelid), '') AS default_value, a.attidentity::text AS identity
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class c ON c.oid = a.attrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
||||||
|
WHERE n.nspname = ? AND c.relname = ? AND a.attnum > 0 AND NOT a.attisdropped
|
||||||
|
ORDER BY a.attnum`
|
||||||
|
|
||||||
|
postgresConstraintsQuery = `SELECT con.conname AS name, pg_get_constraintdef(con.oid) AS definition
|
||||||
|
FROM pg_constraint con
|
||||||
|
JOIN pg_class c ON c.oid = con.conrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = ? AND c.relname = ? AND con.contype IN ('p', 'u', 'c')
|
||||||
|
ORDER BY CASE con.contype WHEN 'p' THEN 0 ELSE 1 END, con.conname`
|
||||||
|
|
||||||
|
postgresIndexesQuery = `SELECT i.indexdef
|
||||||
|
FROM pg_indexes i
|
||||||
|
WHERE i.schemaname = ? AND i.tablename = ?
|
||||||
|
AND i.indexname NOT IN (
|
||||||
|
SELECT con.conname FROM pg_constraint con
|
||||||
|
JOIN pg_class c ON c.oid = con.conrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = ? AND c.relname = ?
|
||||||
|
)
|
||||||
|
ORDER BY i.indexname`
|
||||||
|
|
||||||
|
postgresIdentityColumn = "id"
|
||||||
|
postgresTimeLayout = "2006-01-02 15:04:05.999999999Z07:00"
|
||||||
|
postgresByteaType = "BYTEA"
|
||||||
|
)
|
||||||
|
|
||||||
|
var postgresNumericTypes = map[string]bool{
|
||||||
|
"INT2": true, "INT4": true, "INT8": true, "FLOAT4": true, "FLOAT8": true, "NUMERIC": true, "OID": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var postgresSerialTypes = map[string]string{
|
||||||
|
"smallint": "smallserial",
|
||||||
|
"integer": "serial",
|
||||||
|
"bigint": "bigserial",
|
||||||
|
}
|
||||||
|
|
||||||
|
var postgresDumpDialect = dumpDialect{
|
||||||
|
preamble: []string{
|
||||||
|
"SET client_encoding = 'UTF8';",
|
||||||
|
"SET standard_conforming_strings = on;",
|
||||||
|
"SET TimeZone = 'UTC';",
|
||||||
|
},
|
||||||
|
quote: quotePostgresIdentifier,
|
||||||
|
literal: postgresLiteral,
|
||||||
|
tableTrailer: postgresSequenceReset,
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPostgresBackup(db *gorm.DB, directory string, now func() time.Time) *DumpBackup {
|
||||||
|
backup := NewDumpBackup(func() (DumpSource, error) { return openPostgresDumpSource(db) }, directory, now)
|
||||||
|
backup.dialect = postgresDumpDialect
|
||||||
|
return backup
|
||||||
|
}
|
||||||
|
|
||||||
|
type postgresDumpSource struct {
|
||||||
|
*txDumpSource
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DumpSource = &postgresDumpSource{}
|
||||||
|
|
||||||
|
func openPostgresDumpSource(db *gorm.DB) (*postgresDumpSource, error) {
|
||||||
|
source, err := openTxDumpSource(db, quotePostgresIdentifier, postgresDumpBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &postgresDumpSource{source}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresDumpSource) Tables() ([]string, error) {
|
||||||
|
var tables []string
|
||||||
|
err := s.tx.Raw(postgresTablesQuery, postgresSchema).Scan(&tables).Error
|
||||||
|
return tables, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type postgresColumn struct {
|
||||||
|
Name string
|
||||||
|
DataType string
|
||||||
|
NotNull bool
|
||||||
|
DefaultValue string
|
||||||
|
Identity string
|
||||||
|
}
|
||||||
|
|
||||||
|
type postgresConstraint struct {
|
||||||
|
Name string
|
||||||
|
Definition string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresDumpSource) CreateStatement(table string) (string, error) {
|
||||||
|
var columns []postgresColumn
|
||||||
|
if err := s.tx.Raw(postgresColumnsQuery, postgresSchema, table).Scan(&columns).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var constraints []postgresConstraint
|
||||||
|
if err := s.tx.Raw(postgresConstraintsQuery, postgresSchema, table).Scan(&constraints).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var indexes []string
|
||||||
|
if err := s.tx.Raw(postgresIndexesQuery, postgresSchema, table, postgresSchema, table).Scan(&indexes).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var definitions []string
|
||||||
|
for _, column := range columns {
|
||||||
|
definitions = append(definitions, " "+postgresColumnDefinition(column))
|
||||||
|
}
|
||||||
|
for _, constraint := range constraints {
|
||||||
|
definitions = append(definitions, fmt.Sprintf(" CONSTRAINT %s %s", quotePostgresIdentifier(constraint.Name), constraint.Definition))
|
||||||
|
}
|
||||||
|
statements := []string{fmt.Sprintf("CREATE TABLE %s (\n%s\n)", quotePostgresIdentifier(table), strings.Join(definitions, ",\n"))}
|
||||||
|
statements = append(statements, indexes...)
|
||||||
|
return strings.Join(statements, ";\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func postgresColumnDefinition(column postgresColumn) string {
|
||||||
|
quoted := quotePostgresIdentifier(column.Name)
|
||||||
|
serial, isSerial := postgresSerialTypes[column.DataType]
|
||||||
|
if isSerial && strings.HasPrefix(column.DefaultValue, "nextval(") {
|
||||||
|
return quoted + " " + serial + " NOT NULL"
|
||||||
|
}
|
||||||
|
if column.Identity != "" {
|
||||||
|
return quoted + " " + column.DataType + " GENERATED BY DEFAULT AS IDENTITY NOT NULL"
|
||||||
|
}
|
||||||
|
definition := quoted + " " + column.DataType
|
||||||
|
if column.DefaultValue != "" {
|
||||||
|
definition += " DEFAULT " + column.DefaultValue
|
||||||
|
}
|
||||||
|
if column.NotNull {
|
||||||
|
definition += " NOT NULL"
|
||||||
|
}
|
||||||
|
return definition
|
||||||
|
}
|
||||||
|
|
||||||
|
func postgresDumpBytes(value any) []byte {
|
||||||
|
if moment, isTime := value.(time.Time); isTime {
|
||||||
|
return []byte(moment.UTC().Format(postgresTimeLayout))
|
||||||
|
}
|
||||||
|
return dumpBytes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func quotePostgresIdentifier(name string) string {
|
||||||
|
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func postgresLiteral(databaseType string, value []byte) string {
|
||||||
|
if value == nil {
|
||||||
|
return "NULL"
|
||||||
|
}
|
||||||
|
upper := strings.ToUpper(databaseType)
|
||||||
|
if postgresNumericTypes[upper] && plainNumber.Match(value) {
|
||||||
|
return string(value)
|
||||||
|
}
|
||||||
|
if upper == postgresByteaType {
|
||||||
|
return fmt.Sprintf(`'\x%x'`, value)
|
||||||
|
}
|
||||||
|
return "'" + strings.ReplaceAll(string(value), "'", "''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func postgresSequenceReset(table string, columns []DumpColumn) []string {
|
||||||
|
for _, column := range columns {
|
||||||
|
if column.Name != postgresIdentityColumn {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
quotedTable, quotedColumn := quotePostgresIdentifier(table), quotePostgresIdentifier(column.Name)
|
||||||
|
sequenceTable := strings.ReplaceAll(quotedTable, "'", "''")
|
||||||
|
return []string{fmt.Sprintf(
|
||||||
|
"SELECT setval(pg_get_serial_sequence('%s', '%s'), COALESCE((SELECT MAX(%s) FROM %s), 1), (SELECT COUNT(*) > 0 FROM %s));",
|
||||||
|
sequenceTable, column.Name, quotedColumn, quotedTable, quotedTable,
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -28,14 +28,26 @@ type DumpSource interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
var dumpPreamble = []string{
|
type dumpDialect struct {
|
||||||
"SET NAMES utf8mb4;",
|
preamble []string
|
||||||
"SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';",
|
postamble []string
|
||||||
"SET foreign_key_checks = 0;",
|
quote func(name string) string
|
||||||
|
literal func(databaseType string, value []byte) string
|
||||||
|
tableTrailer func(table string, columns []DumpColumn) []string
|
||||||
}
|
}
|
||||||
|
|
||||||
var dumpPostamble = []string{
|
var mysqlDumpDialect = dumpDialect{
|
||||||
"SET foreign_key_checks = 1;",
|
preamble: []string{
|
||||||
|
"SET NAMES utf8mb4;",
|
||||||
|
"SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';",
|
||||||
|
"SET foreign_key_checks = 0;",
|
||||||
|
},
|
||||||
|
postamble: []string{
|
||||||
|
"SET foreign_key_checks = 1;",
|
||||||
|
},
|
||||||
|
quote: quoteIdentifier,
|
||||||
|
literal: sqlLiteral,
|
||||||
|
tableTrailer: func(string, []DumpColumn) []string { return nil },
|
||||||
}
|
}
|
||||||
|
|
||||||
var plainNumber = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$`)
|
var plainNumber = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$`)
|
||||||
@@ -50,7 +62,11 @@ var textEscapes = strings.NewReplacer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
func WriteDump(output io.Writer, source DumpSource) error {
|
func WriteDump(output io.Writer, source DumpSource) error {
|
||||||
if err := writeStatements(output, dumpPreamble); err != nil {
|
return writeDump(output, source, mysqlDumpDialect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDump(output io.Writer, source DumpSource, dialect dumpDialect) error {
|
||||||
|
if err := writeStatements(output, dialect.preamble); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tables, err := source.Tables()
|
tables, err := source.Tables()
|
||||||
@@ -58,11 +74,11 @@ func WriteDump(output io.Writer, source DumpSource) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
if err := writeTable(output, source, table); err != nil {
|
if err := writeTable(output, source, table, dialect); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return writeStatements(output, dumpPostamble)
|
return writeStatements(output, dialect.postamble)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeStatements(output io.Writer, statements []string) error {
|
func writeStatements(output io.Writer, statements []string) error {
|
||||||
@@ -74,7 +90,7 @@ func writeStatements(output io.Writer, statements []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeTable(output io.Writer, source DumpSource, table string) error {
|
func writeTable(output io.Writer, source DumpSource, table string, dialect dumpDialect) error {
|
||||||
create, err := source.CreateStatement(table)
|
create, err := source.CreateStatement(table)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -87,39 +103,42 @@ func writeTable(output io.Writer, source DumpSource, table string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
return writeRows(output, table, rows)
|
return writeRows(output, table, rows, dialect)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeRows(output io.Writer, table string, rows DumpRows) error {
|
func writeRows(output io.Writer, table string, rows DumpRows, dialect dumpDialect) error {
|
||||||
columns, err := rows.Columns()
|
columns, err := rows.Columns()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
insertPrefix := insertStatementPrefix(table, columns)
|
insertPrefix := insertStatementPrefix(table, columns, dialect)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
values, err := rows.Values()
|
values, err := rows.Values()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := fmt.Fprintf(output, "%s%s);\n", insertPrefix, joinLiterals(columns, values)); err != nil {
|
if _, err := fmt.Fprintf(output, "%s%s);\n", insertPrefix, joinLiterals(columns, values, dialect)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows.Err()
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeStatements(output, dialect.tableTrailer(table, columns))
|
||||||
}
|
}
|
||||||
|
|
||||||
func insertStatementPrefix(table string, columns []DumpColumn) string {
|
func insertStatementPrefix(table string, columns []DumpColumn, dialect dumpDialect) string {
|
||||||
names := make([]string, len(columns))
|
names := make([]string, len(columns))
|
||||||
for index, column := range columns {
|
for index, column := range columns {
|
||||||
names[index] = quoteIdentifier(column.Name)
|
names[index] = dialect.quote(column.Name)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (", quoteIdentifier(table), strings.Join(names, ","))
|
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (", dialect.quote(table), strings.Join(names, ","))
|
||||||
}
|
}
|
||||||
|
|
||||||
func joinLiterals(columns []DumpColumn, values [][]byte) string {
|
func joinLiterals(columns []DumpColumn, values [][]byte, dialect dumpDialect) string {
|
||||||
literals := make([]string, len(columns))
|
literals := make([]string, len(columns))
|
||||||
for index, column := range columns {
|
for index, column := range columns {
|
||||||
literals[index] = sqlLiteral(column.Type, values[index])
|
literals[index] = dialect.literal(column.Type, values[index])
|
||||||
}
|
}
|
||||||
return strings.Join(literals, ",")
|
return strings.Join(literals, ",")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/infrastructure/config"
|
||||||
|
"RenewCMS/internal/infrastructure/persistence/database"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const postgresTestPassword = "p@ss w'rd/\"x:42%"
|
||||||
|
|
||||||
|
func setPostgresEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
setBaseSettingsEnv(t)
|
||||||
|
t.Setenv("DB_DRIVER", "postgres")
|
||||||
|
t.Setenv("DB_HOST", "db.local")
|
||||||
|
t.Setenv("DB_USER", "app")
|
||||||
|
t.Setenv("DB_PASSWORD", postgresTestPassword)
|
||||||
|
t.Setenv("DB_NAME", "cms")
|
||||||
|
for _, optional := range []string{"DB_PORT", "DB_TLS", "DB_SSLMODE", "DB_MAX_OPEN_CONNS", "DB_MAX_IDLE_CONNS", "DB_CONN_MAX_LIFETIME_SECONDS", "DATA_DIR"} {
|
||||||
|
t.Setenv(optional, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresDriverSelection(t *testing.T) {
|
||||||
|
t.Run("Driver selection", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
value string
|
||||||
|
driver string
|
||||||
|
}{
|
||||||
|
{"postgres", "postgres"},
|
||||||
|
{"postgresql", "postgres"},
|
||||||
|
{"mysql", "mysql"},
|
||||||
|
{"oracle", "oracle"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(fmt.Sprintf("DB_DRIVER %q", tc.value), func(t *testing.T) {
|
||||||
|
setPostgresEnv(t)
|
||||||
|
t.Setenv("DB_DRIVER", tc.value)
|
||||||
|
|
||||||
|
settings, err := config.NewSettingsFromEnv()
|
||||||
|
|
||||||
|
if err != nil || settings.Database.Driver != tc.driver {
|
||||||
|
t.Errorf("expected driver %q, got %+v err %v", tc.driver, settings.Database, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresEnvironmentValidation(t *testing.T) {
|
||||||
|
complete := map[string]string{"DB_HOST": "h", "DB_USER": "u", "DB_PASSWORD": "p", "DB_NAME": "n"}
|
||||||
|
|
||||||
|
t.Run("Missing PostgreSQL variable", func(t *testing.T) {
|
||||||
|
for _, driver := range []string{"postgres", "postgresql"} {
|
||||||
|
for _, missing := range []string{"DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"} {
|
||||||
|
t.Run(driver+" "+missing, func(t *testing.T) {
|
||||||
|
values := map[string]string{}
|
||||||
|
for name, value := range complete {
|
||||||
|
if name != missing {
|
||||||
|
values[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars(driver), lookupFrom(values))
|
||||||
|
|
||||||
|
expected := "Environment variable " + missing + " is not set"
|
||||||
|
if err == nil || err.Error() != expected {
|
||||||
|
t.Errorf("expected %q, got %v", expected, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("all PostgreSQL variables set passes validation", func(t *testing.T) {
|
||||||
|
if err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars("postgres"), lookupFrom(complete)); err != nil {
|
||||||
|
t.Errorf("expected success, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SQLite and MySQL do not require PostgreSQL-only variables", func(t *testing.T) {
|
||||||
|
for _, driver := range []string{"sqlite", "mysql"} {
|
||||||
|
setMySQLEnv(t)
|
||||||
|
t.Setenv("DB_DRIVER", driver)
|
||||||
|
t.Setenv("DB_SSLMODE", "maybe")
|
||||||
|
|
||||||
|
settings, err := config.NewSettingsFromEnv()
|
||||||
|
|
||||||
|
if err != nil || settings.Database.SSLMode != "" {
|
||||||
|
t.Errorf("driver %q: DB_SSLMODE must be ignored, got %+v err %v", driver, settings.Database, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresSettingsFromEnv(t *testing.T) {
|
||||||
|
t.Run("defaults apply when the optional variables are empty", func(t *testing.T) {
|
||||||
|
setPostgresEnv(t)
|
||||||
|
|
||||||
|
settings, err := config.NewSettingsFromEnv()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings: %v", err)
|
||||||
|
}
|
||||||
|
database := settings.Database
|
||||||
|
if database.Port != "5432" || database.SSLMode != "disable" || database.MaxOpenConns != 10 || database.MaxIdleConns != 5 || database.ConnMaxLifetime != 300*time.Second {
|
||||||
|
t.Errorf("unexpected defaults %+v", database)
|
||||||
|
}
|
||||||
|
if database.Host != "db.local" || database.User != "app" || database.Password != postgresTestPassword || database.Name != "cms" {
|
||||||
|
t.Errorf("unexpected connection fields %+v", database)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MySQL keeps its default port", func(t *testing.T) {
|
||||||
|
setMySQLEnv(t)
|
||||||
|
|
||||||
|
settings, err := config.NewSettingsFromEnv()
|
||||||
|
|
||||||
|
if err != nil || settings.Database.Port != "3306" {
|
||||||
|
t.Errorf("unexpected settings %+v err %v", settings.Database, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Invalid SSL mode is rejected", func(t *testing.T) {
|
||||||
|
setPostgresEnv(t)
|
||||||
|
t.Setenv("DB_SSLMODE", "maybe")
|
||||||
|
|
||||||
|
_, err := config.NewSettingsFromEnv()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"DB_SSLMODE", "disable", "require", "verify-ca", "verify-full"} {
|
||||||
|
if !strings.Contains(err.Error(), expected) {
|
||||||
|
t.Errorf("error %q must mention %q", err, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("every accepted SSL mode is read", func(t *testing.T) {
|
||||||
|
for _, mode := range []string{"disable", "require", "verify-ca", "verify-full"} {
|
||||||
|
setPostgresEnv(t)
|
||||||
|
t.Setenv("DB_SSLMODE", mode)
|
||||||
|
|
||||||
|
settings, err := config.NewSettingsFromEnv()
|
||||||
|
|
||||||
|
if err != nil || settings.Database.SSLMode != mode {
|
||||||
|
t.Errorf("mode %q: settings %+v, err %v", mode, settings.Database, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Connection pool variables are read", func(t *testing.T) {
|
||||||
|
setPostgresEnv(t)
|
||||||
|
t.Setenv("DB_MAX_OPEN_CONNS", "4")
|
||||||
|
t.Setenv("DB_MAX_IDLE_CONNS", "2")
|
||||||
|
t.Setenv("DB_CONN_MAX_LIFETIME_SECONDS", "60")
|
||||||
|
|
||||||
|
settings, err := config.NewSettingsFromEnv()
|
||||||
|
|
||||||
|
database := settings.Database
|
||||||
|
if err != nil || database.MaxOpenConns != 4 || database.MaxIdleConns != 2 || database.ConnMaxLifetime != 60*time.Second {
|
||||||
|
t.Errorf("unexpected settings %+v, err %v", database, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Data directory with PostgreSQL", func(t *testing.T) {
|
||||||
|
setPostgresEnv(t)
|
||||||
|
t.Setenv("DATA_DIR", "/data")
|
||||||
|
t.Setenv("DB_FILE", "./elsewhere/ignored.db")
|
||||||
|
|
||||||
|
settings, err := config.NewSettingsFromEnv()
|
||||||
|
|
||||||
|
if err != nil || strings.ReplaceAll(settings.DataDir(), "\\", "/") != "/data" {
|
||||||
|
t.Errorf("unexpected data dir %q err %v", settings.DataDir(), err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresDSN(t *testing.T) {
|
||||||
|
t.Run("Connection string built from variables", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
host, port, expectedPort, sslmode, expectedSslmode string
|
||||||
|
}{
|
||||||
|
{"db.local", "", "5432", "", "disable"},
|
||||||
|
{"db.local", "5433", "5433", "require", "require"},
|
||||||
|
{"pg", "5432", "5432", "verify-full", "verify-full"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.host+":"+tc.port+" sslmode "+tc.sslmode, func(t *testing.T) {
|
||||||
|
port, sslmode := tc.port, tc.sslmode
|
||||||
|
if port == "" {
|
||||||
|
port = config.DefaultPostgresPort
|
||||||
|
}
|
||||||
|
if sslmode == "" {
|
||||||
|
sslmode = config.DefaultDatabaseSSLMode
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := database.PostgresDSN(database.PostgresConfig{Host: tc.host, Port: port, User: "app", Password: postgresTestPassword, Name: "cms", SSLMode: sslmode})
|
||||||
|
|
||||||
|
parsed, err := pgconn.ParseConfig(dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if fmt.Sprintf("%s:%d", parsed.Host, parsed.Port) != tc.host+":"+tc.expectedPort || parsed.Database != "cms" || parsed.User != "app" {
|
||||||
|
t.Errorf("unexpected target %s:%d db %q user %q", parsed.Host, parsed.Port, parsed.Database, parsed.User)
|
||||||
|
}
|
||||||
|
if parsed.Password != postgresTestPassword {
|
||||||
|
t.Errorf("the password must survive the DSN, got %q", parsed.Password)
|
||||||
|
}
|
||||||
|
if parsed.RuntimeParams["timezone"] != "UTC" {
|
||||||
|
t.Errorf("the session time zone must be UTC, got %v", parsed.RuntimeParams)
|
||||||
|
}
|
||||||
|
if tc.expectedSslmode == "disable" && parsed.TLSConfig != nil {
|
||||||
|
t.Errorf("sslmode disable must not configure TLS")
|
||||||
|
}
|
||||||
|
if tc.expectedSslmode != "disable" && parsed.TLSConfig == nil {
|
||||||
|
t.Errorf("sslmode %s must configure TLS", tc.expectedSslmode)
|
||||||
|
}
|
||||||
|
if query := mustParseQuery(t, dsn); query.Get("sslmode") != tc.expectedSslmode {
|
||||||
|
t.Errorf("expected sslmode %q, got %q", tc.expectedSslmode, query.Get("sslmode"))
|
||||||
|
}
|
||||||
|
if parsed.ConnectTimeout <= 0 || parsed.ConnectTimeout > 10*time.Second {
|
||||||
|
t.Errorf("connect timeout must be short, got %v", parsed.ConnectTimeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseQuery(t *testing.T, dsn string) url.Values {
|
||||||
|
t.Helper()
|
||||||
|
parsed, err := url.Parse(dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return parsed.Query()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresFailFast(t *testing.T) {
|
||||||
|
t.Run("nothing listens on DB_PORT", func(t *testing.T) {
|
||||||
|
port := closedLocalPort(t)
|
||||||
|
dsn := database.PostgresDSN(database.PostgresConfig{Host: "127.0.0.1", Port: port, User: "app", Password: postgresTestPassword, Name: "cms", SSLMode: "disable"})
|
||||||
|
started := time.Now()
|
||||||
|
|
||||||
|
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: dsn})
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "127.0.0.1:"+port) {
|
||||||
|
t.Errorf("the error must name the host and port: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), postgresTestPassword) {
|
||||||
|
t.Errorf("the error must not contain the password: %v", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(started); elapsed > 8*time.Second {
|
||||||
|
t.Errorf("startup must fail fast, took %v", elapsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an unusable DSN is rejected without echoing it", func(t *testing.T) {
|
||||||
|
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: "postgres://user:" + postgresTestPassword + "@[broken"})
|
||||||
|
|
||||||
|
if err == nil || strings.Contains(err.Error(), postgresTestPassword) {
|
||||||
|
t.Errorf("unexpected error %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresStartupFailures(t *testing.T) {
|
||||||
|
binary := buildServerBinary(t, "")
|
||||||
|
|
||||||
|
t.Run("Missing PostgreSQL variable stops the server", func(t *testing.T) {
|
||||||
|
complete := map[string]string{"DB_HOST": "127.0.0.1", "DB_USER": "app", "DB_PASSWORD": postgresTestPassword, "DB_NAME": "cms"}
|
||||||
|
for missing := range complete {
|
||||||
|
t.Run(missing, func(t *testing.T) {
|
||||||
|
extra := []string{"DB_DRIVER=postgresql"}
|
||||||
|
for name, value := range complete {
|
||||||
|
if name != missing {
|
||||||
|
extra = append(extra, name+"="+value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t, extra...))
|
||||||
|
|
||||||
|
if code == 0 || !strings.Contains(output, "Environment variable "+missing+" is not set") || strings.Contains(output, postgresTestPassword) {
|
||||||
|
t.Errorf("code %d output %q", code, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an invalid DB_SSLMODE stops the server naming the variable", func(t *testing.T) {
|
||||||
|
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
|
||||||
|
"DB_DRIVER=postgres", "DB_HOST=127.0.0.1", "DB_USER=app", "DB_PASSWORD="+postgresTestPassword, "DB_NAME=cms", "DB_SSLMODE=maybe"))
|
||||||
|
|
||||||
|
if code == 0 || !strings.Contains(output, "DB_SSLMODE") || strings.Contains(output, postgresTestPassword) {
|
||||||
|
t.Errorf("code %d output %q", code, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Fail fast when the server cannot be used", func(t *testing.T) {
|
||||||
|
port := closedLocalPort(t)
|
||||||
|
|
||||||
|
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
|
||||||
|
"DB_DRIVER=postgres", "DB_HOST=127.0.0.1", "DB_PORT="+port, "DB_USER=app", "DB_PASSWORD="+postgresTestPassword, "DB_NAME=cms"))
|
||||||
|
|
||||||
|
if code == 0 || !strings.Contains(output, "Unable to open the database") {
|
||||||
|
t.Errorf("code %d output %q", code, output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "127.0.0.1:"+port) {
|
||||||
|
t.Errorf("the message must name the host and port: %q", output)
|
||||||
|
}
|
||||||
|
if strings.Contains(output, postgresTestPassword) {
|
||||||
|
t.Errorf("the message must not contain the password: %q", output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an omitted DB_PORT targets 5432", func(t *testing.T) {
|
||||||
|
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
|
||||||
|
"DB_DRIVER=postgres", "DB_HOST=127.0.0.1", "DB_USER=app", "DB_PASSWORD="+postgresTestPassword, "DB_NAME=cms"))
|
||||||
|
|
||||||
|
if code == 0 || strings.Contains(output, postgresTestPassword) {
|
||||||
|
t.Errorf("code %d output %q", code, output)
|
||||||
|
}
|
||||||
|
if strings.Contains(output, "Unable to open the database") && !strings.Contains(output, "127.0.0.1:5432") {
|
||||||
|
t.Errorf("the default port must be 5432: %q", output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
domainArticle "RenewCMS/internal/domain/article"
|
||||||
|
domainInvite "RenewCMS/internal/domain/invite"
|
||||||
|
"RenewCMS/internal/infrastructure/persistence/database"
|
||||||
|
"RenewCMS/internal/infrastructure/persistence/models"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const postgresTestDSNVariable = "RENEWCMS_TEST_POSTGRES_DSN"
|
||||||
|
|
||||||
|
var postgresTestDatabaseCounter atomic.Int64
|
||||||
|
|
||||||
|
type postgresTestServer struct {
|
||||||
|
admin *sql.DB
|
||||||
|
config database.PostgresConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectPostgresTestServer(t *testing.T) *postgresTestServer {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv(postgresTestDSNVariable)
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip(postgresTestDSNVariable + " is not set; run task test:postgres to test against a real server")
|
||||||
|
}
|
||||||
|
parsed, err := pgconn.ParseConfig(dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", postgresTestDSNVariable, err)
|
||||||
|
}
|
||||||
|
admin, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open admin: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = admin.Close() })
|
||||||
|
return &postgresTestServer{admin: admin, config: database.PostgresConfig{
|
||||||
|
Host: parsed.Host, Port: fmt.Sprint(parsed.Port), User: parsed.User, Password: parsed.Password, SSLMode: "disable",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresTestServer) createDatabase(t *testing.T) database.PostgresConfig {
|
||||||
|
t.Helper()
|
||||||
|
name := fmt.Sprintf("renewcms_test_%d_%d", time.Now().UnixNano()%1_000_000_000, postgresTestDatabaseCounter.Add(1))
|
||||||
|
if _, err := s.admin.Exec("CREATE DATABASE " + name); err != nil {
|
||||||
|
t.Fatalf("create database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _, _ = s.admin.Exec("DROP DATABASE IF EXISTS " + name + " WITH (FORCE)") })
|
||||||
|
config := s.config
|
||||||
|
config.Name = name
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresTestServer) connect(t *testing.T, config database.PostgresConfig) *database.Connection {
|
||||||
|
t.Helper()
|
||||||
|
connection, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(config)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect postgres: %v", err)
|
||||||
|
}
|
||||||
|
connection.DB.Logger = logger.Discard
|
||||||
|
t.Cleanup(func() { _ = connection.Close() })
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func openContractPostgres(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
server := connectPostgresTestServer(t)
|
||||||
|
connection := server.connect(t, server.createDatabase(t))
|
||||||
|
if err := database.Migrate(connection.DB); err != nil {
|
||||||
|
t.Fatalf("migrate postgres: %v", err)
|
||||||
|
}
|
||||||
|
return connection.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func migratedPostgres(t *testing.T) (*postgresTestServer, database.PostgresConfig, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
server := connectPostgresTestServer(t)
|
||||||
|
config := server.createDatabase(t)
|
||||||
|
connection := server.connect(t, config)
|
||||||
|
if err := database.Migrate(connection.DB); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
return server, config, connection.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresRepositoryContract(t *testing.T) {
|
||||||
|
runRepositoryContract(t, contractBackend{name: "postgres", open: openContractPostgres})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresConnectionPool(t *testing.T) {
|
||||||
|
t.Run("Connection pool", func(t *testing.T) {
|
||||||
|
server := connectPostgresTestServer(t)
|
||||||
|
pool := database.PoolSettings{MaxOpenConns: 4, MaxIdleConns: 2, ConnMaxLifetime: 60 * time.Second}
|
||||||
|
connection, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(server.createDatabase(t)), Pool: &pool})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
sqlDb, _ := connection.DB.DB()
|
||||||
|
|
||||||
|
stats := sqlDb.Stats()
|
||||||
|
|
||||||
|
if stats.MaxOpenConnections != 4 {
|
||||||
|
t.Errorf("expected 4 open connections at most, got %d", stats.MaxOpenConnections)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresServerFaults(t *testing.T) {
|
||||||
|
t.Run("the password is wrong", func(t *testing.T) {
|
||||||
|
server := connectPostgresTestServer(t)
|
||||||
|
config := server.createDatabase(t)
|
||||||
|
config.Password = "definitely/wrong p@ss"
|
||||||
|
|
||||||
|
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(config)})
|
||||||
|
|
||||||
|
expectedAddress := net.JoinHostPort(config.Host, config.Port)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), expectedAddress) || strings.Contains(err.Error(), config.Password) {
|
||||||
|
t.Errorf("unexpected error %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the database does not exist", func(t *testing.T) {
|
||||||
|
server := connectPostgresTestServer(t)
|
||||||
|
config := server.config
|
||||||
|
config.Name = "renewcms_missing_database"
|
||||||
|
|
||||||
|
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(config)})
|
||||||
|
|
||||||
|
if err == nil || !strings.Contains(err.Error(), net.JoinHostPort(config.Host, config.Port)) || strings.Contains(err.Error(), config.Password) {
|
||||||
|
t.Errorf("unexpected error %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresMigration(t *testing.T) {
|
||||||
|
t.Run("Migration on PostgreSQL", func(t *testing.T) {
|
||||||
|
_, _, db := migratedPostgres(t)
|
||||||
|
|
||||||
|
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
|
||||||
|
count := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?", table)
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("table %s must exist in schema public", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uniqueColumns := map[string][]string{
|
||||||
|
"users": {"username", "email", "password_reset_code", "verification_code"},
|
||||||
|
"api_keys": {"hashed_key"},
|
||||||
|
}
|
||||||
|
for table, columns := range uniqueColumns {
|
||||||
|
for _, column := range columns {
|
||||||
|
count := columnCount(t, db, `SELECT COUNT(*) FROM pg_index i
|
||||||
|
JOIN pg_class c ON c.oid = i.indrelid
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = i.indkey[0]
|
||||||
|
WHERE c.relname = ? AND a.attname = ? AND i.indisunique AND array_length(i.indkey, 1) = 1`, table, column)
|
||||||
|
if count == 0 {
|
||||||
|
t.Errorf("a unique index on %s.%s must exist", table, column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("migrating twice changes nothing", func(t *testing.T) {
|
||||||
|
_, _, db := migratedPostgres(t)
|
||||||
|
before := columnCount(t, db, "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'")
|
||||||
|
|
||||||
|
if err := database.Migrate(db); err != nil {
|
||||||
|
t.Errorf("the second migration must succeed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := columnCount(t, db, "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'"); after != before {
|
||||||
|
t.Errorf("indexes changed from %d to %d", before, after)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) {
|
||||||
|
_, _, db := migratedPostgres(t)
|
||||||
|
for _, name := range []string{"first", "second", "third"} {
|
||||||
|
insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', true, 0)", name, name+"@x.example")
|
||||||
|
if insert.Error != nil {
|
||||||
|
t.Fatalf("insert %s: %v", name, insert.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.Migrate(db); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roles := map[string]string{}
|
||||||
|
var users []models.User
|
||||||
|
db.Find(&users)
|
||||||
|
for _, user := range users {
|
||||||
|
roles[user.Username] = user.Role
|
||||||
|
}
|
||||||
|
if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" {
|
||||||
|
t.Errorf("unexpected roles %v", roles)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresTimes(t *testing.T) {
|
||||||
|
t.Run("times are stored and read back in UTC", func(t *testing.T) {
|
||||||
|
_, _, db := migratedPostgres(t)
|
||||||
|
repos := newContractRepositories(db)
|
||||||
|
zone := time.FixedZone("plus2", 2*3600)
|
||||||
|
expiration := time.Date(2030, 5, 6, 7, 8, 9, 0, zone)
|
||||||
|
user := contractUser("zoned", "zoned@x.example")
|
||||||
|
user.VerificationExpiration = expiration
|
||||||
|
|
||||||
|
created := mustCreateUser(t, repos, user)
|
||||||
|
read, err := repos.users.Get(created.ID)
|
||||||
|
|
||||||
|
if err != nil || !read.VerificationExpiration.Equal(expiration) || read.VerificationExpiration.Location() != time.UTC {
|
||||||
|
t.Errorf("expected %v in UTC, got %v (%v) err %v", expiration, read.VerificationExpiration, read.VerificationExpiration.Location(), err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresTimesInLists(t *testing.T) {
|
||||||
|
_, _, db := migratedPostgres(t)
|
||||||
|
repos := newContractRepositories(db)
|
||||||
|
zone := time.FixedZone("plus2", 2*3600)
|
||||||
|
for _, name := range []string{"first", "second"} {
|
||||||
|
user := contractUser(name, name+"@x.example")
|
||||||
|
user.VerificationExpiration = time.Date(2030, 5, 6, 7, 8, 9, 0, zone)
|
||||||
|
mustCreateUser(t, repos, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
users := repos.users.GetAll()
|
||||||
|
|
||||||
|
if len(users) != 2 {
|
||||||
|
t.Fatalf("expected 2 users, got %d", len(users))
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
if user.VerificationExpiration.Location() != time.UTC {
|
||||||
|
t.Errorf("%s: expected UTC, got %v", user.Username, user.VerificationExpiration.Location())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackupOfIdentityAndBinaryColumns(t *testing.T) {
|
||||||
|
server, _, db := migratedPostgres(t)
|
||||||
|
if err := db.Exec(`CREATE TABLE extras (id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, payload bytea, label text DEFAULT 'x' NOT NULL, amount numeric(10,2))`).Error; err != nil {
|
||||||
|
t.Fatalf("create extras: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(`INSERT INTO extras (payload, amount) VALUES (decode('00ff27', 'hex'), 12.50), (NULL, NULL)`).Error; err != nil {
|
||||||
|
t.Fatalf("insert extras: %v", err)
|
||||||
|
}
|
||||||
|
path, err := database.NewPostgresBackup(db, filepath.Join(t.TempDir(), "backups"), time.Now).Backup()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backup: %v", err)
|
||||||
|
}
|
||||||
|
content, _ := os.ReadFile(path)
|
||||||
|
dump := string(content)
|
||||||
|
|
||||||
|
for _, fragment := range []string{
|
||||||
|
`"id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL`,
|
||||||
|
`"label" text DEFAULT 'x'::text NOT NULL`,
|
||||||
|
`'\x00ff27'`,
|
||||||
|
`12.50`,
|
||||||
|
`NULL`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(dump, fragment) {
|
||||||
|
t.Errorf("the dump must contain %s", fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
restoredConfig := server.createDatabase(t)
|
||||||
|
replayPostgresDump(t, restoredConfig, dump)
|
||||||
|
restored := server.connect(t, restoredConfig)
|
||||||
|
var payload []byte
|
||||||
|
if err := restored.DB.Raw(`SELECT payload FROM extras WHERE id = 1`).Row().Scan(&payload); err != nil || len(payload) != 3 || payload[1] != 0xff {
|
||||||
|
t.Errorf("unexpected restored payload %v err %v", payload, err)
|
||||||
|
}
|
||||||
|
var next int
|
||||||
|
if err := restored.DB.Raw(`INSERT INTO extras (label) VALUES ('y') RETURNING id`).Row().Scan(&next); err != nil || next != 3 {
|
||||||
|
t.Errorf("the identity sequence must continue after the restored rows, got %d err %v", next, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateForPostgresBackup(t *testing.T, repos contractRepositories) {
|
||||||
|
t.Helper()
|
||||||
|
trickyName := "o'brien \"quoted\" back\\slash\nline é😀"
|
||||||
|
mustCreateUser(t, repos, contractUser(trickyName, "tricky@x.example"))
|
||||||
|
second := contractUser("plain", "plain@x.example")
|
||||||
|
second.PasswordResetCode = "reset-code"
|
||||||
|
mustCreateUser(t, repos, second)
|
||||||
|
if _, err := repos.articles.Create(domainArticle.Article{Title: "Title", Body: "line1\nline2 'q' \"d\" \\ end", IsOnline: true}); err != nil {
|
||||||
|
t.Fatalf("create article: %v", err)
|
||||||
|
}
|
||||||
|
mustCreateImage(t, repos, "pic.png", strings.Repeat("c", 64))
|
||||||
|
if _, err := repos.invites.Create(domainInvite.Invite{Email: "i@x.example", HashedCode: "h", ExpiresAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC), InvitedBy: 1}); err != nil {
|
||||||
|
t.Fatalf("create invite: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackup(t *testing.T) {
|
||||||
|
t.Run("Backup before an update", func(t *testing.T) {
|
||||||
|
_, _, db := migratedPostgres(t)
|
||||||
|
populateForPostgresBackup(t, newContractRepositories(db))
|
||||||
|
directory := filepath.Join(t.TempDir(), "backups")
|
||||||
|
backup := database.NewPostgresBackup(db, directory, steppingClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
|
||||||
|
|
||||||
|
var paths []string
|
||||||
|
for range 5 {
|
||||||
|
path, err := backup.Backup()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backup: %v", err)
|
||||||
|
}
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, _ := os.ReadDir(directory)
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Fatalf("only the 3 newest backups are kept, got %d", len(entries))
|
||||||
|
}
|
||||||
|
for _, path := range paths[2:] {
|
||||||
|
if filepath.Ext(path) != ".sql" || !strings.HasPrefix(filepath.Base(path), "renewcms-") {
|
||||||
|
t.Errorf("unexpected backup name %q", path)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Errorf("the newest backup %q must remain: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a failure returns an error and leaves no file", func(t *testing.T) {
|
||||||
|
server := connectPostgresTestServer(t)
|
||||||
|
connection := server.connect(t, server.createDatabase(t))
|
||||||
|
directory := filepath.Join(t.TempDir(), "backups")
|
||||||
|
backup := database.NewPostgresBackup(connection.DB, directory, time.Now)
|
||||||
|
if err := connection.Close(); err != nil {
|
||||||
|
t.Fatalf("close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := backup.Backup()
|
||||||
|
|
||||||
|
if err == nil || path != "" {
|
||||||
|
t.Errorf("expected a failure, got %q %v", path, err)
|
||||||
|
}
|
||||||
|
if entries, _ := os.ReadDir(directory); len(entries) != 0 {
|
||||||
|
t.Errorf("no file may remain, got %d entries", len(entries))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PostgreSQL backup content", func(t *testing.T) {
|
||||||
|
server, _, db := migratedPostgres(t)
|
||||||
|
populateForPostgresBackup(t, newContractRepositories(db))
|
||||||
|
directory := filepath.Join(t.TempDir(), "backups")
|
||||||
|
|
||||||
|
path, err := database.NewPostgresBackup(db, directory, time.Now).Backup()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backup: %v", err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dump := string(content)
|
||||||
|
|
||||||
|
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
|
||||||
|
if !strings.Contains(dump, `CREATE TABLE "`+table+`"`) {
|
||||||
|
t.Errorf("the dump must contain the schema of %s", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(dump, "CREATE UNIQUE INDEX") {
|
||||||
|
t.Errorf("the dump must contain the indexes")
|
||||||
|
}
|
||||||
|
for _, table := range []string{"articles", "images", "users", "invites"} {
|
||||||
|
if !strings.Contains(dump, `INSERT INTO "`+table+`"`) {
|
||||||
|
t.Errorf("the dump must contain rows of %s", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if password := server.config.Password; password != "" && strings.Contains(dump, password) {
|
||||||
|
t.Errorf("the dump must not contain the DB_PASSWORD value")
|
||||||
|
}
|
||||||
|
|
||||||
|
restoredConfig := server.createDatabase(t)
|
||||||
|
replayPostgresDump(t, restoredConfig, dump)
|
||||||
|
restored := server.connect(t, restoredConfig)
|
||||||
|
restoredPath, err := database.NewPostgresBackup(restored.DB, filepath.Join(t.TempDir(), "again"), time.Now).Backup()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backup of the restored database: %v", err)
|
||||||
|
}
|
||||||
|
restoredContent, _ := os.ReadFile(restoredPath)
|
||||||
|
if line := firstDifferingLine(dump, string(restoredContent)); line != "" {
|
||||||
|
t.Errorf("replaying the dump must restore the same schema and rows, first difference: %s", line)
|
||||||
|
}
|
||||||
|
restoredRepos := newContractRepositories(restored.DB)
|
||||||
|
restoredUsers := restoredRepos.users.GetAll()
|
||||||
|
if len(restoredUsers) != 2 || restoredUsers[0].Username != "o'brien \"quoted\" back\\slash\nline é😀" || restoredUsers[1].PasswordResetCode != "reset-code" {
|
||||||
|
t.Errorf("unexpected restored users %+v", restoredUsers)
|
||||||
|
}
|
||||||
|
restoredArticle, _ := restoredRepos.articles.Get(1)
|
||||||
|
if restoredArticle.Body != "line1\nline2 'q' \"d\" \\ end" {
|
||||||
|
t.Errorf("unexpected restored article body %q", restoredArticle.Body)
|
||||||
|
}
|
||||||
|
next := mustCreateUser(t, restoredRepos, contractUser("third", "third@x.example"))
|
||||||
|
if next.ID != 3 {
|
||||||
|
t.Errorf("sequences must be reset so the next insert gets an unused id, got %d", next.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func replayPostgresDump(t *testing.T, config database.PostgresConfig, dump string) {
|
||||||
|
t.Helper()
|
||||||
|
replay, err := sql.Open("pgx", database.PostgresDSN(config))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer replay.Close()
|
||||||
|
if _, err := replay.Exec(dump); err != nil {
|
||||||
|
t.Fatalf("replay: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresUsersAreCaseSensitiveThroughTheDomain(t *testing.T) {
|
||||||
|
t.Run("Bob and bob can both register", func(t *testing.T) {
|
||||||
|
_, _, db := migratedPostgres(t)
|
||||||
|
repos := newContractRepositories(db)
|
||||||
|
mustCreateUser(t, repos, contractUser("Bob", "b1@x.example"))
|
||||||
|
|
||||||
|
other, err := repos.users.Create(contractUser("bob", "b2@x.example"))
|
||||||
|
_, missing := repos.users.GetByUsername("BOB")
|
||||||
|
|
||||||
|
if err != nil || other.Username != "bob" || missing == nil {
|
||||||
|
t.Errorf("created %+v err %v, uppercase lookup err %v", other, err, missing)
|
||||||
|
}
|
||||||
|
if _, err := repos.users.GetByEmail("B1@X.EXAMPLE"); err == nil {
|
||||||
|
t.Error("emails are compared exactly like SQLite does")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user