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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,14 @@ nebula script.nebula.kts

# Start HTTP server mode
nebula --http=8099

# HTTP server mode, holding submitted stacks until they're started from the UI
# (or set NEBULA_STACK_START=manual)
nebula --http=8099 --stack-start=manual
```

See `docs/pages/cli.mdx` for all CLI options.

### Docker Usage
```bash
docker run -v /var/run/docker.sock:/var/run/docker.sock \
Expand Down
29 changes: 29 additions & 0 deletions docs/pages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,37 @@ nebula --http=8099
| `--http` | | | Start the HTTP server on the given port instead of running a script |
| `--network` | `NEBULA_NETWORK` | `nebula_network` | Disambiguates which docker network to attach started containers to, when Nebula is attached to more than one. Only used in `network` mode (see below); in `host` mode an isolated network is always created |
| `--connectivity` | `NEBULA_CONNECTIVITY`| `host` | How the host + port of each container is reported to consumers (see below) |
| `--stack-start` | `NEBULA_STACK_START` | `auto` | `auto` starts each stack as soon as it's submitted. `manual` holds submitted stacks until they're started from the management UI (see below). Only applies with `--http` |
| `-v`, `--verbose`| | `false` | Enable verbose output |

## Starting stacks manually

By default, the HTTP server starts every stack as soon as it's submitted. With
`--stack-start=manual`, submitted stacks are held as **Not started** until you start them
from the management UI, or with `POST /api/stacks/{id}/start`. This is handy for demos, or
when an Orbital that connects submits more stacks than you want running.

```bash
nebula --http=8099 --stack-start=manual

# or, via the environment
NEBULA_STACK_START=manual nebula --http=8099
```

In docker-compose, set it with the `NEBULA_STACK_START` environment variable:

```yaml
nebula:
image: orbitalhq/nebula:latest
environment:
- NEBULA_STACK_START=manual
```

Consumers still see a held stack: it's reported as `NotStarted`, and they get its
coordinates once it starts. Resubmitting a stack still replaces it, and the replacement
also waits to be started. When running a script directly there's no UI to start it from, so
the setting is ignored and the stack starts straight away.

## Connectivity modes

For every container it starts, Nebula reports a `host` and `port` (and connection strings
Expand Down
25 changes: 23 additions & 2 deletions nebula-cli/src/main/kotlin/com/orbitalhq/nebula/cli/NebulaCli.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.orbitalhq.nebula.HostConfig
import com.orbitalhq.nebula.NebulaConfig
import com.orbitalhq.nebula.NebulaStackWithSource
import com.orbitalhq.nebula.StackRunner
import com.orbitalhq.nebula.StackStartMode
import com.orbitalhq.nebula.runtime.NebulaCompilationException
import com.orbitalhq.nebula.runtime.NebulaScriptExecutor
import com.orbitalhq.nebula.runtime.server.NebulaServer
Expand Down Expand Up @@ -62,6 +63,17 @@ class Nebula : Callable<Int> {
)
lateinit var connectivity: ConsumerConnectivity

@Option(
names = ["--stack-start"],
description = [
"Whether submitted stacks start straight away. ",
"auto (default): start each stack as soon as it's submitted. ",
"manual: hold submitted stacks until they're started from the management UI. Only applies with --http."
],
defaultValue = "\${NEBULA_STACK_START:-auto}"
)
lateinit var stackStart: StackStartMode

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

Expand All @@ -72,9 +84,15 @@ class Nebula : Callable<Int> {
return spec.exitCodeOnExecutionException()
}
val network = networkOrError.getOrThrow()
val nebulaConfig = NebulaConfig(networkName, network, connectivity)
val nebulaConfig = NebulaConfig(networkName, network, connectivity, stackStart)
when {
scriptFile != null -> return executeScript(nebulaConfig)
scriptFile != null -> {
// There's no UI to start a stack from when running a script, so always start it.
if (stackStart == StackStartMode.MANUAL) {
spec.commandLine().err.println("--stack-start=manual only applies with --http - starting the script's stack straight away")
}
return executeScript(nebulaConfig.copy(stackStart = StackStartMode.AUTO))
}
httpPort != null -> return startHttpServer(nebulaConfig)
else -> throw ParameterException(
spec.commandLine(),
Expand Down Expand Up @@ -294,6 +312,9 @@ class Nebula : Callable<Int> {
private fun startHttpServer(nebulaConfig: NebulaConfig): Int {
// Placeholder function for HTTP server
spec.commandLine().out.println("Starting HTTP server on port $httpPort")
if (nebulaConfig.stackStart == StackStartMode.MANUAL) {
spec.commandLine().out.println("Submitted stacks will wait to be started from the UI (--stack-start=manual)")
}
NebulaServer(httpPort!!, config = nebulaConfig).start()
spec.commandLine().out.println("Server running - Press Ctrl+C to stop")
while (true) {
Expand Down
17 changes: 15 additions & 2 deletions nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/NebulaStack.kt
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,23 @@ class NebulaStack(
return component
}

private val listening = AtomicBoolean(false)

/**
* Relays the components' lifecycle events and logs to the stack's [lifecycleEvents]
* and [logMessages]. Idempotent - a stack only needs wiring once, whether it's
* started on submission or later on.
*/
fun attachListeners() {
if (listening.compareAndSet(false, true)) {
stackStateEventSource.listenForEvents(name, components)
logStream.attachLogStreams(components)
}
}

fun startComponents(config: NebulaConfig, hostConfig: HostConfig): Map<String, ComponentInfo<out Any?>> {
markStarted()
stackStateEventSource.listenForEvents(name, components)
logStream.attachLogStreams(components)
attachListeners()
return components.mapNotNull { component ->
try {
component.type to component.start(config, hostConfig)
Expand Down
37 changes: 36 additions & 1 deletion nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/StackRunner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,21 @@ import kotlin.concurrent.thread
*/
enum class ConsumerConnectivity { HOST, NETWORK }

/**
* Whether a submitted stack starts straight away.
*
* - [AUTO]: start every stack as soon as it's submitted. **(default)**
* - [MANUAL]: hold submitted stacks (NotStarted) until they're started explicitly,
* eg: from the management UI. Resubmitting a stack still replaces it, but the
* replacement also waits to be started.
*/
enum class StackStartMode { AUTO, MANUAL }

data class NebulaConfig(
val networkName: String = "nebula_network",
val network: Network = Network.newNetwork(),
val connectivity: ConsumerConnectivity = ConsumerConnectivity.HOST
val connectivity: ConsumerConnectivity = ConsumerConnectivity.HOST,
val stackStart: StackStartMode = StackStartMode.AUTO
)

/**
Expand Down Expand Up @@ -85,6 +96,16 @@ class StackRunner(private val config: NebulaConfig = NebulaConfig()) {
submittedStack
} ?: error("After submitting stack $name, no stack was created.")

if (config.stackStart == StackStartMode.MANUAL) {
// Wire up events + logs now, so consumers see the stack (as NotStarted)
// and hear about it once it's started.
storedStack.stack.attachListeners()
if (!storedStack.stack.started) {
logger.info { "Stack $name submitted - waiting to be started manually" }
}
return stackEvents(name)
}

// Only start if the stack that got stored was the one that got submitted.
// Otherwise it's someone elses stack, and already running
if (submittedStack == storedStack || !storedStack.stack.started) {
Expand Down Expand Up @@ -188,7 +209,10 @@ class StackRunner(private val config: NebulaConfig = NebulaConfig()) {
val stackWithSource = this.stacks[stackName] ?: error("Stack $stackName not found")
val component = findComponent(stackName, componentId)
logger.info { "Starting component $componentId in stack $stackName" }
// A stack that was never started (see StackStartMode.MANUAL) isn't listening yet
stackWithSource.stack.attachListeners()
component.start(config, stackWithSource.hostConfig)
recordState(stackName)
}


Expand Down Expand Up @@ -218,6 +242,8 @@ class StackRunner(private val config: NebulaConfig = NebulaConfig()) {
fun startStack(name: StackName) {
val stackWithSource = this.stacks[name] ?: error("Stack $name not found")
logger.info { "Starting stack $name" }
// A stack that was never started (see StackStartMode.MANUAL) isn't listening yet
stackWithSource.stack.attachListeners()
stackWithSource.stack.components.forEach { component ->
if (component.currentState.state != ComponentState.Running) {
try {
Expand All @@ -228,6 +254,15 @@ class StackRunner(private val config: NebulaConfig = NebulaConfig()) {
}
}
stackWithSource.stack.markStarted()
recordState(name)
}

/** Refreshes the component info reported by [stateState] (the Orbital-facing GET /stacks). */
private fun recordState(name: StackName) {
val stack = this.stacks[name]?.stack ?: return
_stackState[name] = stack.components
.mapNotNull { component -> component.componentInfo?.let { component.type to it } }
.toMap()
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.orbitalhq.nebula

import com.orbitalhq.nebula.core.ComponentState
import com.orbitalhq.nebula.http.http
import io.kotest.assertions.nondeterministic.eventually
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.maps.shouldBeEmpty
import io.kotest.matchers.maps.shouldContainKey
import io.kotest.matchers.shouldBe
import io.ktor.server.response.*
import kotlin.time.Duration.Companion.seconds

class StackStartModeTest : DescribeSpec({

fun httpStack(response: String) = NebulaStackWithSource(
stack {
http {
get("/hello") { call ->
call.respondText(response)
}
}
},
source = "script-$response",
hostConfig = HostConfig.UNKNOWN
).withName("start-mode-test")

fun stackStateIn(runner: StackRunner, name: StackName): Map<ComponentState, Int> =
runner.stackEvents(name).blockFirst()!!.stateCounts

describe("StackStartMode.MANUAL") {
it("holds a submitted stack until it's started") {
val runner = StackRunner(NebulaConfig(stackStart = StackStartMode.MANUAL))
try {
runner.submit(httpStack("v1"))

runner.http.single().currentState.state shouldBe ComponentState.NotStarted
runner.stateState.shouldBeEmpty()
// Consumers listening to the stack's events see it waiting
stackStateIn(runner, "start-mode-test") shouldBe mapOf(ComponentState.NotStarted to 1)

runner.startStack("start-mode-test")

runner.http.single().currentState.state shouldBe ComponentState.Running
runner.stateState["start-mode-test"]!!.shouldContainKey("http")
// ...and hear about it once it's started
eventually(5.seconds) {
stackStateIn(runner, "start-mode-test") shouldBe mapOf(ComponentState.Running to 1)
}
} finally {
runner.shutDownAll()
}
}

it("holds a replacement stack too") {
val runner = StackRunner(NebulaConfig(stackStart = StackStartMode.MANUAL))
try {
runner.submit(httpStack("v1"))
runner.startStack("start-mode-test")
runner.http.single().currentState.state shouldBe ComponentState.Running

runner.submit(httpStack("v2"))

runner.http.single().currentState.state shouldBe ComponentState.NotStarted
} finally {
runner.shutDownAll()
}
}

it("leaves a started stack running when the same source is resubmitted") {
val runner = StackRunner(NebulaConfig(stackStart = StackStartMode.MANUAL))
try {
runner.submit(httpStack("v1"))
runner.startStack("start-mode-test")

runner.submit(httpStack("v1"))

runner.http.single().currentState.state shouldBe ComponentState.Running
} finally {
runner.shutDownAll()
}
}
}

describe("StackStartMode.AUTO") {
it("starts a stack as soon as it's submitted") {
val runner = StackRunner(NebulaConfig(stackStart = StackStartMode.AUTO))
try {
runner.submit(httpStack("v1"))
runner.http.single().currentState.state shouldBe ComponentState.Running
} finally {
runner.shutDownAll()
}
}
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -279,12 +279,14 @@ class NebulaServer(
require(frame is Frame.Text) { "Only text frames supported" }
val payloadJson = frame.readText()

logger.info { "Received updated stack submission: \n$payloadJson" }
// Any failure handling a submission must not tear down the socket —
// the client would see nothing but a dropped connection.
try {
val updateStacksRequest =
objectMapper.readValue<UpdateStackRSocketRequest>(payloadJson)
// Just the names - the scripts themselves are far too noisy to log.
val submitted = updateStacksRequest.allBundles().keys
logger.info { "Received submission of ${submitted.size} stack(s): ${submitted.joinToString()}" }

// Compile each stack independently: a broken script must not block
// the other stacks in the submission. Failures are reported back to
Expand Down
4 changes: 4 additions & 0 deletions nebula-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ It talks to the HTTP API exposed when Nebula is started with an HTTP port:
nebula --http=8099 # 8099 is the default
```

Start it with `--stack-start=manual` (or `NEBULA_STACK_START=manual`) and submitted
stacks wait as **Not started** until you start them from the UI. By default they start
as soon as they're submitted.

## Tech

React 19 + Vite + TypeScript + Tailwind 4 + shadcn/ui.
Expand Down
15 changes: 14 additions & 1 deletion nebula-ui/src/lib/types/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ export type ComponentState =
| 'Stopped'
| 'Failed';

/** States a stack can be started from. */
export function isStartable(state: ComponentState): boolean {
return state === 'Stopped' || state === 'NotStarted';
}

/** Human-readable label for a state. */
export function stateLabel(state: ComponentState): string {
return state === 'NotStarted' ? 'Not started' : state;
}

/** The states that represent an in-progress transition (show a spinner). */
export const TRANSITION_STATES: ComponentState[] = ['Starting', 'Stopping'];

Expand Down Expand Up @@ -125,7 +135,9 @@ export interface StackSummary {

/**
* Collapses a stack's per-component states into one headline status.
* Failed > any transition > Running (if all running) > otherwise Stopped.
* Failed > any transition > Running (if all running) > NotStarted (if none have
* ever started, eg: submitted to a server running with --stack-start=manual) >
* otherwise Stopped.
*/
export function deriveOverallState(
components: ComponentInfoWithState[],
Expand All @@ -135,6 +147,7 @@ export function deriveOverallState(
if (states.includes('Failed')) return 'Failed';
if (states.some((s) => TRANSITION_STATES.includes(s))) return 'Starting';
if (states.every((s) => s === 'Running')) return 'Running';
if (states.every((s) => s === 'NotStarted')) return 'NotStarted';
if (states.every((s) => s === 'Stopped' || s === 'NotStarted')) return 'Stopped';
return 'Running';
}
Expand Down
Loading
Loading