diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6bfd6a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Version control +.git +.github + +# Gradle build outputs / caches (must always be rebuilt fresh inside the image) +.gradle +build/ +.kotlin +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea +.vscode +.run +*.iml +*.iws +*.ipr + +# Docker-only local files (secrets/env must never be baked into the image) +docker/.env +docker/*.env.docker +docker/*.secret + +# Misc +*.hprof +.gradle-docker-cache/ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 65f7ba9..e86e059 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -109,7 +109,7 @@ jobs: chmod -R 777 "$GRADLE_CACHE_DIR" - name: Run tests in Docker - run: docker compose -f docker/docker-compose-test.yaml run --rm tests + run: docker compose -f docker/docker-compose-test.yaml run tests - name: Shut down Docker services if: always() diff --git a/build.gradle.kts b/build.gradle.kts index 8e02f94..79a446e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -58,40 +58,6 @@ tasks.withType().configureEach { jvmArgs("-javaagent:$agentJar") } } -tasks.test { - dependsOn("copyEnv") -} - -tasks.register("copyEnv") { - group = "docker" - description = "copy the default dotenv file" - from("docker") - into("docker") - rename { - it.removeSuffix(".template") - } - include(".env.template") - eachFile { - if (File("docker/$name").exists()) { - exclude() - } - } - val files = - buildList { - add(File("docker/postgresql.secret")) - if (!project.hasProperty("ci")) { - add(File("docker/pgadmin.secret")) - } - } - outputs.files(*files.toTypedArray()) - doLast { - files.forEach { - if (!it.exists()) { - it.writeText("changeit") - } - } - } -} dependencies { implementation("io.ktor:ktor-server-core-jvm") diff --git a/doc/installation.md b/doc/installation.md index 0fced39..01eecca 100644 --- a/doc/installation.md +++ b/doc/installation.md @@ -1,12 +1,24 @@ Installation ============ -To run the stack: +To run the stack in production: ```shell docker compose -f docker\docker-compose-prod.yaml -p event-demo up -d ``` +To run only the app dependencies in development mode and run the app localy (not in docker): + +```shell +docker compose -f docker\docker-compose-dev.yaml up -d +``` + +To run the tests in docker (it's designed for the CI): + +```shell +docker compose -f docker\docker-compose-test.yaml up -d +``` + Api url: - [Backend API](http://api.traefik.me/) - [Frontend web site](http://app.traefik.me/) (WIP) diff --git a/docker/.env.docker b/docker/.env.docker new file mode 100644 index 0000000..ab6a6cf --- /dev/null +++ b/docker/.env.docker @@ -0,0 +1,3 @@ +REDIS_URL=redis://redis:6379 +POSTGRESQL_URL=jdbc:postgresql://postgresql/event-demo +RABBITMQ_URL=rabbitmq diff --git a/docker/.env.template b/docker/.env.template deleted file mode 100644 index f4e167b..0000000 --- a/docker/.env.template +++ /dev/null @@ -1 +0,0 @@ -PGADMIN_DEFAULT_EMAIL= \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 0d6ed75..2eef580 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Cache Gradle dependencies -FROM gradle:latest AS cache +FROM gradle:9.6.1-jdk21-alpine AS cache RUN mkdir -p /home/gradle/cache_home ENV GRADLE_USER_HOME=/home/gradle/cache_home COPY build.gradle.* gradle.properties /home/gradle/app/ @@ -7,7 +7,7 @@ WORKDIR /home/gradle/app RUN gradle build -i -x check # Stage 2: Build Application -FROM gradle:latest AS build +FROM gradle:9.6.1-jdk21-alpine AS build COPY --from=cache /home/gradle/cache_home /home/gradle/.gradle COPY --chown=gradle:gradle . /home/gradle/src WORKDIR /home/gradle/src @@ -16,8 +16,8 @@ WORKDIR /home/gradle/src RUN gradle buildFatJar --no-daemon # Stage 3: Create the Runtime Image -FROM amazoncorretto:21 AS runtime +FROM eclipse-temurin:21-jre-alpine AS runtime EXPOSE 8080 RUN mkdir /app -COPY --from=build /home/gradle/src/build/libs/*.jar /app/event-demo-all.jar +COPY --from=build /home/gradle/src/build/libs/*-all.jar /app/event-demo-all.jar ENTRYPOINT ["java","-jar","/app/event-demo-all.jar"] \ No newline at end of file diff --git a/docker/DockerfileTest b/docker/DockerfileTest index 183ddcd..aae096a 100644 --- a/docker/DockerfileTest +++ b/docker/DockerfileTest @@ -1,8 +1,8 @@ # Image officielle Gradle avec JDK 21 déjà installé FROM gradle:9.6.1-jdk21 - + WORKDIR /app - + # Copie du wrapper et des fichiers de config en premier pour profiter du cache Docker COPY build.gradle.kts settings.gradle.kts ./ diff --git a/docker/docker-compose-ci.yaml b/docker/docker-compose-ci.yaml deleted file mode 100644 index 78ccfde..0000000 --- a/docker/docker-compose-ci.yaml +++ /dev/null @@ -1,5 +0,0 @@ -name: event-demo-ci -include: - - path: - - parts/docker-compose-databases.yaml - - parts/docker-compose-databases-expose.yaml \ No newline at end of file diff --git a/docker/docker-compose-dev.yaml b/docker/docker-compose-dev.yaml index 87b6cbf..6574721 100644 --- a/docker/docker-compose-dev.yaml +++ b/docker/docker-compose-dev.yaml @@ -1,8 +1,17 @@ -name: event-demo-test +name: event-demo-dev include: - path: - parts/docker-compose-databases.yaml - parts/docker-compose-databases-expose.yaml - parts/docker-compose-tools.yaml - parts/docker-compose-tools-local.yaml - - parts/docker-compose-traefik.yaml \ No newline at end of file + - parts/docker-compose-traefik.yaml + +services: + postgresql: + environment: + POSTGRES_PASSWORD: "changeit" + + pgadmin: + environment: + PGADMIN_DEFAULT_PASSWORD: "changeit" \ No newline at end of file diff --git a/docker/docker-compose-prod.yaml b/docker/docker-compose-prod.yaml index f5a1142..673849b 100644 --- a/docker/docker-compose-prod.yaml +++ b/docker/docker-compose-prod.yaml @@ -5,3 +5,16 @@ include: - parts/docker-compose-app.yaml - parts/docker-compose-tools.yaml - parts/docker-compose-traefik.yaml + +services: + postgresql: + environment: + POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password + volumes: + - ./postgresql.secret:/run/secrets/postgresql_password:ro + + pgadmin: + environment: + PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password + volumes: + - ./pgadmin.secret:/run/secrets/pgadmin_password:ro \ No newline at end of file diff --git a/docker/docker-compose-test.yaml b/docker/docker-compose-test.yaml index c338ab7..91205c9 100644 --- a/docker/docker-compose-test.yaml +++ b/docker/docker-compose-test.yaml @@ -3,4 +3,9 @@ include: - path: - parts/docker-compose-databases.yaml - parts/docker-compose-test.yaml - - parts/docker-compose-traefik.yaml \ No newline at end of file + - parts/docker-compose-traefik.yaml + +services: + postgresql: + environment: + POSTGRES_PASSWORD: "changeit" \ No newline at end of file diff --git a/docker/parts/docker-compose-app.yaml b/docker/parts/docker-compose-app.yaml index 6fe1537..59a1b91 100644 --- a/docker/parts/docker-compose-app.yaml +++ b/docker/parts/docker-compose-app.yaml @@ -12,6 +12,8 @@ services: condition: service_healthy redis: condition: service_healthy + env_file: + - ../.env.docker labels: - "traefik.http.routers.api.rule=Host(`api.traefik.me`)" - "traefik.http.services.api.loadbalancer.server.port=8080" diff --git a/docker/parts/docker-compose-databases.yaml b/docker/parts/docker-compose-databases.yaml index 8f1d81a..491d9bd 100644 --- a/docker/parts/docker-compose-databases.yaml +++ b/docker/parts/docker-compose-databases.yaml @@ -22,7 +22,6 @@ services: image: postgres:18.4 command: postgres -c 'max_connections=500' environment: - POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password POSTGRES_USER: event-demo healthcheck: test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"] @@ -31,7 +30,6 @@ services: retries: 10 volumes: - postgresql_data:/var/lib/postgresql - - ../postgresql.secret:/run/secrets/postgresql_password:ro rabbitmq: image: rabbitmq:4-management-alpine diff --git a/docker/parts/docker-compose-test.yaml b/docker/parts/docker-compose-test.yaml index 0e5d60b..d36f9f9 100644 --- a/docker/parts/docker-compose-test.yaml +++ b/docker/parts/docker-compose-test.yaml @@ -15,6 +15,8 @@ services: condition: service_healthy redis: condition: service_healthy + env_file: + - ../.env.docker volumes: gradle-cache: \ No newline at end of file diff --git a/docker/parts/docker-compose-tools.yaml b/docker/parts/docker-compose-tools.yaml index ee35d57..d5d9be0 100644 --- a/docker/parts/docker-compose-tools.yaml +++ b/docker/parts/docker-compose-tools.yaml @@ -2,10 +2,7 @@ services: pgadmin: image: dpage/pgadmin4 environment: - PGADMIN_DEFAULT_EMAIL: $PGADMIN_DEFAULT_EMAIL - PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password - secrets: - - pgadmin_password + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@event-demo.dev} volumes: - pgadmin_data:/var/lib/pgadmin configs: @@ -45,9 +42,5 @@ configs: } } -secrets: - pgadmin_password: - file: ../pgadmin.secret - volumes: pgadmin_data: \ No newline at end of file diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 0280eae..66a0f43 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -13,12 +13,12 @@ jwt { } redis { - url = "redis://redis:6379" + url = "redis://localhost:6379" url = ${?REDIS_URL} } postgresql { - url = "jdbc:postgresql://postgresql/event-demo" + url = "jdbc:postgresql://localhost:5432/event-demo" url = ${?POSTGRESQL_URL} username = "event-demo" @@ -29,7 +29,7 @@ postgresql { } rabbitmq { - url = "rabbitmq" + url = "localhost" url = ${?RABBITMQ_URL} port = "5672" diff --git a/src/test/kotlin/eventDemo/contexts/game/application/GameSimulationTest.kt b/src/test/kotlin/eventDemo/contexts/game/application/GameSimulationTest.kt index 231fe1d..a0cf0bf 100644 --- a/src/test/kotlin/eventDemo/contexts/game/application/GameSimulationTest.kt +++ b/src/test/kotlin/eventDemo/contexts/game/application/GameSimulationTest.kt @@ -26,11 +26,13 @@ import eventDemo.testHelpers.createNewUser import eventDemo.testHelpers.testKoinApplicationWithConfig import io.kotest.assertions.nondeterministic.eventually import io.kotest.assertions.nondeterministic.until +import io.kotest.assertions.retry import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.equals.shouldEqual import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.should import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -49,223 +51,225 @@ class GameSimulationTest : tags(Tag.Postgresql) test("Simulation of a game") { - withTimeout(10.seconds) { - disableRandomForTest() - val gameId = GameId() - val user1 = createNewUser("user1") - val user2 = createNewUser("user2") + should { + retry(maxRetry = 3, timeout = 20.seconds) { + disableRandomForTest() + val gameId = GameId() + val user1 = createNewUser("user1") + val user2 = createNewUser("user2") - val channelCommand1 = Channel(Channel.BUFFERED) - val channelCommand2 = Channel(Channel.BUFFERED) - val channelNotification1 = Channel(Channel.BUFFERED) - val channelNotification2 = Channel(Channel.BUFFERED) + val channelCommand1 = Channel(Channel.BUFFERED) + val channelCommand2 = Channel(Channel.BUFFERED) + val channelNotification1 = Channel(Channel.BUFFERED) + val channelNotification2 = Channel(Channel.BUFFERED) - var playedCard1: Card? = null - var playedCard2: Card? = null + var playedCard1: Card? = null + var playedCard2: Card? = null - var player1HasJoin = false + var player1HasJoin = false - testKoinApplicationWithConfig { - val gameRepository = get() - val userRepository = get() - userRepository.run { - save(user1) - save(user2) - } + testKoinApplicationWithConfig { + val gameRepository = get() + val userRepository = get() + userRepository.run { + save(user1) + save(user2) + } - gameRepository.create(gameId) + gameRepository.create(gameId) - // Run command/notification subscriber - // In the normal process, these subscriber is invoque on players connect to the websocket - GlobalScope.launch(Dispatchers.IO) { - get().subscribePlayerToGameChannels( - gameId, - user1.id, - channelCommand1, - channelNotification1, - ) - } - GlobalScope.launch(Dispatchers.IO) { - get().subscribePlayerToGameChannels( - gameId, - user2.id, - channelCommand2, - channelNotification2, - ) - } + // Run command/notification subscriber + // In the normal process, these subscriber is invoque on players connect to the websocket + GlobalScope.launch(Dispatchers.IO) { + get().subscribePlayerToGameChannels( + gameId, + user1.id, + channelCommand1, + channelNotification1, + ) + } + GlobalScope.launch(Dispatchers.IO) { + get().subscribePlayerToGameChannels( + gameId, + user2.id, + channelCommand2, + channelNotification2, + ) + } - // Consume etch notification of players, and put theses in a list. - // Is used later to control when other players can execute the next action - val player1Notifications = mutableListOf() - val player2Notifications = mutableListOf() - run { - GlobalScope.launch { - for (notification in channelNotification1) { - player1Notifications.add(notification) + // Consume etch notification of players, and put theses in a list. + // Is used later to control when other players can execute the next action + val player1Notifications = mutableListOf() + val player2Notifications = mutableListOf() + run { + GlobalScope.launch { + for (notification in channelNotification1) { + player1Notifications.add(notification) + } + } + + GlobalScope.launch { + for (notification in channelNotification2) { + player2Notifications.add(notification) + } } } - GlobalScope.launch { - for (notification in channelNotification2) { - player2Notifications.add(notification) - } - } - } + // Player 1 actions + val player1Job = + launch { + createGameWithCommandsInChannels(channelCommand1, gameId, user1) { - // Player 1 actions - val player1Job = - launch { - createGameWithCommandsInChannels(channelCommand1, gameId, user1) { - - joinTheGame() - player1Notifications.waitNotification { - players.map { it.userId }.contains(user1.id) - } - - player1HasJoin = true - - player1Notifications.waitNotification { - player.userId == user2.id - } - - readyToPlay() - player1Notifications.waitNotification { - playerId == getPlayer(user2).id - } - - playedCard1 = - player1Notifications - .waitNotification { hand.size == 7 } - .hand - .first() - .apply { - this.shouldBeInstanceOf() - number shouldEqual 1 - color shouldEqual Card.Color.Red - } - - player1Notifications.waitNotification { - if (player.userId == user2.id) error("WRONG PLAYER TURN") - player.userId == user1.id - } - - game - .shouldBeInstanceOf() - .discardPile - .topCard - .shouldNotBeNull() - .shouldBeInstanceOf { - it.number shouldEqual 0 - it.color shouldEqual Card.Color.Red + joinTheGame() + player1Notifications.waitNotification { + players.map { it.userId }.contains(user1.id) } - playCard(playedCard1!!) + player1HasJoin = true - player1Notifications.waitNotification { - player == getPlayer(user2) - } - - player1Notifications.waitNotification { - playerId == getPlayer(user2).id && card == playedCard2 - } - - playedCard1 = - assertInstanceOf(game) - .playableCards(currentPlayer.id) - .first() - - playedCard1.run { - this.shouldBeInstanceOf() - number shouldEqual 2 - color shouldEqual Card.Color.Red - } - - playCard(playedCard1) - - player1Notifications.waitNotification { - player == getPlayer(user2) - } - } - } - - // Player 2 actions - val player2Job = - launch { - createGameWithCommandsInChannels(channelCommand2, gameId, user2) { - // wait player 1 has joined the game - until(3.seconds) { player1HasJoin } - - joinTheGame() - - player2Notifications.waitNotification { - players.map { it.userId }.contains(user1.id) && - players.map { it.userId }.contains(user2.id) - } - player2Notifications.waitNotification { playerId == getPlayer(user1).id } - - readyToPlay() - - playedCard2 = - player2Notifications - .waitNotification { hand.size == 7 } - .hand - .first() - .apply { - this.shouldBeInstanceOf() - number shouldEqual 8 - color shouldEqual Card.Color.Red - } - - player2Notifications.waitNotification { - if (player.userId == user2.id) error("WRONG PLAYER TURN") - player.userId == user1.id - } - player2Notifications.waitNotification { - playerId == getPlayer(user1).id && card == playedCard1 - } - - player2Notifications.waitNotification { - player == currentPlayer - } - - game - .shouldBeInstanceOf() - .discardPile - .topCard - .shouldNotBeNull() - .shouldBeInstanceOf { - it.number shouldEqual 1 - it.color shouldEqual Card.Color.Red + player1Notifications.waitNotification { + player.userId == user2.id } - playCard(playedCard2) + readyToPlay() + player1Notifications.waitNotification { + playerId == getPlayer(user2).id + } - player2Notifications.waitNotification { - player.userId == user1.id - } - player2Notifications.waitNotification { - playerId == currentPlayer.id && card == playedCard2 + playedCard1 = + player1Notifications + .waitNotification { hand.size == 7 } + .hand + .first() + .apply { + this.shouldBeInstanceOf() + number shouldEqual 1 + color shouldEqual Card.Color.Red + } + + player1Notifications.waitNotification { + if (player.userId == user2.id) error("WRONG PLAYER TURN") + player.userId == user1.id + } + + game + .shouldBeInstanceOf() + .discardPile + .topCard + .shouldNotBeNull() + .shouldBeInstanceOf { + it.number shouldEqual 0 + it.color shouldEqual Card.Color.Red + } + + playCard(playedCard1!!) + + player1Notifications.waitNotification { + player == getPlayer(user2) + } + + player1Notifications.waitNotification { + playerId == getPlayer(user2).id && card == playedCard2 + } + + playedCard1 = + assertInstanceOf(game) + .playableCards(currentPlayer.id) + .first() + + playedCard1.run { + this.shouldBeInstanceOf() + number shouldEqual 2 + color shouldEqual Card.Color.Red + } + + playCard(playedCard1) + + player1Notifications.waitNotification { + player == getPlayer(user2) + } } } - } - // Wait the end of the game - joinAll(player1Job, player2Job) + // Player 2 actions + val player2Job = + launch { + createGameWithCommandsInChannels(channelCommand2, gameId, user2) { + // wait player 1 has joined the game + until(3.seconds) { player1HasJoin } - // Build the last state from the event store - val game = gameRepository.get(gameId) - assertInstanceOf(game) + joinTheGame() - // Check if the state is correct - game.aggregateId shouldBeEqual gameId - game.players.map { it.userId } shouldContainExactly setOf(user1.id, user2.id) - assertNotNull(game.players.find { it.userId == user1.id }) - .hand.size shouldBeEqual 5 - assertNotNull(game.players.find { it.userId == user2.id }) - .hand.size shouldBeEqual 6 - game.direction shouldBeEqual Game.Direction.CLOCKWISE - assertNotNull(game.lastPlayer?.userId) shouldBeEqual user1.id - assertNotNull(game.discardPile.topCard) shouldBeEqual assertNotNull(playedCard1) + player2Notifications.waitNotification { + players.map { it.userId }.contains(user1.id) && + players.map { it.userId }.contains(user2.id) + } + player2Notifications.waitNotification { playerId == getPlayer(user1).id } + + readyToPlay() + + playedCard2 = + player2Notifications + .waitNotification { hand.size == 7 } + .hand + .first() + .apply { + this.shouldBeInstanceOf() + number shouldEqual 8 + color shouldEqual Card.Color.Red + } + + player2Notifications.waitNotification { + if (player.userId == user2.id) error("WRONG PLAYER TURN") + player.userId == user1.id + } + player2Notifications.waitNotification { + playerId == getPlayer(user1).id && card == playedCard1 + } + + player2Notifications.waitNotification { + player == currentPlayer + } + + game + .shouldBeInstanceOf() + .discardPile + .topCard + .shouldNotBeNull() + .shouldBeInstanceOf { + it.number shouldEqual 1 + it.color shouldEqual Card.Color.Red + } + + playCard(playedCard2) + + player2Notifications.waitNotification { + player.userId == user1.id + } + player2Notifications.waitNotification { + playerId == currentPlayer.id && card == playedCard2 + } + } + } + + // Wait the end of the game + joinAll(player1Job, player2Job) + + // Build the last state from the event store + val game = gameRepository.get(gameId) + assertInstanceOf(game) + + // Check if the state is correct + game.aggregateId shouldBeEqual gameId + game.players.map { it.userId } shouldContainExactly setOf(user1.id, user2.id) + assertNotNull(game.players.find { it.userId == user1.id }) + .hand.size shouldBeEqual 5 + assertNotNull(game.players.find { it.userId == user2.id }) + .hand.size shouldBeEqual 6 + game.direction shouldBeEqual Game.Direction.CLOCKWISE + assertNotNull(game.lastPlayer?.userId) shouldBeEqual user1.id + assertNotNull(game.discardPile.topCard) shouldBeEqual assertNotNull(playedCard1) + } } } } @@ -274,7 +278,7 @@ class GameSimulationTest : context(user: User) private suspend inline fun MutableList.waitNotification(crossinline block: T.() -> Boolean): T { println("NOTIFICATION WAITING: ${T::class.simpleName} for user: ${user.username}") - return eventually(3.seconds) { + return eventually(5.seconds) { filterIsInstance() .first { block(it) } .also { remove(it) }