A joiner's registration, long-polled (join listener only).
curl --request GET \ --url https://stratum.example.com/v1/join/registrations/example. Pinned to the key that registered: another
key gets 404. With wait, answers when the state changes or after that
many seconds (at most 60). Once the join’s turn has come (delivering,
installing), the answer carries the delivery: the node.yaml and the
join bundle.
Parameters
Section titled “Parameters”Path Parameters
Section titled “Path Parameters”Query Parameters
Section titled “Query Parameters”The state the caller last saw; wait answers as soon as it differs.
Responses
Section titled “Responses”The registration.
A joiner’s view of its registration.
object
needs-upgrade: the joiner runs an older release and is upgrading
itself to the cluster’s (upgrade says to which). It is not approvable
until it registers again on the cluster’s release, when it becomes
pending. Only a joiner that sent selfUpgrade is ever in it.
Why it was rejected.
The upgrade a joiner on an older release makes before it can register: from the release it was installed with to
the release the cluster runs. Only a joiner that sent selfUpgrade: true
is ever offered one; any other joiner on another release is refused with
RELEASE_MISMATCH.
object
The release the joiner reported.
The release the cluster runs, which the joiner upgrades to.
Where the joiner fetches the release from (the release mirror’s URL of the release directory). Every file is checked against the release’s signed manifest. Empty when the founder names none.
The founder gives the release itself, when no release mirror can (a lab,
an air gap): fetch it from GET /v1/join/registrations/{joinId}/release/{path}
on the join listener, the release layout a mirror publishes
(payload/manifest.json, os/blobs/..., sysext/...). source is then
empty. Served only to this registration while it is needs-upgrade,
read only, a few files at once (503 RELEASE_BUSY: retry); ranges are
accepted so a download resumes. The joiner verifies the manifest’s
signature against the release keys of its own OS and every file against
the manifest, as it does from a mirror.
What an approved joiner receives when its turn comes, only over the channel pinned to its key: its node.yaml, built from the assignment, and the join bundle, with its transport leaf issued for its node id.
object
object
The cluster’s id (e.g. cl-3vin6yzz). The joiner MUST adopt it as its own so every node’s Patroni scope matches — otherwise each node manages a separate single-node cluster and the replica never finds the leader (M1).
The role this node joins as (from the redeemed token).
The cluster members to peer with: every member in joining or active, this node included (admitted as joining when this bundle was issued), in join order.
object
Availability zone; empty on single-AZ installs.
Management-plane address.
The node’s traffic-class bindings, keyed by class
(storage, overlay). Carried in the join bundle so a joiner’s
ClusterConfig matches the founder’s.
Without it a joined node has no networks entry, so ClassAddr falls
through to mgmt_ip and EVERY traffic class silently collapses onto the
management plane. That put Ceph’s public_network — and therefore all
client I/O — on the 1G mgmt NIC while only OSD replication used the 10G
storage VLAN, with the cluster reporting HEALTH_OK throughout.
object
One traffic class’s binding on a node (-TOPO-1).
object
The address this class uses on the node (a bare IP, not a CIDR).
Host interface carrying the class, when one is declared.
The node’s DECLARED label map (topology.rack,
topology.power, and any operator-namespace key). Carried in the join
bundle so a joiner’s ClusterConfig matches the founder’s.
Without it a joined node has NO declared labels at all, and every consumer degrades to “the founder has a rack, nobody else does” — silently. Rack spread sees one value and two empties, so the anti-affinity guarantee reports success and places anywhere; a CRUSH hierarchy built from labels finds one rack and is refused as a half-declared topology; a rack failure domain can never be satisfied; and a taint declared on a joiner repels nothing.
topology.az is NOT carried here — the AZ rides its own az field, and
two records of one fact is the bug each cost a
session.
This is the THIRD field to go missing from this schema the same way (networks in the bundle, networks in the status report). A field added to config.ClusterNode does not reach a joiner unless someone remembers this file.
object
Per-node boot progress, probed from the node’s /startupz.
ready = serving; starting = still booting (see bootPhase);
unreachable = the node didn’t answer (down, early-boot before the
listener, or wedged). Lets the UI tell “booting / stuck / down”
apart from a vague “unknown”. Omitted when the boot-health probe
isn’t wired (single-node).
The in-flight startup phase when bootState=starting (e.g. migrations).
How long the current boot phase has run (ms); climbs while a phase is stuck.
The node’s last boot duration (ms), when bootState=ready.
The Stratum-owned domain the cluster serves its API, console and S3 endpoints under. The joiner ADOPTS the founder’s value rather than restating it in its own manifest, so the cluster cannot end up with two nodes disagreeing about its domain. Load-bearing since the parent domain lost its built-in default: a joining node’s control plane refuses to start without one, and a joiner manifest legitimately omits the field.
The cluster’s region. Adopted by the joiner for the same reason as parentDomain: it is cluster identity, and a node that decides it locally can disagree with the cluster it just joined. The region is baked into every ARN the cluster mints and into every delegated zone’s served FQDN, so two nodes disagreeing is not a cosmetic split. A joiner’s node.yaml legitimately omits it, and the built-in fallback is the test-region-1 placeholder — so before this field a joiner on a cluster with a real region silently ran in a different one. Empty on a pre- founder, where the joiner keeps its previous local behaviour.
The resolvers the cluster’s VPC recursors forward every name they do not serve to: the founder’s node.yaml dns.upstream_servers, as its control plane loaded them (network.default_dns_servers). Adopted by the joiner for the same reason as parentDomain. Every control plane runs the recursor reconciler, which writes this list into the one shared “.” forward zone, so a node holding another list (or none) would flip that zone on every tick. An EMPTY list is a value, not an omission: the cluster resolves from the root servers, and the joiner adopts that too. Absent only from a pre- founder, where the joiner keeps the list its own manifest names.
PEM cluster CA bundle the node trusts (HA installs with an internal PKI).
PEM transport leaf issued to this node by the cluster CA (mTLS identity).
PEM private key for nodeCert. Sensitive — the node persists it mode 0600.
The cluster’s Postgres superuser/replication password (M1). Sensitive — a joining node uses it so its Patroni replica authenticates streaming replication against the primary instead of minting a fresh password the cluster wouldn’t accept. The node persists it only as the podman secret (mode 0600). Replaced by cert-based replication auth in a later hardening.
The cluster’s bootstrap IAM admin email: the email of the admin the founder’s control plane found at start, not that node’s configured one. Adopted by the joiner for the same reason as adminPassword: the admin identity lives once in the cluster Postgres. Before this field a joiner always wrote the built-in default email, and control planes then ensured the admin by email, so on a cluster whose admin had another email its control plane created a second bootstrap admin under the default name. Since a control plane finds the admin by its stamp whatever its env names. Empty on a pre- founder, where the joiner uses that founder’s default, admin@stratum.local.
The cluster’s bootstrap IAM admin password. Sensitive — the admin identity is shared (one iam.users row + argon2 hash in the cluster Postgres), so a joining node MUST adopt the founder’s admin password rather than generate its own; otherwise each node’s controlplane.env carries a different STRATUM_IAM_ADMIN_PASSWORD and only the founder’s actually authenticates against the shared hash. The node persists it only in controlplane.env + bootstrap-credentials.txt (mode 0600). Empty on a Postgres-only replica (which runs no control plane).
The cluster’s OpenBao token. Sensitive — a full-stack joiner runs a control plane that needs OpenBao access, but increment 1 keeps OpenBao a single instance behind the.242 VIP (raft is the separate EPIC). Rather than init its own, the joiner adopts this token and points STRATUM_OPENBAO_ADDR at the VIP. The node persists it only in controlplane.env (mode 0600). Empty on a Postgres-only replica (which runs no control plane). a scoped, periodic token (policy stratum-cp), never the root token; a founder still on root refuses the bundle until its token converge has run.
A token minted for this joiner’s host tools (policy stratum-node: the raft doctor’s peer list, removing a failed member from the OpenBao raft, the unseal key in the cluster KV). Sensitive. The node persists it only in /etc/stratum/secrets/openbao-node.token (mode 0600) and its host-helper renews it. Absent from a founder that predates.
The cluster’s axdnsd ENCRYPTION_KEY, 64 hex characters: the AES-256 key every node’s authoritative DNS seals and opens the DNSSEC private keys and TSIG secrets in the shared database with. Sensitive. The serving control plane reads it from its own node’s axdnsd, because nothing can re-derive it: the founder derived it from the Postgres password it had at install, and a joiner that derived it from postgresPassword after a rotation got a key the cluster’s rows were never sealed with. The joiner persists it only in axdnsd.env (mode 0600). Absent from a pre- founder, where the joiner still derives the key from postgresPassword.
The cluster’s OpenBao Shamir unseal key. Sensitive — when OpenBao runs as a 3-node integrated- storage raft every member shares the founder’s single unseal key, so a joiner needs it to unseal its local voter right after operator raft join. The joiner persists it NOT in plaintext: it TPM-seals the key to its own TPM immediately on receipt (the boot-time stratum-openbao-unseal.service then re-unseals on every reboot with no human). Transits the same already-authenticated join channel that carries openBaoToken. Empty on increment-1 single-instance clusters (the joiner used the.242 VIP, ran no local OpenBao) and on a Postgres-only replica.
The membership generation this node was admitted at. Its join is done when every member in joining or active reports at least this generation as applied (GET /v1/admin/cluster/members).
The cluster’s keepalived service VIPs (HA installs,). firstboot renders the joiner’s keepalived from these so the API + Postgres VIPs match the rest of the cluster.
object
Control-plane API VIP.
Postgres VIP (follows the Patroni primary, M1.2 /).
OpenBao VIP (WS-B).
OVN northbound DB VIP (WS-C).
OVN southbound DB VIP (WS-C).
Vmselect read VIP for the VictoriaMetrics cluster. The controlplane’s PromQL read path follows it to a healthy vmselect.
Routed-host floating-IP next hop: the LAN router routes the floating ranges to it, and it follows the node whose chassis holds the VPC gateways. Absent on a bridged cluster.
Example
{ "state": "needs-upgrade", "upgrade": { "from": "0.5.0", "to": "0.6.0", "source": "https://mirror.example/releases/stratum/0.6.0/bundle" }, "delivery": { "bundle": { "clusterId": "cl-3vin6yzz", "role": "control-plane", "nodes": [ { "nodeId": "n-abc12def", "role": "control-plane", "networks": { "additionalProperty": { "ip": "192.0.2.111", "interface": "vlan90" } }, "bootState": "ready", "bootPhase": "migrations" } ], "parentDomain": "stratum.example.com", "region": "lab-1", "dnsUpstreamServers": [ "192.0.2.53" ], "adminEmail": "admin@cloud.internal" } }}Resource not found.
object
The findings of a rejected IAM policy document (custom and resource policies).
One finding of a rejected IAM policy document. A rejection lists every finding, so the author fixes the document in one round trip.
object
The finding, e.g. INVALID_DOCUMENT, UNKNOWN_ACTION, ACTION_NOT_APPLICABLE.
Index of the statement at fault; -1 for the document as a whole.
The offending action, where one is at fault (ACTION_NOT_APPLICABLE).
Field errors, for codes that carry them: each problem at its field
(see FieldError). Documented on the responses that set it, e.g.
approveNodeJoin’s INVALID_INPUT and JOIN_APPROVAL_REFUSED.
Absent otherwise.
One field’s problem, the stage-2 installer API’s shape.
field is the field’s path, message what is wrong with it.
object
The field’s path, e.g. links[0].interface, disks[1].confirm,
mgmt.address or role; empty for the whole request.
Structured facts about the refusal, for codes that carry them. The
shape is fixed per code and documented on the response that answers
it, e.g. IMAGE_POOL_MISMATCH (see ImagePoolMismatchDetails) and
IMAGE_IN_USE. Absent for codes that carry none.
object
Example
{ "issues": [ { "code": "ACTION_NOT_APPLICABLE", "action": "compute:CreateVpc" } ]}Upstream temporarily unavailable (driver / scheduler / dependency).
object
The findings of a rejected IAM policy document (custom and resource policies).
One finding of a rejected IAM policy document. A rejection lists every finding, so the author fixes the document in one round trip.
object
The finding, e.g. INVALID_DOCUMENT, UNKNOWN_ACTION, ACTION_NOT_APPLICABLE.
Index of the statement at fault; -1 for the document as a whole.
The offending action, where one is at fault (ACTION_NOT_APPLICABLE).
Field errors, for codes that carry them: each problem at its field
(see FieldError). Documented on the responses that set it, e.g.
approveNodeJoin’s INVALID_INPUT and JOIN_APPROVAL_REFUSED.
Absent otherwise.
One field’s problem, the stage-2 installer API’s shape.
field is the field’s path, message what is wrong with it.
object
The field’s path, e.g. links[0].interface, disks[1].confirm,
mgmt.address or role; empty for the whole request.
Structured facts about the refusal, for codes that carry them. The
shape is fixed per code and documented on the response that answers
it, e.g. IMAGE_POOL_MISMATCH (see ImagePoolMismatchDetails) and
IMAGE_IN_USE. Absent for codes that carry none.
object
Example
{ "issues": [ { "code": "ACTION_NOT_APPLICABLE", "action": "compute:CreateVpc" } ]}