feat: propper db persistence w/ docker support

This commit is contained in:
Finn Linck Ryan 2025-12-13 21:54:04 +01:00
parent 2c4995f9d7
commit 4c3d939d9a
9 changed files with 276 additions and 160 deletions

View file

@ -1,4 +1,6 @@
PORT=9000
HOST=localhost
DB_URL=jdbc:postgresql://localhost:5432/homepage
DB_MIGRATION=src/main/resources/db/migration
DB_USERNAME=postgres
DB_PASSWORD=postgres

View file

@ -1,6 +1,13 @@
# --- Stage 1: Build the JAR ---
FROM gradle:9.2.1-jdk21 AS build
ARG DB_URL
ARG DB_USERNAME
ARG DB_PASSWORD
ENV DB_URL=${DB_URL}
ENV DB_USERNAME=${DB_USERNAME}
ENV DB_PASSWORD=${DB_PASSWORD}
# Set working dir
WORKDIR /app
@ -12,18 +19,20 @@ COPY --chown=gradle:gradle gradle ./gradle
COPY --chown=gradle:gradle src ./src
# Build the fat jar
RUN ./gradlew clean shadowJar --no-daemon
RUN ./gradlew clean build --no-daemon
# --- Stage 2: Run the app ---
FROM eclipse-temurin:21-jdk-alpine
ARG PORT=9000
WORKDIR /app
# Copy the built JAR from the build stage
COPY --from=build /app/build/libs/*.jar app.jar
# Expose port (same as your server)
EXPOSE 9000
EXPOSE ${PORT}
# Run the app
ENTRYPOINT ["java", "-jar", "app.jar"]

View file

@ -1,159 +1,147 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import io.github.klahap.dotenv.DotEnv
import io.github.klahap.dotenv.DotEnvBuilder
import org.flywaydb.gradle.task.FlywayMigrateTask
import org.gradle.api.JavaVersion.VERSION_21
import org. gradle.api.JavaVersion.VERSION_21
import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
import kotlin.io.path.Path
val env = DotEnvBuilder.dotEnv {
addFile("$rootDir/.env")
addSystemEnv()
// ====================================================================================================
// ENVIRONMENT CONFIGURATION
// ====================================================================================================
val env = if (File("${layout.projectDirectory.asFile.absolutePath}/.env").exists()) {
DotEnvBuilder.dotEnv {
addFile("${layout.projectDirectory}/.env")
addSystemEnv()
}
} else {
DotEnvBuilder.dotEnv {
addSystemEnv()
}
}
val envDbUrl: String = env["DB_URL"] ?: ""
val envDbMigration: String = env["DB_MIGRATIONS"] ?: "src/main/resources/db/migration"
val envDbUsername: String = env["DB_USERNAME"] ?: ""
val envDbPassword: String = env["DB_PASSWORD"] ?: ""
val generatedResourcesDirectory = "${layout.buildDirectory.get()}/generated-resources"
val generatedSourcesDirectory = "${layout.buildDirectory.get()}/generated-src"
// ====================================================================================================
// PLUGIN CONFIGURATION
// ====================================================================================================
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.shadow)
alias(libs.plugins.dotenv.plugin)
alias(libs.plugins.jte)
alias(libs.plugins.flyway)
alias(libs.plugins.tasktree)
alias(libs.plugins.jooq.codegen.gradle)
alias(libs.plugins.taskinfo)
alias(libs.plugins.flyway)
}
// ====================================================================================================
// BASIC CONFIGURATION
// ====================================================================================================
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
java {
sourceCompatibility = VERSION_21
targetCompatibility = VERSION_21
}
repositories {
mavenCentral()
}
// ====================================================================================================
// GENERATED CODE DIRECTORIES
// ====================================================================================================
val generatedResourcesDir = layout.buildDirectory.dir("generated-resources")
val generatedSourcesDir = layout.buildDirectory.dir("generated-src")
val migrationSourceDir = layout.projectDirectory.dir("src/main/resources/db/migration")
val jtwSourceDir = layout.projectDirectory.dir("src/main/kte")
val jteOutputDir = generatedResourcesDir.get().dir("jte")
val jooqOutputDir = generatedSourcesDir.get().dir("jooq")
sourceSets {
main {
resources.srcDir(jteOutputDir)
kotlin.srcDir(jooqOutputDir)
}
}
// ====================================================================================================
// DEPENDENCIES
// ====================================================================================================
dependencies {
// HTTP4K
implementation(platform(libs.http4k.bom))
implementation(libs.bundles.http4k)
// Environment & Configuration
implementation(libs.dotenv)
// Templating
implementation(libs.jte.kotlin)
// Database
implementation(libs.bundles.database)
implementation(libs.flyway.core)
implementation(libs.flyway.database.postgresql)
// Testing
testImplementation(libs.bundles.testing)
// Jooq Codegen
jooqCodegen(libs.jooq.meta)
jooqCodegen(libs.jooq.meta.extensions)
jooqCodegen(libs.jooq.postgres)
}
buildscript {
repositories {
mavenCentral()
gradlePluginPortal()
}
dependencies {
classpath(libs.postgresql)
classpath(libs.jooq.codegen)
classpath(libs.jooq.meta)
classpath(libs.jooq.meta.extensions)
classpath(libs.flyway.database.postgresql)
}
}
sourceSets.main {
resources.srcDir("$generatedResourcesDirectory/jte")
kotlin.srcDir("$generatedSourcesDirectory/jooq")
}
// ====================================================================================================
// JTE TEMPLATE GENERATION
// ====================================================================================================
tasks {
withType<KotlinJvmCompile>().configureEach {
dependsOn("jooqCodegen")
compilerOptions {
allWarningsAsErrors = false
jvmTarget.set(JVM_21)
freeCompilerArgs.add("-Xjvm-default=all")
}
}
withType<Test> {
useJUnitPlatform()
}
withType<FlywayMigrateTask> {
dependsOn("initDb")
}
named("precompileJte") {
dependsOn("compileKotlin")
}
named<ShadowJar>("shadowJar") {
manifest {
attributes("Main-Class" to "at.dokkae.homepage.HomepageKt")
}
dependsOn("precompileJte")
mustRunAfter("flywayMigrate", "jooqCodegen")
from("$generatedResourcesDirectory/jte")
archiveFileName.set("app.jar")
exclude("META-INF/*.RSA", "META-INF/*.SF", "META-INF/*.DSA")
}
register("buildDocker") {
}
java {
sourceCompatibility = VERSION_21
targetCompatibility = VERSION_21
}
}
dependencies {
implementation(platform(libs.http4k.bom))
implementation(libs.dotenv)
implementation(libs.bundles.http4k)
implementation(libs.jte.kotlin)
implementation(libs.bundles.database)
testImplementation(libs.bundles.testing)
jooqCodegen(libs.jooq.meta)
jooqCodegen(libs.jooq.postgres)
}
// ========== JTE Templating ==========
jte {
sourceDirectory.set(Path("src/main/kte"))
targetDirectory.set(Path("$generatedResourcesDirectory/jte"))
sourceDirectory.set(Path(jtwSourceDir.asFile.absolutePath))
targetDirectory.set(Path(jteOutputDir.asFile.absolutePath))
precompile()
}
// ========== FlyWay ==========
flyway {
url = envDbUrl
user = envDbUsername
password = envDbPassword
locations = arrayOf("filesystem:$envDbMigration")
baselineOnMigrate = true
validateMigrationNaming = true
tasks.named("precompileJte") {
dependsOn("compileKotlin")
}
tasks.register("initDb") {
doFirst {
println("Database Configuration:")
println(" Raw URL from env: $envDbUrl")
println(" Resolved URL: $envDbUrl")
println(" Migrations: $envDbMigration")
println(" Credentials:")
println(" Username: $envDbUsername")
println(" Password: ${"*".repeat(envDbPassword.length)}")
}
tasks.register("genJte") {
group = "codegen"
description = "Precompile jte template into classes"
dependsOn("precompileJte")
}
tasks.named("flywayMigrate") {
finalizedBy("jooqCodegen")
}
// ====================================================================================================
// JOOQ CODE GENERATION FROM SQL FILES
// ====================================================================================================
// ========== Jooq ==========
jooq {
configuration {
logging = org.jooq.meta.jaxb.Logging.WARN
@ -172,7 +160,6 @@ jooq {
name = "org.jooq.meta.postgres.PostgresDatabase"
inputSchema = "public"
// SQLite specific configuration
includes = ".*"
excludes = """
flyway_.*|
@ -195,7 +182,7 @@ jooq {
target {
packageName = "at.dokkae.homepage.generated.jooq"
directory = "$generatedSourcesDirectory/jooq"
directory = jooqOutputDir.asFile.absolutePath
}
strategy {
@ -204,3 +191,112 @@ jooq {
}
}
}
tasks.register("genJooq") {
group = "codegen"
description = "Generate jooq classes from migrations"
dependsOn("jooqCodegen")
}
// ====================================================================================================
// FLYWAY MIGRATE AND CODEGEN TASK
// ====================================================================================================
flyway {
url = envDbUrl
user = envDbUsername
password = envDbPassword
locations = arrayOf("filesystem:${migrationSourceDir.asFile.absolutePath}")
baselineOnMigrate = true
validateMigrationNaming = true
}
tasks.register("migrate") {
group = "codegen"
description = "Run Flyway migrations and generate JOOQ code (no compilation)"
dependsOn("flywayMigrate")
finalizedBy("jooqCodegen")
doFirst {
logger.lifecycle("╔═══════════════════════════════════════════════════════════════╗")
logger.lifecycle("║ Running Migrations and Code Generation ║")
logger.lifecycle("╚═══════════════════════════════════════════════════════════════╝")
logger.lifecycle("| Database URL: $envDbUrl")
logger.lifecycle("| Migrations: ${migrationSourceDir.asFile.absolutePath}")
logger.lifecycle("| Username: $envDbUsername")
logger.lifecycle("| Password: ${if (envDbUsername.isEmpty()) "not " else ""}provided")
}
doLast {
logger.lifecycle("✓ Migration and code generation completed")
}
}
// ====================================================================================================
// COMPILATION ORDER
// ====================================================================================================
tasks {
withType<KotlinJvmCompile>().configureEach {
dependsOn("genJooq")
compilerOptions {
allWarningsAsErrors = false
jvmTarget.set(JVM_21)
freeCompilerArgs.add("-Xjvm-default=all")
}
}
withType<Test> {
useJUnitPlatform()
}
}
// ====================================================================================================
// JAR BUILDING
// ====================================================================================================
tasks.named<ShadowJar>("shadowJar") {
manifest {
attributes("Main-Class" to "at.dokkae.homepage.HomepageKt")
}
dependsOn("genJte", "genJooq")
from(jteOutputDir)
archiveFileName.set("app.jar")
mergeServiceFiles()
exclude(
"META-INF/*. RSA",
"META-INF/*.SF",
"META-INF/*.DSA"
)
}
tasks.named("build") {
dependsOn("shadowJar")
}
// ====================================================================================================
// HELPER TASKS
// ====================================================================================================
tasks.register("cleanGenerated") {
group = "build"
description = "Clean all generated code"
doLast {
delete(generatedResourcesDir)
delete(generatedSourcesDir)
logger.lifecycle("✓ Cleaned generated code directories")
}
}
tasks.named("clean") {
dependsOn("cleanGenerated")
}

13
docker/entrypoint.sh Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env sh
set -e
echo "🚀 Setting up deployment environment"
wait_for_db() {
echo "⏳ Waiting for database at $DB_HOST:$DB_PORT..."
while ! nc -z $DB_HOST $DB_PORT; do
sleep 2
done
echo "✅ Database is ready!"
}

View file

@ -9,16 +9,16 @@ flyway = "11.19.0"
jooq = "3.20.10"
junit = "6.0.0"
postgresql = "42.7.7"
taskinfo = "3.0.0"
tasktree = "4.0.1"
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
dotenv-plugin = { id = "io.github.klahap.dotenv", version.ref = "dotenv-plugin" }
jte = { id = "gg.jte.gradle", version.ref = "jte" }
flyway = { id = "org.flywaydb.flyway", version.ref = "flyway" }
jooq-codegen-gradle = { id = "org.jooq.jooq-codegen-gradle", version.ref = "jooq" }
taskinfo = { id = "org.barfuin.gradle.taskinfo", version.ref = "taskinfo" }
tasktree = { id = "com.dorongold.task-tree", version.ref = "tasktree" }
flyway = { id = "org.flywaydb.flyway", version.ref = "flyway" }
[bundles]
http4k = [
@ -38,11 +38,9 @@ testing = [
]
database = [
"postgresql",
"flyway-core",
"jooq",
"jooq-meta",
"jooq-codegen",
"jooq-postgres"
"jooq"
]
[libraries]
@ -72,6 +70,7 @@ flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql
# Jooq
jooq = { module = "org.jooq:jooq", version.ref = "jooq" }
jooq-meta = { module = "org.jooq:jooq-meta", version.ref = "jooq" }
jooq-meta-extensions = { module = "org.jooq:jooq-meta-extensions", version.ref = "jooq" }
jooq-codegen = { module = "org.jooq:jooq-codegen", version.ref = "jooq" }
jooq-postgres = { module = "org.jooq:jooq-postgres-extensions", version.ref = "jooq" }

View file

@ -1,5 +1,6 @@
package at.dokkae.homepage
import at.dokkae.homepage.config.Environment
import at.dokkae.homepage.extensions.Precompiled
import at.dokkae.homepage.repository.MessageRepository
import at.dokkae.homepage.repository.impls.JooqMessageRepository
@ -24,7 +25,6 @@ import org.http4k.sse.SseMessage
import org.http4k.sse.SseResponse
import org.http4k.template.JTETemplates
import org.http4k.template.ViewModel
import org.jetbrains.kotlin.backend.common.push
import org.jooq.SQLDialect
import org.jooq.impl.DSL
import java.sql.DriverManager
@ -51,9 +51,12 @@ data class Message(
}
fun main() {
val env = dotenv()
val env = Environment.load(dotenv {
ignoreIfMissing = true
ignoreIfMalformed = true
})
val connection = DriverManager.getConnection(env["DB_URL"], env["DB_USERNAME"], env["DB_PASSWORD"])
val connection = DriverManager.getConnection(env.dbUrl, env.dbUsername, env.dbPassword)
val dslContext = DSL.using(connection, SQLDialect.POSTGRES)
val messageRepository: MessageRepository = JooqMessageRepository(dslContext)
@ -98,7 +101,7 @@ fun main() {
}
)
poly(http, sse).asServer(Jetty(port = 9000)).start()
poly(http, sse).asServer(Jetty(port = env.port)).start()
println("Server started on http://localhost:9000")
println("Server started on http://${env.host}:${env.port}")
}

View file

@ -0,0 +1,31 @@
package at.dokkae.homepage.config
import io.github.cdimascio.dotenv.Dotenv
data class Environment(
val port: Int,
val host: String,
val dbUrl: String,
val dbUsername: String,
val dbPassword: String,
) {
companion object {
/**
* Returns a loaded Environment object instance.
* @throws IllegalStateException if required environment variables were not found within the provided `dotenv` instance.
*/
fun load(dotenv: Dotenv): Environment = Environment(
port = requireEnv(dotenv, "PORT").toInt(),
host = requireEnv(dotenv, "HOST"),
dbUrl = requireEnv(dotenv, "DB_URL"),
dbUsername = requireEnv(dotenv, "DB_USERNAME"),
dbPassword = requireEnv(dotenv, "DB_PASSWORD"),
)
private fun requireEnv(dotenv: Dotenv, key: String): String {
return dotenv[key] ?: throw IllegalStateException(
"Missing required environment variable: $key"
)
}
}
}

View file

@ -1,19 +0,0 @@
package at.dokkae.homepage
import org.http4k.client.OkHttp
import org.http4k.core.HttpHandler
import org.http4k.core.Method.GET
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.then
import org.http4k.filter.DebuggingFilters.PrintResponse
fun main() {
val client: HttpHandler = OkHttp()
val printingClient: HttpHandler = PrintResponse().then(client)
val response: Response = printingClient(Request(GET, "http://localhost:9000/ping"))
println(response.bodyString())
}

View file

@ -1,18 +0,0 @@
package at.dokkae.homepage
import org.http4k.core.Method.GET
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class HomepageTest {
@Test
fun `Ping test`() {
assertEquals(
Response(OK).body("pong"),
app(Request(GET, "/ping"))
)
}
}