Database setup for the Website service (#13)
This PR contains the work done to introduce a _Fluent_-based persistence layer for the Website service, selectable at runtime alongside the existing in-memory default, plus the local dev tooling and docs to support it. To provide further details about the work: * Persistence package * The `Driver` and `TLS` enumerations * The `Configuration` type * The `Service` factory that builds the service * `PrepareDB` for migrations registration * The `Probe` for readiness checks. * App integration * Builds the driver, registers migrations, and attaches `Fluent` to the service lifecycle so it starts/stops with the HTTP server. * Migrate-on-boot is gated to the in-memory backend; MySQL/MariaDB is migrated out of band via --database-migrate so shared databases never race on startup. * The `ConfigReader+Properties` extension maps database.* config keys onto the driver. * Library * Added database configuration constants. * The `HealthController` controller gains a readiness probe: `GET /health/ready` checks whether the database is reachable, separate from the existing liveness check. * Others * Updated the `docker-compose` files to support a database service behind a database profile, and hardened for local development * New database targets on the `Makefile` file and overall documentation updated * Updated the `.env.local`, `Dockerfile`, and `README` files to document the persistence workflow, config keys, and local DB commands Reviewed-on: rock-n-code/loud-amsterdam#13 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
@@ -5,24 +5,30 @@ The **Loud** public website service — a [Hummingbird](https://github.com/hummi
|
||||
The service:
|
||||
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached).
|
||||
- Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteCore` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
|
||||
- Answers health checks at `GET /health` with a static JSON payload.
|
||||
- Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable).
|
||||
- Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`.
|
||||
- Returns a custom HTML 404 page, localized like the landing page, for any request that matches neither a route nor a static file.
|
||||
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
|
||||
- Stamps a hardened set of security headers on every response.
|
||||
- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a MySQL/MariaDB server, selected by a single configuration key.
|
||||
|
||||
## Requirements
|
||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||
- Docker (optional) for the containerized run/deploy workflow.
|
||||
- The [Hummingbird](https://github.com/hummingbird-project/hummingbird) CLI (`hb`) — optional, only for `make site-run` (watch and rebuild on change).
|
||||
|
||||
## Architecture
|
||||
Two SwiftPM targets:
|
||||
| Target | Kind | Path | Role |
|
||||
| --- | --- | --- | --- |
|
||||
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds and runs the application. |
|
||||
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
|
||||
| `WebsiteCore` | library | `Sources/Library` | Controllers, middlewares, pages, cached responses, and configuration helpers. |
|
||||
|
||||
`WebsiteCore` also depends on the local `Localization` package (`Packages/Localization`), which provides the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages.
|
||||
The `Website` executable depends on two local packages:
|
||||
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteCore`).
|
||||
- `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver.
|
||||
|
||||
The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination).
|
||||
|
||||
Requests pass through the middleware chain in this order (outermost first), then reach the routes:
|
||||
```
|
||||
@@ -33,7 +39,7 @@ LogRequestsMiddleware
|
||||
→ NotFoundMiddleware (renders the localized 404 page on .notFound)
|
||||
→ FileMiddleware (serves Resources/Static)
|
||||
RootController (GET / → landing page)
|
||||
HealthController (GET /health → health check)
|
||||
HealthController (GET /health → liveness, GET /health/ready → readiness)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -74,6 +80,21 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
|
||||
| --- | --- | --- | --- |
|
||||
| `log.level` | `LOG_LEVEL` | `info` | Minimum log level. |
|
||||
|
||||
### Persistence
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `mysql` (MySQL/MariaDB). |
|
||||
| `database.migrate` | `DATABASE_MIGRATE` (flag `--database-migrate`) | `false` | When set, run the migrations and exit instead of serving. |
|
||||
| `database.host` | `DATABASE_HOST` | `localhost` | MySQL/MariaDB host. Ignored for `inMemory`. |
|
||||
| `database.port` | `DATABASE_PORT` | `3306` | MySQL/MariaDB port. Ignored for `inMemory`. |
|
||||
| `database.name` | `DATABASE_NAME` | `loud` | Database name. Ignored for `inMemory`. |
|
||||
| `database.username` | `DATABASE_USERNAME` | `loud` | Database username. Ignored for `inMemory`. |
|
||||
| `database.password` | `DATABASE_PASSWORD` | _(empty)_ | Database password. Provide via the environment/a secret — never commit it. |
|
||||
| `database.tls` | `DATABASE_TLS` | `prefer` | TLS posture when connecting: `off`, `prefer`, or `require`. Ignored for `inMemory`. |
|
||||
| `database.pool.maxPerEventLoop` | `DATABASE_POOL_MAX_PER_EVENT_LOOP` | `4` | Maximum pooled connections per event loop. Ignored for `inMemory`. |
|
||||
|
||||
See [Persistence](#persistence-1) below for the workflow.
|
||||
|
||||
### Paths
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -101,12 +122,42 @@ swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
|
||||
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
|
||||
```sh
|
||||
make pkg-build # swift build
|
||||
make img-mount # docker compose up --build --detach
|
||||
make img-unmount # docker compose down + remove the local image
|
||||
make site-run # run locally with hot reload (hb watch)
|
||||
make site-mount # docker compose up --build --detach
|
||||
make site-unmount # docker compose down + remove the local image
|
||||
```
|
||||
|
||||
`make help` lists every available target.
|
||||
|
||||
## Persistence
|
||||
The service persists data through Fluent and selects its backend at runtime with `database.driver`.
|
||||
|
||||
### In-memory (default)
|
||||
With no configuration, the service uses an ephemeral in-memory SQLite database. It is created and **migrated on startup** every launch, so `swift run Website` and `docker compose up` work with no external database — ideal for local development and tests.
|
||||
|
||||
### MySQL / MariaDB
|
||||
Set `DATABASE_DRIVER=mysql` and the connection values (`DATABASE_HOST`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, …). Unlike the in-memory backend, a MySQL/MariaDB database is **not** migrated on boot — a shared database is migrated out of band so multiple instances never race:
|
||||
```sh
|
||||
# Run the registered migrations against the configured database, then exit.
|
||||
swift run Website --database-migrate
|
||||
# In a container (production), against the managed database:
|
||||
docker compose -f docker-compose.yml run --rm website --database-migrate
|
||||
```
|
||||
|
||||
A local MariaDB for development lives behind the `database` Compose profile (so a plain `docker compose up` still runs in-memory). Its data directory is bind-mounted to `Tests/DB` (git-ignored), so the database survives `db-unmount` and container restarts:
|
||||
```sh
|
||||
make db-mount # start MariaDB (docker compose --profile database up --wait mariadb)
|
||||
make db-migrate # run migrations against it
|
||||
make db-shell # open a SQL shell on it
|
||||
make db-unmount # stop and remove the container (data kept in Tests/DB)
|
||||
make db-reset # stop and remove the container
|
||||
DATABASE_DRIVER=mysql make site-mount # run the site against MariaDB
|
||||
```
|
||||
> **Note:** because the data lives in the bind-mounted `Tests/DB` folder rather than a named volume, `db-reset`'s `--volumes` flag does **not** clear it. To start from an empty database, delete `Tests/DB` by hand.
|
||||
|
||||
### Health checks
|
||||
`GET /health` is a liveness check (process is up, no dependency check). `GET /health/ready` is a readiness check that runs `SELECT 1` against the database and returns `200` when reachable or `503` otherwise — so an orchestrator restarts on liveness failure but only withholds traffic on readiness failure.
|
||||
|
||||
## Testing
|
||||
```sh
|
||||
make pkg-test
|
||||
@@ -115,8 +166,21 @@ make pkg-test
|
||||
|
||||
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteCoreTests` (the library unit tests).
|
||||
|
||||
The `Persistence` package has its own suite (run it from `Packages/Persistence`). Its tests run against the in-memory backend by default; the MySQL integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`/`NAME`/`USERNAME`/`PASSWORD`), so `swift test` stays runnable with no database:
|
||||
```sh
|
||||
cd ../../Packages/Persistence && swift test # in-memory only
|
||||
# With the local MariaDB up (make db-mount):
|
||||
cd ../../Packages/Persistence && MYSQL_TEST_HOST=127.0.0.1 swift test
|
||||
```
|
||||
|
||||
## Deployment
|
||||
The production image is built for `linux/amd64` in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080` (`ENTRYPOINT ./Website --http-host 0.0.0.0 --http-port 8080`).
|
||||
|
||||
Verify the image builds for its `linux/amd64` target without tagging or publishing:
|
||||
```sh
|
||||
make img-check
|
||||
```
|
||||
|
||||
Build, tag, and push a release to the registry (an explicit version is required):
|
||||
```sh
|
||||
make img-release version=1.2.3
|
||||
@@ -141,3 +205,8 @@ The Makefile and Compose files read these from a `.env` file (or the environment
|
||||
| `LOG_LEVEL` | Runtime log level (default `info`). |
|
||||
| `HTTP_SERVER_NAME` | Runtime server name (default `LoudWebsite`). |
|
||||
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
|
||||
| `DATABASE_DRIVER` | `inMemory` (default) or `mysql`. Set to `mysql` in production to use a managed database. |
|
||||
| `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | MySQL/MariaDB connection (when `DATABASE_DRIVER=mysql`). Provide the password via a secret. |
|
||||
| `DATABASE_TLS` | TLS posture when connecting: `off`, `prefer`, or `require` (default `require` in production). |
|
||||
|
||||
Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`.
|
||||
|
||||
Reference in New Issue
Block a user