Updated the ConfigReader+Properties extension in the Website service target to fail to the boot in case of unknown database tokens.

This commit is contained in:
2026-08-30 09:03:50 +02:00
parent 5a833be33d
commit cf58b97de1
5 changed files with 151 additions and 57 deletions
+4 -2
View File
@@ -146,17 +146,19 @@ Empty by default, which leaves the [HTTPS redirect](#https-redirect) off: a depl
### Persistence
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `postgres` (PostgreSQL). |
| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `postgres` (PostgreSQL). Any other value fails the boot. |
| `database.migrate` | `DATABASE_MIGRATE` (flag `--database-migrate`) | `false` | When set, run the migrations and exit instead of serving. |
| `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.tls` | `DATABASE_TLS` | `prefer` | TLS posture when connecting: `off`, `prefer`, or `require`. Any other value fails the boot. 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`. |
> **Unrecognized tokens fail the boot.** Neither `database.driver` nor `database.tls` falls back, because both fallbacks are silent and costly: an unrecognized driver would run on the ephemeral in-memory database and discard every write on restart, and an unrecognized posture would land on `prefer`, which hands the password over in plaintext when the upgrade is stripped. The thrown `ConfigError` names the tokens the key accepts.
> **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.
@@ -16,7 +16,8 @@ import WebsiteLibrary
/// 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).
/// - Throws: a ``ConfigError`` when a `database.*` key holds an unrecognized token, or an error when the persistence service cannot be built
/// (e.g. its TLS context fails to build).
func application(
reader: ConfigReader
) async throws -> some ApplicationProtocol {
@@ -34,8 +35,9 @@ func application(
logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys")
}
let driver = try reader.driver
let persistence = try Service(
driver: reader.driver,
driver: driver,
logger: logger
)
let fluent = persistence()
@@ -68,7 +70,7 @@ func application(
// 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 {
if case .inMemory = driver {
app.beforeServerStarts {
try await fluent.migrate()
}
@@ -91,7 +93,7 @@ func migration(
logLevel: reader.logLevel
)
let service = try Service(
driver: reader.driver,
driver: try reader.driver,
logger: logger
)
@@ -108,48 +108,53 @@ package extension ConfigReader {
///
/// 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.
/// `database.pool.maxPerEventLoop`, and `database.pool.timeout` keys. Any token but `inMemory` and `postgres` throws a
/// ``ConfigError``: a mistyped driver fails the boot rather than running on the in-memory database and discarding every write on restart.
var driver: Driver {
switch string(
forKey: .Database.driver,
default: .Database.driver
) {
case .Database.driverPostgres:
return .postgres(
.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
),
poolTimeout: .seconds(int(
forKey: .Database.poolTimeout,
default: .Database.poolTimeout
))
get throws {
switch string(
forKey: .Database.driver,
default: .Database.driver
) {
case .Database.driverInMemory:
return .inMemory
case .Database.driverPostgres:
return .postgres(
.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: try tls,
maxConnectionsPerEventLoop: int(
forKey: .Database.poolMaxPerEventLoop,
default: .Database.poolMaxPerEventLoop
),
poolTimeout: .seconds(int(
forKey: .Database.poolTimeout,
default: .Database.poolTimeout
))
)
)
)
default:
return .inMemory
case let token:
throw ConfigError.unknownDatabaseDriver(token)
}
}
}
@@ -275,16 +280,45 @@ private extension ConfigReader {
// MARK: Properties
/// 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`.
/// The TLS posture for the PostgreSQL connection, mapped from the `database.tls` key.
///
/// Any token but `off`, `prefer`, and `require` throws a ``ConfigError``: a mistyped posture (`required`, say) fails the boot rather than
/// falling back to `prefer`, which hands the password over in plaintext when the upgrade is stripped.
var tls: TLS {
switch string(
forKey: .Database.tls,
default: .Database.tls
) {
case .Database.tlsOff: .off
case .Database.tlsRequire: .require
default: .prefer
get throws {
switch string(
forKey: .Database.tls,
default: .Database.tls
) {
case .Database.tlsOff: .off
case .Database.tlsPrefer: .prefer
case .Database.tlsRequire: .require
case let token: throw ConfigError.unknownDatabaseTLS(token)
}
}
}
}
// MARK: - ConfigError
/// A configuration value the executable refuses to boot with; ``description`` names the tokens the key accepts.
package enum ConfigError: Error, Equatable, CustomStringConvertible {
/// The `database.driver` key holds an unrecognized token.
case unknownDatabaseDriver(String)
/// The `database.tls` key holds an unrecognized token.
case unknownDatabaseTLS(String)
// MARK: Computed
package var description: String {
switch self {
case .unknownDatabaseDriver(let token):
"Unknown 'database.driver' value '\(token)': use '\(String.Database.driverInMemory)' or '\(String.Database.driverPostgres)'."
case .unknownDatabaseTLS(let token):
"Unknown 'database.tls' value '\(token)': use '\(String.Database.tlsOff)', '\(String.Database.tlsPrefer)', or '\(String.Database.tlsRequire)'."
}
}
@@ -24,7 +24,9 @@ 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"
public static let driver = driverInMemory
/// The driver token selecting the in-memory SQLite backend.
public static let driverInMemory = "inMemory"
/// The driver token selecting the PostgreSQL backend.
public static let driverPostgres = "postgres"
/// The default PostgreSQL host.
@@ -34,9 +36,11 @@ extension String {
/// The default database username.
public static let username = "site"
/// The default TLS posture token.
public static let tls = "prefer"
public static let tls = tlsPrefer
/// The TLS token disabling TLS.
public static let tlsOff = "off"
/// The TLS token upgrading to TLS only when the server offers it.
public static let tlsPrefer = "prefer"
/// The TLS token requiring TLS.
public static let tlsRequire = "require"
}
@@ -99,6 +99,58 @@ struct ConfigReaderPropertiesTests {
#expect(!header.contains("immutable"))
}
@Test
func `driver to default to the in-memory database`() throws {
guard case .inMemory = try reader().driver else {
Issue.record("Expected the in-memory driver when 'database.driver' is unset.")
return
}
}
@Test
func `driver to select postgres when configured`() throws {
guard case .postgres = try reader(values: [.Database.driver: "postgres"]).driver else {
Issue.record("Expected the postgres driver when 'database.driver' selects it.")
return
}
}
@Test
func `driver to throw on an unknown token`() {
// A silent fallback would run the deployment on the ephemeral database and discard every write on restart.
#expect(throws: ConfigError.unknownDatabaseDriver("mariadb")) {
_ = try reader(values: [.Database.driver: "mariadb"]).driver
}
}
@Test(arguments: [
String.Database.tlsOff,
String.Database.tlsPrefer,
String.Database.tlsRequire
])
func `driver to accept a recognized tls token`(token: String) throws {
let reader = reader(values: [
.Database.driver: .init(stringLiteral: .Database.driverPostgres),
.Database.tls: .init(stringLiteral: token)
])
guard case .postgres = try reader.driver else {
Issue.record("Expected the postgres driver for the '\(token)' TLS token.")
return
}
}
@Test
func `driver to throw on an unknown tls token`() {
// A silent fallback to `prefer` hands the password over in plaintext when the upgrade is stripped.
#expect(throws: ConfigError.unknownDatabaseTLS("required")) {
_ = try reader(values: [
.Database.driver: .init(stringLiteral: .Database.driverPostgres),
.Database.tls: "required"
]).driver
}
}
}
// MARK: - Helpers