From 60a43c164c3ed4a1af3587aabb8090135c34cf10 Mon Sep 17 00:00:00 2001 From: Marty Pitt Date: Sat, 26 Sep 2026 11:37:20 +0100 Subject: [PATCH] feat: --stack-start=manual holds submitted stacks until started from the UI Adds a startup option to control whether submitted stacks start straight away: nebula --http=8099 --stack-start=manual (or NEBULA_STACK_START=manual) - auto (default): unchanged - stacks start as soon as they're submitted. - manual: stacks are held as NotStarted until started from the management UI (or POST /api/stacks/{id}/start). A resubmission still replaces the stack, and the replacement waits too. Ignored when running a script, where there's no UI to start it from. A held stack is wired to its event and log streams on submission, so consumers (eg: Orbital) see it as NotStarted and receive its coordinates once it starts. Previously that wiring only happened on the initial start path, so starting a never-started stack would have left consumers without updates. startStack/startComponent now also refresh the state reported by GET /stacks. The UI shows these stacks as "Not started", with a Start action. Also stops logging the full payload of each /stream/stacks submission (every script in it) - it now logs a single line naming the submitted stacks. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 ++ docs/pages/cli.mdx | 29 ++++++ .../com/orbitalhq/nebula/cli/NebulaCli.kt | 25 ++++- .../com/orbitalhq/nebula/NebulaStack.kt | 17 +++- .../com/orbitalhq/nebula/StackRunner.kt | 37 +++++++- .../orbitalhq/nebula/StackStartModeTest.kt | 95 +++++++++++++++++++ .../nebula/runtime/server/NebulaServer.kt | 4 +- nebula-ui/README.md | 4 + nebula-ui/src/lib/types/stack.ts | 15 ++- nebula-ui/src/pages/StackDetail.tsx | 13 ++- nebula-ui/src/pages/StacksList.tsx | 12 ++- .../src/pages/stack-detail/ComponentsView.tsx | 4 +- 12 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 nebula-dsl/src/test/kotlin/com/orbitalhq/nebula/StackStartModeTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index d5c4ab6..4c85143 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 \ diff --git a/docs/pages/cli.mdx b/docs/pages/cli.mdx index eed3eee..19a6e4e 100644 --- a/docs/pages/cli.mdx +++ b/docs/pages/cli.mdx @@ -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 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 1e9a361..ca0c6e4 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 @@ -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 @@ -62,6 +63,17 @@ class Nebula : Callable { ) 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 @@ -72,9 +84,15 @@ class Nebula : Callable { 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(), @@ -294,6 +312,9 @@ class Nebula : Callable { 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) { 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 98a2a95..cd9b22a 100644 --- a/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/NebulaStack.kt +++ b/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/NebulaStack.kt @@ -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> { markStarted() - stackStateEventSource.listenForEvents(name, components) - logStream.attachLogStreams(components) + attachListeners() return components.mapNotNull { component -> try { component.type to component.start(config, hostConfig) 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 7c4bfff..917022a 100644 --- a/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/StackRunner.kt +++ b/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/StackRunner.kt @@ -33,10 +33,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 ) /** @@ -82,6 +93,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) { @@ -168,7 +189,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) } @@ -198,6 +222,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 { @@ -208,6 +234,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() } /** diff --git a/nebula-dsl/src/test/kotlin/com/orbitalhq/nebula/StackStartModeTest.kt b/nebula-dsl/src/test/kotlin/com/orbitalhq/nebula/StackStartModeTest.kt new file mode 100644 index 0000000..9670c31 --- /dev/null +++ b/nebula-dsl/src/test/kotlin/com/orbitalhq/nebula/StackStartModeTest.kt @@ -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 = + 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() + } + } + } +}) 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 ac3f2be..e56765c 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 @@ -265,12 +265,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(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 diff --git a/nebula-ui/README.md b/nebula-ui/README.md index 8e9033c..da4c8ba 100644 --- a/nebula-ui/README.md +++ b/nebula-ui/README.md @@ -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. diff --git a/nebula-ui/src/lib/types/stack.ts b/nebula-ui/src/lib/types/stack.ts index 7a47794..6a5fd5c 100644 --- a/nebula-ui/src/lib/types/stack.ts +++ b/nebula-ui/src/lib/types/stack.ts @@ -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']; @@ -104,7 +114,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[], @@ -114,6 +126,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'; } diff --git a/nebula-ui/src/pages/StackDetail.tsx b/nebula-ui/src/pages/StackDetail.tsx index 61a2ca1..7ced562 100644 --- a/nebula-ui/src/pages/StackDetail.tsx +++ b/nebula-ui/src/pages/StackDetail.tsx @@ -6,7 +6,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { StatusDot } from '@/lib/display'; import { fetchStacks, deleteStack, startStack, stopStack } from '@/lib/api'; import { showSuccessToast } from '@/lib/toast'; -import { TRANSITION_STATES, toStackSummary, type StackSummary } from '@/lib/types/stack'; +import { + TRANSITION_STATES, + isStartable, + toStackSummary, + type StackSummary, +} from '@/lib/types/stack'; import { Loader2, Square, Play, Trash2 } from 'lucide-react'; import ComponentsView from '@/pages/stack-detail/ComponentsView'; import LogsView from '@/pages/stack-detail/LogsView'; @@ -87,7 +92,9 @@ export default function StackDetail() { {stack ? stack.failed ? 'Compilation failed' - : `${stack.stateCounts.Running ?? 0}/${stack.total} components running` + : stack.overallState === 'NotStarted' + ? 'Waiting to be started' + : `${stack.stateCounts.Running ?? 0}/${stack.total} components running` : loaded ? 'Stack not found' : 'Loading…'} @@ -101,7 +108,7 @@ export default function StackDetail() { {busy ? : } Remove - ) : stack.overallState === 'Stopped' ? ( + ) : isStartable(stack.overallState) ? ( <> ); } - if (stack.overallState === 'Stopped') { + if (isStartable(stack.overallState)) { return ( <>