From dc6b22e6482346f6d38b37aeecc5fe747a26a67f Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 11 Jul 2026 09:15:58 +0000 Subject: [PATCH] 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: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/13 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .gitignore | 3 + Packages/Localization/Package.swift | 2 - .../Protocols/CatalogResolving.swift | 0 .../{ => Internal}/Types/StringCatalog.swift | 0 .../{ => Public}/Methods/Localize.swift | 0 .../{ => Public}/Methods/Negotiate.swift | 0 .../{ => Public}/Types/LanguageList.swift | 0 .../{ => Public}/Methods/LocalizeTests.swift | 0 .../{ => Public}/Methods/NegotiateTests.swift | 0 .../Types/LanguageListTests.swift | 0 .../xcschemes/Persistence.xcscheme | 77 +++++++ Packages/Persistence/Package.swift | 67 ++++++ .../Migrations/CreateExampleRecord.swift | 28 +++ .../Internal/Models/ExampleRecord.swift | 45 ++++ .../Repositories/ExampleRepository.swift | 63 ++++++ .../Sources/Public/Enumerations/Driver.swift | 20 ++ .../Sources/Public/Enumerations/TLS.swift | 42 ++++ .../Sources/Public/Methods/PrepareDB.swift | 29 +++ .../Sources/Public/Methods/Probe.swift | 49 +++++ .../Sources/Public/Methods/Service.swift | 78 +++++++ .../Sources/Public/Types/Configuration.swift | 60 ++++++ .../Cases/Public/Enumerations/TLSTests.swift | 28 +++ .../Cases/Public/Methods/ProbeTests.swift | 77 +++++++ .../Cases/Public/Methods/ServiceTests.swift | 165 ++++++++++++++ .../Utils/Fakes/NotSQLConfiguration.swift | 13 ++ .../Tests/Utils/Fakes/NotSQLDatabase.swift | 44 ++++ .../Tests/Utils/Fakes/NotSQLDriver.swift | 13 ++ Services/Website/.env.local | 25 +++ Services/Website/Dockerfile | 1 + Services/Website/Makefile | 72 +++++-- Services/Website/Package.swift | 5 + Services/Website/README.md | 81 ++++++- Services/Website/Sources/App/App+build.swift | 203 ------------------ Services/Website/Sources/App/App.swift | 21 ++ .../Sources/App/Extensions/App+Build.swift | 166 ++++++++++++++ .../Extensions/ConfigReader+Properties.swift | 182 ++++++++++++++++ .../Public/Controllers/HealthController.swift | 108 ++++++++-- .../AbsoluteConfigKey+Constants.swift | 21 ++ .../Extensions/ConfigKey+Constants.swift | 21 ++ .../Public/Extensions/Int+Constants.swift | 7 + .../Public/Extensions/String+Constants.swift | 19 ++ Services/Website/Tests/App/AppTests.swift | 38 ++++ .../Controllers/HealthControllerTests.swift | 125 ++++++++++- Services/Website/docker-compose.override.yml | 37 +++- Services/Website/docker-compose.yml | 10 + 45 files changed, 1791 insertions(+), 254 deletions(-) rename Packages/Localization/Sources/{ => Internal}/Protocols/CatalogResolving.swift (100%) rename Packages/Localization/Sources/{ => Internal}/Types/StringCatalog.swift (100%) rename Packages/Localization/Sources/{ => Public}/Methods/Localize.swift (100%) rename Packages/Localization/Sources/{ => Public}/Methods/Negotiate.swift (100%) rename Packages/Localization/Sources/{ => Public}/Types/LanguageList.swift (100%) rename Packages/Localization/Tests/Cases/{ => Public}/Methods/LocalizeTests.swift (100%) rename Packages/Localization/Tests/Cases/{ => Public}/Methods/NegotiateTests.swift (100%) rename Packages/Localization/Tests/Cases/{ => Public}/Types/LanguageListTests.swift (100%) create mode 100644 Packages/Persistence/.swiftpm/xcode/xcshareddata/xcschemes/Persistence.xcscheme create mode 100644 Packages/Persistence/Package.swift create mode 100644 Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift create mode 100644 Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift create mode 100644 Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift create mode 100644 Packages/Persistence/Sources/Public/Enumerations/Driver.swift create mode 100644 Packages/Persistence/Sources/Public/Enumerations/TLS.swift create mode 100644 Packages/Persistence/Sources/Public/Methods/PrepareDB.swift create mode 100644 Packages/Persistence/Sources/Public/Methods/Probe.swift create mode 100644 Packages/Persistence/Sources/Public/Methods/Service.swift create mode 100644 Packages/Persistence/Sources/Public/Types/Configuration.swift create mode 100644 Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift create mode 100644 Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift create mode 100644 Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift create mode 100644 Packages/Persistence/Tests/Utils/Fakes/NotSQLConfiguration.swift create mode 100644 Packages/Persistence/Tests/Utils/Fakes/NotSQLDatabase.swift create mode 100644 Packages/Persistence/Tests/Utils/Fakes/NotSQLDriver.swift delete mode 100644 Services/Website/Sources/App/App+build.swift create mode 100644 Services/Website/Sources/App/Extensions/App+Build.swift create mode 100644 Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift diff --git a/.gitignore b/.gitignore index e33ea89..6ca7d7a 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,9 @@ playground.xcworkspace .env !.env.local +## Local MariaDB data files +Services/Website/Tests/DB/ + # Fastlane fastlane/report.xml fastlane/Preview.html diff --git a/Packages/Localization/Package.swift b/Packages/Localization/Package.swift index 99625f3..8778c92 100644 --- a/Packages/Localization/Package.swift +++ b/Packages/Localization/Package.swift @@ -7,8 +7,6 @@ let package = Package( defaultLocalization: "en", platforms: [ .macOS(.v15), - .iOS(.v18), - .tvOS(.v18), ], products: [ .library( diff --git a/Packages/Localization/Sources/Protocols/CatalogResolving.swift b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift similarity index 100% rename from Packages/Localization/Sources/Protocols/CatalogResolving.swift rename to Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift diff --git a/Packages/Localization/Sources/Types/StringCatalog.swift b/Packages/Localization/Sources/Internal/Types/StringCatalog.swift similarity index 100% rename from Packages/Localization/Sources/Types/StringCatalog.swift rename to Packages/Localization/Sources/Internal/Types/StringCatalog.swift diff --git a/Packages/Localization/Sources/Methods/Localize.swift b/Packages/Localization/Sources/Public/Methods/Localize.swift similarity index 100% rename from Packages/Localization/Sources/Methods/Localize.swift rename to Packages/Localization/Sources/Public/Methods/Localize.swift diff --git a/Packages/Localization/Sources/Methods/Negotiate.swift b/Packages/Localization/Sources/Public/Methods/Negotiate.swift similarity index 100% rename from Packages/Localization/Sources/Methods/Negotiate.swift rename to Packages/Localization/Sources/Public/Methods/Negotiate.swift diff --git a/Packages/Localization/Sources/Types/LanguageList.swift b/Packages/Localization/Sources/Public/Types/LanguageList.swift similarity index 100% rename from Packages/Localization/Sources/Types/LanguageList.swift rename to Packages/Localization/Sources/Public/Types/LanguageList.swift diff --git a/Packages/Localization/Tests/Cases/Methods/LocalizeTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift similarity index 100% rename from Packages/Localization/Tests/Cases/Methods/LocalizeTests.swift rename to Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift diff --git a/Packages/Localization/Tests/Cases/Methods/NegotiateTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift similarity index 100% rename from Packages/Localization/Tests/Cases/Methods/NegotiateTests.swift rename to Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift diff --git a/Packages/Localization/Tests/Cases/Types/LanguageListTests.swift b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift similarity index 100% rename from Packages/Localization/Tests/Cases/Types/LanguageListTests.swift rename to Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift diff --git a/Packages/Persistence/.swiftpm/xcode/xcshareddata/xcschemes/Persistence.xcscheme b/Packages/Persistence/.swiftpm/xcode/xcshareddata/xcschemes/Persistence.xcscheme new file mode 100644 index 0000000..1bd174b --- /dev/null +++ b/Packages/Persistence/.swiftpm/xcode/xcshareddata/xcschemes/Persistence.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/Persistence/Package.swift b/Packages/Persistence/Package.swift new file mode 100644 index 0000000..ca63107 --- /dev/null +++ b/Packages/Persistence/Package.swift @@ -0,0 +1,67 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +let package = Package( + name: "Persistence", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "Persistence", + targets: [ + "Persistence" + ] + ) + ], + dependencies: [ + .package( + url: "https://github.com/hummingbird-project/hummingbird-fluent.git", + from: "2.0.0" + ), + .package( + url: "https://github.com/vapor/fluent-mysql-driver.git", + from: "4.8.0" + ), + .package( + url: "https://github.com/vapor/fluent-sqlite-driver.git", + from: "4.9.0" + ), + .package( + url: "https://github.com/vapor/sql-kit.git", + from: "3.36.0" + ), + ], + targets: [ + .target( + name: "Persistence", + dependencies: [ + .product( + name: "HummingbirdFluent", + package: "hummingbird-fluent" + ), + .product( + name: "FluentMySQLDriver", + package: "fluent-mysql-driver" + ), + .product( + name: "FluentSQLiteDriver", + package: "fluent-sqlite-driver" + ), + .product( + name: "SQLKit", + package: "sql-kit" + ), + ], + path: "Sources" + ), + .testTarget( + name: "PersistenceTests", + dependencies: [ + .byName(name: "Persistence") + ], + path: "Tests" + ), + ] +) diff --git a/Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift b/Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift new file mode 100644 index 0000000..ef06d99 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift @@ -0,0 +1,28 @@ +import FluentKit + +/// Creates and drops the `example_records` table backing ``ExampleRecord``. +/// +/// Reference scaffolding paired with ``ExampleRecord``; replace it with the first real migration once a +/// domain model is defined. Migrations are append-only in production — add a new migration to alter the +/// schema rather than editing one that has already run. +struct CreateExampleRecord: AsyncMigration { + + // MARK: Methods + + /// Creates the `example_records` table with an `id` primary key and a required `name` column. + /// - Parameter database: the database the schema change is applied to. + func prepare(on database: Database) async throws { + try await database.schema(ExampleRecord.schema) + .id() + .field("name", .string, .required) + .create() + } + + /// Drops the `example_records` table, reverting ``prepare(on:)``. + /// - Parameter database: the database the schema change is applied to. + func revert(on database: Database) async throws { + try await database.schema(ExampleRecord.schema) + .delete() + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift b/Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift new file mode 100644 index 0000000..350a8b1 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift @@ -0,0 +1,45 @@ +import FluentKit +import Foundation + +/// A FluentKit model of a single `example_records` row. +/// +/// This is reference scaffolding: it demonstrates the model → migration → repository pattern the rest of +/// the package is built around, and is what the tests exercise. Replace it with the first real domain model +/// (paired with its own migration and repository) once one is defined. +/// +/// FluentKit models are mutable reference types whose property wrappers are not `Sendable`; the model never +/// crosses a concurrency boundary (repositories map it to a `Sendable` snapshot before returning), so the +/// conformance is declared `@unchecked Sendable`. +final class ExampleRecord: Model, @unchecked Sendable { + + // MARK: Properties + + /// The name of the backing table. + static let schema = "example_records" + + /// The row's primary key, assigned on first save. + @ID(key: .id) + var id: UUID? + + /// The row's name column. + @Field(key: "name") + var name: String + + // MARK: Initializers + + /// Creates an empty record, as required by FluentKit to hydrate query results. + init() {} + + /// Creates a record with the given values. + /// - Parameters: + /// - id: the primary key, or `nil` to have one assigned on save. + /// - name: the value of the name column. + init( + id: UUID? = nil, + name: String + ) { + self.id = id + self.name = name + } + +} diff --git a/Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift b/Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift new file mode 100644 index 0000000..081dea1 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift @@ -0,0 +1,63 @@ +import FluentKit +import Foundation +import HummingbirdFluent + +/// A `Sendable` snapshot of an ``ExampleRecord``, safe to return across concurrency boundaries. +/// +/// Repositories return these value-type snapshots rather than FluentKit models, which are mutable reference +/// types that must not escape the database's execution context. +public struct Example: Sendable, Equatable { + + // MARK: Properties + + /// The record's primary key, or `nil` if it has never been saved. + public let id: UUID? + /// The record's name. + public let name: String + +} + +/// Reads and writes ``ExampleRecord`` rows through the default database. +/// +/// This is the shape every real repository takes: it holds the `Sendable` `Fluent` service, resolves the +/// default database per call, and maps FluentKit models to `Sendable` snapshots before returning — so no +/// model ever escapes across an async boundary. It is reference scaffolding paired with ``ExampleRecord``; +/// replace it with the first real repository once a domain model is defined. +public struct ExampleRepository: Sendable { + + // MARK: Properties + + /// The service providing the default database the repository reads and writes through. + private let fluent: Fluent + + // MARK: Initializers + + /// Creates a repository backed by the given `Fluent` service. + /// - Parameter fluent: the service whose default database the repository operates on. + public init(fluent: Fluent) { + self.fluent = fluent + } + + // MARK: Methods + + /// Inserts a record with the given name. + /// - Parameter name: the name of the record to insert. + /// - Returns: a `Sendable` snapshot of the inserted record, including its assigned identifier. + public func create(name: String) async throws -> Example { + let record = ExampleRecord(name: name) + + try await record.save(on: fluent.db()) + + return Example(id: record.id, name: record.name) + } + + /// Fetches every record, ordered by name. + /// - Returns: a `Sendable` snapshot of each record, sorted by name. + public func all() async throws -> [Example] { + try await ExampleRecord.query(on: fluent.db()) + .sort(\.$name) + .all() + .map { Example(id: $0.id, name: $0.name) } + } + +} diff --git a/Packages/Persistence/Sources/Public/Enumerations/Driver.swift b/Packages/Persistence/Sources/Public/Enumerations/Driver.swift new file mode 100644 index 0000000..c52b9ef --- /dev/null +++ b/Packages/Persistence/Sources/Public/Enumerations/Driver.swift @@ -0,0 +1,20 @@ +/// The persistence backend the service runs against. +/// +/// The executable picks a driver at startup and hands it to ``Service``, which registers the +/// matching database as the default one. Repositories resolve that default and stay agnostic +/// of which backend is in use. +public enum Driver: Sendable { + + /// A MySQL/MariaDB server, reached with the given connection parameters. + /// + /// - Parameter configuration: the host, credentials, TLS posture, and pooling limits the + /// connection is opened with. + case mysql(Configuration) + + /// An ephemeral, in-process SQLite database held entirely in memory. + /// + /// Nothing is written to disk, and all data is lost when the service stops — intended for + /// local development and tests. + case inMemory + +} diff --git a/Packages/Persistence/Sources/Public/Enumerations/TLS.swift b/Packages/Persistence/Sources/Public/Enumerations/TLS.swift new file mode 100644 index 0000000..1efaa16 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Enumerations/TLS.swift @@ -0,0 +1,42 @@ +import NIOSSL + +/// The TLS posture used when connecting to the database. +/// +/// The executable derives a posture from its `database.tls` configuration and passes it along as +/// part of ``Configuration``; the MySQL driver receives the resulting `TLSConfiguration` through +/// ``tlsConfiguration``. +public enum TLS: Sendable { + + /// Connect without TLS, in plaintext. + case off + + /// Connect over TLS when the server offers it, falling back to plaintext otherwise. + case prefer + + /// Connect only over TLS, refusing the connection when the server offers none. + case require + +} + +// MARK: - Properties + +extension TLS { + + /// The NIO TLS configuration passed to the MySQL driver for this posture. + /// + /// Returns `nil` for ``off`` (connect in plaintext) and the default client configuration for + /// ``prefer`` and ``require``. + /// + /// - Note: `prefer` and `require` currently map to the same client configuration — both enable TLS. + /// The distinction (fall back to plaintext vs. fail when the server offers no TLS) is not yet + /// enforced here; tighten this mapping if that guarantee becomes required. + var tlsConfiguration: TLSConfiguration? { + switch self { + case .off: + return nil + case .prefer, .require: + return .makeClientConfiguration() + } + } + +} diff --git a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift new file mode 100644 index 0000000..43283b3 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift @@ -0,0 +1,29 @@ +import HummingbirdFluent + +/// A registrar declaring every migration against a `Fluent` service. +/// +/// Built once around the application's `Fluent` service and called as a function — `await migrate()` — +/// during startup, before the migrations are applied. +public struct PrepareDB { + + // MARK: Initializers + + /// Creates a registrar for the migrations for a `Fluent` service. + public init() {} + + // MARK: Methods + + /// Registers every migration against the `Fluent` service, in order. + /// + /// This is the single place migrations are declared: add each new migration here, in the order it must + /// run (migrations are applied in registration order and are append-only). Registering does not apply + /// them — the caller runs `fluent.migrate()` (or the executable's migrate-and-exit mode) to do that. + public func callAsFunction( + for fluent: Fluent + ) async { + await fluent.migrations.add([ + CreateExampleRecord() + ]) + } + +} diff --git a/Packages/Persistence/Sources/Public/Methods/Probe.swift b/Packages/Persistence/Sources/Public/Methods/Probe.swift new file mode 100644 index 0000000..cdbda36 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Methods/Probe.swift @@ -0,0 +1,49 @@ +import HummingbirdFluent +import SQLKit + +/// A readiness probe reporting whether the database behind a `Fluent` service is reachable. +/// +/// Built once around the application's `Fluent` service and called as a function whenever a fresh +/// answer is needed — typically from a readiness endpoint: `let ready = await probe()`. +public struct Probe: Sendable { + + // MARK: Properties + + /// The `Fluent` service whose default database is probed. + private let fluent: Fluent + + // MARK: Initializers + + /// Creates a probe for the default database of the given `Fluent` service. + /// - Parameter fluent: the `Fluent` service whose default database is probed. + public init( + fluent: Fluent + ) { + self.fluent = fluent + } + + // MARK: Methods + + /// Reports whether the database behind the `Fluent` service is reachable. + /// + /// Runs a trivial `SELECT 1` against the default database — the cheapest statement both the MySQL/MariaDB + /// and SQLite backends understand — so a readiness check does not depend on any particular schema or model. + /// Any failure (connection refused, authentication error, pool exhausted) is reported as not reachable + /// rather than thrown, so callers can map it straight onto a readiness response. A default database that + /// is not an SQL database is likewise reported as not reachable. + /// - Returns: `true` when the database answers the probe, `false` otherwise. + public func callAsFunction() async -> Bool { + guard let database = fluent.db() as? any SQLDatabase else { + return false + } + + do { + try await database.raw("SELECT 1").run() + + return true + } catch { + return false + } + + } +} diff --git a/Packages/Persistence/Sources/Public/Methods/Service.swift b/Packages/Persistence/Sources/Public/Methods/Service.swift new file mode 100644 index 0000000..033adcf --- /dev/null +++ b/Packages/Persistence/Sources/Public/Methods/Service.swift @@ -0,0 +1,78 @@ +import FluentMySQLDriver +import FluentSQLiteDriver +import HummingbirdFluent +import Logging + +/// A factory building the `Fluent` service the application persists through. +/// +/// Built once around the driver the executable picks at startup and called as a function to produce +/// the configured service: `let fluent = service()`. +public struct Service: Sendable { + + // MARK: Properties + + /// The persistence backend to register. + private let driver: Driver + + /// The logger the database emits through. + private let logger: Logger + + // MARK: Initializers + + /// Creates a factory for a `Fluent` service backed by the given driver. + /// - Parameters: + /// - driver: the persistence backend to register. + /// - logger: the logger the database emits through. + public init( + driver: Driver, + logger: Logger + ) { + self.driver = driver + self.logger = logger + } + + // MARK: Methods + + /// Builds a `Fluent` service configured for the driver. + /// + /// The selected backend is registered as the *default* database, so repositories resolve it with a plain + /// `fluent.db()` and stay agnostic of which driver is in use. The returned service is not yet running; add + /// it to the application's service group (`app.addServices(_:)`) so it starts and shuts its connection pool + /// down alongside the server. + /// - Returns: the configured `Fluent` service, ready to be added to the service group. + public func callAsFunction() -> Fluent { + let fluent = Fluent( + logger: logger + ) + + switch driver { + case .mysql(let configuration): + fluent.databases.use( + .mysql( + configuration: .init( + hostname: configuration.host, + port: configuration.port, + username: configuration.username, + password: configuration.password, + database: configuration.name, + tlsConfiguration: configuration.tls.tlsConfiguration + ), + maxConnectionsPerEventLoop: configuration.maxConnectionsPerEventLoop + ), + as: .mysql, + isDefault: true + ) + case .inMemory: + // A single connection keeps every query pointed at the same in-memory store, + // rather than each pooled connection getting its own private database. + fluent.databases.use( + .sqlite(.memory, maxConnectionsPerEventLoop: 1), + as: .sqlite, + isDefault: true + ) + } + + return fluent + } + +} diff --git a/Packages/Persistence/Sources/Public/Types/Configuration.swift b/Packages/Persistence/Sources/Public/Types/Configuration.swift new file mode 100644 index 0000000..a60408e --- /dev/null +++ b/Packages/Persistence/Sources/Public/Types/Configuration.swift @@ -0,0 +1,60 @@ +/// The connection parameters for the MySQL/MariaDB backend. +/// +/// The executable builds this from its `database.*` configuration; the package itself reads no +/// configuration, so these values arrive as plain data. +public struct Configuration: Sendable { + + // MARK: Properties + + /// The host the database server is reached at. + let host: String + + /// The maximum number of pooled connections opened per event loop. + let maxConnectionsPerEventLoop: Int + + /// The name of the database to open. + let name: String + + /// The password the connection authenticates with. + let password: String + + /// The port the database server listens on. + let port: Int + + /// The TLS posture used when connecting. + let tls: TLS + + /// The username the connection authenticates as. + let username: String + + // MARK: Initializers + + /// Creates a set of MySQL/MariaDB connection parameters. + /// - Parameters: + /// - host: the host the database server is reached at. + /// - port: the port the database server listens on. + /// - name: the name of the database to open. + /// - username: the username the connection authenticates as. + /// - password: the password the connection authenticates with. + /// - tls: the TLS posture used when connecting. + /// - maxConnectionsPerEventLoop: the maximum number of pooled connections opened per event loop. + public init( + host: String, + port: Int, + name: String, + username: String, + password: String, + tls: TLS, + maxConnectionsPerEventLoop: Int + ) { + self.host = host + self.port = port + self.name = name + self.username = username + self.password = password + self.tls = tls + self.maxConnectionsPerEventLoop = maxConnectionsPerEventLoop + } + +} + diff --git a/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift new file mode 100644 index 0000000..aa12a00 --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift @@ -0,0 +1,28 @@ +import NIOSSL +import Testing + +@testable import Persistence + +@Suite("TLS enumeration") +struct TLSTests { + + // MARK: Properties tests + + @Test + func `off has no TLS configuration`() { + #expect(TLS.off.tlsConfiguration == nil) + } + + @Test(arguments: [ + TLS.prefer, + TLS.require + ]) + func `maps to the default client configuration`( + for tls: TLS + ) throws { + let configuration = try #require(tls.tlsConfiguration) + + #expect(configuration.bestEffortEquals(.makeClientConfiguration())) + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift new file mode 100644 index 0000000..92ff75e --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift @@ -0,0 +1,77 @@ +import FluentKit +import HummingbirdFluent +import Logging +import NIOCore +import Testing + +@testable import Persistence + +@Suite("Probe method") +struct ProbeTests { + + // MARK: Methods tests + + @Test + func `reports a reachable database`() async throws { + let service = Service( + driver: .inMemory, + logger: Logger(label: "test") + ) + let fluent = service() + let probe = Probe(fluent: fluent) + + let isReachable = await probe() + + try await fluent.shutdown() + + #expect(isReachable) + } + + @Test + func `reports an unreachable database`() async throws { + // Port 1 on the loopback interface has nothing listening, so the 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() + let probe = Probe(fluent: fluent) + + let isReachable = await probe() + + try await fluent.shutdown() + + #expect(!isReachable) + } + + @Test + func `reports a default database that is not an SQL database`() async throws { + let fluent = Fluent(logger: Logger(label: "test")) + + fluent.databases.use( + .init(make: { NotSQLConfiguration() }), + as: .init(string: "not-sql"), + isDefault: true + ) + + let probe = Probe(fluent: fluent) + + let isReachable = await probe() + + try await fluent.shutdown() + + #expect(!isReachable) + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift new file mode 100644 index 0000000..8066f4d --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift @@ -0,0 +1,165 @@ +import Foundation +import Logging +import SQLKit +import Testing + +@testable import Persistence + +@Suite("Service method") +struct ServiceTests { + + // MARK: Methods tests + + @Test + func `registers an SQLite database as the default for the in-memory driver`() async throws { + let service = Service( + driver: .inMemory, + logger: Logger(label: "test") + ) + + let fluent = service() + let database = fluent.db() as? any SQLDatabase + + try await fluent.shutdown() + + let dialect = try #require(database?.dialect) + + #expect(dialect.name == "sqlite") + } + + @Test + func `registers a MySQL database as the default for the mysql driver`() async throws { + // Resolving the default database opens no connection — pooling is lazy — so no server + // needs to be listening on the configured host and port. + let service = Service( + driver: .mysql( + .init( + host: "127.0.0.1", + port: 3306, + name: "loud", + username: "loud", + password: "loud", + tls: .off, + maxConnectionsPerEventLoop: 1 + ) + ), + logger: Logger(label: "test") + ) + + let fluent = service() + let database = fluent.db() as? any SQLDatabase + + try await fluent.shutdown() + + let dialect = try #require(database?.dialect) + + #expect(dialect.name == "mysql") + } + + @Test + func `builds a usable in-memory database`() async throws { + let service = Service( + driver: .inMemory, + logger: Logger(label: "test") + ) + + let fluent = service() + + do { + let database = try #require(fluent.db() as? any SQLDatabase) + + try await database.raw("SELECT 1").run() + } catch { + try? await fluent.shutdown() + + throw error + } + + try await fluent.shutdown() + } + + @Test("in-memory: migrate, insert, read back") + func inMemoryRoundTrip() async throws { + try await roundTrip(driver: .inMemory) + } + + @Test( + "mysql: migrate, insert, read back", + .enabled(if: mysqlDriver != nil) + ) + func mysqlRoundTrip() async throws { + try await roundTrip( + driver: mysqlDriver!, + revertAfter: true + ) + } + +} + +// MARK: - Helpers + +private extension ServiceTests { + + /// Migrates, inserts, and reads back a record against the given driver, shutting the pool down after. + /// + /// The `Fluent` service normally owns pool shutdown via its `run()` in the service group; outside that, + /// the test must shut it down explicitly — even on failure — or the pool asserts on `deinit`. + /// - Parameters: + /// - driver: the persistence backend to exercise. + /// - revertAfter: whether to revert the migrations afterwards; set for a shared database (the + /// in-memory database is discarded on shutdown, so it needs no revert). + func roundTrip( + driver: Persistence.Driver, + revertAfter: Bool = false + ) async throws { + let service = Service( + driver: driver, + logger: Logger(label: "test") + ) + let fluent = service() + + do { + await registerMigrations(fluent) + try await fluent.migrate() + + let repository = ExampleRepository(fluent: fluent) + let created = try await repository.create(name: "loud") + + #expect(try await repository.all().contains(created)) + + if revertAfter { + try await fluent.revert() + } + } catch { + try? await fluent.shutdown() + + throw error + } + + try await fluent.shutdown() + } + +} + +/// The MySQL/MariaDB driver built from the `MYSQL_TEST_*` environment variables, or `nil` when the gate +/// variable `MYSQL_TEST_HOST` is unset — in which case the MySQL integration test is skipped, so the suite +/// stays runnable with no database available. +private let mysqlDriver: Persistence.Driver? = { + let environment = ProcessInfo.processInfo.environment + + guard let host = environment["MYSQL_TEST_HOST"] else { + return nil + } + + return .mysql( + .init( + host: host, + port: environment["MYSQL_TEST_PORT"].flatMap(Int.init) ?? 3306, + name: environment["MYSQL_TEST_NAME"] ?? "loud", + username: environment["MYSQL_TEST_USERNAME"] ?? "loud", + password: environment["MYSQL_TEST_PASSWORD"] ?? "loud", + tls: .off, + maxConnectionsPerEventLoop: 2 + ) + ) +}() diff --git a/Packages/Persistence/Tests/Utils/Fakes/NotSQLConfiguration.swift b/Packages/Persistence/Tests/Utils/Fakes/NotSQLConfiguration.swift new file mode 100644 index 0000000..ed276e5 --- /dev/null +++ b/Packages/Persistence/Tests/Utils/Fakes/NotSQLConfiguration.swift @@ -0,0 +1,13 @@ +import FluentKit + +struct NotSQLConfiguration: DatabaseConfiguration { + + var middleware: [any AnyModelMiddleware] = [] + + func makeDriver( + for databases: Databases + ) -> any DatabaseDriver { + NotSQLDriver() + } + +} diff --git a/Packages/Persistence/Tests/Utils/Fakes/NotSQLDatabase.swift b/Packages/Persistence/Tests/Utils/Fakes/NotSQLDatabase.swift new file mode 100644 index 0000000..a612808 --- /dev/null +++ b/Packages/Persistence/Tests/Utils/Fakes/NotSQLDatabase.swift @@ -0,0 +1,44 @@ +import FluentKit + +/// A Fluent database that is not an `SQLDatabase`, so the probe's downcast fails. +/// +/// Every query succeeds, proving the probe reports "not reachable" because of the failed downcast +/// rather than a failing backend. +struct NotSQLDatabase: Database { + + let context: DatabaseContext + + var inTransaction: Bool { false } + + func execute( + query: DatabaseQuery, + onOutput: @escaping @Sendable (any DatabaseOutput) -> Void + ) -> EventLoopFuture { + context.eventLoop.makeSucceededVoidFuture() + } + + func execute( + schema: DatabaseSchema + ) -> EventLoopFuture { + context.eventLoop.makeSucceededVoidFuture() + } + + func execute( + enum: DatabaseEnum + ) -> EventLoopFuture { + context.eventLoop.makeSucceededVoidFuture() + } + + func transaction( + _ closure: @escaping @Sendable (any Database) -> EventLoopFuture + ) -> EventLoopFuture { + closure(self) + } + + func withConnection( + _ closure: @escaping @Sendable (any Database) -> EventLoopFuture + ) -> EventLoopFuture { + closure(self) + } + +} diff --git a/Packages/Persistence/Tests/Utils/Fakes/NotSQLDriver.swift b/Packages/Persistence/Tests/Utils/Fakes/NotSQLDriver.swift new file mode 100644 index 0000000..82b8ff4 --- /dev/null +++ b/Packages/Persistence/Tests/Utils/Fakes/NotSQLDriver.swift @@ -0,0 +1,13 @@ +import FluentKit + +struct NotSQLDriver: DatabaseDriver { + + func makeDatabase( + with context: DatabaseContext + ) -> any Database { + NotSQLDatabase(context: context) + } + + func shutdown() {} + +} diff --git a/Services/Website/.env.local b/Services/Website/.env.local index a3d7faa..1987239 100644 --- a/Services/Website/.env.local +++ b/Services/Website/.env.local @@ -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 diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index db51f45..ec87365 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -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 diff --git a/Services/Website/Makefile b/Services/Website/Makefile index a086c67..47d91c8 100644 --- a/Services/Website/Makefile +++ b/Services/Website/Makefile @@ -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; \ diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index b8e1f07..5f7eeea 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -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" diff --git a/Services/Website/README.md b/Services/Website/README.md index 614ab5e..5bccb5c 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -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`. diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift deleted file mode 100644 index 3898109..0000000 --- a/Services/Website/Sources/App/App+build.swift +++ /dev/null @@ -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.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.Configuration, - logLevel: Logger.Level -) -> Router { - 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().routes - HealthController().routes - } - - return router -} diff --git a/Services/Website/Sources/App/App.swift b/Services/Website/Sources/App/App.swift index cf3e8bf..4736863 100644 --- a/Services/Website/Sources/App/App.swift +++ b/Services/Website/Sources/App/App.swift @@ -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() } + } diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift new file mode 100644 index 0000000..06a167b --- /dev/null +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -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.Configuration, + logLevel: Logger.Level, + probe: Probe +) -> Router { + 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().routes + HealthController(probe: probe).routes + } + + return router +} diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift new file mode 100644 index 0000000..0ddb7a8 --- /dev/null +++ b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift @@ -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.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 + } + } + +} diff --git a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift index f465d38..ed37869 100644 --- a/Services/Website/Sources/Library/Public/Controllers/HealthController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/HealthController.swift @@ -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().routes) +/// router.addRoutes(HealthController(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: 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 { let routes = RouteCollection(context: Context.self) @@ -39,6 +48,13 @@ public struct HealthController: 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"}"# } } diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift index 06afdd4..9e3ac7d 100644 --- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -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. diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index 5d27088..cef6a42 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -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. diff --git a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift index d6976ef..201068a 100644 --- a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift @@ -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 + } } diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift index c9620ca..23fa620 100644 --- a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift @@ -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. diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 8a968f9..e5ed3a7 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -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 diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift index 5a5d3c1..5b3c547 100644 --- a/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/HealthControllerTests.swift @@ -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().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(probe: probe).routes) + + return router + }()) + } + } diff --git a/Services/Website/docker-compose.override.yml b/Services/Website/docker-compose.override.yml index 24390a3..c297e23 100644 --- a/Services/Website/docker-compose.override.yml +++ b/Services/Website/docker-compose.override.yml @@ -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 diff --git a/Services/Website/docker-compose.yml b/Services/Website/docker-compose.yml index d3dc25e..81c40d5 100644 --- a/Services/Website/docker-compose.yml +++ b/Services/Website/docker-compose.yml @@ -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}