Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.novacula.io/llms.txt

Use this file to discover all available pages before exploring further.

Novacula is declarative. You don’t tell an executor “start this process now”; you write a desired spec to the control plane, and the executor on the other side polls, reconciles, and reports back. This page covers the shape of a spec, the revision lifecycle, and how the UI surfaces the gap between desired and observed state.

The spec

Every node has a single serialized NodeSpec stored on the Node row in the control plane. It is the complete declaration of what the executor must produce — chain, network, node type, client + version per role, container images, storage volumes, optional config overrides, sidecars, resource requests, and the desired lifecycle state (running or stopped). The control plane is the only writer. Mutations like Deploy a node, Edit configuration, Upgrade a node, and lifecycle actions all produce a new spec.

Revision

Each spec write bumps a revision counter on the row. The revision is the contract between control plane and executor:
  • The control plane attaches the latest revision to every spec it serves.
  • The executor reports back the revision it has currently materialized in its ObservedNodeState.
  • If observed.revision < desired.revision, the node is drifted — the UI shows a “pending change” indicator.
Revisions are monotonically increasing per node. The executor never produces a revision; it only acknowledges one.

Reconcile loop

The executor runs three concurrent loops (see Agent (bare-metal) and Operator (Kubernetes) for backend specifics):
LoopDefault cadenceJob
PollingClient5sLong-poll the control plane for assigned specs
Reconciler5sDiff desired vs observed; apply changes through the chain adapter
Reporter10sProbe each process, write ObservedNodeState back
The reconciler’s job is convergent, not transactional: each tick recomputes the diff from scratch, so a partial apply (process started but config not yet rewritten, for example) heals on the next tick.

Observed state

The reported ObservedNodeState is what the UI renders on the node detail page. Its shape mirrors the spec, with the addition of a per-process ObservedNodeStatus enum:
StatusMeaning
startingProcess is launching but not yet healthy
runningProcess is healthy and accepting peers
syncingProcess is healthy and importing blocks
stoppedProcess is not running (matches desiredState: stopped)
errorProcess exited or failed health checks
The control plane derives a single composite node phase from the desired state, observed status, and revision drift — used for the colored status badge in lists.

Detecting drift

Two flavors of drift the platform raises automatically:
  • Revision drift — observed revision is older than desired. Usually transient: the executor will pick it up on the next poll.
  • Liveness drift — the executor hasn’t called syncExecutor for longer than HEARTBEAT_TIMEOUT_MS (30s default). The executor’s status virtual field flips to offline, and the node appears as unreachable until the executor reconnects.
If desiredState is running but observedStatus is stopped or error, the node_down alert rule fires — see Alert rules.

Why declarative

Three properties fall out of this design:
  1. Crash-safe executors. An executor can restart, lose memory, even reinstall — on the next poll it sees the latest spec and reconciles. There is no in-memory state to corrupt.
  2. Outbound-only deployment. The control plane never initiates anything; the executor pulls. See Architecture.
  3. Auditable history. Every revision is a discrete write; the Events feed records who changed what and when.
Coming from Kubernetes? The relationship is the same as kubectl apply (writes a desired spec) and a controller (converges to it). The executor is the controller; the Node row is the spec.