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:
2026-07-11 09:15:58 +00:00
committed by javier
parent 9774454bba
commit dc6b22e648
45 changed files with 1791 additions and 254 deletions
+25
View File
@@ -40,3 +40,28 @@ HTTP_SERVER_NAME=LoudWebsite
# Log verbosity: trace | debug | info | notice | warning | error | critical
LOG_LEVEL=info
# --- Persistence ----------------------------------------------------------------
# Persistence driver: inMemory (default, no infrastructure) or mysql.
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`.
DATABASE_HOST=localhost
# Port of the database to connect to.
DATABASE_PORT=3306
# Name of the database to connect to.
DATABASE_NAME=loud-ams
# Username of the database to connect as.
DATABASE_USERNAME=loud-ams
# Provide the real password via the environment or a secret — never commit it.
DATABASE_PASSWORD=loud-ams
# TLS posture when connecting: off | prefer | require (use `require` in production).
DATABASE_TLS=off
+1
View File
@@ -18,6 +18,7 @@ WORKDIR /build
# not change. The Website package depends on the local Localization package via
# a relative path, so its manifest must be present for resolution to succeed.
COPY ./Packages/Localization/Package.swift ./Packages/Localization/
COPY ./Packages/Persistence/Package.swift ./Packages/Persistence/
COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/
RUN swift package --package-path ./Services/Website resolve
+60 -12
View File
@@ -38,36 +38,84 @@ pkg-clean: ## Remove the Swift build artifacts
@swift package clean
.PHONY: pkg-reset
pkg-reset: ## Resets the complete SPM cache/build folder
pkg-reset: ## Reset the SPM cache and build folder
@swift package reset
.PHONY: pkg-outdated
pkg-outdated: ## Lists the SPM package dependencies that can be updated
pkg-outdated: ## List the SPM dependencies that can be updated
@swift package update --dry-run
.PHONY: pkg-update
pkg-update: ## Updates the SPM package dependencies
pkg-update: ## Update the SPM dependencies
@swift package update
# --- Local development --------------------------------------------------------
.PHONY: img-build
img-build: ## Build the local dev image
@docker compose build
.PHONY: site-run
site-run: ## Run the website locally, rebuilding on source changes
@hb watch
.PHONY: img-mount
img-mount: ## Mount the service locally (build if needed)
@docker compose up --build --detach
.PHONY: site-mount
site-mount: ## Mount the website locally
@docker compose up \
--build \
--detach
.PHONY: img-unmount
img-unmount: ## Unmount and remove the local service
.PHONY: site-unmount
site-unmount: ## Unmount and remove the local website
@docker compose down
@$(MAKE) img-remove
# --- Local database -----------------------------------------------------------
.PHONY: db-mount
db-mount: ## Start the local database instance
@docker compose \
--profile database up \
--detach \
--wait mariadb
.PHONY: db-migrate
db-migrate: ## Run the migrations against the local database instance
@DATABASE_DRIVER=mysql \
DATABASE_HOST=127.0.0.1 \
DATABASE_TLS=off \
swift run \
Website \
--database-migrate
.PHONY: db-shell
db-shell: ## Open a SQL shell on the local database instance
@docker compose \
--profile database \
exec mariadb \
mariadb \
--user=$(or $(DATABASE_USERNAME),loud) \
--password=$(or $(DATABASE_PASSWORD),loud) \
$(or $(DATABASE_NAME),loud)
.PHONY: db-unmount
db-unmount: ## Stop and remove the local database instance (keeps the data volume)
@docker compose \
--profile database down mariadb
.PHONY: db-reset
db-reset: ## Stop and remove the local database instance and delete its data volume
@docker compose \
--profile database down mariadb \
--volumes
# --- Registry deployment ------------------------------------------------------
.PHONY: img-check
img-check: ## Check the production image builds
@docker build \
--platform linux/amd64 \
--file Dockerfile \
../..
.PHONY: img-release
img-release: ## Build the production (amd64) image, tag with version + latest, push both
img-release: ## Build and push the production image into the container registry
@if [ -z "$(version)" ] || [ "$(version)" = "latest" ]; then \
echo "Error: 'version' must be an explicit tag — e.g. make img-release version=1.2.3"; \
exit 1; \
+5
View File
@@ -23,6 +23,9 @@ let package = Package(
.package(
path: "../../Packages/Localization"
),
.package(
path: "../../Packages/Persistence"
),
.package(
url: "https://github.com/elementary-swift/elementary.git",
from: "0.6.0"
@@ -52,6 +55,7 @@ let package = Package(
.executableTarget(
name: "Website",
dependencies: [
.byName(name: "Persistence"),
.byName(name: "WebsiteCore"),
.product(
name: "Configuration",
@@ -72,6 +76,7 @@ let package = Package(
name: "WebsiteCore",
dependencies: [
.byName(name: "Localization"),
.byName(name: "Persistence"),
.product(
name: "Configuration",
package: "swift-configuration"
+75 -6
View File
@@ -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`.
@@ -1,203 +0,0 @@
import Configuration
import Hummingbird
import HummingbirdCompression
import Logging
import WebsiteCore
/// Builds the website application.
///
/// 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.
/// - Parameter reader: the configuration reader the values are read from.
/// - Returns: the configured application, ready to run as a service.
func application(
reader: ConfigReader
) async -> some ApplicationProtocol {
let cacheControl = cacheControl(
textMaxAge: reader.int(
forKey: .Cache.maxAgeText,
default: .Cache.maxAgeText
),
imageMaxAge: reader.int(
forKey: .Cache.maxAgeImage,
default: .Cache.maxAgeImage
),
defaultMaxAge: reader.int(
forKey: .Cache.maxAgeDefault,
default: .Cache.maxAgeDefault
)
)
let compressionMinResponseSize = reader.int(
forKey: .Compression.minResponseSize,
default: .Compression.minResponseSize
)
let logLevel = reader.string(
forKey: .Log.level,
as: Logger.Level.self,
default: .info
)
let serverName = reader.string(
forKey: .HTTP.serverName,
default: .Server.name
)
let staticFilesPath = reader.string(
forKey: .Path.staticFiles,
default: .Path.staticResources
)
let securityHeaders = securityHeaders(
reader: reader
)
return Application(
router: router(
staticFilesPath: staticFilesPath,
cacheControl: cacheControl,
compressionMinResponseSize: compressionMinResponseSize,
securityHeaders: securityHeaders,
logLevel: logLevel
),
configuration: ApplicationConfiguration(
reader: reader.scoped(to: "http")
),
logger: logger(
serverName: serverName,
logLevel: logLevel
)
)
}
// MARK: - Helpers
// Request context used by application
private typealias AppRequestContext = WebsiteRequestContext
/// Builds the cache-control policy applied to the served static files.
///
/// Static files are public and validated by `FileMiddleware` through their `ETag` and
/// `Last-Modified` headers, so each media type is given a `max-age` after which the browser
/// revalidates. Text-based assets (CSS, JavaScript) are additionally marked `must-revalidate`
/// since they change between deployments while keeping their filenames.
/// - Parameters:
/// - textMaxAge: the max-age, in seconds, applied to text-based static files (CSS, JavaScript, plain text).
/// - imageMaxAge: the max-age, in seconds, applied to image static files (ICO, PNG, SVG).
/// - defaultMaxAge: the max-age, in seconds, applied to all other static files (e.g. the web manifest).
/// - Returns: the configured cache-control policy.
private func cacheControl(
textMaxAge: Int,
imageMaxAge: Int,
defaultMaxAge: Int
) -> CacheControl {
.init([
(.text, [.public, .maxAge(textMaxAge), .mustRevalidate]),
(.image, [.public, .maxAge(imageMaxAge)]),
(.init(type: .any), [.public, .maxAge(defaultMaxAge)]),
])
}
/// Builds the security-headers configuration applied to every response.
///
/// Each header value falls back to the hardened default in `String.Security` when the matching
/// configuration key is unset. `Strict-Transport-Security` has no default: it is read as an optional
/// and omitted entirely unless explicitly configured, so it stays off in plain-HTTP development and
/// is enabled only behind TLS in production.
/// - Parameter reader: the configuration reader the header values are read from.
/// - Returns: the configured security-headers configuration.
private func securityHeaders(
reader: ConfigReader
) -> SecurityHeadersMiddleware<AppRequestContext>.Configuration {
.init(
contentSecurityPolicy: reader.string(
forKey: .Security.contentSecurityPolicy,
default: .Security.contentSecurityPolicy
),
contentTypeOptions: reader.string(
forKey: .Security.contentTypeOptions,
default: .Security.contentTypeOptions
),
frameOptions: reader.string(
forKey: .Security.frameOptions,
default: .Security.frameOptions
),
referrerPolicy: reader.string(
forKey: .Security.referrerPolicy,
default: .Security.referrerPolicy
),
permissionsPolicy: reader.string(
forKey: .Security.permissionsPolicy,
default: .Security.permissionsPolicy
),
strictTransportSecurity: reader.string(
forKey: .Security.strictTransportSecurity
)
)
}
/// Builds the application's logger.
/// - Parameters:
/// - serverName: the label applied to the logger.
/// - logLevel: the minimum level the logger emits.
/// - Returns: the configured logger.
private func logger(
serverName: String,
logLevel: Logger.Level
) -> Logger {
var logger = Logger(label: serverName)
logger.logLevel = logLevel
return logger
}
/// Builds the application's router.
///
/// Registers the request-logging middleware, the security-headers middleware that stamps the given
/// `securityHeaders` onto every response, the response-compression middleware that compresses
/// responses larger than `minimumResponseSizeToCompress` when the client advertises support, the
/// localization middleware that negotiates the request's language from its `Accept-Language` header,
/// the not-found middleware that serves the error page, and the static file middleware that serves the
/// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), then
/// adds the `RootController` routes that render the landing page and the `HealthController` routes
/// that serve the health check.
///
/// The security-headers middleware sits just inside request logging so it covers every response that
/// reaches a client the landing page, the compressed responses, the rendered error page, and the
/// served static files.
/// - Parameters:
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
/// - cacheControl: the cache-control directives applied to the served static files.
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
/// - securityHeaders: the security headers applied to every response.
/// - logLevel: the level the request-logging middleware logs at.
/// - Returns: the configured router.
private func router(
staticFilesPath: String,
cacheControl: CacheControl,
compressionMinResponseSize: Int,
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
logLevel: Logger.Level
) -> Router<AppRequestContext> {
let router = Router(context: AppRequestContext.self)
router.addMiddleware {
LogRequestsMiddleware(logLevel)
SecurityHeadersMiddleware(
configuration: securityHeaders
)
ResponseCompressionMiddleware(
minimumResponseSizeToCompress: compressionMinResponseSize
)
LocalizationMiddleware()
NotFoundMiddleware()
FileMiddleware(
staticFilesPath,
cacheControl: cacheControl
)
}
router.addRoutes {
RootController<AppRequestContext>().routes
HealthController<AppRequestContext>().routes
}
return router
}
+21
View File
@@ -2,8 +2,17 @@ import Configuration
import Hummingbird
import Logging
/// The entry point of the website executable.
///
/// Loads the configuration, then either serves the website or when the `database.migrate` flag is set runs the registered migrations against the
/// configured backend and exits.
@main
struct App {
/// Loads the configuration and runs the mode it selects.
///
/// The configuration is read from the providers in precedence order: command-line arguments first, then process environment variables, then a
/// `.env` file when one is present, and finally the in-memory defaults (currently just the server name).
static func main() async throws {
let reader = try await ConfigReader(
providers: [
@@ -19,10 +28,22 @@ struct App {
]
)
// Migrate-and-exit mode runs the registered migrations against the configured backend and returns,
// so a shared database is migrated by a single deliberate invocation (`--database-migrate`) rather
// than by every booting instance.
guard !reader.migrate else {
try await migration(
reader: reader
)
return
}
let app = await application(
reader: reader
)
try await app.runService()
}
}
@@ -0,0 +1,166 @@
import Configuration
import Hummingbird
import HummingbirdCompression
import Logging
import Persistence
import WebsiteCore
/// Builds the website application.
///
/// 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 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
/// 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.
func application(
reader: ConfigReader
) async -> some ApplicationProtocol {
let logger = logger(
serverName: reader.serverName,
logLevel: reader.logLevel
)
let persistence = Service(
driver: reader.driver,
logger: logger
)
let fluent = persistence()
let prepareDB = PrepareDB()
await prepareDB(for: fluent)
var app = Application(
router: router(
staticFilesPath: reader.staticFilesPath,
cacheControl: reader.cacheControl,
compressionMinResponseSize: reader.compressionMinResponseSize,
securityHeaders: reader.securityHeaders,
logLevel: reader.logLevel,
probe: Probe(fluent: fluent)
),
configuration: ApplicationConfiguration(
reader: reader.scoped(to: "http")
),
logger: logger
)
app.addServices(fluent)
// The in-memory backend is recreated on every launch, so it is migrated on startup. The MySQL/MariaDB
// 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 {
try await fluent.migrate()
}
}
return app
}
/// 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
/// booting instance.
/// - Parameter reader: the configuration reader the values are read from.
func migration(
reader: ConfigReader
) async throws {
let logger = logger(
serverName: reader.serverName,
logLevel: reader.logLevel
)
let service = Service(
driver: reader.driver,
logger: logger
)
let fluent = service()
let prepareDB = PrepareDB()
await prepareDB(for: fluent)
do {
try await fluent.migrate()
}
catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
// MARK: - Helpers
/// The request context type the application serves its routes with.
private typealias AppRequestContext = WebsiteRequestContext
/// Builds the application's logger.
/// - Parameters:
/// - serverName: the label applied to the logger.
/// - logLevel: the minimum level the logger emits.
/// - Returns: the configured logger.
private func logger(
serverName: String,
logLevel: Logger.Level
) -> Logger {
var logger = Logger(label: serverName)
logger.logLevel = logLevel
return logger
}
/// Builds the application's router.
///
/// Registers the request-logging middleware, the security-headers middleware that stamps the given `securityHeaders` onto every response, the
/// response-compression middleware that compresses responses larger than `minimumResponseSizeToCompress` when the client advertises support,
/// the localization middleware that negotiates the request's language from its `Accept-Language` header, the not-found middleware that serves the
/// error page, and the static file middleware that serves the contents of `staticFilesPath` (tagging responses with the given `cacheControl`
/// directives), then adds the `RootController` routes that render the landing page and the `HealthController` routes that serve the health check.
///
/// The security-headers middleware sits just inside request logging so it covers every response that reaches a client the landing page, the compressed
/// responses, the rendered error page, and the served static files.
/// - Parameters:
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
/// - cacheControl: the cache-control directives applied to the served static files.
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
/// - securityHeaders: the security headers applied to every response.
/// - logLevel: the level the request-logging middleware logs at.
/// - probe: the probe consulted by the `HealthController` readiness route.
/// - Returns: the configured router.
private func router(
staticFilesPath: String,
cacheControl: CacheControl,
compressionMinResponseSize: Int,
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
logLevel: Logger.Level,
probe: Probe
) -> Router<AppRequestContext> {
let router = Router(context: AppRequestContext.self)
router.addMiddleware {
LogRequestsMiddleware(logLevel)
SecurityHeadersMiddleware(
configuration: securityHeaders
)
ResponseCompressionMiddleware(
minimumResponseSizeToCompress: compressionMinResponseSize
)
LocalizationMiddleware()
NotFoundMiddleware()
FileMiddleware(
staticFilesPath,
cacheControl: cacheControl
)
}
router.addRoutes {
RootController<AppRequestContext>().routes
HealthController<AppRequestContext>(probe: probe).routes
}
return router
}
@@ -0,0 +1,182 @@
import Configuration
import Hummingbird
import Logging
import Persistence
import WebsiteCore
package extension ConfigReader {
// MARK: Type aliases
/// The request context type the application serves its routes with; the security headers configuration is generic over it.
typealias AppRequestContext = WebsiteRequestContext
// MARK: Computed
/// The `Cache-Control` policy applied to static files, grouped by media type.
///
/// The max-ages are read from the `cache.maxAge.text`, `cache.maxAge.image`, and `cache.maxAge.default` keys. Text files (CSS,
/// JavaScript, plain text) additionally require revalidation once stale; images and everything else are served public with their max-age alone.
var cacheControl: CacheControl {
let maxAgeDefault = int(
forKey: .Cache.maxAgeDefault,
default: .Cache.maxAgeDefault
)
let maxAgeImage = int(
forKey: .Cache.maxAgeImage,
default: .Cache.maxAgeImage
)
let maxAgeText = int(
forKey: .Cache.maxAgeText,
default: .Cache.maxAgeText
)
return .init([
(.text, [.public, .maxAge(maxAgeText), .mustRevalidate]),
(.image, [.public, .maxAge(maxAgeImage)]),
(.init(type: .any), [.public, .maxAge(maxAgeDefault)]),
])
}
/// The minimum response body size, in bytes, before a response is compressed read from the `compression.minimumResponseSize` key.
var compressionMinResponseSize: Int {
int(
forKey: .Compression.minResponseSize,
default: .Compression.minResponseSize
)
}
/// 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.
var driver: Driver {
switch string(
forKey: .Database.driver,
default: .Database.driver
) {
case .Database.driverMySQL:
return .mysql(
.init(
host: string(
forKey: .Database.host,
default: .Database.host
),
port: int(
forKey: .Database.port,
default: .Database.port
),
name: string(
forKey: .Database.name,
default: .Database.name
),
username: string(
forKey: .Database.username,
default: .Database.username
),
password: string(
forKey: .Database.password,
default: ""
),
tls: tls,
maxConnectionsPerEventLoop: int(
forKey: .Database.poolMaxPerEventLoop,
default: .Database.poolMaxPerEventLoop
)
)
)
default:
return .inMemory
}
}
/// The minimum log level the application emits at, read from the `log.level` key.
///
/// Falls back to `.info` when the key is unset or its value names no `Logger.Level` case.
var logLevel: Logger.Level {
string(
forKey: .Log.level,
as: Logger.Level.self,
default: .info
)
}
/// Whether the executable runs in migrate-and-exit mode instead of serving, read from the `database.migrate` flag; off by default.
var migrate: Bool {
bool(
forKey: .Database.migrate,
default: false
)
}
/// The security headers middleware configuration, built from the `security.*` keys.
///
/// Every header value has a default except `Strict-Transport-Security`, which is only sent when `security.strictTransportSecurity`
/// is set the header is a commitment browsers cache, so it must be opted into for deployments actually served over HTTPS.
var securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration {
.init(
contentSecurityPolicy: string(
forKey: .Security.contentSecurityPolicy,
default: .Security.contentSecurityPolicy
),
contentTypeOptions: string(
forKey: .Security.contentTypeOptions,
default: .Security.contentTypeOptions
),
frameOptions: string(
forKey: .Security.frameOptions,
default: .Security.frameOptions
),
referrerPolicy: string(
forKey: .Security.referrerPolicy,
default: .Security.referrerPolicy
),
permissionsPolicy: string(
forKey: .Security.permissionsPolicy,
default: .Security.permissionsPolicy
),
strictTransportSecurity: string(
forKey: .Security.strictTransportSecurity
)
)
}
/// The name the server reports in its `Server` response header, read from the `http.serverName` key.
var serverName: String {
string(
forKey: .HTTP.serverName,
default: .Server.name
)
}
/// The directory the static files are served from, read from the `path.staticFiles` key.
var staticFilesPath: String {
string(
forKey: .Path.staticFiles,
default: .Path.staticResources
)
}
}
// MARK: - Helpers
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
/// other value falls back to `prefer`.
var tls: TLS {
switch string(
forKey: .Database.tls,
default: .Database.tls
) {
case .Database.tlsOff: .off
case .Database.tlsRequire: .require
default: .prefer
}
}
}
@@ -1,36 +1,45 @@
import Hummingbird
import NIOCore
import Persistence
/// Serves the website's health-check route.
/// Serves the website's health-check routes.
///
/// The controller exposes its routes as a `RouteCollection` so they can be added to a router
/// (or a sub-group) by the application that composes it:
/// The controller exposes its routes as a `RouteCollection` so they can be added to a router (or a sub-group) by the application that composes it:
///
/// ```swift
/// router.addRoutes(HealthController<AppRequestContext>().routes)
/// router.addRoutes(HealthController<AppRequestContext>(probe: probe).routes)
/// ```
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the
/// context of the router the routes are added to.
/// It always serves a liveness check at `/health`; when a `Probe` is supplied it also serves a readiness check at `/health/ready` that reports
/// whether the service's database is reachable. The two are kept distinct so an orchestrator can restart on liveness failure but only withhold traffic on
/// readiness failure.
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct HealthController<Context: RequestContext>: Sendable {
// MARK: Properties
/// The JSON payload returned for every health check.
private let payload: String
/// The probe consulted for the readiness check, or `nil` when only liveness is served.
private let probe: Probe?
// MARK: Initializers
/// Creates a health controller.
public init() {
self.payload = #"{"status":"ok"}"#
/// - Parameter probe: the probe consulted by the readiness route; when `nil`, only the liveness
/// route is served.
public init(
probe: Probe? = nil
) {
self.probe = probe
}
// MARK: Computed
/// The routes served by the controller.
///
/// Serves a `GET` request for the health path (`/health`) with a static JSON status payload.
/// Serves a `GET` request for the liveness path (`/health`) with a static JSON status payload, and
/// when a `Probe` was supplied a `GET` request for the readiness path (`/health/ready`)
/// that consults the probe.
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
@@ -39,6 +48,13 @@ public struct HealthController<Context: RequestContext>: Sendable {
use: check
)
if probe != nil {
routes.get(
.Health.ready,
use: ready
)
}
return routes
}
@@ -50,10 +66,12 @@ private extension HealthController {
// MARK: Methods
/// Handles a request for the health check.
/// Handles a request for the liveness check.
///
/// Returns a constant JSON body built directly per request the payload is a tiny literal with no
/// rendering step, so there is nothing to pre-render or cache.
/// rendering step, so there is nothing to pre-render or cache. It reports only that the process is up,
/// with no dependency check, so an orchestrator restarts the process only when the process itself is
/// unresponsive.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
@@ -63,8 +81,50 @@ private extension HealthController {
request: Request,
context: some RequestContext
) -> Response {
Response(
json(
status: .ok,
payload: .Payload.live
)
}
/// Handles a request for the readiness check.
///
/// Consults the `Probe` supplied at initialization and reports `200 OK` when the service's
/// database is reachable, or `503 Service Unavailable` otherwise, so a load balancer withholds
/// traffic from an instance that cannot yet serve it without restarting the process.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - Returns: a `200 OK` response when ready, or `503 Service Unavailable` when not.
@Sendable
func ready(
request: Request,
context: some RequestContext
) async -> Response {
guard await probe?() == true else {
return json(
status: .serviceUnavailable,
payload: .Payload.unavailable
)
}
return json(
status: .ok,
payload: .Payload.ready
)
}
/// Builds a JSON response carrying the given status and payload.
/// - Parameters:
/// - status: the HTTP status of the response.
/// - payload: the JSON body of the response.
/// - Returns: the configured JSON response.
func json(
status: HTTPResponse.Status,
payload: String
) -> Response {
Response(
status: status,
headers: [.contentType: "application/json"],
body: .init(byteBuffer: .init(string: payload))
)
@@ -72,12 +132,24 @@ private extension HealthController {
}
// MARK: - Constants
// MARK: - RouterPath+Constants
private extension RouterPath {
/// A namespace for the ``HealthController`` route paths.
enum Health {
/// The path of the health-check endpoint.
/// The path of the liveness endpoint.
static let check: RouterPath = "/health"
/// The path of the readiness endpoint.
static let ready: RouterPath = "/health/ready"
}
}
// MARK: - String+Constants
private extension String {
enum Payload {
static let live = #"{"status":"ok"}"#
static let ready = #"{"status":"ready"}"#
static let unavailable = #"{"status":"unavailable"}"#
}
}
@@ -15,6 +15,27 @@ extension AbsoluteConfigKey {
/// The absolute configuration key for the minimum response body size, in bytes, before compression is applied.
public static let minResponseSize: AbsoluteConfigKey = .init(.Compression.minResponseSize)
}
/// A namespace for the persistence configuration keys, as absolute keys.
public enum Database {
/// The absolute configuration key selecting migrate-and-exit mode.
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.
public static let host: AbsoluteConfigKey = .init(.Database.host)
/// The absolute configuration key for the MySQL/MariaDB 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)
/// The absolute configuration key for the database username.
public static let username: AbsoluteConfigKey = .init(.Database.username)
/// The absolute configuration key for the database password.
public static let password: AbsoluteConfigKey = .init(.Database.password)
/// The absolute configuration key for the TLS posture used when connecting.
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)
}
/// A namespace for the HTTP server configuration keys, as absolute keys.
public enum HTTP {
/// The absolute configuration key for the host the server binds to.
@@ -15,6 +15,27 @@ extension ConfigKey {
/// The configuration key for the minimum response body size, in bytes, before compression is applied.
public static let minResponseSize: ConfigKey = "compression.minimumResponseSize"
}
/// A namespace for the persistence configuration keys.
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`).
public static let driver: ConfigKey = "database.driver"
/// The configuration key for the MySQL/MariaDB host.
public static let host: ConfigKey = "database.host"
/// The configuration key for the MySQL/MariaDB port.
public static let port: ConfigKey = "database.port"
/// The configuration key for the database name.
public static let name: ConfigKey = "database.name"
/// The configuration key for the database username.
public static let username: ConfigKey = "database.username"
/// The configuration key for the database password.
public static let password: ConfigKey = "database.password"
/// The configuration key for the TLS posture used when connecting (`off`, `prefer`, or `require`).
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"
}
/// A namespace for the HTTP server configuration keys.
public enum HTTP {
/// The configuration key for the host the server binds to.
@@ -13,4 +13,11 @@ extension Int {
/// The default minimum response body size, in bytes, before compression is applied (1 KB).
public static let minResponseSize = 1_024
}
/// 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 maximum pooled connections per event loop.
public static let poolMaxPerEventLoop = 4
}
}
@@ -1,4 +1,23 @@
extension String {
/// 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.
public static let host = "localhost"
/// The default database name.
public static let name = "loud"
/// The default database username.
public static let username = "loud"
/// The default TLS posture token.
public static let tls = "prefer"
/// The TLS token disabling TLS.
public static let tlsOff = "off"
/// The TLS token requiring TLS.
public static let tlsRequire = "require"
}
/// A namespace for well-known path string constants.
public enum Path {
/// The directory, relative to the working directory, that the website's static files are served from.
+38
View File
@@ -49,6 +49,44 @@ struct AppTests {
}
}
@Test
func `health check to be served at the health path`() async throws {
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/health",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(response.headers[.contentType] == "application/json")
#expect(body == #"{"status":"ok"}"#)
}
}
}
@Test
func `readiness check to be served at the readiness path`() async throws {
// Live mode runs the application's service group, so the `Fluent` service starts before the
// request and shuts its connection pool down after the router-only mode never would.
try await app(
staticFilesPath: staticFilesPath
).test(.live) { client in
try await client.execute(
uri: "/health/ready",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(response.headers[.contentType] == "application/json")
#expect(body == #"{"status":"ready"}"#)
}
}
}
@Test(arguments: StaticFile.allCases)
func `static files to be served`(
staticFile file: StaticFile
@@ -1,6 +1,8 @@
import Hummingbird
import HummingbirdTesting
import Logging
import NIOCore
import Persistence
import Testing
@testable import WebsiteCore
@@ -8,21 +10,13 @@ import Testing
@Suite("HealthController controller")
struct HealthControllerTests {
// MARK: Constants
private let app: Application = .init(router: {
let router = Router()
router.addRoutes(HealthController<BasicRequestContext>().routes)
return router
}())
// MARK: Functional tests
@Test
func `serves the status payload at the health path`() async throws {
try await app.test(.router) { client in
try await app(
probe: nil
).test(.router) { client in
try await client.execute(
uri: "/health",
method: .get
@@ -36,4 +30,113 @@ struct HealthControllerTests {
}
}
@Test
func `serves ready at the readiness path when the database is reachable`() async throws {
let service = Service(
driver: .inMemory,
logger: Logger(label: "test")
)
let fluent = service()
do {
try await app(
probe: Probe(fluent: fluent)
).test(.router) { client in
try await client.execute(
uri: "/health/ready",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(response.headers[.contentType] == "application/json")
#expect(body == #"{"status":"ready"}"#)
}
}
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
@Test
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(
.init(
host: "127.0.0.1",
port: 1,
name: "unreachable",
username: "nobody",
password: "nothing",
tls: .off,
maxConnectionsPerEventLoop: 1
)
),
logger: Logger(label: "test")
)
let fluent = service()
do {
try await app(
probe: Probe(fluent: fluent)
).test(.router) { client in
try await client.execute(
uri: "/health/ready",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .serviceUnavailable)
#expect(response.headers[.contentType] == "application/json")
#expect(body == #"{"status":"unavailable"}"#)
}
}
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
@Test
func `does not serve the readiness path without a probe`() async throws {
try await app(probe: nil).test(.router) { client in
try await client.execute(
uri: "/health/ready",
method: .get
) { response in
#expect(response.status == .notFound)
}
}
}
}
// MARK: - Helpers
private extension HealthControllerTests {
/// Builds a test application serving the ``HealthController`` routes for the given probe.
/// - Parameter probe: the probe supplied to the controller, or `nil` for liveness only.
/// - Returns: the configured test application.
func app(
probe: Probe?
) -> some ApplicationProtocol {
Application(router: {
let router = Router()
router.addRoutes(HealthController<BasicRequestContext>(probe: probe).routes)
return router
}())
}
}
+35 -2
View File
@@ -12,9 +12,42 @@ services:
image: ${IMAGE_NAME}:${IMAGE_TAG:-latest}
platform: linux/arm64
build:
# The build context is the repo root so the local Localization package
# (referenced via ../../Packages/Localization) is inside the context.
context: ../..
dockerfile: Services/Website/Dockerfile
environment:
LOG_LEVEL: debug
DATABASE_DRIVER: ${DATABASE_DRIVER:-inMemory}
DATABASE_HOST: ${DATABASE_HOST:-localhost}
DATABASE_TLS: ${DATABASE_TLS:-off}
# 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
container_name: ${HOST_OWNER:-loud}-db
restart: unless-stopped
profiles:
- database
ports:
- "127.0.0.1:${DATABASE_PORT:-3306}:3306"
environment:
MARIADB_RANDOM_ROOT_PASSWORD: "yes"
MARIADB_DATABASE: ${DATABASE_NAME:-loud-ams}
MARIADB_USER: ${DATABASE_USERNAME:-loud-ams}
MARIADB_PASSWORD: ${DATABASE_PASSWORD:-loud-ams}
MARIADB_AUTO_UPGRADE: "1"
command:
- "--character-set-server=utf8mb4"
- "--collation-server=utf8mb4_unicode_ci"
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
volumes:
- ./Tests/DB:/var/lib/mysql
+10
View File
@@ -21,3 +21,13 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-info}
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite}
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}
DATABASE_NAME: ${DATABASE_NAME:-loud-ams}
DATABASE_USERNAME: ${DATABASE_USERNAME:-loud-ams}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-}
DATABASE_TLS: ${DATABASE_TLS:-require}