diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4df956e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,103 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Nebula is a Kotlin-based DSL for quickly defining and orchestrating test ecosystems using Docker containers. It allows developers to spin up complex infrastructure stacks (Kafka, databases, HTTP servers, etc.) with associated behaviors for testing and demonstrations. + +## Development Commands + +### Build and Test +```bash +# Build all modules +mvn clean compile + +# Run tests +mvn test + +# Package the application +mvn package + +# Run a specific test class +mvn test -Dtest=ClassName + +# Run tests for a specific module +mvn test -pl nebula-dsl +``` + +### Documentation +```bash +# In docs/ directory +npm run dev # Start development server +npm run build # Build documentation +npm start # Start production server +``` + +### Release Management +```bash +./tag-release.sh # Automated release tagging and version management +``` + +## Architecture + +### Multi-Module Structure +- **nebula-api**: Core API definitions and events +- **nebula-dsl**: Main DSL implementation and infrastructure components +- **nebula-cli**: Command-line interface using PicoCLI +- **nebula-runtime**: Script execution engine and HTTP server + +### Key Technologies +- **Kotlin 2.2.0** on JVM 21 +- **TestContainers 1.19.0** for container orchestration +- **Ktor 2.3.12** for HTTP servers and clients +- **Kotest 5.9.1** for testing with descriptive specifications +- **Project Reactor** for reactive programming + +### Infrastructure Components +The DSL supports: Kafka, HTTP servers, SQL databases (PostgreSQL/MySQL with JOOQ), MongoDB, S3 (via LocalStack), Hazelcast, Avro/Protobuf schemas. + +## Code Patterns + +### DSL Scripts +- Files use `.nebula.kts` extension +- Kotlin scripting with fluent builder APIs +- Default imports provide common utilities +- Components implement `InfrastructureComponent<*>` interface + +### Testing +- Uses Kotest framework with descriptive specifications +- TestContainers for integration testing +- Tests located in `src/test/kotlin` directories + +### Lifecycle Management +- Event-driven architecture with lifecycle events +- Centralized stack runner for orchestration +- Graceful shutdown handling for containers + +## Running Nebula + +### CLI Usage +```bash +# Execute a script +nebula script.nebula.kts + +# Start HTTP server mode +nebula --http=8099 +``` + +### Docker Usage +```bash +docker run -v /var/run/docker.sock:/var/run/docker.sock \ + --privileged --network host \ + orbitalhq/nebula:latest +``` + +## Development Notes + +- Source code in `src/main/kotlin`, tests in `src/test/kotlin` +- Maven manages multi-module dependencies +- Custom S3-based Maven repository at repo.orbitalhq.com +- Multi-platform Docker builds (AMD64/ARM64) +- Requires Docker daemon access for TestContainers +- Uses official Kotlin coding style conventions \ No newline at end of file diff --git a/nebula-cli/src/main/kotlin/com/orbitalhq/nebula/cli/NebulaCli.kt b/nebula-cli/src/main/kotlin/com/orbitalhq/nebula/cli/NebulaCli.kt index 2017ebe..6a83f73 100644 --- a/nebula-cli/src/main/kotlin/com/orbitalhq/nebula/cli/NebulaCli.kt +++ b/nebula-cli/src/main/kotlin/com/orbitalhq/nebula/cli/NebulaCli.kt @@ -1,6 +1,7 @@ package com.orbitalhq.nebula.cli import com.orbitalhq.nebula.NebulaConfig +import com.orbitalhq.nebula.NebulaStackWithSource import com.orbitalhq.nebula.StackRunner import com.orbitalhq.nebula.runtime.NebulaScriptExecutor import com.orbitalhq.nebula.runtime.server.NebulaServer @@ -86,9 +87,10 @@ class Nebula : Callable { val scriptRunner = NebulaScriptExecutor() val stack = scriptRunner.runScript(file) + val stackWithSource = NebulaStackWithSource(stack, file.readText()) val stackRunner = StackRunner(nebulaConfig) - stackRunner.submit(stack) + stackRunner.submit(stackWithSource) Runtime.getRuntime().addShutdownHook(Thread { if (verbose) spec.commandLine().out.println("Shutting down services...") stackRunner.shutDownAll() diff --git a/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/NebulaStack.kt b/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/NebulaStack.kt index e35ae70..59cc648 100644 --- a/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/NebulaStack.kt +++ b/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/NebulaStack.kt @@ -16,6 +16,17 @@ import java.util.concurrent.atomic.AtomicBoolean typealias StackName = String +data class NebulaStackWithSource( + val stack: NebulaStack, + val source: String +) { + fun withName(id: String):NebulaStackWithSource { + return this.copy(stack = stack.withName(id)) + } + + val name = stack.name +} + class NebulaStack( val name: StackName = NameGenerator.generateName(), initialComponents: List> = emptyList() diff --git a/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/StackRunner.kt b/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/StackRunner.kt index 8774f63..ad39796 100644 --- a/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/StackRunner.kt +++ b/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/StackRunner.kt @@ -3,10 +3,9 @@ package com.orbitalhq.nebula import com.orbitalhq.nebula.core.ComponentInfo import com.orbitalhq.nebula.core.StackStateEvent import io.github.oshai.kotlinlogging.KotlinLogging -import org.junit.runner.Description -import org.junit.runners.model.Statement import org.testcontainers.containers.Network import reactor.core.publisher.Flux +import java.util.UUID import java.util.concurrent.ConcurrentHashMap import kotlin.concurrent.thread @@ -16,17 +15,22 @@ data class NebulaConfig( ) class StackRunner(private val config: NebulaConfig = NebulaConfig()) { private val logger = KotlinLogging.logger {} - val stacks = ConcurrentHashMap() + val stacks = ConcurrentHashMap() private val _stackState = ConcurrentHashMap>>() - fun submit(stack: NebulaStack, name: StackName = stack.name, startAsync: Boolean = false): Flux { - this.stacks.compute(name) { key, existingSpec -> + fun submit(submittedStack: NebulaStackWithSource, name: StackName = submittedStack.name, startAsync: Boolean = false): Flux { + val stack = this.stacks.compute(name) { key, existingSpec -> if (existingSpec != null) { - logger.info { "Replacing spec $key" } - shutDown(name) + if (existingSpec.source == submittedStack.source) { + logger.info { "Received duplicate submission for spec $key - reusing existing stack" } + return@compute existingSpec + } else { + logger.info { "Replacing spec $key" } + shutDown(name) + } } - stack - } + submittedStack + } ?: error("After submitting stack $name, no stack was created.") if (startAsync) { thread { start(name) @@ -46,12 +50,12 @@ class StackRunner(private val config: NebulaConfig = NebulaConfig()) { fun stackEvents(name:String):Flux { val stack = this.stacks[name] ?: error("Stack $name not found") - return stack.lifecycleEvents + return stack.stack.lifecycleEvents } private fun start(name: String) { - val stack = this.stacks[name] ?: error("Stack $name not found") + val stack = this.stacks[name]?.stack ?: error("Stack $name not found") stack.lifecycleEvents.subscribe { event -> logger.info { event.toString() } } @@ -63,7 +67,7 @@ class StackRunner(private val config: NebulaConfig = NebulaConfig()) { inline fun > component(): List { return this.stacks.values.flatMap { - it.components.filterIsInstance() + it.stack.components.filterIsInstance() } } @@ -74,7 +78,7 @@ class StackRunner(private val config: NebulaConfig = NebulaConfig()) { fun shutDown(name: String) { val spec = this.stacks[name] ?: error("Spec $name not found") logger.info { "Shutting down ${spec.name}" } - spec.components.forEach { + spec.stack.components.forEach { try { logger.info { "Stopping ${it.name}" } it.stop() @@ -89,8 +93,20 @@ class StackRunner(private val config: NebulaConfig = NebulaConfig()) { /** * Convenience for testing */ -fun NebulaStack.start(): StackRunner { +fun NebulaStackWithSource.start(): StackRunner { val executor = StackRunner() executor.submit(this) return executor +} + +/** + * Testing method. + * Uses a random UUID in the source to replicate legacy behaviour, where submission + * would not check for duplicates, or replace existing stacks if present + */ +fun NebulaStack.start(): StackRunner { + val executor = StackRunner() + val stackWithSource = NebulaStackWithSource(this, "Source not provided - Random UUID follows - ${UUID.randomUUID()}" ) + executor.submit(stackWithSource) + return executor } \ No newline at end of file diff --git a/nebula-runtime/src/main/kotlin/com/orbitalhq/nebula/runtime/NebulaScriptExecutor.kt b/nebula-runtime/src/main/kotlin/com/orbitalhq/nebula/runtime/NebulaScriptExecutor.kt index 2230885..a654fef 100644 --- a/nebula-runtime/src/main/kotlin/com/orbitalhq/nebula/runtime/NebulaScriptExecutor.kt +++ b/nebula-runtime/src/main/kotlin/com/orbitalhq/nebula/runtime/NebulaScriptExecutor.kt @@ -2,6 +2,7 @@ package com.orbitalhq.nebula.runtime import com.orbitalhq.nebula.NebulaScript import com.orbitalhq.nebula.NebulaStack +import com.orbitalhq.nebula.NebulaStackWithSource import io.github.oshai.kotlinlogging.KotlinLogging import java.io.File import kotlin.script.experimental.api.EvaluationResult @@ -50,8 +51,14 @@ class NebulaScriptExecutor { resultWithDiagnostics.reports.filter { it.severity == ScriptDiagnostic.Severity.ERROR }.forEach { logger.error { it.toString() } } } + @Deprecated("call toStackWithSource") fun toStack(string: String): NebulaStack { val source = string.toScriptSource() return runScript(source) } + fun toStackWithSource(string: String): NebulaStackWithSource { + val source = string.toScriptSource() + val stack = runScript(source) + return NebulaStackWithSource(stack, string) + } } \ No newline at end of file diff --git a/nebula-runtime/src/main/kotlin/com/orbitalhq/nebula/runtime/server/NebulaServer.kt b/nebula-runtime/src/main/kotlin/com/orbitalhq/nebula/runtime/server/NebulaServer.kt index 7839dbe..9dab9c3 100644 --- a/nebula-runtime/src/main/kotlin/com/orbitalhq/nebula/runtime/server/NebulaServer.kt +++ b/nebula-runtime/src/main/kotlin/com/orbitalhq/nebula/runtime/server/NebulaServer.kt @@ -5,6 +5,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.orbitalhq.nebula.NebulaConfig import com.orbitalhq.nebula.NebulaStack +import com.orbitalhq.nebula.NebulaStackWithSource import com.orbitalhq.nebula.StackName import com.orbitalhq.nebula.StackRunner import com.orbitalhq.nebula.runtime.NebulaScriptExecutor @@ -66,7 +67,7 @@ class NebulaServer( // Create a stack without an id -- an id is assigned post { val script = call.receiveText() - val stack = scriptExecutor.toStack(script) + val stack = scriptExecutor.toStackWithSource(script) stackExecutor.submit(stack, startAsync = true) call.respond(stack.name) } @@ -76,8 +77,8 @@ class NebulaServer( "Missing or malformed id" ) val script = call.receiveText() - val stack = scriptExecutor.toStack(script).let { - NebulaStack(name = id, initialComponents = it.components) + val stack = scriptExecutor.toStackWithSource(script).let { stack -> + stack.withName(id) } stackExecutor.submit(stack) call.respond(stack.name) @@ -125,9 +126,9 @@ class NebulaServer( }.start(wait = wait) } - private fun compile(updateStacksRequest: UpdateStackRSocketRequest): Map { + private fun compile(updateStacksRequest: UpdateStackRSocketRequest): Map { return updateStacksRequest.stacks.mapValues { (key, stackScript) -> - scriptExecutor.toStack(stackScript).withName(key) + scriptExecutor.toStackWithSource(stackScript).withName(key) } }