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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user