diff --git a/build.sbt b/build.sbt index 72bc846d..05bed439 100755 --- a/build.sbt +++ b/build.sbt @@ -79,7 +79,7 @@ lazy val buildInfoSettings = Seq( lazy val root = (project in file(".")) .settings( name := "ottochain" - ).aggregate(proto, models, sharedData, currencyL0, currencyL1, dataL1) + ).aggregate(proto, models, sharedData, currencyL0, currencyL1, dataL1, ci) lazy val proto = (project in file("modules/proto")) .dependsOn(models) @@ -153,4 +153,15 @@ lazy val dataL1 = (project in file("modules/data_l1")) commonSettings, commonTestSettings, name := "ottochain-data-l1" + ) + +lazy val ci = (project in file("modules/ci")) + .settings( + commonSettings, + commonTestSettings, + name := "ottochain-ci", + libraryDependencies ++= Seq( + Libraries.catsEffect, + Libraries.catsCore + ) ) \ No newline at end of file diff --git a/modules/ci/src/test/scala/xyz/kd5ujc/ci/DockerWorkflowTest.scala b/modules/ci/src/test/scala/xyz/kd5ujc/ci/DockerWorkflowTest.scala new file mode 100644 index 00000000..84d92d58 --- /dev/null +++ b/modules/ci/src/test/scala/xyz/kd5ujc/ci/DockerWorkflowTest.scala @@ -0,0 +1,210 @@ +package xyz.kd5ujc.ci + +import cats.effect.IO + +import weaver.SimpleIOSuite + +import java.nio.file.{Files, Paths} +import scala.util.matching.Regex + +/** + * TDD tests for Docker workflow integration with tessellation base images. + * These tests verify the Docker and GitHub Actions workflow functionality. + */ +object DockerWorkflowTest extends SimpleIOSuite { + + test("tessellation-base.Dockerfile should start with minimal base") { + IO { + val dockerfilePath = Paths.get("tessellation-base.Dockerfile") + val content = Files.readString(dockerfilePath) + val lines = content.split("\n").map(_.trim).filter(_.nonEmpty) + + // First non-comment line should be FROM with minimal base + val fromLine = lines.find(_.startsWith("FROM")).getOrElse("") + expect(fromLine.contains("scratch") || fromLine.contains("alpine")) + } + } + + test("tessellation-base.Dockerfile should copy only tessellation JARs") { + IO { + val dockerfilePath = Paths.get("tessellation-base.Dockerfile") + val content = Files.readString(dockerfilePath) + + // Should copy gl0, gl1, keytool, wallet but NOT metagraph-specific JARs + val copyLines = content.lines().filter(_.trim.startsWith("COPY")).toArray + + expect(copyLines.nonEmpty) && + expect(copyLines.exists(_.contains("gl0"))) && + expect(copyLines.exists(_.contains(".jar"))) && + expect(!content.contains("ml0")) && + expect(!content.contains("cl1")) && + expect(!content.contains("dl1")) + } + } + + test("build-tessellation-base.yml should have correct job structure") { + IO { + val workflowPath = Paths.get(".github/workflows/build-tessellation-base.yml") + val content = Files.readString(workflowPath) + + expect(content.contains("name:")) && + expect(content.contains("jobs:")) && + expect(content.contains("build:")) && + expect(content.contains("runs-on: ubuntu-latest")) + } + } + + test("build-tessellation-base.yml should setup Java and sbt") { + IO { + val workflowPath = Paths.get(".github/workflows/build-tessellation-base.yml") + val content = Files.readString(workflowPath) + + expect(content.contains("actions/setup-java@v4")) && + expect(content.contains("temurin")) && + expect(content.contains("java-version: '21'")) && + expect(content.contains("sbt/setup-sbt@v1")) + } + } + + test("build-tessellation-base.yml should clone tessellation") { + IO { + val workflowPath = Paths.get(".github/workflows/build-tessellation-base.yml") + val content = Files.readString(workflowPath) + + expect(content.contains("git clone")) && + expect(content.contains("tessellation.git")) && + expect(content.contains("TESSELLATION_VERSION")) + } + } + + test("build-tessellation-base.yml should build tessellation JARs") { + IO { + val workflowPath = Paths.get(".github/workflows/build-tessellation-base.yml") + val content = Files.readString(workflowPath) + + // Should build tessellation components but not metagraph + expect(content.contains("sbt")) && + (expect(content.contains("assembly")) || expect(content.contains("compile"))) && + expect(!content.contains("--metagraph")) + } + } + + test("build-tessellation-base.yml should login to GHCR") { + IO { + val workflowPath = Paths.get(".github/workflows/build-tessellation-base.yml") + val content = Files.readString(workflowPath) + + expect(content.contains("docker/login-action")) && + expect(content.contains("ghcr.io")) && + expect(content.contains("GITHUB_TOKEN")) + } + } + + test("build-tessellation-base.yml should build and push Docker image") { + IO { + val workflowPath = Paths.get(".github/workflows/build-tessellation-base.yml") + val content = Files.readString(workflowPath) + + expect(content.contains("docker/build-push-action")) && + expect(content.contains("file: tessellation-base.Dockerfile")) && + expect(content.contains("push: true")) && + expect(content.contains("ghcr.io/ottobot-ai/tessellation-base")) + } + } + + test("build-tessellation-base.yml should tag with version") { + IO { + val workflowPath = Paths.get(".github/workflows/build-tessellation-base.yml") + val content = Files.readString(workflowPath) + + // Should tag with version (latest and specific version) + expect(content.contains("tags:")) && + expect(content.contains("latest")) && + expect(content.contains("${{ env.TESSELLATION_VERSION }}") || content.contains("$TESSELLATION_VERSION")) + } + } + + test("e2e.yml should extract JARs before starting cluster") { + IO { + val e2eWorkflowPath = Paths.get(".github/workflows/e2e.yml") + val content = Files.readString(e2eWorkflowPath) + + // Should have a step to extract JARs from pre-built image + expect(content.contains("docker create")) && + expect(content.contains("docker cp")) && + expect(content.contains("tessellation-base")) + } + } + + test("e2e.yml should set tessellation environment variables") { + IO { + val e2eWorkflowPath = Paths.get(".github/workflows/e2e.yml") + val content = Files.readString(e2eWorkflowPath) + + // Should set SKIP_ASSEMBLY=true and PUBLISH=false + expect(content.contains("SKIP_ASSEMBLY=true")) && + expect(content.contains("PUBLISH=false")) + } + } + + test("e2e.yml should use extracted JARs in tessellation directory") { + IO { + val e2eWorkflowPath = Paths.get(".github/workflows/e2e.yml") + val content = Files.readString(e2eWorkflowPath) + + // Should copy extracted JARs to tessellation directory + val dockerCpPattern = "docker cp.*tessellation".r + expect(dockerCpPattern.findFirstIn(content).isDefined) + } + } + + test("e2e.yml should still clone tessellation repo") { + IO { + val e2eWorkflowPath = Paths.get(".github/workflows/e2e.yml") + val content = Files.readString(e2eWorkflowPath) + + // Should still clone tessellation for source code even though JARs are pre-built + expect(content.contains("git clone")) && + expect(content.contains("tessellation.git")) + } + } + + test("e2e.yml should still apply patches to tessellation") { + IO { + val e2eWorkflowPath = Paths.get(".github/workflows/e2e.yml") + val content = Files.readString(e2eWorkflowPath) + + // Should still apply patches since some source code is needed + expect(content.contains("git apply")) && + expect(content.contains("patches/")) + } + } + + test("e2e.yml should validate performance improvement") { + IO { + // This test checks that the workflow includes performance validation + val e2eWorkflowPath = Paths.get(".github/workflows/e2e.yml") + val content = Files.readString(e2eWorkflowPath) + + // Should have some mechanism to track timing + expect(content.contains("timeout-minutes")) && + expect(content.contains("30") || content.contains("20")) // reduced timeout for faster CI + } + } + + test("workflow files should use consistent tessellation version") { + IO { + val buildWorkflow = Files.readString(Paths.get(".github/workflows/build-tessellation-base.yml")) + val e2eWorkflow = Files.readString(Paths.get(".github/workflows/e2e.yml")) + + // Both workflows should reference the same tessellation version + val versionPattern = "TESSELLATION_VERSION.*[0-9]+\\.[0-9]+\\.[0-9]+".r + val buildVersion = versionPattern.findFirstIn(buildWorkflow) + val e2eVersion = versionPattern.findFirstIn(e2eWorkflow) + + expect(buildVersion.isDefined) && + expect(e2eVersion.isDefined) && + expect(buildVersion == e2eVersion) + } + } +} \ No newline at end of file diff --git a/modules/ci/src/test/scala/xyz/kd5ujc/ci/PerformanceIntegrationTest.scala b/modules/ci/src/test/scala/xyz/kd5ujc/ci/PerformanceIntegrationTest.scala new file mode 100644 index 00000000..dbf9d3f4 --- /dev/null +++ b/modules/ci/src/test/scala/xyz/kd5ujc/ci/PerformanceIntegrationTest.scala @@ -0,0 +1,213 @@ +package xyz.kd5ujc.ci + +import cats.effect.IO + +import weaver.SimpleIOSuite + +import java.time.{Duration, Instant} +import scala.util.Try + +/** + * TDD tests for performance improvements and integration testing. + * These tests verify the actual performance benefits and end-to-end functionality. + */ +object PerformanceIntegrationTest extends SimpleIOSuite { + + test("E2E CI run should complete in ≤10 minutes with pre-built images") { + IO { + // This test should FAIL until the optimization is implemented + val startTime = Instant.now() + val ciResult = simulateE2ECIRun() + val endTime = Instant.now() + val duration = Duration.between(startTime, endTime) + + expect(ciResult.isSuccess) && + expect(duration.toMinutes <= 10) // Target: ≤10 min (currently ~16 min) + } + } + + test("tessellation assembly should be skipped in E2E") { + IO { + val assemblySkipped = checkTessellationAssemblySkipped() + expect(assemblySkipped) // Should fail until SKIP_ASSEMBLY=true is implemented + } + } + + test("metagraph assembly should still occur in E2E") { + IO { + val metagraphBuilt = checkMetagraphBuiltFromSource() + expect(metagraphBuilt) // Should pass - metagraph still built from source + } + } + + test("pre-built image should contain expected tessellation JARs") { + IO { + val imageJars = extractJarListFromImage("ghcr.io/ottobot-ai/tessellation-base:latest") + + val expectedJars = Set( + "global-l0.jar", + "global-l1.jar", + "keytool.jar", + "wallet.jar" + ) + + val actualJars = imageJars.toSet + + expect(expectedJars.subsetOf(actualJars)) && + expect(!actualJars.exists(_.contains("ml0"))) && + expect(!actualJars.exists(_.contains("cl1"))) && + expect(!actualJars.exists(_.contains("dl1"))) + } + } + + test("image extraction should be faster than compilation") { + IO { + val extractionStart = Instant.now() + val extractionResult = extractJarsFromPreBuiltImage() + val extractionEnd = Instant.now() + val extractionDuration = Duration.between(extractionStart, extractionEnd) + + val compilationStart = Instant.now() + val compilationResult = compileTessellationFromSource() + val compilationEnd = Instant.now() + val compilationDuration = Duration.between(compilationStart, compilationEnd) + + expect(extractionResult.isSuccess) && + expect(compilationResult.isSuccess) && + expect(extractionDuration.compareTo(compilationDuration) < 0) // extraction should be faster + } + } + + test("docker image should be publicly pullable") { + IO { + val pullResult = pullImageWithoutAuth("ghcr.io/ottobot-ai/tessellation-base:latest") + expect(pullResult.isSuccess) // Should fail until image is public + } + } + + test("workflow should rebuild image on tessellation version changes") { + IO { + // Test that the workflow triggers when tessellation version is updated + val workflowTriggers = checkWorkflowTriggerConditions() + + expect(workflowTriggers.contains("push")) && + expect(workflowTriggers.contains("paths")) && + expect(workflowTriggers.exists(_.contains("project/")) || workflowTriggers.exists(_.contains("build.sbt"))) + } + } + + test("E2E tests should pass with pre-built tessellation JARs") { + IO { + val e2eTestResults = runE2ETestsWithPreBuiltJars() + expect(e2eTestResults.allPassed) // Should fail until integration is complete + } + } + + test("cluster startup should work with extracted JARs") { + IO { + val clusterStartup = startTessellationClusterWithExtractedJars() + + expect(clusterStartup.gl0Started) && + expect(clusterStartup.ml0Started) && + expect(clusterStartup.dl1Started) && + expect(clusterStartup.consensusWorking) + } + } + + test("JAR extraction should preserve file permissions") { + IO { + val extractedJars = extractJarsWithPermissions("ghcr.io/ottobot-ai/tessellation-base:latest") + + expect(extractedJars.nonEmpty) && + expect(extractedJars.forall(_.isExecutable)) // JAR files should be executable + } + } + + test("build cache should improve subsequent builds") { + IO { + val firstBuildTime = measureWorkflowBuildTime() + val secondBuildTime = measureWorkflowBuildTime() // Should use cache + + expect(firstBuildTime > 0) && + expect(secondBuildTime > 0) && + expect(secondBuildTime <= firstBuildTime) // Second build should be faster or same + } + } + + // Helper methods that simulate the actual functionality + // These will return failure states until the real implementation exists + + private def simulateE2ECIRun(): Try[Boolean] = { + Try { + // Simulate current 16-minute CI time - should fail ≤10 minute target + Thread.sleep(16 * 60 * 1000) // 16 minutes in ms + false // Will timeout and fail the test + } + } + + private def checkTessellationAssemblySkipped(): Boolean = { + // Should return false until SKIP_ASSEMBLY=true is implemented + false + } + + private def checkMetagraphBuiltFromSource(): Boolean = { + // Metagraph should continue to be built from source + true // This should pass even after optimization + } + + private def extractJarListFromImage(imageName: String): List[String] = { + // Should return empty list until image exists + List.empty + } + + private def extractJarsFromPreBuiltImage(): Try[Boolean] = { + Try(false) // Should fail until extraction is implemented + } + + private def compileTessellationFromSource(): Try[Boolean] = { + Try(true) // Compilation works currently + } + + private def pullImageWithoutAuth(imageName: String): Try[Boolean] = { + Try(false) // Should fail until image is public + } + + private def checkWorkflowTriggerConditions(): List[String] = { + // Should return empty list until workflow exists + List.empty + } + + private def runE2ETestsWithPreBuiltJars(): E2ETestResults = { + E2ETestResults(allPassed = false) // Should fail until integration complete + } + + private def startTessellationClusterWithExtractedJars(): ClusterStartupResult = { + ClusterStartupResult( + gl0Started = false, + ml0Started = false, + dl1Started = false, + consensusWorking = false + ) + } + + private def extractJarsWithPermissions(imageName: String): List[ExecutableJar] = { + List.empty // Should be empty until extraction works + } + + private def measureWorkflowBuildTime(): Double = { + // Return high build time until optimization exists + 16.0 // 16 minutes baseline + } +} + +// Helper case classes +case class E2ETestResults(allPassed: Boolean) + +case class ClusterStartupResult( + gl0Started: Boolean, + ml0Started: Boolean, + dl1Started: Boolean, + consensusWorking: Boolean +) + +case class ExecutableJar(name: String, isExecutable: Boolean) \ No newline at end of file diff --git a/modules/ci/src/test/scala/xyz/kd5ujc/ci/TessellationBaseImageTest.scala b/modules/ci/src/test/scala/xyz/kd5ujc/ci/TessellationBaseImageTest.scala new file mode 100644 index 00000000..cbd1a15d --- /dev/null +++ b/modules/ci/src/test/scala/xyz/kd5ujc/ci/TessellationBaseImageTest.scala @@ -0,0 +1,205 @@ +package xyz.kd5ujc.ci + +import cats.effect.IO + +import weaver.SimpleIOSuite + +import java.nio.file.{Files, Paths} +import scala.util.Try + +/** + * TDD tests for tessellation base image CI optimization feature. + * These tests MUST FAIL before implementation to prove they test the right thing. + * + * Specification: Pre-build tessellation JARs into Docker image to reduce E2E CI time. + * Expected improvement: ~16 min → ~8-10 min (40-45% improvement) + */ +object TessellationBaseImageTest extends SimpleIOSuite { + + test("build-tessellation-base.yml workflow should exist") { + IO { + val workflowPath = Paths.get(".github/workflows/build-tessellation-base.yml") + expect(Files.exists(workflowPath)) + } + } + + test("build-tessellation-base.yml should trigger on manual dispatch") { + IO { + val workflowPath = Paths.get(".github/workflows/build-tessellation-base.yml") + val content = Files.readString(workflowPath) + + expect(content.contains("workflow_dispatch")) && + expect(content.contains("on:")) + } + } + + test("build-tessellation-base.yml should trigger on tessellation version changes") { + IO { + val workflowPath = Paths.get(".github/workflows/build-tessellation-base.yml") + val content = Files.readString(workflowPath) + + // Should trigger when tessellation version in project files changes + expect(content.contains("push:")) && + expect(content.contains("paths:")) && + (expect(content.contains("project/")) || expect(content.contains("build.sbt"))) + } + } + + test("build-tessellation-base.yml should publish to GHCR") { + IO { + val workflowPath = Paths.get(".github/workflows/build-tessellation-base.yml") + val content = Files.readString(workflowPath) + + expect(content.contains("ghcr.io/ottobot-ai/tessellation-base")) && + expect(content.contains("docker push")) + } + } + + test("tessellation-base.Dockerfile should exist") { + IO { + val dockerfilePath = Paths.get("tessellation-base.Dockerfile") + expect(Files.exists(dockerfilePath)) + } + } + + test("tessellation-base.Dockerfile should use minimal base image") { + IO { + val dockerfilePath = Paths.get("tessellation-base.Dockerfile") + val content = Files.readString(dockerfilePath) + + // Should use scratch or minimal base for JAR-only image + expect(content.contains("FROM scratch") || content.contains("FROM alpine")) && + expect(content.contains("COPY")) && + expect(content.contains(".jar")) + } + } + + test("tessellation-base.Dockerfile should copy tessellation JARs") { + IO { + val dockerfilePath = Paths.get("tessellation-base.Dockerfile") + val content = Files.readString(dockerfilePath) + + // Should copy gl0, gl1, keytool, wallet JARs but NOT metagraph JARs + expect(content.contains("gl0") || content.contains("keytool")) && + expect(content.contains(".jar")) && + expect(!content.contains("ml0")) && // metagraph JARs should not be pre-built + expect(!content.contains("dl1")) + } + } + + test("e2e.yml should be updated to extract JARs from pre-built image") { + IO { + val e2eWorkflowPath = Paths.get(".github/workflows/e2e.yml") + val content = Files.readString(e2eWorkflowPath) + + // Should use docker create + docker cp pattern to extract JARs + expect(content.contains("docker create")) && + expect(content.contains("docker cp")) && + expect(content.contains("tessellation-base")) + } + } + + test("e2e.yml should set SKIP_ASSEMBLY=true") { + IO { + val e2eWorkflowPath = Paths.get(".github/workflows/e2e.yml") + val content = Files.readString(e2eWorkflowPath) + + // Should skip tessellation assembly since JARs are pre-built + expect(content.contains("SKIP_ASSEMBLY=true")) + } + } + + test("e2e.yml should set PUBLISH=false") { + IO { + val e2eWorkflowPath = Paths.get(".github/workflows/e2e.yml") + val content = Files.readString(e2eWorkflowPath) + + // Should skip publishing since we're using pre-built JARs + expect(content.contains("PUBLISH=false")) + } + } + + test("e2e.yml should still build metagraph from source") { + IO { + val e2eWorkflowPath = Paths.get(".github/workflows/e2e.yml") + val content = Files.readString(e2eWorkflowPath) + + // Metagraph JARs (ml0, cl1, dl1) should still be built from source + expect(content.contains("--metagraph")) && + expect(content.contains("--dl1")) && + expect(!content.contains("SKIP_METAGRAPH_ASSEMBLY=true")) + } + } + + test("CI time improvement should be measurable") { + IO { + // This test will measure actual CI performance after implementation + // For now, it should fail since no optimization exists yet + val ciTimeBenchmark = measureE2ECITime() + + // Current baseline is ~16 minutes, target is ≤10 minutes + expect(ciTimeBenchmark <= 10.0) // This should FAIL before implementation + } + } + + test("tessellation JARs should be extractable from pre-built image") { + IO { + // Test that we can extract the expected JARs from the pre-built image + val extractionTest = Try { + // This would test the docker create + docker cp extraction pattern + val imageExists = checkDockerImageExists("ghcr.io/ottobot-ai/tessellation-base:latest") + val jarsExtractable = extractJarsFromImage("ghcr.io/ottobot-ai/tessellation-base:latest") + imageExists && jarsExtractable + } + + expect(extractionTest.isSuccess && extractionTest.get) + } + } + + test("pre-built image should be public and not require authentication") { + IO { + // Test that the image can be pulled without GITHUB_TOKEN + val pullTest = Try { + // This would test docker pull without auth + pullDockerImagePublic("ghcr.io/ottobot-ai/tessellation-base:latest") + } + + expect(pullTest.isSuccess && pullTest.get) + } + } + + test("workflow should tag image with version from build") { + IO { + val workflowPath = Paths.get(".github/workflows/build-tessellation-base.yml") + val content = Files.readString(workflowPath) + + // Should tag with version (e.g., v4.0.0-rc.2) + expect(content.contains("$TESSELLATION_VERSION") || content.contains("tags:")) && + expect(content.contains("ghcr.io/ottobot-ai/tessellation-base")) + } + } + + // Helper methods that will be implemented with the actual functionality + + private def measureE2ECITime(): Double = { + // This would measure actual CI time - for now return a high value to fail the test + 16.5 // Current baseline - should fail target of ≤10 minutes + } + + private def checkDockerImageExists(imageName: String): Boolean = { + // This would check if the Docker image exists in GHCR + false // Should fail until image is published + } + + private def extractJarsFromImage(imageName: String): Boolean = { + // This would test the JAR extraction pattern + false // Should fail until extraction logic is implemented + } + + private def pullDockerImagePublic(imageName: String): Boolean = { + // This would test pulling the image without authentication + false // Should fail until image is public + + + } +} \ No newline at end of file