Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -86,9 +87,10 @@ class Nebula : Callable<Int> {

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()
Expand Down
11 changes: 11 additions & 0 deletions nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/NebulaStack.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<InfrastructureComponent<*>> = emptyList()
Expand Down
44 changes: 30 additions & 14 deletions nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/StackRunner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -16,17 +15,22 @@ data class NebulaConfig(
)
class StackRunner(private val config: NebulaConfig = NebulaConfig()) {
private val logger = KotlinLogging.logger {}
val stacks = ConcurrentHashMap<StackName, NebulaStack>()
val stacks = ConcurrentHashMap<StackName, NebulaStackWithSource>()
private val _stackState = ConcurrentHashMap<StackName, Map<String, ComponentInfo<*>>>()

fun submit(stack: NebulaStack, name: StackName = stack.name, startAsync: Boolean = false): Flux<StackStateEvent> {
this.stacks.compute(name) { key, existingSpec ->
fun submit(submittedStack: NebulaStackWithSource, name: StackName = submittedStack.name, startAsync: Boolean = false): Flux<StackStateEvent> {
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)
Expand All @@ -46,12 +50,12 @@ class StackRunner(private val config: NebulaConfig = NebulaConfig()) {

fun stackEvents(name:String):Flux<StackStateEvent> {
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() }
}
Expand All @@ -63,7 +67,7 @@ class StackRunner(private val config: NebulaConfig = NebulaConfig()) {

inline fun <reified T : InfrastructureComponent<*>> component(): List<T> {
return this.stacks.values.flatMap {
it.components.filterIsInstance<T>()
it.stack.components.filterIsInstance<T>()
}
}

Expand All @@ -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()
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -125,9 +126,9 @@ class NebulaServer(
}.start(wait = wait)
}

private fun compile(updateStacksRequest: UpdateStackRSocketRequest): Map<StackName, NebulaStack> {
private fun compile(updateStacksRequest: UpdateStackRSocketRequest): Map<StackName, NebulaStackWithSource> {
return updateStacksRequest.stacks.mapValues { (key, stackScript) ->
scriptExecutor.toStack(stackScript).withName(key)
scriptExecutor.toStackWithSource(stackScript).withName(key)
}
}

Expand Down