Versions and upgrades
Three versions govern a Helix deployment, and confusing them is the usual cause of a puzzling install. This page is the one place they are all explained: what each governs, which of them is actually enforced, how to see what a node is running, and how to move it forward safely.
If you only take one rule away, take this one: float while you develop, pin for anything live.
1. The three versions
Section titled “1. The three versions”| where it lives | what it governs | enforced? | |
|---|---|---|---|
| Core | helix/runtime/VERSION — an image tag | the engine: Spiral, crud, dispatch, the SPA | no — provenance only |
| SDK | helix/custom/package.json, exact | the contract between your extension and the platform | yes — deploy refuses a mismatched minor |
| Client bundle | helix/custom/package.json version | your extension, domain, map and config | no — recorded, never checked |
Only the SDK decides whether a pairing is allowed to run. A 0.1.x bundle runs on any 0.1.x core; a
0.1 bundle on a 0.2 core is refused outright. The core version is provenance — it tells you what a
thing was built and signed off against, which is what you need when something misbehaves, but it never
blocks a deployment on its own.
That is why the core can float while the SDK cannot: picking up a newer core is a fix, picking up a different contract is a rebuild.
2. Floating versus exact
Section titled “2. Floating versus exact”Two tags move:
sdk-<MAJOR.MINOR>(e.g.sdk-0.1) — always the newest core on that SDK line. This is what a client repo develops against.latest— always the newest core, full stop.
Anything else (0.0.5d, 1.2.0) is exact and never moves.
A live node may not run a floating tag. spiral, spiralOnly, create, replicant and
standby refuse to start:
helixctl: 'replicant' is a live mode but the image is the floating tag 'latest' (currently core 0.0.5d). A live node must state what it runs. Pin it: helixctl pin # writes 0.0.5d to /srv/helix/runtime/VERSION dev, tuning and demo may float.The reason is the one in the message: a node on a moving tag cannot answer “which core is this?”, and that is
precisely the question you need answered when a deployed system is behaving strangely. helixctl pin
resolves the float to the exact tag it currently points at and writes it — one command, and the node can then
account for itself.
dev, tuning and demo are exempt by design. They mount your repo and switching core is part of the
job. That difference — a working copy floats, a release is pinned — is the whole distinction.
3. Reading what a node runs
Section titled “3. Reading what a node runs”Three views of the same fact, from three sides.
From the host — helixctl version:
runtime : /usr/bin/podman (podman)image : registry.gitlab.com/honeywick-consulting/helix/helixcore:0.0.5dpinned tag : 0.0.5d (helix/runtime/VERSION — the tag helixctl pulls)core version : 0.0.5d (true helixCore baked in the image)SDK (image) : 0.1.8 (contract the image provides)SDK (client) : 0.1.6 (deployed bundle built against)core (client): 0.0.5c (deployed bundle built against)client (image): none (client-agnostic core; the client comes from a deployed bundle)Read it as two pairs. pinned tag versus core version says whether you are on a float and what it resolved
to. The two (client) lines say what the deployed bundle was built against — so the example above is a
bundle built on 0.0.5c now running on 0.0.5d, which is fine (same SDK line) and which deploy will have
mentioned in passing when it landed.
From the container — every node prints its identity at boot, taken from the image itself rather than the tag it was pulled under:
[startHelix] start=replicant mode=replicant env=live joinable=1 client=AA core=0.0.15 bundle=AA-1.4.0 sdk=0.1.6 | mongo:durable(ext4)@…From the artifact — the bundle’s client/manifest.json:
{ "company": "AA", "sdkVersion": "0.1.6", "version": "1.4.0", "coreVersion": "0.0.5c", "coreRegistry": "registry.gitlab.com/honeywick-consulting/helix/helixcore", "commit": "4d5ee84…", "branch": "main", "clean": true, "dirtyPaths": 0, "upstream": "origin/main", "ahead": 0, "behind": 0 }Since core 0.0.13.7 the manifest also names the repository that core image came from (coreRegistry),
so <coreRegistry>:<coreVersion> is the exact image the bundle was built in. Since 0.0.13.6, release also
records where the bundle was built from: the commit and branch,
whether the bundle’s inputs were clean (clean / dirtyPaths count only uncommitted changes under
helix/custom/, helix/scenarios/ and the VERSION pin, so an edited test elsewhere does not count), and how
the branch stood against its upstream as of the last fetch — the build never goes to the network for it. A
bundle built outside a git checkout records these as empty or null. The manifest is served with the
extension, so a running node exposes these fields just as it exposes the version.
4. Publishing a client
Section titled “4. Publishing a client”One command builds a release, in one of four forms. All of them resolve your pin to an exact core, build your extension inside a container of that core, and refuse if the two disagree — so the machine you release from needs no node, npm or client toolchain, only the container runtime it already had.
./helix/runtime/helixctl release [version] [--as studio|set|bundle|image|core-set]The version is optional. It defaults to your client’s helix/custom/package.json version — the same
number the About dialog shows, because the build injects it there too. So a release is reproducible from a
checkout: git checkout <tag> && helixctl release produces the same artifact, with nothing to retype and
nothing to keep in step. Give a version explicitly to override it — a release candidate, or rebuilding
identical source against a different --core.
Ordered from most self-contained to least:
--as | what you hand over | the node takes it with | the node then records | when |
|---|---|---|---|---|
studio | helix-<client>-studio-<ver>.tar — the bundle plus the repo’s own source | helixctl import <tar> | studio | a demo instance or a tuning lab. Never a production site |
set (default) | one tar: core image + bundle + launcher + pin | helixctl install | set | air-gapped, or a controlled production estate |
core-set | the core alone | helixctl install, then import | coreOnly, then bundle | bootstrapping or air-gap — see below |
bundle | helix-<client>-<ver>.tar | helixctl import <tar> | bundle | the node already runs the core you want |
image | one image, everything baked in | helixctl pull | image | you deploy from a registry |
studio is the only form that carries source, and that is why it is a form rather than an option on
another one: the three that reach production cannot accidentally carry it. A set’s bundle becomes the node’s
own client.tar and is unpacked at every start; an image is pushed to a registry. Inside a studio artefact,
beside client/ and helix-config/, sits a source/repo.bundle — a git bundle of the repo at the commit the
manifest records — so a machine with no credential, no registry login and no network can mount the client
repo the simulator start types need. See
Stand up a demo server.
What the node records, and why it matters. Every node keeps a manifest.json saying what it is and how it
got there, and delivery.method is one of those five: the four client release forms, plus coreOnly for a machine
holding a core and no client — which is not a release at all. The artefact declares its own form (a bundle in
its own manifest, an image in a label) and the node inherits it, so the record cannot disagree with what was
handed over. It is what tells you how a node can be cloned: a set node holds the core archive and can seed a
machine that can reach no registry; a bundle node tells the target to pull and sends only the bundle.
Useful flags: --core <tag> releases against a specific core rather than your current pin; --from <dir>
points at a client repo elsewhere; --image-ref and --no-push apply to --as image.
A bundle
Section titled “A bundle”The extension, domain, map and config, for a node already on the right core.
./helix/runtime/helixctl release 1.4.0 --as bundle# on the node:./helixctl import helix-<client>-1.4.0.tar # take it into the release area./helixctl <CLIENT> start replicant helix-<client>-1.4.0.tar # start SELECTS itThe artefact is named at the start, not installed into place beforehand: import acquires it and the
start selects it, which is the split between what can be done while a node runs and what needs the downtime.
A version is a pair — the bundle’s manifest says which core it was built against, so naming one file pins
both halves.
A set — nothing needed on the far side
Section titled “A set — nothing needed on the far side”./helix/runtime/helixctl release 1.4.0# -> helix-<client>-release-1.4.0.tar (core image + bundle + helixctl + VERSION)
tar xf helix-<client>-release-1.4.0.tar && cd helix-<client>-release-1.4.0./helixctl install # load the image, take the artefacts in./helixctl <CLIENT> create <name> # then: start replicantAn image — one artifact, nothing to install
Section titled “An image — one artifact, nothing to install”echo registry.example.com/helix > helix/runtime/CLIENT_REGISTRY # once./helix/runtime/helixctl release 1.4.0 --as image # builds and pushes; --no-push to hold backThe image is the exact core with your sealed bundle baked in, labelled with the client, its version and its
SDK. On the node, point helix/runtime/REGISTRY and VERSION at it and helixctl pull — there is nothing
to deploy. helixctl reads the label at launch and mounts no bundle; at that point it refuses if the node
also has a deployed bundle, or if the image is baked for a different client, so there is only ever one answer
to what the node is running.
The refusal is at launch, not at import. helixctl import does not read the label, so taking a bundle in
on a node pinned to a client image succeeds — and every later launch then fails, because the node has two
answers. If you are moving a node off a baked image onto a bundle, pin it to a client-agnostic core in the
same breath.
A core set — the special case
Section titled “A core set — the special case”--as core-set is the odd one out: no client repo, no build, no bundle. It is the core travelling on its
own — the exact image, the launcher from it, and a pin — for the two situations where a client bundle is not
the thing being moved:
- Bootstrapping a machine that has no core yet, ahead of any client.
- An air-gapped estate, where the core arrives separately from the client.
It is normally cut by the core release itself (publishHelixCore.sh <ver> --set) rather than by hand.
install recognises a set with no bundle and runs two steps instead of three:
tar xf helixcore-release-<ver>.tar && cd helixcore-release-<ver>./helixctl install # loads the image and pins VERSION — that is all./helixctl import <client-bundle>.tar # the client arrives separately./helixctl <CLIENT> create <name> # then: start replicant — or start replicant <client-bundle>.tar5. Upgrading
Section titled “5. Upgrading”A single node
Section titled “A single node”./helix/runtime/helixctl pull # brings the core AND the new launcher (the stub self-updates)./helix/runtime/helixctl pin # resolve the float to the exact tag./helix/runtime/helixctl version # confirmTo avoid pulling a moving tag at all, write the exact tag first — echo 0.0.5d > helix/runtime/VERSION —
then pull. You fetch precisely what you asked for and never touch the float.
Whether the client bundle needs rebuilding depends only on the SDK. Same MAJOR.MINOR, and the deployed
bundle keeps serving; a moved minor, and you rebuild and redeploy.
One node
Section titled “One node”An upgrade is not a verb of its own. It is three of the five, in order, and the middle one is the point:
cd ~/HelixRuntime/aa-prod./helixctl import helix-aa-1.4.1.tar # take it in; nothing running changes./helixctl AA stop./helixctl AA start replicant helix-aa-1.4.1.tarImporting changes nothing that is running, so a version can sit in a node’s release area for as long as you like before anyone chooses it. That is what makes rolling an estate forward node by node free until the moment of choosing — and it is why the two halves are separate commands rather than one.
The version is confirmed when the node starts. start reads the artefact’s own manifest for the core it
was built against, pins that core, obtains it (from the release area if the archive is there, otherwise the
registry), places the bundle, and starts. One artefact name resolves both halves of the pair, which is why you
name a file rather than a core tag — a tag can only ever set half of it.
Compatibility is checked at start, against the tar the node is about to run: a bundle whose SDK
MAJOR.MINOR differs from the image’s is refused there. That is deliberately the last possible moment, because
an artefact can arrive by import, by scp, or by hand, and start is the only thing every one of those paths
passes through.
If the SDK has moved, the bundle has to be rebuilt against the new core on a machine that has the client
repository — release --as bundle — and imported like any other version. A deployed node has no custom/
beside it and cannot rebuild, which is the right answer rather than a missing setting.
Several nodes on one machine is a loop you write, and can see:
for h in ~/HelixRuntime/*/; do ( cd "$h" && ./helixctl import helix-aa-1.4.1.tar ); doneThere used to be a helixctl upgrade that did the whole move in one command, and before that a sweep driven by
a hand-maintained helix-tenants file. Both are gone. The file was the only thing on a machine nobody was ever
prompted to update, so it went stale between an install and the upgrade that trusted it. upgrade went because
it was the sequence above with the choice of version taken away: it re-ran whatever command the node had
recorded for itself, which meant the one thing you most want to be explicit about — which version — was the
one thing it decided for you. Nothing ever called it but a person, and Mission Control’s Import and Start do
the same work with the version named.
A cluster
Section titled “A cluster”Not with this command. A node has no channel to its peers, and a rolling upgrade has to know which member is writing so it can step that one down last — which is cluster knowledge, and lives in Mission Control. Host and cluster are different axes: one machine may hold instances of two clusters, and a cluster’s other members are on machines this one knows nothing about.
The procedure depends on one thing, and Mission Control reports both halves of it on the primary’s card — what the world is stamped at, and what the running build reads and writes:
| compatible schemas | a schema floor has moved | |
|---|---|---|
| what it means | the new core can read the world as it stands | the world has to be converted before the new core can use it |
| the procedure | per member: stop → install → start. Secondaries first, then one stepdown, then the old primary last | pause the cluster, install on the primary, let it convert at boot, then install and restart the others |
| why that order | one election instead of one per member; a secondary is not the writer, so it needs no stepdown | the schema gate runs at boot, and replication is not gated — a primary converting while old code is still serving reads would stream it documents it cannot interpret |
The secondaries never convert. They receive the converted world by replication, stamp included, so what they need is the new binary, not a reload. And once the primary has converted, an older core cannot boot against that world at all — it refuses, naming both versions — so installing the rest is compulsory rather than optional. The oplog holds at least 24 hours, so a member can be out for a maintenance window and still catch up instead of needing a full resync.
helixctl install on a node will refuse while that node is running: software is not swapped under a live
node, and the far side is asked rather than assumed. That refusal is what makes one-at-a-time the default.
6. helixctl command reference
Section titled “6. helixctl command reference”Run helixctl help on any node for the same list.
Image and bundle
Section titled “Image and bundle”| command | what it does |
|---|---|
check (alias doctor) | audit host readiness: run / client-dev / core-dev (runs automatically before pull) |
login | log in to the image registry (once per server) |
pull [--force] [--no-prune] | fetch the pinned image only if it is not already on this host — an exact tag already present is used as it stands, so the registry need not be reachable. A floating tag (latest, sdk-*) is always re-checked. --force re-checks an exact tag (after one has been republished). Then prunes old core images unless --no-prune |
load [tar] | load an image tar on an air-gapped host; refuses to guess between several |
pin [tag] | pin VERSION to an exact core, resolving the current float |
pack [out.tar] | a copy of client.tar, for redistribution — the artefact itself, not a repack |
harness [dir] | pull the Spiral test harness (testSpiral, testSpiralRun) out of the image into tests/ |
version | what this node runs — image, pin, core, SDKs, baked client |
stop / logs / shell | act on the running container — found, not assumed: a node names its container differently per mode, and these look for any of them. With several up at once (concurrent spiral feeds are a normal case) they list what they found and ask for HELIX_CONTAINER_NAME rather than picking one |
facts | one JSON object: what this machine is, as the launcher sees it — runtime, pin, image and whether it is present, client, seed directory, containers that are up. It starts nothing and reaches no registry, and it answers on a machine with no container runtime at all (saying so). Written for the node agent; useful to anyone scripting against a host |
help | the same reference, on the node |
Releasing
Section titled “Releasing”| command | what it does |
|---|---|
release [ver] [--as studio|set|bundle|image|core-set] | build a release in a client or demo repo; ver defaults to the client’s package.json — see §4 |
install [set.tar] | install a set on a node, or clone another node: load, pin, place the bundle if the set carries one. |
import [artefact] | take a version in from staging/ into this node’s release area. With no argument it lists what is on offer. Nothing running changes |
<CLIENT> start <type> [artefact] | start a node. Naming an artefact confirms the version: its manifest gives the core, which is pinned and obtained, and the bundle is placed before the node comes up |
release --to <dir> | where a built artefact lands. The printed path is what a later environment’s staging is pointed at — a release is pulled, not pushed |
Running
Section titled “Running”helixctl <CLIENT> start <type>, mirroring startHelix <CLIENT> <startType>. The action is start; the type
is its argument, and it decides the mode the node runs as — solo, multiplexor, replicant or
standby. primary is not among them: it is whichever replicant holds the write, which an election decides.
| start type | mode | what it is |
|---|---|---|
demo [artefact|repo] | solo | throwaway, many at once, each on its own writable copy of the client repo — try or show the product, and what a public demo server runs. In your repo it needs no argument; name a studio artefact and the repo is restored from inside it, with no credential and no network (needs git) — see Stand up a demo server |
dev <repo> | solo | mount the client repo and rebuild the extension on save |
tuning [artefact|repo] | solo | the same node as demo but on the actual tree, so a tuner’s saves persist — domain tuning and the Spiral Laboratory |
replicant | replicant | a durable voting member. Given HELIX_PRIMARY it joins a set; given none it brings this machine’s own world back up |
standby | standby | a voting member at priority 0 — never elected; with a vault directory it is the cluster’s capture member |
spiral | multiplexor | fed anchor — the full stack driven from stdin; stops when its input ends |
spiralOnly | — | the raw optimiser as a command-line filter; not a Helix node at all |
And one word that is not a start type, because it is a ceremony rather than a kind of node:
| what it is | |
|---|---|
helixctl <CLIENT> create <name> | makes a NEW cluster, names it once, and refuses if this machine already holds a world. What it leaves running is an ordinary replicant |
A cluster node’s environment
Section titled “A cluster node’s environment”The whole deployment — the three nodes, the ports between them, and the one address your operators use — is Deploy a cluster.
Cluster modes need to know where this node is, because the other members address it directly:
export HELIX_PUBLIC_HOST=fred # this node's reachable NAME (prefer it to an address — see Deploy a cluster)export HELIX_PORT=8001 # the SPA port browsers useexport HELIX_MONGO_PORT=3001 # the replica set — chosen for you when unset; name it for a clusterexport HELIX_PUBLIC_CRUD_PORT=8090 # optional, recommended: fixes the port the other nodes reachexport HELIX_PRIMARY=fred:3001 # to JOIN a set (replicant / standby); omit it to bring back a world already hereThree ports are published, and only two are the cluster’s business:
| Port | Published as | Who reaches it |
|---|---|---|
| mongo | the same number inside and out | every other member, in both directions — replica-set heartbeats are mutual |
| CRUD | HELIX_PUBLIC_CRUD_PORT, else a port picked at start | the other nodes, whose SPA proxies writes to the active writer |
| SPA | HELIX_PORT, else a port picked at start | browsers only — never needed for joining |
Name the CRUD port unless you enjoy firewall rules over a whole range: unset, it is picked afresh on every start. Because mongo’s port is published unchanged, a host runs one node. Secrets (the Spiral licence, map and any client keys) are forwarded from the launching environment by name, so a service or cron wrapper must export them itself — a non-interactive shell does not read your profile. And the licence is keyed to the hostname the container reports, so every node needs its own.
For the two stdin-driven modes there are launch commands — drop-ins for a Spiral binary, which a system outside Helix can run with no arguments at all:
./spiralOnly < feed.json > records.json 2> diagnostics.json # the raw optimiser./spiral < feed.json > records.json 2> diagnostics.json # the full stackThe client’s bundle is the pin. A bundle records the client and the exact core image it was built in
(<coreRegistry>:<coreVersion>), so a core that disagrees with its bundle cannot happen. Nothing else
chooses the version: there is no VERSION read, no REGISTRY file, no stamp, and no option to run another.
Name the file for its versions where you can — <Client>-<ver>-core-<core>.tar — so an artifact can be
identified wherever it travels. Helix does not require it: where the directory is read by something outside
Helix that expects one path, a fixed name and replace-in-place is a perfectly good answer, and the AA’s
AA_helixConfig.tar is exactly that case.
Nothing in Helix infers a version from a bundle’s name, whichever you choose. The version lives in two
places that can be trusted: client/manifest.json inside the tar, and the node’s own record of what it was
installed from (helixctl node-manifest) — which the launcher reads and passes on when a node seeds
another. A name is an address, not a description.
They are one line each, and your build pipeline writes them (core 0.0.14s):
#!/bin/shexec env HELIX_QUIET=1 HELIX_HOME=/opt/aa-config HELIX_IMAGE=…/helixcore:0.0.14s \ HELIX_CLIENT_TAR=/opt/aa-config/AA_helixConfig.tar \ /opt/aa-config/helixctl AA start spiralOnly "$@"Everything a run needs to decide is decided at build time, by the only thing that knows it: the pipeline
that just produced that tar on that core. There is nothing to search for, nothing to disambiguate, and no way
to run the wrong bundle. The running itself belongs to helixctl, the one owner of every container Helix
starts — the hostname pin, the signal handling, the mount forms, the ports, and the handover of this host’s
own resolver and the launch account’s ssh identity. Your pipeline installs it beside them, from the same core
image, and replaces all of it together.
That pipeline is a few steps — helixctl release --as bundle, deploy, lift helixctl out of the image,
write the two commands — so Honeywick will write the one-command tool that does it for your environment,
checks the result and reports what it built. It is part of fitting Helix to a system that already exists, not
part of the product: the AA’s bundleClient is an example of one. A build there never overwrites an earlier
tar, so the directory keeps a readable history and an operator can always say which bundle a run used.
Where the commands live does not matter, and neither does the working directory — both paths are written
in. Earlier cores worked the other way round: until 0.0.14r the commands searched the working directory and
then HELIX_CONFIG for a <Client>_helixConfig.tar, with HELIX_CLIENT to pick between several. If you
have that arrangement, rebuild with a current pipeline; the old commands still work against the tar they
find.
A worked example: fred
Section titled “A worked example: fred”The commands take no arguments, so everything else comes from the environment. Keep it in one file the launching account sources — a service or cron wrapper must, because a non-interactive shell does not read a profile, and an unset secret is simply not forwarded.
# /etc/helix/fred.env — chmod 600: it holds a licence and a password.# . /etc/helix/fred.env && ./spiral < feed.json > records.json 2> diagnostics.json
# ── This node ─────────────────────────────────────────────────────────────────export HELIX_PUBLIC_HOST=fred.example.com # how the other nodes and browsers reach itexport HELIX_SITE=london # where it is; drives read locality and node binding
# ── Ports ─────────────────────────────────────────────────────────────────────export HELIX_PORT=8001 # SPA — browsers onlyexport HELIX_MONGO_PORT=3001 # the replica set — every member, in BOTH directionsexport HELIX_PUBLIC_CRUD_PORT=8090 # writes the other nodes forward to the writer
# ── The administrator ─────────────────────────────────────────────────────────# OPTIONAL. Leave both unset and helixctl creates `admin` with a four-digit code,# prints it when the node starts, and requires it to be changed at the first# sign-in. Set them to choose the account yourself; set the password to nothing# (HELIX_SEED_PASSWORD=) to say this administrator needs no password at all.export HELIX_SEED_USER=ops@example.comexport HELIX_SEED_PASSWORD='…'
# ── Secrets ───────────────────────────────────────────────────────────────────export SPIRAL_LICENCE='{"set":{"licence":"…"}}' # keyed to the hostname this node reportsexport GOOGLE_MAPS_API_KEY='…'
# ── So a run can be explained ─────────────────────────────────────────────────export HELIX_LOG_DIR_HOST=/var/log/helix # crud.log, spiral.log, 2crud.log on the HOST # (defaults to <node>/storage/logs — see "Storage and logs")The node’s ssh identity is not in that file, and should not be. So that Mission Control can ask another
machine whether it could host a node, the launcher lends the container the launch account’s own ~/.ssh,
read-only, together with an ssh-agent if one is running. It works the account out from SUDO_USER, else
the passwd entry for the uid it is running as — deliberately not $HOME or $USER, which sudo, a
systemd unit and cron each rewrite to the account doing the running rather than the one that asked.
The rule to remember is that the node can do exactly what a non-interactive ssh from that account can do, and nothing more:
ssh -o BatchMode=yes <other-host> true # this must succeed, as the account that launches HelixIf it does not, neither will Helix, and for the same reason. A Kerberos ticket or a typed password gets you
in at a prompt and cannot cross into a container — so a service account whose ~/.ssh holds a known_hosts
and no key needs a keypair of its own (ssh-keygen, then ssh-copy-id). The launcher says so at start-up
when the identity it is lending has no key in it. HELIX_SSH_DIR_HOST=<dir> names a different directory, and
HELIX_SSH_DIR_HOST= (empty) lends nothing at all.
Helix generates no keys and stores no credentials: it borrows an operator’s reach rather than accumulating its own. The other side of that bargain is worth stating plainly — a node lent an identity can reach whatever that identity can reach, and it is also the process that loads your client extension at runtime. Empty is how a deployment that does not want that says so.
If fred is one of several nodes, add the three that make a cluster of them:
export HELIX_JWT_SECRET='…' # THE SAME long random string on every nodeexport HELIX_COOKIE_DOMAIN=.helix.example.com # so a session follows a client between nodes# export HELIX_PRIMARY=fred.example.com:3001 # JOINERS only — fred is the first, so not hereThere is no list of node addresses to maintain. A client asks any node it can reach and is told the others, with a recommendation, through the binding service — the membership comes from the nodes themselves. The one thing you may want to set is the opposite: a subset a given node is allowed to offer, when a region’s clients should stay on a region’s nodes.
export HELIX_BIND_ALLOW=fred.example.com,barney.example.comOn top of the tar, each launch can overwrite the domain and the map. The first column with an answer wins:
| 1. flag | 2. environment | 3. current directory | otherwise | |
|---|---|---|---|---|
| domain | --domain | SPIRAL_DOMAIN | ./<Client>.domain.json | the tar’s own |
| map | --map | SPIRAL_MAP | ./<Client>.map.json | the tar’s own |
The third column is what makes them a drop-in for the legacy layout — the two JSON files sitting in the
directory Spiral runs in, under their canonical, capitalised names. The lower-case legacy name
(aa.domain.json) is deprecated: it is not picked up from the working directory and is not staged in the
tree, so a stale copy cannot quietly win over a new tar; a lower-case file you still mean to run is named in
SPIRAL_DOMAIN, where it is visible. Whichever domain and map end up in force are also staged under the
helix/custom/-qualified name a run file captured from a repo root might include. A set-but-missing overwrite is fatal rather
than ignored: one silently dropped across a parallel test fan-out reports a green pass against the wrong
domain.
The engine is not an overwrite. Until 0.0.14 a spiral.l64 in the working directory, or --exe, or
SPIRAL_EXE, would be rotated in on top of the image’s. That is gone. A container runs the engine its image
was built with and there is no way to hand it another, so the core recorded in the bundle’s manifest is the
complete answer to which Spiral produced a given result — today and when someone asks again next year. A
spiral.l64 left in the directory from the legacy layout is ignored rather than adopted.
Trialling a Spiral build is a real need and a separate one: the Spiral Laboratory runs a batch against a candidate engine, which is named to the test harness and never becomes the engine a client is quietly running.
They are silent unless they fail. Stdout is Spiral’s data and stderr is Spiral’s own record stream, whose
second line test harnesses read for Spiral’s version — so the commands add nothing to either: no banner and
no progress. On a server’s first run the core is pulled, quietly, if it is not already loaded, so that
run is slower; after that the container is invariant and only the overwrite files change per test. Anything a
container runtime prints is shown only when something fails. spiral does not print its address either — set
HELIX_PORT to fix the SPA port.
They last as long as their input. At end of stdin Spiral finishes what it was given, the container stops,
and the command returns Spiral’s exit status — ./spiral < feed > out returns just as spiral.l64 < feed > out
does, rather than leaving a finished world running. spiral needs SPIRAL_LICENCE exported and refuses to
start without it; if the stack itself cannot start, the reason is printed on stderr as a helix: line, since
there is then no Spiral stream to protect.
They behave like the binary when signalled. Each run is one container, started in the foreground with a
small init process, so a signal sent to the command (Ctrl-C, a harness’s timeout, a hang-up) reaches Spiral
as it would reach spiral.l64, and the command returns the exit status the binary would have. A signal that
cannot be caught — a harness escalating a timeout to SIGKILL — leaves the container with nobody attached;
nothing can run in a process that has been killed, so it is removed by the next run instead.
Their inputs are staged once and reused. The first run of a tar (with a given set of overwrites) unpacks
it, rotates in whichever domain and map are in force, and re-packs the result into a cache on the host:
$HELIX_CACHE/spiral, else $XDG_CACHE_HOME/helix/spiral, else ~/.cache/helix/spiral. That staged copy is
mounted read-only, so parallel runs share it safely, and later runs with the same inputs stage nothing at all.
Entries unused for a week are removed. Set HELIX_CACHE where the home directory is not writable.
They pass -i without a TTY (which would mangle the stream), and each command execs, so the file
descriptors go straight through. How this compares with every other mode — which ones mount your repo, which mount a sealed tar
read-only — is laid out in
How your client reaches the container.
Running them from somewhere else
Section titled “Running them from somewhere else”Call them by path, or symlink them in — the tar and the core are written into the command, so neither where the command lives nor where you run it changes what runs:
cd /tests/run42 # may hold AA.domain.json / AA.map.json to overwrite the tar's/opt/aa-config/spiralOnly < feed.json > records.json 2> diagnostics.jsonThe working directory still decides the overwrites (the table above), which is the whole point of being a drop-in: a test directory carrying its own domain gets its own domain. It no longer decides the bundle.
To run a different bundle with the same commands, name it: HELIX_CLIENT_TAR=/path/to/another.tar /opt/aa-config/spiralOnly < feed.json. That is the same override the command itself uses, so comparing two
builds is one variable rather than two directories.
Mode and environment are the two axes a node has: dev, tuning and demo all report mode solo, while
the environment — a name the client chooses, live, test, acceptance — says how seriously to treat the
instance. Both are printed at boot. (Until 0.0.15 the second axis was a closed three-value “deployment class”;
it became a name, so a client can add an environment without a core change.)
Container log levels follow the same split: dev and tuning log at INFO — they exist for watching
the system, and INFO carries the load/snapshot timings — while every other mode logs at ERROR (spiral
raises itself to WARN). HELIX_LOG_LEVEL overrides in either direction; the ambient LOG_LEVEL is
deliberately ignored, because a stray DEBUG in a launching shell once flooded a production dispatch log to
gigabytes.