~jan0sch/smederee

Showing details for patch 837d6a6728a25f7d0ccfa7368b4cd99c670d5f6c.
2025-04-22 (Tue), 10:29 AM - Jens Grassel - 837d6a6728a25f7d0ccfa7368b4cd99c670d5f6c

vcs: Prepare database functionality for health checks.

- change database column types for stderr and stdout to support lines
- add `Eq` instance for `VcsRepositoryHealth`
- add `findHealthCheck` and `saveHealthCheck` to `VcsMetadataRepository`
- implement `findHealthCheck` and `saveHealthCheck` in `DoobieVcsMetadataRepository`
- add tests for implemented methods
Summary of changes
1 files added
  • modules/hub/src/main/resources/db/migration/hub/V11__repository_health_tables_stdout_and_stderr_to_array.sql
5 files modified with 138 lines added and 0 lines removed
  • modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala with 37 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala with 20 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala with 4 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala with 60 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/Generators.scala with 17 added and 0 removed lines
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V11__repository_health_tables_stdout_and_stderr_to_array.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V11__repository_health_tables_stdout_and_stderr_to_array.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/hub/V11__repository_health_tables_stdout_and_stderr_to_array.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V11__repository_health_tables_stdout_and_stderr_to_array.sql	2025-06-20 16:42:14.175271697 +0000
@@ -0,0 +1,2 @@
+ALTER TABLE hub.repository_health ALTER COLUMN stderr SET DATA TYPE TEXT[] USING string_to_array(stderr, '\n');
+ALTER TABLE hub.repository_health ALTER COLUMN stdout SET DATA TYPE TEXT[] USING string_to_array(stdout, '\n');
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-06-20 16:42:14.171271703 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-06-20 16:42:14.175271697 +0000
@@ -8,6 +8,7 @@
 
 import java.util.UUID
 
+import cats.data.*
 import cats.effect.*
 import cats.syntax.all.*
 import de.smederee.email.EmailAddress
@@ -23,6 +24,7 @@
 import org.http4s.Uri
 
 final class DoobieVcsMetadataRepository[F[_]: Async](tx: Transactor[F]) extends VcsMetadataRepository[F] {
+    given Get[Chain[String]]             = Get[Array[String]].tmap(Chain.fromIterableOnce)
     given Meta[EmailAddress]             = Meta[String].timap(EmailAddress.apply)(_.toString)
     given Meta[OrganisationId]           = Meta[UUID].timap(OrganisationId.apply)(_.toUUID)
     given Meta[Set[Permission]]          = Meta[Int].timap(Permission.decode)(_.encode)
@@ -86,6 +88,19 @@
           WHERE owner = ${repository.owner.uid}
           AND name = ${repository.name}""".update.run.transact(tx)
 
+    override def findHealthCheck(repositoryId: VcsRepositoryId): F[Option[VcsRepositoryHealth]] =
+        sql"""SELECT
+            command,
+            exit_code,
+            stderr,
+            stdout,
+            created_at
+        FROM hub.repository_health
+        WHERE repository = $repositoryId"""
+            .query[VcsRepositoryHealth]
+            .option
+            .transact(tx)
+
     override def findVcsRepository(
         owner: VcsRepositoryOwner,
         name: VcsRepositoryName
@@ -172,6 +187,28 @@
         query.query[VcsRepository].stream.transact(tx)
     }
 
+    override def saveHealthCheck(repositoryId: VcsRepositoryId)(health: VcsRepositoryHealth): F[Int] =
+        sql"""INSERT INTO hub.repository_health (
+            repository,
+            command,
+            exit_code,
+            stderr,
+            stdout,
+            created_at
+        ) VALUES (
+            $repositoryId,
+            ${health.command},
+            ${health.exitCode},
+            ${health.stderr.toList},
+            ${health.stdout.toList},
+            ${health.createdAt}
+        ) ON CONFLICT (repository) DO UPDATE SET
+            command = EXCLUDED.command,
+            exit_code = EXCLUDED.exit_code,
+            stderr = EXCLUDED.stderr,
+            stdout = EXCLUDED.stdout,
+            created_at = EXCLUDED.created_at""".update.run.transact(tx)
+
     override def setPermissions(repositoryId: VcsRepositoryId)(organisation: Organisation)(
         permissions: Set[Permission]
     ): F[Int] =
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-06-20 16:42:14.171271703 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-06-20 16:42:14.175271697 +0000
@@ -58,6 +58,15 @@
       */
     def getPermissions(repositoryId: VcsRepositoryId)(organisation: Organisation): F[Set[Permission]]
 
+    /** Search for a health check result for the given vcs repository id.
+      *
+      * @param repositoryId
+      *   The id of the repository.
+      * @return
+      *   An option to the result of a repository health check.
+      */
+    def findHealthCheck(repositoryId: VcsRepositoryId): F[Option[VcsRepositoryHealth]]
+
     /** Search for the vcs repository entry with the given owner and name.
       *
       * @param owner
@@ -137,6 +146,17 @@
       */
     def listRepositories(requester: Option[Account])(owner: VcsRepositoryOwner): Stream[F, VcsRepository]
 
+    /** Save the given result of a repository health check to the database overriding an existing one.
+      *
+      * @param repositoryId
+      *   The id of the repository.
+      * @param health
+      *   The result of a repository health check.
+      * @return
+      *   The number of affected database rows.
+      */
+    def saveHealthCheck(repositoryId: VcsRepositoryId)(health: VcsRepositoryHealth): F[Int]
+
     /** Set the permissions on the given repository for the given organisation.
       *
       * @param repositoryId
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala	2025-06-20 16:42:14.171271703 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala	2025-06-20 16:42:14.175271697 +0000
@@ -561,6 +561,10 @@
     createdAt: OffsetDateTime
 )
 
+object VcsRepositoryHealth {
+    given Eq[VcsRepositoryHealth] = Eq.fromUniversalEquals
+}
+
 /** Data about a VCS respository.
   *
   * @param name
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-06-20 16:42:14.175271697 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-06-20 16:42:14.175271697 +0000
@@ -6,6 +6,8 @@
 
 package de.smederee.hub
 
+import java.time.ZoneOffset
+
 import cats.data.NonEmptyList
 import cats.effect.*
 import cats.syntax.all.*
@@ -168,6 +170,36 @@
         }
     }
 
+    test("findHealthCheck must return an existing health check".tag(NeedsDatabase)) {
+        PropF.forAllF { (account: Account, repository: VcsRepository, health: VcsRepositoryHealth) =>
+            val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+            val dbConfig      = configuration.database
+            val tx = Transactor.fromDriverManager[IO](
+                driver = dbConfig.driver,
+                url = dbConfig.url,
+                user = dbConfig.user,
+                password = dbConfig.pass,
+                logHandler = None
+            )
+            val repo = new DoobieVcsMetadataRepository[IO](tx)
+            val test = for {
+                _      <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                _      <- repo.createVcsRepository(vcsRepository)
+                repoId <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name)
+                saved  <- repoId.traverse(id => repo.saveHealthCheck(id)(health))
+                found  <- repoId.traverse(id => repo.findHealthCheck(id))
+            } yield (repoId, saved, found.getOrElse(None))
+            test.start.flatMap(_.joinWithNever).map { result =>
+                val (repoId, saved, found) = result
+                // Adjust timestamp because the one from the database will be in UTC.
+                val expectedHealth = health.copy(createdAt = health.createdAt.withOffsetSameInstant(ZoneOffset.UTC))
+                assert(repoId.nonEmpty, "No test repository created in database!")
+                assert(saved.exists(_ === 1), "No rows written to database!")
+                assert(found.exists(_ === expectedHealth), "Returned health status does not match!")
+            }
+        }
+    }
+
     test("findVcsRepository must return an existing repository".tag(NeedsDatabase)) {
         PropF.forAllF { (account: Account, repository: VcsRepository) =>
             val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
@@ -638,6 +670,34 @@
             }
         }
     }
+
+    test("saveHealthCheck must save the data".tag(NeedsDatabase)) {
+        PropF.forAllF { (account: Account, repository: VcsRepository, health: VcsRepositoryHealth) =>
+            val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
+            val dbConfig      = configuration.database
+            val tx = Transactor.fromDriverManager[IO](
+                driver = dbConfig.driver,
+                url = dbConfig.url,
+                user = dbConfig.user,
+                password = dbConfig.pass,
+                logHandler = None
+            )
+            val repo = new DoobieVcsMetadataRepository[IO](tx)
+            val test = for {
+                _       <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+                _       <- repo.createVcsRepository(vcsRepository)
+                repoId  <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name)
+                saved   <- repoId.traverse(id => repo.saveHealthCheck(id)(health))
+                updated <- repoId.traverse(id => repo.saveHealthCheck(id)(health))
+            } yield (repoId, saved, updated)
+            test.start.flatMap(_.joinWithNever).map { result =>
+                val (repoId, saved, updated) = result
+                assert(repoId.nonEmpty, "No test repository created in database!")
+                assert(saved.exists(_ === 1), "No rows written to database!")
+                assert(updated.exists(_ === 1), "No rows updated in to database!")
+            }
+        }
+    }
 
     test("setPermissions must create permissions correctly".tag(NeedsDatabase)) {
         PropF.forAllF {
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-06-20 16:42:14.175271697 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-06-20 16:42:14.175271697 +0000
@@ -11,6 +11,7 @@
 import java.util.Locale
 import java.util.UUID
 
+import cats.data.Chain
 import de.smederee.email.EmailAddress
 import de.smederee.i18n.LanguageCode
 import de.smederee.security.*
@@ -221,6 +222,22 @@
 
     given Arbitrary[VcsRepository] = Arbitrary(genValidVcsRepository)
 
+    val genVcsRepositoryHealth: Gen[VcsRepositoryHealth] = for {
+        command   <- Gen.nonEmptyStringOf(Gen.alphaChar)
+        exitCode  <- Gen.choose(Int.MinValue, Int.MaxValue)
+        stderr    <- Gen.listOf(Gen.alphaNumStr).map(Chain.fromSeq)
+        stdout    <- Gen.listOf(Gen.alphaNumStr).map(Chain.fromSeq)
+        createdAt <- genOffsetDateTime
+    } yield VcsRepositoryHealth(
+        command = command,
+        exitCode = exitCode,
+        stderr = stderr,
+        stdout = stdout,
+        createdAt = createdAt
+    )
+
+    given Arbitrary[VcsRepositoryHealth] = Arbitrary(genVcsRepositoryHealth)
+
     val genPermissions: Gen[Set[Permission]] = Gen.choose(0, 7).map(Permission.decode)
 
     given Arbitrary[Set[Permission]] = Arbitrary(genPermissions)