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 parent
9774454bba
commit
dc6b22e648
45 files changed
+1791
-254
No files matched your search
@@ -51,6 +51,9 @@ playground.xcworkspace
|
||||
.env
|
||||
!.env.local
|
||||
|
||||
## Local MariaDB data files
|
||||
Services/Website/Tests/DB/
|
||||
|
||||
# Fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
|
||||
@@ -7,8 +7,6 @@ let package = Package(
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.macOS(.v15),
|
||||
.iOS(.v18),
|
||||
.tvOS(.v18),
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2700"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Persistence"
|
||||
BuildableName = "Persistence"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "PersistenceTests"
|
||||
BuildableName = "PersistenceTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnabled = "No">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Persistence"
|
||||
BuildableName = "Persistence"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -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"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
])
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}()
|
||||
@@ -0,0 +1,13 @@
|
||||
import FluentKit
|
||||
|
||||
struct NotSQLConfiguration: DatabaseConfiguration {
|
||||
|
||||
var middleware: [any AnyModelMiddleware] = []
|
||||
|
||||
func makeDriver(
|
||||
for databases: Databases
|
||||
) -> any DatabaseDriver {
|
||||
NotSQLDriver()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Void> {
|
||||
context.eventLoop.makeSucceededVoidFuture()
|
||||
}
|
||||
|
||||
func execute(
|
||||
schema: DatabaseSchema
|
||||
) -> EventLoopFuture<Void> {
|
||||
context.eventLoop.makeSucceededVoidFuture()
|
||||
}
|
||||
|
||||
func execute(
|
||||
enum: DatabaseEnum
|
||||
) -> EventLoopFuture<Void> {
|
||||
context.eventLoop.makeSucceededVoidFuture()
|
||||
}
|
||||
|
||||
func transaction<T>(
|
||||
_ closure: @escaping @Sendable (any Database) -> EventLoopFuture<T>
|
||||
) -> EventLoopFuture<T> {
|
||||
closure(self)
|
||||
}
|
||||
|
||||
func withConnection<T>(
|
||||
_ closure: @escaping @Sendable (any Database) -> EventLoopFuture<T>
|
||||
) -> EventLoopFuture<T> {
|
||||
closure(self)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import FluentKit
|
||||
|
||||
struct NotSQLDriver: DatabaseDriver {
|
||||
|
||||
func makeDatabase(
|
||||
with context: DatabaseContext
|
||||
) -> any Database {
|
||||
NotSQLDatabase(context: context)
|
||||
}
|
||||
|
||||
func shutdown() {}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
+60
-12
@@ -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; \
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
@@ -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<AppRequestContext>.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<AppRequestContext>.Configuration,
|
||||
logLevel: Logger.Level
|
||||
) -> Router<AppRequestContext> {
|
||||
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<AppRequestContext>().routes
|
||||
HealthController<AppRequestContext>().routes
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<AppRequestContext>.Configuration,
|
||||
logLevel: Logger.Level,
|
||||
probe: Probe
|
||||
) -> Router<AppRequestContext> {
|
||||
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<AppRequestContext>().routes
|
||||
HealthController<AppRequestContext>(probe: probe).routes
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -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<AppRequestContext>.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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+114
-11
@@ -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<BasicRequestContext>().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<BasicRequestContext>(probe: probe).routes)
|
||||
|
||||
return router
|
||||
}())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user