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
|
### Persistence
|
||||||
| Config key | Environment variable | Default | Description |
|
| 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.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.host` | `DATABASE_HOST` | `localhost` | PostgreSQL host. Ignored for `inMemory`. |
|
||||||
| `database.port` | `DATABASE_PORT` | `5432` | PostgreSQL port. Ignored for `inMemory`. |
|
| `database.port` | `DATABASE_PORT` | `5432` | PostgreSQL port. Ignored for `inMemory`. |
|
||||||
| `database.name` | `DATABASE_NAME` | `site` | Database name. Ignored for `inMemory`. |
|
| `database.name` | `DATABASE_NAME` | `site` | Database name. Ignored for `inMemory`. |
|
||||||
| `database.username` | `DATABASE_USERNAME` | `site` | Database username. 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.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.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`. |
|
| `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.
|
> **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.
|
See [Persistence](#persistence-1) below for the workflow.
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import WebsiteLibrary
|
|||||||
/// band (so a shared database is never migrated on boot).
|
/// band (so a shared database is never migrated on boot).
|
||||||
/// - Parameter reader: the configuration reader the values are read from.
|
/// - Parameter reader: the configuration reader the values are read from.
|
||||||
/// - Returns: the configured application, ready to run as a service.
|
/// - 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(
|
func application(
|
||||||
reader: ConfigReader
|
reader: ConfigReader
|
||||||
) async throws -> some ApplicationProtocol {
|
) async throws -> some ApplicationProtocol {
|
||||||
@@ -34,8 +35,9 @@ func application(
|
|||||||
logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys")
|
logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let driver = try reader.driver
|
||||||
let persistence = try Service(
|
let persistence = try Service(
|
||||||
driver: reader.driver,
|
driver: driver,
|
||||||
logger: logger
|
logger: logger
|
||||||
)
|
)
|
||||||
let fluent = persistence()
|
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
|
// 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.
|
// 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 {
|
app.beforeServerStarts {
|
||||||
try await fluent.migrate()
|
try await fluent.migrate()
|
||||||
}
|
}
|
||||||
@@ -91,7 +93,7 @@ func migration(
|
|||||||
logLevel: reader.logLevel
|
logLevel: reader.logLevel
|
||||||
)
|
)
|
||||||
let service = try Service(
|
let service = try Service(
|
||||||
driver: reader.driver,
|
driver: try reader.driver,
|
||||||
logger: logger
|
logger: logger
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -108,12 +108,16 @@ package extension ConfigReader {
|
|||||||
///
|
///
|
||||||
/// When `database.driver` selects PostgreSQL, the connection parameters are assembled from the `database.host`, `database.port`,
|
/// 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.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 {
|
var driver: Driver {
|
||||||
|
get throws {
|
||||||
switch string(
|
switch string(
|
||||||
forKey: .Database.driver,
|
forKey: .Database.driver,
|
||||||
default: .Database.driver
|
default: .Database.driver
|
||||||
) {
|
) {
|
||||||
|
case .Database.driverInMemory:
|
||||||
|
return .inMemory
|
||||||
case .Database.driverPostgres:
|
case .Database.driverPostgres:
|
||||||
return .postgres(
|
return .postgres(
|
||||||
.init(
|
.init(
|
||||||
@@ -137,7 +141,7 @@ package extension ConfigReader {
|
|||||||
forKey: .Database.password,
|
forKey: .Database.password,
|
||||||
default: ""
|
default: ""
|
||||||
),
|
),
|
||||||
tls: tls,
|
tls: try tls,
|
||||||
maxConnectionsPerEventLoop: int(
|
maxConnectionsPerEventLoop: int(
|
||||||
forKey: .Database.poolMaxPerEventLoop,
|
forKey: .Database.poolMaxPerEventLoop,
|
||||||
default: .Database.poolMaxPerEventLoop
|
default: .Database.poolMaxPerEventLoop
|
||||||
@@ -148,8 +152,9 @@ package extension ConfigReader {
|
|||||||
))
|
))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
default:
|
case let token:
|
||||||
return .inMemory
|
throw ConfigError.unknownDatabaseDriver(token)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,16 +280,45 @@ private extension ConfigReader {
|
|||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The TLS posture for the PostgreSQL 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.
|
||||||
/// other value falls back to `prefer`.
|
///
|
||||||
|
/// 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 {
|
var tls: TLS {
|
||||||
|
get throws {
|
||||||
switch string(
|
switch string(
|
||||||
forKey: .Database.tls,
|
forKey: .Database.tls,
|
||||||
default: .Database.tls
|
default: .Database.tls
|
||||||
) {
|
) {
|
||||||
case .Database.tlsOff: .off
|
case .Database.tlsOff: .off
|
||||||
|
case .Database.tlsPrefer: .prefer
|
||||||
case .Database.tlsRequire: .require
|
case .Database.tlsRequire: .require
|
||||||
default: .prefer
|
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.
|
/// A namespace for the persistence's default configuration values and recognized tokens.
|
||||||
public enum Database {
|
public enum Database {
|
||||||
/// The default persistence driver: in-memory SQLite, which needs no external infrastructure.
|
/// 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.
|
/// The driver token selecting the PostgreSQL backend.
|
||||||
public static let driverPostgres = "postgres"
|
public static let driverPostgres = "postgres"
|
||||||
/// The default PostgreSQL host.
|
/// The default PostgreSQL host.
|
||||||
@@ -34,9 +36,11 @@ extension String {
|
|||||||
/// The default database username.
|
/// The default database username.
|
||||||
public static let username = "site"
|
public static let username = "site"
|
||||||
/// The default TLS posture token.
|
/// The default TLS posture token.
|
||||||
public static let tls = "prefer"
|
public static let tls = tlsPrefer
|
||||||
/// The TLS token disabling TLS.
|
/// The TLS token disabling TLS.
|
||||||
public static let tlsOff = "off"
|
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.
|
/// The TLS token requiring TLS.
|
||||||
public static let tlsRequire = "require"
|
public static let tlsRequire = "require"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,58 @@ struct ConfigReaderPropertiesTests {
|
|||||||
#expect(!header.contains("immutable"))
|
#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
|
// MARK: - Helpers
|
||||||
|
|||||||
Reference in New Issue
Block a user