From 4c3d939d9a1c6f45a5051085099c89e62e7b4767 Mon Sep 17 00:00:00 2001 From: Dokkae6949 Date: Sat, 13 Dec 2025 21:54:04 +0100 Subject: [PATCH] feat: propper db persistence w/ docker support --- .env.example | 4 +- Dockerfile | 13 +- build.gradle.kts | 312 ++++++++++++------ docker/entrypoint.sh | 13 + gradle/libs.versions.toml | 13 +- .../kotlin/at/dokkae/homepage/Homepage.kt | 13 +- .../at/dokkae/homepage/config/Environment.kt | 31 ++ .../at/dokkae/homepage/HomepageClient.kt | 19 -- .../kotlin/at/dokkae/homepage/HomepageTest.kt | 18 - 9 files changed, 276 insertions(+), 160 deletions(-) create mode 100755 docker/entrypoint.sh create mode 100644 src/main/kotlin/at/dokkae/homepage/config/Environment.kt delete mode 100644 src/test/kotlin/at/dokkae/homepage/HomepageClient.kt delete mode 100644 src/test/kotlin/at/dokkae/homepage/HomepageTest.kt diff --git a/.env.example b/.env.example index 60c183c..5f66adc 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3cb50b4..8afe2ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/build.gradle.kts b/build.gradle.kts index ce86718..2181e03 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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().configureEach { - dependsOn("jooqCodegen") - - compilerOptions { - allWarningsAsErrors = false - jvmTarget.set(JVM_21) - freeCompilerArgs.add("-Xjvm-default=all") - } - } - - withType { - useJUnitPlatform() - } - - withType { - dependsOn("initDb") - } - - named("precompileJte") { - dependsOn("compileKotlin") - } - - named("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 { @@ -203,4 +190,113 @@ 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().configureEach { + dependsOn("genJooq") + + compilerOptions { + allWarningsAsErrors = false + jvmTarget.set(JVM_21) + freeCompilerArgs.add("-Xjvm-default=all") + } + } + + withType { + useJUnitPlatform() + } +} + +// ==================================================================================================== +// JAR BUILDING +// ==================================================================================================== + +tasks.named("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") } \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..91dadf8 --- /dev/null +++ b/docker/entrypoint.sh @@ -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!" +} + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c284c06..d58e295 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/src/main/kotlin/at/dokkae/homepage/Homepage.kt b/src/main/kotlin/at/dokkae/homepage/Homepage.kt index f2dd08d..cad4d81 100644 --- a/src/main/kotlin/at/dokkae/homepage/Homepage.kt +++ b/src/main/kotlin/at/dokkae/homepage/Homepage.kt @@ -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}") } diff --git a/src/main/kotlin/at/dokkae/homepage/config/Environment.kt b/src/main/kotlin/at/dokkae/homepage/config/Environment.kt new file mode 100644 index 0000000..83b3248 --- /dev/null +++ b/src/main/kotlin/at/dokkae/homepage/config/Environment.kt @@ -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" + ) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/at/dokkae/homepage/HomepageClient.kt b/src/test/kotlin/at/dokkae/homepage/HomepageClient.kt deleted file mode 100644 index b43a83d..0000000 --- a/src/test/kotlin/at/dokkae/homepage/HomepageClient.kt +++ /dev/null @@ -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()) -} diff --git a/src/test/kotlin/at/dokkae/homepage/HomepageTest.kt b/src/test/kotlin/at/dokkae/homepage/HomepageTest.kt deleted file mode 100644 index 782ed31..0000000 --- a/src/test/kotlin/at/dokkae/homepage/HomepageTest.kt +++ /dev/null @@ -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")) - ) - } -}