From cf58b97de14a36779bdf22b169de407d8f7597d6 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 30 Aug 2026 09:03:50 +0200 Subject: [PATCH] Updated the ConfigReader+Properties extension in the Website service target to fail to the boot in case of unknown database tokens. --- Services/Website/README.md | 6 +- .../Sources/App/Extensions/App+Build.swift | 10 +- .../Extensions/ConfigReader+Properties.swift | 132 +++++++++++------- .../Public/Extensions/String+Constants.swift | 8 +- .../App/ConfigReaderPropertiesTests.swift | 52 +++++++ 5 files changed, 151 insertions(+), 57 deletions(-) diff --git a/Services/Website/README.md b/Services/Website/README.md index 5d21b0a..a5d5378 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -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. diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index 616075a..5659f00 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -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 ) diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift index 767bbc7..338cd5f 100644 --- a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift +++ b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift @@ -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)'." } } diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift index 3eda526..17ab005 100644 --- a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift @@ -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" } diff --git a/Services/Website/Tests/App/ConfigReaderPropertiesTests.swift b/Services/Website/Tests/App/ConfigReaderPropertiesTests.swift index 0f89b72..8f41914 100644 --- a/Services/Website/Tests/App/ConfigReaderPropertiesTests.swift +++ b/Services/Website/Tests/App/ConfigReaderPropertiesTests.swift @@ -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