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: rock-n-code/loud-amsterdam#13 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
@@ -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<AppRequestContext>().routes)
|
||||
/// router.addRoutes(HealthController<AppRequestContext>(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<Context: RequestContext>: 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<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
@@ -39,6 +48,13 @@ public struct HealthController<Context: RequestContext>: 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"}"#
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user