Merge branch 'setup' into template

This commit is contained in:
2026-08-13 02:37:43 +02:00
37 changed files with 1249 additions and 474 deletions
+14 -11
View File
@@ -37,25 +37,28 @@ LOG_LEVEL=debug
# --- Persistence ----------------------------------------------------------------
# Persistence driver: inMemory (default, no infrastructure) or mysql.
# Persistence driver: inMemory (default, no infrastructure) or postgres.
DATABASE_DRIVER=inMemory
# MySQL/MariaDB connection, used when DATABASE_DRIVER=mysql.
# `mariadb` is the local database's Compose service name; use 127.0.0.1 when
# running the app directly with `swift run`.
# PostgreSQL connection, used when DATABASE_DRIVER=postgres.
DATABASE_HOST=localhost
# Port of the database to connect to.
DATABASE_PORT=3306
# Name of the database to connect to.
DATABASE_NAME=site
# Username of the database to connect as.
DATABASE_USERNAME=site
# Provide the real password via the environment or a secret — never commit it.
DATABASE_PASSWORD=site
# TLS posture when connecting: off | prefer | require (use `require` in production).
# Maximum pooled connections per event loop, one loop per core — an 8-core host
# can open 8 times this, and every replica that many again.
DATABASE_POOL_MAX_PER_EVENT_LOOP=4
# Port of the database to connect to.
DATABASE_PORT=5432
# TLS posture when connecting: off | prefer | require. Plaintext is the usual
# choice over a private container network; use `require` across one you share.
DATABASE_TLS=off
# Username of the database to connect as.
DATABASE_USERNAME=site
+16 -11
View File
@@ -73,11 +73,11 @@ db-mount: ## Start the local database instance
@docker compose \
--profile database up \
--detach \
--wait mariadb
--wait postgres
.PHONY: db-migrate
db-migrate: ## Run the migrations against the local database instance
@DATABASE_DRIVER=mysql \
@DATABASE_DRIVER=postgres \
DATABASE_HOST=127.0.0.1 \
DATABASE_TLS=off \
swift run \
@@ -88,22 +88,27 @@ db-migrate: ## Run the migrations against the local database instance
db-shell: ## Open a SQL shell on the local database instance
@docker compose \
--profile database \
exec mariadb \
mariadb \
--user=$(or $(DATABASE_USERNAME),site) \
--password=$(or $(DATABASE_PASSWORD),site) \
$(or $(DATABASE_NAME),site)
exec \
--env PGPASSWORD=$(or $(DATABASE_PASSWORD),site) \
postgres \
psql \
--username=$(or $(DATABASE_USERNAME),site) \
--dbname=$(or $(DATABASE_NAME),site)
.PHONY: db-unmount
db-unmount: ## Stop and remove the local database instance (keeps the data volume)
db-unmount: ## Stop and remove the local database instance while keeping its data
@docker compose \
--profile database down mariadb
--profile database down postgres
.PHONY: db-reset
db-reset: ## Stop and remove the local database instance and delete its data volume
db-reset: ## Stop and remove the local database instance, then delete its data
@docker compose \
--profile database down mariadb \
--profile database down postgres \
--volumes
@if [ -d Tests/DB ]; then \
rm -rf Tests/DB; \
mkdir -p Tests/DB; \
fi
# --- Assets minification ------------------------------------------------------
+82 -61
View File
@@ -5,14 +5,15 @@ The **Site** 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 `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, and the JSON-LD structured-data script (see [Page metadata](#page-metadata)).
- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)).
- 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).
- Answers `HEAD` on every `GET` route: the router is built with `.autoGenerateHeadEndpoints`, so uptime monitors and crawlers probing with `HEAD` get the route's status and headers instead of a `404`.
- Serves static files (CSS, JS, icons, manifest, `robots.txt`, `sitemap.xml`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`; the production image ships minified copies (see [Static assets](#static-assets)).
- Returns a custom not-found (404) HTML page, localized like the landing page, for any request that matches neither a route nor a static file.
- Embeds a cookieless [Umami](https://umami.is) tracker on the pages that provide one, configured through the `analytics.*` keys (see [Analytics](#analytics)).
- 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.
- 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 PostgreSQL server, selected by a single configuration key.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
@@ -26,11 +27,11 @@ Two SwiftPM targets:
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
| `WebsiteLibrary` | library | `Sources/Library` | Controllers, the pages (`IndexPage`, `NotFoundPage`) and their shared `Page` defaults, the `StaticFile` asset catalog, the request context, the String Catalog, and the `*+Defaults` extensions and configuration-key constants that supply the site's specifics to `Infrastructure`. |
The `Website` executable depends on four local packages:
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
- `Infrastructure` (`Packages/Infrastructure`) — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the `SocialCard` and `StructuredData` head-metadata types, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata) through the `*+Defaults` extensions in `WebsiteLibrary`.
- `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.
- `Utility` (`Packages/Utility`) — small shared helpers with no server dependencies, currently the `NormalizeEmail` method.
The `Website` executable depends on four local packages, each under `Packages/`:
- `Localization` — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
- `Infrastructure` — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the `SocialCard`/`StructuredData`/`Analytics` head-metadata types, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata, analytics) through the `*+Defaults` extensions in `WebsiteLibrary` and the `ConfigReader` properties in the executable.
- `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.
- `Utility` — small shared helpers with no server dependencies, currently the `NormalizeEmail` method.
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).
@@ -50,21 +51,21 @@ HealthController (GET /health → liveness, GET /health/rea
The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` routes gets a `HEAD` sibling for free.
### Page metadata
Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags.
Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags.
Four of those are optional and **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values):
Five of those are optional and **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values):
| Property | Renders as | Notes |
| --- | --- | --- |
| `summary` | `<meta name="description">` | The page's one-line description. |
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. |
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, share image. Scrapers require absolute URLs, so the page composes them from its own origin. |
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:profiles:)` builds the site-wide `Organization` + `WebSite` pair. The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. |
| `analytics` | `<link rel="preconnect">` + a deferred `<script>` | An `Analytics` tracker — script URL, website identifier, reported domains, and the behavior flags, following the [Umami](https://umami.is) `data-` attribute convention. Unlike the structured data it *is* executable, so the `Content-Security-Policy` must allow its origin; with recorder mode on, a second deferred script follows it. The executable builds one from the `analytics.*` keys (see [Analytics](#analytics)). |
What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extensions`), shared by every page: the document `lang`, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two `theme-color` metas.
## Configuration
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration) from
the following sources, **highest precedence first**:
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration), **highest precedence first**:
1. Command-line arguments (e.g. `--http-host 0.0.0.0`)
2. Process environment variables
3. A `.env.local` file in the working directory (optional)
@@ -72,8 +73,10 @@ the following sources, **highest precedence first**:
5. Built-in defaults
The two files play different roles:
- **`.env`** (git-ignored) holds your deployment values — it is the file the Makefile and Compose read for the `${VAR}` placeholders, and typically selects the MySQL/MariaDB backend.
- **`.env.local`** (tracked) holds the local development values — the in-memory database and `debug` logging, plus the image/deployment placeholders the Makefile falls back to when no `.env` exists. Because it sits *above* `.env`, a direct launch (`swift run` or a debugger) runs against the local values even when `.env` points at a deployment, the same way `docker-compose.override.yml` overrides the base Compose file. Compose itself never reads it, and the production image does not ship it — only the executable, its resources, and the static files are staged into the final stage.
- **`.env`** (git-ignored) holds your deployment values — including the database password — and is the file the Makefile and Compose read for their `${VAR}` placeholders; it typically selects the PostgreSQL backend. Keep it out of version control and off shared machines: Compose passes its values to the container as environment variables, so anything in it is readable through `docker inspect` and by every process in the container.
- **`.env.local`** (tracked) holds the local development overrides: in-memory database, `debug` logging. Sitting *above* `.env`, it keeps a direct launch (`swift run` or a debugger) on the local values even when `.env` points at a deployment. Compose never reads it, and the production image does not ship it.
The Makefile `include`s `.env` and exports every value, so a target launched through `make` runs with the deployment configuration rather than the `.env.local` one: `make site-run` uses the backend `.env` selects, a bare `swift run Website` the in-memory one. And because a makefile assignment outranks an inherited environment variable, overriding a value for a single invocation takes a command-line variable *after* the target (`make site-mount DATABASE_DRIVER=postgres`) — an environment prefix is silently discarded.
### Environment variable naming
A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with `_`. For example `http.serverName``HTTP_SERVER_NAME`,
@@ -111,15 +114,18 @@ The groups are matched in order, so the specific media types (`text/css`, `text/
### Persistence
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `mysql` (MySQL/MariaDB). |
| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `postgres` (PostgreSQL). |
| `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.host` | `DATABASE_HOST` | `localhost` | PostgreSQL host. Ignored for `inMemory`. |
| `database.port` | `DATABASE_PORT` | `5432` | PostgreSQL port. Ignored for `inMemory`. |
| `database.name` | `DATABASE_NAME` | `site` | Database name. Ignored for `inMemory`. |
| `database.username` | `DATABASE_USERNAME` | `site` | 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`. |
| `database.pool.timeout` | `DATABASE_POOL_TIMEOUT` | `10` | Seconds a query waits for a pooled connection before failing. Ignored for `inMemory`. |
> **Connection budget:** the pool holds `database.pool.maxPerEventLoop` connections *per event loop*, and the event loop group runs one loop per core. An 8-core instance can therefore open 32, and each replica that many again — three replicas exhaust PostgreSQL's default `max_connections` of 100. Size this against the server's limit, not against the number alone. On an exhausted pool, a query waits up to `database.pool.timeout` before failing.
See [Persistence](#persistence-1) below for the workflow.
@@ -129,24 +135,39 @@ See [Persistence](#persistence-1) below for the workflow.
| `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. |
### Rate limiting
These keys configure the `RateLimitMiddleware` budget for the upcoming newsletter subscription endpoint. They are read at startup, but the middleware is **not yet attached to any route** — the values have no effect until the subscription endpoint ships.
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window on the subscribe endpoint; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. |
| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable only behind a reverse proxy that sets the header — otherwise clients can forge it; leave it off when the server is directly reachable. |
| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable **only** behind a reverse proxy that sets the header — when the server is directly reachable, clients can forge it. |
### Site
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `site.origin` | `SITE_ORIGIN` | `https://loud.amsterdam` | Public origin the site is served at (scheme and host, no trailing slash). The pages derive their canonical URL and other absolute links (social card image, structured data) from it, so a staging deployment can point it at itself instead of leaking the production origin into its markup. |
### Analytics
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `analytics.websiteID` | `ANALYTICS_WEBSITE_ID` | `f28681d6-20e8-43f3-9c3b-5d6a0f8e0591` | The analytics website identifier the tracker on both pages reports as. **Set it to an empty string to disable analytics entirely** — the tracker script is then omitted from the pages. |
| `analytics.domains` | `ANALYTICS_DOMAINS` | `loud.amsterdam` | Comma-delimited domains the tracker reports from; visits from any other host (development, staging) are ignored. |
| `analytics.recorder` | `ANALYTICS_RECORDER` | `true` | Whether the pages also embed the session recorder script (`recorder.js`, loaded from the tracker's origin) alongside the tracker. Set it to `false` to disable session recording on a deployment. |
The tracker's origin (`https://analytics.rock-n-code.com`) is not configurable: it is single-sourced in code so the tracker tag and the `Content-Security-Policy` that must allow it (`security.contentSecurityPolicy` below) always agree. The pages also emit a `preconnect` hint for it, so the cross-origin handshake starts before the parser reaches the deferred tracker script.
> **Keep `analytics.domains` in sync with `site.origin`.** Both encode the deployment's public host — the hosts the tracker reports from, and the host the pages are served at. Override one without the other (say, pointing a staging deployment at itself) and the domain filter stops matching: every visit is dropped silently, with no error. To disable analytics on a deployment instead, clear `analytics.websiteID` (see above).
### Security headers
| Config key | Environment variable | Default |
| --- | --- | --- |
| `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'` |
| `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; script-src 'self' https://analytics.rock-n-code.com; connect-src 'self' https://analytics.rock-n-code.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'` |
| `security.contentTypeOptions` | `SECURITY_CONTENT_TYPE_OPTIONS` | `nosniff` |
| `security.frameOptions` | `SECURITY_FRAME_OPTIONS` | `DENY` |
| `security.referrerPolicy` | `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` |
| `security.permissionsPolicy` | `SECURITY_PERMISSIONS_POLICY` | `accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()` |
| `security.strictTransportSecurity` | `SECURITY_STRICT_TRANSPORT_SECURITY` | _none (omitted)_ |
`Strict-Transport-Security` has **no default** and is omitted unless explicitly configured: it only takes effect over HTTPS (browsers ignore it on plain HTTP) and is "sticky" in browsers, so it must stay off in local HTTP development. It is enabled for production in `docker-compose.yml`, where it only has an effect once traffic is served over HTTPS behind a TLS-terminating proxy.
`Strict-Transport-Security` has **no default** and is omitted unless explicitly configured: browsers ignore it on plain HTTP but remember it stickily once seen, so it must stay off in local HTTP development. `docker-compose.yml` enables it for production, where it takes effect once traffic is served over HTTPS behind a TLS-terminating proxy.
## Running locally
Directly with Swift:
@@ -155,7 +176,7 @@ swift run Website # binds to Hummingbird's default 127.0.0.1:8080
swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
```
A direct run picks up the local development overrides from `.env.local` (in-memory database, `debug` logging) over whatever `.env` configures. To run against another backend, override per launch — e.g. `DATABASE_DRIVER=mysql swift run Website` — since process environment variables outrank both files.
A direct run picks up the `.env.local` development overrides (in-memory database, `debug` logging) over whatever `.env` configures. To run against another backend, override per launch — `DATABASE_DRIVER=postgres swift run Website` — since process environment variables outrank both files.
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
```sh
@@ -166,8 +187,7 @@ 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.
Unlike a direct `swift run`, these targets inherit the exported `.env` values (see [Configuration](#configuration)), so they run against whichever backend `.env` selects. The one exception is `DATABASE_HOST` under `make site-mount`: the local Compose override pins it to the `postgres` service name, since the `.env` value addresses the database from the host rather than from inside the container. `make help` lists every available target.
## Persistence
The service persists data through Fluent and selects its backend at runtime with `database.driver`.
@@ -175,47 +195,53 @@ The service persists data through Fluent and selects its backend at runtime with
### 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:
### PostgreSQL
Set `DATABASE_DRIVER=postgres` and the connection values (`DATABASE_HOST`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, …). Unlike the in-memory backend, PostgreSQL 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.
# Run the registered migrations, then exit.
swift run Website --database-migrate
# In a container (production), against the managed database:
# The same, in a container 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:
A local PostgreSQL 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) and initialised once from the `DATABASE_NAME`/`DATABASE_USERNAME`/`DATABASE_PASSWORD` values in `.env`:
```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 (keeps the data volume)
make db-reset # stop and remove the container, and delete its data volume
DATABASE_DRIVER=mysql make site-mount # run the site against MariaDB
make db-mount # start PostgreSQL (docker compose --profile database up --wait postgres)
make db-migrate # migrate it, from the host (forced to 127.0.0.1 with TLS off)
make db-shell # open a SQL shell on it (psql)
make db-unmount # stop and remove the container, keeping the data
make db-reset # stop and remove the container, and delete the data
```
> **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.
Then run the site against it, on the host or in its container:
```sh
make site-run # hot reload on the host, via localhost:5432
make site-mount # containerised, via the Compose service name
curl -i localhost:8080/health/ready # 200 once the database is reachable
```
The containerised run needs no `DATABASE_HOST`: `docker-compose.override.yml` pins it to `postgres`, the Compose service name, which is the only address that resolves from inside the network. The `.env` value is the *host machine's* view (`localhost`) and is left to `make site-run`, which does run on the host. The override also `depends_on` the database, so with the `database` profile enabled the website waits for PostgreSQL to pass its healthcheck; without the profile it still starts alone on the in-memory backend.
> **Note:** every other `DATABASE_*` override must be passed *after* the target — `DATABASE_TLS=off make site-mount` is silently discarded (see [Configuration](#configuration)), `make site-mount DATABASE_TLS=off` is not.
> **Note:** `db-reset` deletes `Tests/DB` itself, because Compose's `--volumes` flag cannot clear a bind mount. Use it to start from an empty database — for instance after changing `DATABASE_PASSWORD`, which is only read when the cluster is first initialised.
### 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.
`docker-compose.yml` configures the `website` container healthcheck against `GET /health`, so Compose reports process liveness without coupling container health to database reachability.
`GET /health` is a liveness check (process is up, no dependency check). `GET /health/ready` 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. A hanging database is reported as not ready within the probe's 2-second deadline, so the route itself never stalls. `docker-compose.yml` points the `website` container healthcheck at `/health`, keeping container health decoupled from database reachability.
## Testing
```sh
make pkg-test
# = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
make pkg-test # = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
```
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. `make pkg-test` runs the service package's own two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests).
`Tests/Website.xctestplan` — the plan the `Site.xcodeproj` scheme runs — adds the vendored packages' suites on top of those two: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`. From the command line, each of those is run from its own package directory (`swift test` in `Packages/<Name>`).
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:
The `Persistence` package also has its own suite, run from `Packages/Persistence`. It uses the in-memory backend by default; the PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at a database, so `swift test` stays runnable without one:
```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
cd ../../Packages/Persistence && swift test # in-memory only
cd ../../Packages/Persistence && POSTGRES_TEST_HOST=127.0.0.1 swift test # against make db-mount
```
`POSTGRES_TEST_NAME`/`USERNAME`/`PASSWORD` each default to `site` (and `POSTGRES_TEST_PORT` is optional), so pass the password explicitly when the local container was initialised with a different `DATABASE_PASSWORD`.
## Deployment
The production image is built in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080`. `make img-check` pins the build to `linux/amd64`; `make img-release` builds for `IMAGE_PLATFORM`.
@@ -227,14 +253,9 @@ CMD ["--http-host", "0.0.0.0", "--http-port", "8080"]
```
The split is what makes the migrate-and-exit invocation below work: `docker compose run --rm website --database-migrate` replaces the `CMD` flags without having to override the entrypoint.
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
make img-check # verify it builds for linux/amd64, without tagging or publishing
make img-release version=1.2.3 # build, tag, and push a release (an explicit version is required)
```
Pull and run the prebuilt image in production — the `-f docker-compose.yml` flag is important, as it skips the local-development override:
@@ -253,11 +274,9 @@ The image build optimizes the files under `Resources/Static` in its `assets` sta
The PNG and SVG passes walk the tree (`--recursive`), so images added in a subdirectory are optimized without touching the Dockerfile.
Files keep their names and paths, so the URLs derived from the `StaticFile` enumeration are unaffected. The sources in the repository stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one, which builds the same Dockerfile — serves the optimized copies.
Files keep their names and paths, so the URLs derived from the `StaticFile` enumeration are unaffected. The repository sources stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one — serves the optimized copies. Assets are copied from the `assets` stage *after* the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.
The Dockerfile copies only package manifests and Swift source inputs into the release build stage. Static assets are copied from the separate `assets` stage after the binary is built, so editing a CSS/JS/image file does not invalidate the release binary build cache.
Preview the optimized output locally — requires only Docker and writes to the git-ignored `.build/minified`:
Preview the optimized output locally (Docker only, writes to the git-ignored `.build/minified`):
```sh
make ast-minify
```
@@ -270,7 +289,7 @@ To change the origin later, edit the `Sitemap:` line in `robots.txt` and the `<l
> **Still manual:** `site.webmanifest` ships empty `name` / `short_name` fields; bootstrap does not fill them in.
### Icons
All icons are renditions of the star mark in `icon.svg`, which is the canonical source — there is no external design file to regenerate from.
All icons are renditions of the star mark in `icon.svg`, the canonical source — there is no external design file to regenerate from.
| File | Size | Used by | Dark mode |
| --- | --- | --- | --- |
@@ -283,7 +302,7 @@ The icon and manifest links come from the shared `Page+Defaults` extension, so e
> **Note:** a browser applies the *first* `theme-color` whose media query matches, so the unqualified light value currently wins on both themes. Put the dark, media-qualified meta first in `Page+Defaults` if the dark value should take effect.
The raster icons are committed binaries, regenerated from `icon.svg` on demand via a throwaway container (no local toolchain needed) — e.g. for the 512px rendition:
The raster icons are committed binaries, regenerated from `icon.svg` on demand via a throwaway container (no local toolchain needed) — e.g. the 512px rendition:
```sh
docker run --rm -v "$PWD/Resources/Static:/work" alpine sh -c '
apk add --no-cache imagemagick librsvg oxipng &&
@@ -293,7 +312,7 @@ docker run --rm -v "$PWD/Resources/Static:/work" alpine sh -c '
(`-density` scales the 192px viewBox: `96 × target ÷ 192`. For `favicon.ico`, rasterize a star-only copy of the SVG at 32px and pack it with `icotool -c --raw`.)
### Required variables
The Makefile and Compose files read these from a `.env` file (or the environment). Provide your own values — do **not** commit secrets.
The Makefile and Compose files read these from `.env` (or the environment). Provide your own values — do **not** commit secrets.
| Variable | Used for |
| --- | --- |
| `HOST_CONTAINER` | Container registry host (e.g. `registry.example.com`). |
@@ -305,8 +324,10 @@ 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 `SiteWebsite`). |
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
| `DATABASE_DRIVER` | `inMemory` or `mysql`. The production Compose file defaults it to `mysql`; the local override defaults back to the in-memory backend. |
| `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 `prefer` in production — set `require` when the database enforces TLS, so a stripped connection fails instead of silently downgrading to plaintext). |
| `DATABASE_DRIVER` | `inMemory` or `postgres`. The production Compose file defaults it to `postgres`; the local override defaults back to the in-memory backend. |
| `DATABASE_HOST`, `DATABASE_PASSWORD` | **Mandatory** — the production Compose file carries no default for either, since none can be correct: `localhost` inside the container is the container itself, and a blank password authenticates as nobody. It refuses to start without them rather than booting a website that serves 503s. Both come from `.env`, which is git-ignored — never commit the password. Compose interpolates each file before merging, so both must be set for a local `docker compose up` too, even though the override pins the host. |
| `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME` | The rest of the PostgreSQL connection (when `DATABASE_DRIVER=postgres`); these do default (`5432`, `site`, `site`). |
| `DATABASE_POOL_MAX_PER_EVENT_LOOP` | Pooled connections per event loop (default `4`) — see the [connection budget](#persistence) before scaling out. |
| `DATABASE_TLS` | TLS posture: `off`, `prefer`, or `require`. The production Compose file defaults it to `require`, which refuses a server offering no TLS; the local override defaults it to `off` for the plaintext development container. `prefer` continues in plaintext when the upgrade is stripped, handing over the password — so it is not a safe production posture. |
Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`.
+1 -1
View File
@@ -42,7 +42,7 @@ struct App {
return
}
let app = await application(
let app = try await application(
reader: reader
)
@@ -12,13 +12,14 @@ import WebsiteLibrary
/// Reads the log level, server name, static files location, minimum response size to compress, and security headers from the configuration, then assembles
/// the router, server configuration, and logger. It warns when the localization catalog cannot be read, since pages would serve raw localization keys.
/// It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts
/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated on startup, while a MySQL/MariaDB backend is migrated out of
/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated on startup, while a PostgreSQL backend is migrated out of
/// band (so a shared database is never migrated on boot).
/// - Parameter reader: the configuration reader the values are read from.
/// - Returns: the configured application, ready to run as a service.
/// - Throws: an error when the persistence service cannot be built (e.g. its TLS context fails to build).
func application(
reader: ConfigReader
) async -> some ApplicationProtocol {
) async throws -> some ApplicationProtocol {
let languages = LanguageList()
let logger = logger(
serverName: reader.serverName,
@@ -33,7 +34,7 @@ func application(
logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys")
}
let persistence = Service(
let persistence = try Service(
driver: reader.driver,
logger: logger
)
@@ -63,7 +64,7 @@ func application(
app.addServices(fluent)
// The in-memory backend is recreated on every launch, so it is migrated on startup. The MySQL/MariaDB backend is
// The in-memory backend is recreated on every launch, so it is migrated on startup. The PostgreSQL backend is
// left untouched here: a shared database is migrated out of band to avoid multi-instance races.
if case .inMemory = reader.driver {
app.beforeServerStarts {
@@ -77,7 +78,7 @@ func application(
/// Runs every registered migration against the configured backend, then exits.
///
/// This is the out-of-band migration path selected by the `database.migrate` flag: it builds the same driver the service would run against, applies the
/// migrations, and shuts the database down so a shared MySQL/MariaDB database is migrated by a single deliberate invocation rather than by every
/// migrations, and shuts the database down so a shared PostgreSQL database is migrated by a single deliberate invocation rather than by every
/// booting instance.
/// - Parameter reader: the configuration reader the values are read from.
func migration(
@@ -87,7 +88,7 @@ func migration(
serverName: reader.serverName,
logLevel: reader.logLevel
)
let service = Service(
let service = try Service(
driver: reader.driver,
logger: logger
)
@@ -14,6 +14,43 @@ package extension ConfigReader {
// MARK: Computed
/// The analytics tracker the landing page embeds, built from the `analytics.*` keys, or `nil` when `analytics.websiteID` resolves
/// empty a deployment disables analytics entirely by clearing the identifier.
///
/// The script URL is not configurable: its origin is single-sourced in `String.Analytics`, so the tracker tag and the
/// `Content-Security-Policy` that must allow it derive from one constant and cannot drift apart.
///
/// The `analytics.domains` filter must name the host the pages are served from i.e. the host of ``siteOrigin``. The two keys are
/// independent, so a deployment that overrides `site.origin` without matching `analytics.domains` reports from a host it no longer
/// serves and records nothing; change them together.
///
/// Recorder mode is on by default the pages embed the session recorder script alongside the tracker and the `analytics.recorder`
/// flag turns it off for a deployment. The recorder loads from the same origin as the tracker, so the `Content-Security-Policy` needs
/// no extra allowance.
var analytics: Analytics? {
let websiteID = string(
forKey: .Analytics.websiteID,
default: .Analytics.websiteID
)
guard !websiteID.isEmpty else {
return nil
}
return .init(
scriptURL: .Analytics.scriptURL,
websiteID: websiteID,
domains: string(
forKey: .Analytics.domains,
default: .Analytics.domains
),
recorder: bool(
forKey: .Analytics.recorder,
default: true
)
)
}
/// The `Cache-Control` policy applied to static files, grouped by media type.
///
/// The max-ages are read from the `cache.maxAge.asset`, `cache.maxAge.text`, `cache.maxAge.image`, and
@@ -59,16 +96,16 @@ package extension ConfigReader {
/// The persistence backend the service runs against, derived from the `database.*` keys.
///
/// When `database.driver` selects MySQL, the connection parameters are assembled from the `database.host`, `database.port`,
/// `database.name`, `database.username`, `database.password` (empty when unset), `database.tls`, and
/// `database.pool.maxPerEventLoop` keys. Any other driver value falls back to the in-memory database.
/// When `database.driver` selects PostgreSQL, the connection parameters are assembled from the `database.host`, `database.port`,
/// `database.name`, `database.username`, `database.password` (empty when unset), `database.tls`,
/// `database.pool.maxPerEventLoop`, and `database.pool.timeout` keys. Any other driver value falls back to the in-memory database.
var driver: Driver {
switch string(
forKey: .Database.driver,
default: .Database.driver
) {
case .Database.driverMySQL:
return .mysql(
case .Database.driverPostgres:
return .postgres(
.init(
host: string(
forKey: .Database.host,
@@ -94,7 +131,11 @@ package extension ConfigReader {
maxConnectionsPerEventLoop: int(
forKey: .Database.poolMaxPerEventLoop,
default: .Database.poolMaxPerEventLoop
)
),
poolTimeout: .seconds(int(
forKey: .Database.poolTimeout,
default: .Database.poolTimeout
))
)
)
default:
@@ -199,7 +240,7 @@ private extension ConfigReader {
// MARK: Properties
/// The TLS posture for the MySQL connection, mapped from the `database.tls` key: `off` and `require` map to their postures, and any
/// The TLS posture for the PostgreSQL connection, mapped from the `database.tls` key: `off` and `require` map to their postures, and any
/// other value falls back to `prefer`.
var tls: TLS {
switch string(
@@ -1,6 +1,15 @@
import Configuration
extension AbsoluteConfigKey {
/// A namespace for the analytics configuration keys, as absolute keys.
public enum Analytics {
/// The absolute configuration key for the analytics website identifier.
public static let websiteID: AbsoluteConfigKey = .init(.Analytics.websiteID)
/// The absolute configuration key for the comma-delimited domains the tracker reports from.
public static let domains: AbsoluteConfigKey = .init(.Analytics.domains)
/// The absolute configuration key for recorder mode, loading the session recorder script alongside the tracker.
public static let recorder: AbsoluteConfigKey = .init(.Analytics.recorder)
}
/// A namespace for the static files cache configuration keys, as absolute keys.
public enum Cache {
/// The absolute configuration key for the max-age, in seconds, applied to fingerprinted assets and fonts.
@@ -23,9 +32,9 @@ extension AbsoluteConfigKey {
public static let migrate: AbsoluteConfigKey = .init(.Database.migrate)
/// The absolute configuration key for the persistence driver.
public static let driver: AbsoluteConfigKey = .init(.Database.driver)
/// The absolute configuration key for the MySQL/MariaDB host.
/// The absolute configuration key for the PostgreSQL host.
public static let host: AbsoluteConfigKey = .init(.Database.host)
/// The absolute configuration key for the MySQL/MariaDB port.
/// The absolute configuration key for the PostgreSQL port.
public static let port: AbsoluteConfigKey = .init(.Database.port)
/// The absolute configuration key for the database name.
public static let name: AbsoluteConfigKey = .init(.Database.name)
@@ -37,6 +46,8 @@ extension AbsoluteConfigKey {
public static let tls: AbsoluteConfigKey = .init(.Database.tls)
/// The absolute configuration key for the maximum pooled connections per event loop.
public static let poolMaxPerEventLoop: AbsoluteConfigKey = .init(.Database.poolMaxPerEventLoop)
/// The absolute configuration key for the longest wait, in seconds, for a pooled connection to become available.
public static let poolTimeout: AbsoluteConfigKey = .init(.Database.poolTimeout)
}
/// A namespace for the HTTP server configuration keys, as absolute keys.
public enum HTTP {
@@ -1,6 +1,15 @@
import Configuration
extension ConfigKey {
/// A namespace for the analytics configuration keys.
public enum Analytics {
/// The configuration key for the analytics website identifier (cleared to disable analytics).
public static let websiteID: ConfigKey = "analytics.websiteID"
/// The configuration key for the comma-delimited domains the tracker reports from.
public static let domains: ConfigKey = "analytics.domains"
/// The configuration key for recorder mode, loading the session recorder script alongside the tracker (set to `false` to disable).
public static let recorder: ConfigKey = "analytics.recorder"
}
/// A namespace for the static files cache configuration keys.
public enum Cache {
/// The configuration key for the max-age, in seconds, applied to fingerprinted assets (CSS, JavaScript) and fonts.
@@ -21,11 +30,11 @@ extension ConfigKey {
public enum Database {
/// The configuration key selecting migrate-and-exit mode (run migrations, then exit) instead of serving.
public static let migrate: ConfigKey = "database.migrate"
/// The configuration key for the persistence driver (`inMemory` or `mysql`).
/// The configuration key for the persistence driver (`inMemory` or `postgres`).
public static let driver: ConfigKey = "database.driver"
/// The configuration key for the MySQL/MariaDB host.
/// The configuration key for the PostgreSQL host.
public static let host: ConfigKey = "database.host"
/// The configuration key for the MySQL/MariaDB port.
/// The configuration key for the PostgreSQL port.
public static let port: ConfigKey = "database.port"
/// The configuration key for the database name.
public static let name: ConfigKey = "database.name"
@@ -37,6 +46,8 @@ extension ConfigKey {
public static let tls: ConfigKey = "database.tls"
/// The configuration key for the maximum pooled connections per event loop.
public static let poolMaxPerEventLoop: ConfigKey = "database.pool.maxPerEventLoop"
/// The configuration key for the longest wait, in seconds, for a pooled connection to become available.
public static let poolTimeout: ConfigKey = "database.pool.timeout"
}
/// A namespace for the HTTP server configuration keys.
public enum HTTP {
@@ -17,9 +17,11 @@ extension Int {
}
/// A namespace for the persistence's default configuration values.
public enum Database {
/// The default MySQL/MariaDB port.
public static let port = 3_306
/// The default PostgreSQL port.
public static let port = 5_432
/// The default maximum pooled connections per event loop.
public static let poolMaxPerEventLoop = 4
/// The default longest wait, in seconds, for a pooled connection to become available (the driver's own default).
public static let poolTimeout = 10
}
}
@@ -1,11 +1,28 @@
extension String {
/// A namespace for the analytics default configuration values.
public enum Analytics {
/// The origin the analytics scripts are loaded from and their beacons are sent to (scheme and host, no trailing slash).
///
/// Single-sourced here: both ``scriptURL`` and the session recorder script the pages embed in recorder mode derive from this
/// constant, and the site's `Content-Security-Policy` must allow it.
public static let origin = "https://analytics.rock-n-code.com"
/// The URL the analytics tracker script is loaded from.
public static let scriptURL = "\(origin)/script"
/// The default analytics website identifier the tracker reports as.
public static let websiteID = "f28681d6-20e8-43f3-9c3b-5d6a0f8e0591"
/// The default comma-delimited domains the tracker reports from; visits from any other host are ignored.
///
/// Keep it paired with the host the pages are served at: a deployment that serves from another host without overriding
/// `analytics.domains` to match reports from a host it no longer serves, so analytics silently records nothing.
public static let domains = "loud.amsterdam"
}
/// A namespace for the persistence's default configuration values and recognized tokens.
public enum Database {
/// The default persistence driver: in-memory SQLite, which needs no external infrastructure.
public static let driver = "inMemory"
/// The driver token selecting the MySQL/MariaDB backend.
public static let driverMySQL = "mysql"
/// The default MySQL/MariaDB host.
/// The driver token selecting the PostgreSQL backend.
public static let driverPostgres = "postgres"
/// The default PostgreSQL host.
public static let host = "localhost"
/// The default database name.
public static let name = "site"
+2 -2
View File
@@ -395,8 +395,8 @@ private extension AppTests {
func app(
staticFilesPath: String,
strictTransportSecurity: String? = nil
) async -> some ApplicationProtocol {
await application(
) async throws -> some ApplicationProtocol {
try await application(
reader: reader(
staticFilesPath: staticFilesPath,
strictTransportSecurity: strictTransportSecurity
@@ -0,0 +1,70 @@
import Configuration
import Infrastructure
import Persistence
import Testing
@testable import Website
@testable import WebsiteLibrary
@Suite("ConfigReader properties")
struct ConfigReaderPropertiesTests {
// MARK: Functional tests
@Test
func `analytics to default to the production tracker`() throws {
let analytics = try #require(reader().analytics)
#expect(analytics.scriptURL == .Analytics.scriptURL)
#expect(analytics.websiteID == .Analytics.websiteID)
#expect(analytics.domains == .Analytics.domains)
#expect(analytics.excludeHash)
#expect(analytics.doNotTrack)
#expect(analytics.performance)
#expect(analytics.recorder)
}
@Test
func `analytics to switch recorder mode off when configured`() throws {
let analytics = try #require(reader(values: [
.Analytics.recorder: false
]).analytics)
#expect(!analytics.recorder)
}
@Test
func `analytics to override the website id and domains when configured`() throws {
let analytics = try #require(reader(values: [
.Analytics.websiteID: "custom-website-id",
.Analytics.domains: "staging.loud.amsterdam"
]).analytics)
#expect(analytics.websiteID == "custom-website-id")
#expect(analytics.domains == "staging.loud.amsterdam")
#expect(analytics.scriptURL == .Analytics.scriptURL)
}
@Test
func `analytics to be omitted when the website id is cleared`() {
#expect(reader(values: [.Analytics.websiteID: ""]).analytics == nil)
}
}
// MARK: - Helpers
private extension ConfigReaderPropertiesTests {
// MARK: Methods
/// Builds a configuration reader over the given in-memory values alone.
func reader(
values: [AbsoluteConfigKey: ConfigValue] = [:]
) -> ConfigReader {
ConfigReader(providers: [
InMemoryProvider(values: values)
])
}
}
@@ -35,7 +35,7 @@ struct HealthControllerTests {
@Test
func `serves ready at the readiness path when the database is reachable`() async throws {
let service = Service(
let service = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)
@@ -69,8 +69,8 @@ struct HealthControllerTests {
func `serves unavailable at the readiness path when the database is unreachable`() async throws {
// Port 1 on the loopback interface has nothing listening, so the probe's connection is refused
// immediately instead of timing out.
let service = Service(
driver: .mysql(
let service = try Service(
driver: .postgres(
.init(
host: "127.0.0.1",
port: 1,
@@ -78,7 +78,8 @@ struct HealthControllerTests {
username: "nobody",
password: "nothing",
tls: .off,
maxConnectionsPerEventLoop: 1
maxConnectionsPerEventLoop: 1,
poolTimeout: .seconds(10)
)
),
logger: Logger(label: "test")
+14 -15
View File
@@ -17,37 +17,36 @@ services:
environment:
LOG_LEVEL: debug
DATABASE_DRIVER: ${DATABASE_DRIVER:-inMemory}
DATABASE_HOST: ${DATABASE_HOST:-localhost}
DATABASE_HOST: postgres
DATABASE_TLS: ${DATABASE_TLS:-off}
depends_on:
postgres:
condition: service_healthy
required: false
# Local development database, started only with the `database` profile so a plain `docker compose up` still runs the
# in-memory backend:
#
# docker compose --profile database up mariadb
mariadb:
image: mariadb:11
# docker compose --profile database up postgres
postgres:
image: postgres:18
container_name: ${HOST_OWNER:-site}-db
restart: unless-stopped
profiles:
- database
ports:
- "127.0.0.1:${DATABASE_PORT:-3306}:3306"
- "127.0.0.1:${DATABASE_PORT:-5432}:5432"
environment:
MARIADB_RANDOM_ROOT_PASSWORD: "yes"
MARIADB_DATABASE: ${DATABASE_NAME:-site}
MARIADB_USER: ${DATABASE_USERNAME:-site}
MARIADB_PASSWORD: ${DATABASE_PASSWORD:-site}
MARIADB_AUTO_UPGRADE: "1"
command:
- "--character-set-server=utf8mb4"
- "--collation-server=utf8mb4_unicode_ci"
POSTGRES_DB: ${DATABASE_NAME:-site}
POSTGRES_USER: ${DATABASE_USERNAME:-site}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-site}
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
test: ["CMD-SHELL", "pg_isready --username=$${POSTGRES_USER} --dbname=$${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
volumes:
- ./Tests/DB:/var/lib/mysql
- ./Tests/DB:/var/lib/postgresql
+8 -7
View File
@@ -20,15 +20,16 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-info}
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-SiteWebsite}
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
# Persistence: in-memory by default; set DATABASE_DRIVER=mysql to run against a managed MySQL/MariaDB database.
# Provide the password via the environment or a secret — never commit it.
DATABASE_DRIVER: ${DATABASE_DRIVER:-mysql}
DATABASE_HOST: ${DATABASE_HOST:-localhost}
DATABASE_PORT: ${DATABASE_PORT:-3306}
# Persistence: a managed PostgreSQL database. Provide the password via the environment or a secret — never
# commit it.
DATABASE_DRIVER: ${DATABASE_DRIVER:-postgres}
DATABASE_HOST: ${DATABASE_HOST:?DATABASE_HOST is required}
DATABASE_PORT: ${DATABASE_PORT:-5432}
DATABASE_NAME: ${DATABASE_NAME:-site}
DATABASE_USERNAME: ${DATABASE_USERNAME:-site}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-}
DATABASE_TLS: ${DATABASE_TLS:-prefer}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD is required}
DATABASE_TLS: ${DATABASE_TLS:-require}
DATABASE_POOL_MAX_PER_EVENT_LOOP: ${DATABASE_POOL_MAX_PER_EVENT_LOOP:-4}
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/health"]
interval: 30s