Skip to content
Open
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
16 changes: 14 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,18 @@ jobs:
determine-version:
runs-on: ubuntu-latest
outputs:
VERSION: ${{ steps.get-version.outputs.VERSION }} # Pass the version as output
VERSION: ${{ steps.get-version.outputs.VERSION }}
DOCKER_TAGS: ${{ steps.get-version.outputs.DOCKER_TAGS }}
steps:
- name: Determine version
id: get-version
run: |
if [ "${{ github.ref_name }}" == "main" ]; then
echo "VERSION=next" >> $GITHUB_OUTPUT
echo "DOCKER_TAGS=orbitalhq/nebula:next" >> $GITHUB_OUTPUT
elif [ "${{ github.ref_type }}" == "tag" ]; then
echo "VERSION=${{ github.ref_name }}" >> $GITHUB_OUTPUT
echo "DOCKER_TAGS=orbitalhq/nebula:${{ github.ref_name }},orbitalhq/nebula:latest" >> $GITHUB_OUTPUT
fi

build-docker:
Expand Down Expand Up @@ -82,5 +85,14 @@ jobs:
run: |
docker buildx create --use --name multiarch-builder
docker buildx inspect multiarch-builder --bootstrap
docker buildx build --platform linux/amd64,linux/arm64 -t orbitalhq/nebula:${{ needs.determine-version.outputs.VERSION }} --push .

# Parse DOCKER_TAGS and build -t flags
TAGS="${{ needs.determine-version.outputs.DOCKER_TAGS }}"
TAG_FLAGS=""
IFS=',' read -ra TAG_ARRAY <<< "$TAGS"
for tag in "${TAG_ARRAY[@]}"; do
TAG_FLAGS="$TAG_FLAGS -t $tag"
done

docker buildx build --platform linux/amd64,linux/arm64 $TAG_FLAGS --push .

39 changes: 0 additions & 39 deletions nebula-cli/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -139,44 +139,5 @@
</plugin>
</plugins>
</build>
<profiles>
<!-- Tried gettin graal image compilation working.
However, it's unlikely this would ever work - as we use Kotlin's scripting interface,
which uses reflection for setup. It looks possible to remove the reflection from this part,
but we're still dealing with things like defaultImports, and custom scripting - it seems like that couldn't work.
-->
<!-- <profile>-->
<!-- <id>native</id>-->
<!-- <build>-->
<!-- <plugins>-->
<!-- <plugin>-->
<!-- <groupId>org.graalvm.buildtools</groupId>-->
<!-- <artifactId>native-maven-plugin</artifactId>-->
<!-- <version>${native.maven.plugin.version}</version>-->
<!-- <extensions>true</extensions>-->
<!-- <executions>-->
<!-- <execution>-->
<!-- <id>build-native</id>-->
<!-- <goals>-->
<!-- <goal>compile-no-fork</goal>-->
<!-- </goals>-->
<!-- <phase>package</phase>-->
<!-- </execution>-->
<!-- <execution>-->
<!-- <id>test-native</id>-->
<!-- <goals>-->
<!-- <goal>test</goal>-->
<!-- </goals>-->
<!-- <phase>test</phase>-->
<!-- </execution>-->
<!-- </executions>-->
<!-- <configuration>-->
<!-- <mainClass>com.orbitalhq.nebula.cli.NebulaCliKt</mainClass>-->
<!-- </configuration>-->
<!-- </plugin>-->
<!-- </plugins>-->
<!-- </build>-->
<!-- </profile>-->
</profiles>

</project>
111 changes: 111 additions & 0 deletions nebula-cli/src/main/kotlin/com/orbitalhq/nebula/cli/FileWatcher.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.orbitalhq.nebula.cli

import io.github.oshai.kotlinlogging.KotlinLogging
import java.io.File
import java.nio.file.FileSystems
import java.nio.file.Path
import java.nio.file.StandardWatchEventKinds
import java.nio.file.WatchEvent
import java.nio.file.WatchKey
import java.nio.file.WatchService
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread

/**
* Watches a file for changes and triggers a callback when modifications are detected.
*/
class FileWatcher(
private val file: File,
private val onChange: (File) -> Unit
) {
companion object {
private val logger = KotlinLogging.logger {}
}

private val watching = AtomicBoolean(false)
private var watcherThread: Thread? = null

/**
* Starts watching the file for changes.
*/
fun start() {
if (watching.getAndSet(true)) {
logger.warn { "FileWatcher is already running for ${file.absolutePath}" }
return
}

logger.info { "Starting file watcher for ${file.absolutePath}" }

watcherThread = thread(name = "FileWatcher-${file.name}") {
val watchService: WatchService = FileSystems.getDefault().newWatchService()
// Use absoluteFile to ensure we have a parent directory
val directory = (file.parentFile ?: File(".")).toPath()

try {
directory.register(
watchService,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_CREATE
)

while (watching.get()) {
val key: WatchKey = try {
watchService.take()
} catch (e: InterruptedException) {
logger.debug { "File watcher interrupted" }
break
}

for (event in key.pollEvents()) {
val kind = event.kind()

if (kind == StandardWatchEventKinds.OVERFLOW) {
continue
}

@Suppress("UNCHECKED_CAST")
val ev = event as WatchEvent<Path>
val filename = ev.context()

// Check if the modified file is the one we're watching
if (filename.toString() == file.name) {
logger.info { "Detected change in ${file.name}" }
try {
onChange(file)
} catch (e: Exception) {
logger.error(e) { "Error processing file change" }
}
}
}

val valid = key.reset()
if (!valid) {
logger.warn { "Watch key no longer valid, stopping watcher" }
break
}
}
} catch (e: Exception) {
logger.error(e) { "Error in file watcher" }
} finally {
try {
watchService.close()
} catch (e: Exception) {
logger.error(e) { "Error closing watch service" }
}
}
}
}

/**
* Stops watching the file.
*/
fun stop() {
if (!watching.getAndSet(false)) {
return
}

logger.info { "Stopping file watcher for ${file.absolutePath}" }
watcherThread?.interrupt()
watcherThread?.join(5000)
}
}
102 changes: 95 additions & 7 deletions nebula-cli/src/main/kotlin/com/orbitalhq/nebula/cli/NebulaCli.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package com.orbitalhq.nebula.cli

import arrow.core.getOrElse
import com.orbitalhq.nebula.HostConfig
import com.orbitalhq.nebula.NebulaConfig
import com.orbitalhq.nebula.NebulaStackWithSource
import com.orbitalhq.nebula.StackRunner
import com.orbitalhq.nebula.runtime.NebulaCompilationException
import com.orbitalhq.nebula.runtime.NebulaScriptExecutor
import com.orbitalhq.nebula.runtime.server.NebulaServer
import io.github.oshai.kotlinlogging.KotlinLogging
import org.junit.runner.Description
import org.junit.runners.model.Statement
import org.testcontainers.DockerClientFactory
Expand All @@ -18,6 +21,7 @@ import picocli.CommandLine.Parameters
import java.io.File
import java.lang.Thread.sleep
import java.util.concurrent.Callable
import kotlin.script.experimental.api.isError

@Command(
name = "nebula",
Expand All @@ -27,6 +31,10 @@ import java.util.concurrent.Callable
)
class Nebula : Callable<Int> {

companion object {
private val logger = KotlinLogging.logger {}
}

@CommandLine.Spec
private lateinit var spec: CommandLine.Model.CommandSpec

Expand All @@ -42,6 +50,9 @@ class Nebula : Callable<Int> {
@Option(names = ["--network"], description = ["The name of the docker network created"], defaultValue = "\${NEBULA_NETWORK:-nebula_network}")
lateinit var networkName: String

private var fileWatcher: FileWatcher? = null
private var currentStackRunner: StackRunner? = null

override fun call(): Int {
val networkOrError = discoverActualNetworkName()
if (networkOrError.isFailure) {
Expand Down Expand Up @@ -86,24 +97,101 @@ class Nebula : Callable<Int> {
throw ParameterException(spec.commandLine(), "${file.toPath()} not found or cannot be read")
}

val scriptRunner = NebulaScriptExecutor()
val stack = scriptRunner.runScript(file)
val stackWithSource = NebulaStackWithSource(stack, file.readText(), HostConfig.UNKNOWN)
// Initial script execution - don't exit if it fails, just log and wait
loadAndStartScript(file, nebulaConfig)

// Set up file watcher
fileWatcher = FileWatcher(file) { changedFile ->
logger.info { "Script file changed, reloading..." }
reloadScript(changedFile, nebulaConfig)
}
fileWatcher?.start()

val stackRunner = StackRunner(nebulaConfig)
stackRunner.submit(stackWithSource)
Runtime.getRuntime().addShutdownHook(Thread {
if (verbose) spec.commandLine().out.println("Shutting down services...")
stackRunner.shutDownAll()
fileWatcher?.stop()
currentStackRunner?.shutDownAll()
if (verbose) spec.commandLine().out.println("Services shut down gracefully.")
})

spec.commandLine().out.println("${stack.components.size} services running - Press Ctrl+C to stop")
spec.commandLine().out.println("Watching ${file.name} for changes - Press Ctrl+C to stop")
while (true) {
sleep(200)
}
}

private fun loadAndStartScript(file: File, nebulaConfig: NebulaConfig) {
val scriptExecutor = NebulaScriptExecutor(logCompilationErrors = false)
val scriptContent = file.readText()

val stackOrError = scriptExecutor.compileToStackWithSource(scriptContent, HostConfig.UNKNOWN)

stackOrError.fold(
ifLeft = { exception ->
logCompilationErrors(exception)
logger.warn { "Script has compilation errors. Waiting for changes..." }
},
ifRight = { stackWithSource ->
val stackRunner = StackRunner(nebulaConfig)
currentStackRunner = stackRunner
stackRunner.submit(stackWithSource)
spec.commandLine().out.println("${stackWithSource.stack.components.size} services running")
logger.info { "Successfully loaded and started script" }
}
)
}

private fun reloadScript(file: File, nebulaConfig: NebulaConfig) {
// Stop current stack
currentStackRunner?.let { runner ->
logger.info { "Stopping current stack..." }
try {
runner.shutDownAll()
} catch (e: Exception) {
logger.error(e) { "Error shutting down current stack" }
}
currentStackRunner = null
}

// Load and start new stack
// Don't log errors inside the executor, log them from here.
val scriptExecutor = NebulaScriptExecutor(logCompilationErrors = false)
val scriptContent = file.readText()

val stackOrError = scriptExecutor.compileToStackWithSource(scriptContent, HostConfig.UNKNOWN)

stackOrError.fold(
ifLeft = { exception ->
logCompilationErrors(exception)
logger.warn { "Script has compilation errors. Waiting for next change..." }
},
ifRight = { stackWithSource ->
val stackRunner = StackRunner(nebulaConfig)
currentStackRunner = stackRunner
try {
stackRunner.submit(stackWithSource)
spec.commandLine().out.println("Reloaded: ${stackWithSource.stack.components.size} services running")
logger.info { "Successfully reloaded script" }
} catch (e: Exception) {
logger.error(e) { "Error starting new stack" }
currentStackRunner = null
}
}
)
}

private fun logCompilationErrors(exception: NebulaCompilationException) {
logger.error { "Script compilation failed with ${exception.errors.size} error(s):" }
exception.errors.forEach { diagnostic ->
val location = diagnostic.location
if (location != null) {
logger.error { " Line ${location.start.line}, Column ${location.start.col}: ${diagnostic.message}" }
} else {
logger.error { " ${diagnostic.message}" }
}
}
}

private fun startHttpServer(nebulaConfig: NebulaConfig): Int {
// Placeholder function for HTTP server
spec.commandLine().out.println("Starting HTTP server on port $httpPort")
Expand Down
Loading