Skip to content

Redeem a join token and receive the cluster bundle.

POST
/v1/ha/nodes/{nodeId}/actions/join
Code sample: Shell / cURL
curl --request POST \
--url https://stratum.example.com/v1/ha/nodes/example/actions/join \
--header 'Content-Type: application/json' \
--data '{ "token": "example", "mgmtIp": "example", "dataIp": "example", "az": "example", "name": "example", "labels": { "additionalProperty": "example" }, "networks": { "additionalProperty": { "ip": "192.0.2.111", "interface": "vlan90" } } }'

Node-facing endpoint — authenticated by the join token in the body, NOT a session (a joining node has no operator credentials). The node presents its minted token + self-reported identity and receives the cluster bundle: the role it joins as plus the existing members it configures its raft/replication peers from. The token is single-use..

issuing the bundle admits the node as a joining cluster member and bumps the membership generation, so every member renders it into its peer lists. The node becomes active once it has applied the generation it was admitted at. A node that is decommissioned, whose management address another member has, or whose declared traffic classes or topology labels do not match the members’, is refused with 409 before the token is consumed.

nodeId
required
string

The joining node’s self-generated node id.

Media typeapplication/json
object
token
required

The plaintext join token minted via POST /v1/ha/join-tokens.

string
mgmtIp
required

The joining node’s management-plane IP.

string
dataIp
string
az
string
name

The joining node’s display name. With labels and networks it is what the node declares about itself (its own cluster.nodes entry) and seeds its member row once.

string
labels

The node’s declared labels (topology.rack).

object
key
additional properties
string
networks

The node’s storage/overlay bindings. Refused (409) when the other members declare a different set of traffic classes.

object
key
additional properties

One traffic class’s binding on a node (-TOPO-1).

object
ip
required

The address this class uses on the node (a bare IP, not a CIDR).

string

Example

192.0.2.111
interface

Host interface carrying the class, when one is declared.

string

Example

vlan90

Token accepted; the cluster join bundle.

Media typeapplication/json
object
clusterId

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).

string
role
required

The role this node joins as (from the redeemed token).

string
Allowed values: control-plane hybrid hypervisor storage
nodes
required

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.

Array<object>
object
nodeId
required
string
role
required
string
Allowed values: control-plane hybrid hypervisor storage
az

Availability zone; empty on single-AZ installs.

string
mgmtIp

Management-plane address.

string
networks

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
key
additional properties

One traffic class’s binding on a node (-TOPO-1).

object
ip
required

The address this class uses on the node (a bare IP, not a CIDR).

string
interface

Host interface carrying the class, when one is declared.

string
labels

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
key
additional properties
string
bootState

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).

string
Allowed values: ready starting unreachable
bootPhase

The in-flight startup phase when bootState=starting (e.g. migrations).

string
bootElapseMs

How long the current boot phase has run (ms); climbs while a phase is stuck.

integer format: int64
readyAfterMs

The node’s last boot duration (ms), when bootState=ready.

integer format: int64
parentDomain

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.

string
region

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.

string
dnsUpstreamServers

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.

Array<string>
caBundle

PEM cluster CA bundle the node trusts (HA installs with an internal PKI).

string
nodeCert

PEM transport leaf issued to this node by the cluster CA (mTLS identity).

string
nodeKey

PEM private key for nodeCert. Sensitive — the node persists it mode 0600.

string
postgresPassword

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.

string
adminEmail

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.

string
adminPassword

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).

string
openBaoToken

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.

string
openBaoNodeToken

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.

string
dnsEncryptionKey

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.

string
openBaoUnsealKey

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.

string
membershipGeneration

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).

integer format: int64
vips

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
api

Control-plane API VIP.

string
postgres

Postgres VIP (follows the Patroni primary, M1.2 /).

string
openbao

OpenBao VIP (WS-B).

string
ovnNb

OVN northbound DB VIP (WS-C).

string
ovnSb

OVN southbound DB VIP (WS-C).

string
vmselect

Vmselect read VIP for the VictoriaMetrics cluster. The controlplane’s PromQL read path follows it to a healthy vmselect.

string
providerGw

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.

string

Example

{
"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"
}

Bad request.

Media typeapplication/problem+json
object
type
required
string format: uri
title
required
string
status
required
integer
>= 400 <= 599
detail
string
instance
string format: uri-reference
requestId
required
string
code
required
string
issues

The findings of a rejected IAM policy document (custom and resource policies).

Array<object>

One finding of a rejected IAM policy document. A rejection lists every finding, so the author fixes the document in one round trip.

object
code
required

The finding, e.g. INVALID_DOCUMENT, UNKNOWN_ACTION, ACTION_NOT_APPLICABLE.

string
statement
required

Index of the statement at fault; -1 for the document as a whole.

integer
>= -1
detail
required
string
action

The offending action, where one is at fault (ACTION_NOT_APPLICABLE).

string
errors

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.

Array<object>

One field’s problem, the stage-2 installer API’s shape. field is the field’s path, message what is wrong with it.

object
field
required

The field’s path, e.g. links[0].interface, disks[1].confirm, mgmt.address or role; empty for the whole request.

string
message
required
string
details

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
key
additional properties
any

Example

{
"issues": [
{
"code": "ACTION_NOT_APPLICABLE",
"action": "compute:CreateVpc"
}
]
}

Missing or invalid token.

Media typeapplication/problem+json
object
type
required
string format: uri
title
required
string
status
required
integer
>= 400 <= 599
detail
string
instance
string format: uri-reference
requestId
required
string
code
required
string
issues

The findings of a rejected IAM policy document (custom and resource policies).

Array<object>

One finding of a rejected IAM policy document. A rejection lists every finding, so the author fixes the document in one round trip.

object
code
required

The finding, e.g. INVALID_DOCUMENT, UNKNOWN_ACTION, ACTION_NOT_APPLICABLE.

string
statement
required

Index of the statement at fault; -1 for the document as a whole.

integer
>= -1
detail
required
string
action

The offending action, where one is at fault (ACTION_NOT_APPLICABLE).

string
errors

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.

Array<object>

One field’s problem, the stage-2 installer API’s shape. field is the field’s path, message what is wrong with it.

object
field
required

The field’s path, e.g. links[0].interface, disks[1].confirm, mgmt.address or role; empty for the whole request.

string
message
required
string
details

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
key
additional properties
any

Example

{
"issues": [
{
"code": "ACTION_NOT_APPLICABLE",
"action": "compute:CreateVpc"
}
]
}
WWW-Authenticate
string

Conflicting state.

Media typeapplication/problem+json
object
type
required
string format: uri
title
required
string
status
required
integer
>= 400 <= 599
detail
string
instance
string format: uri-reference
requestId
required
string
code
required
string
issues

The findings of a rejected IAM policy document (custom and resource policies).

Array<object>

One finding of a rejected IAM policy document. A rejection lists every finding, so the author fixes the document in one round trip.

object
code
required

The finding, e.g. INVALID_DOCUMENT, UNKNOWN_ACTION, ACTION_NOT_APPLICABLE.

string
statement
required

Index of the statement at fault; -1 for the document as a whole.

integer
>= -1
detail
required
string
action

The offending action, where one is at fault (ACTION_NOT_APPLICABLE).

string
errors

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.

Array<object>

One field’s problem, the stage-2 installer API’s shape. field is the field’s path, message what is wrong with it.

object
field
required

The field’s path, e.g. links[0].interface, disks[1].confirm, mgmt.address or role; empty for the whole request.

string
message
required
string
details

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
key
additional properties
any

Example

{
"issues": [
{
"code": "ACTION_NOT_APPLICABLE",
"action": "compute:CreateVpc"
}
]
}

Upstream temporarily unavailable (driver / scheduler / dependency).

Media typeapplication/problem+json
object
type
required
string format: uri
title
required
string
status
required
integer
>= 400 <= 599
detail
string
instance
string format: uri-reference
requestId
required
string
code
required
string
issues

The findings of a rejected IAM policy document (custom and resource policies).

Array<object>

One finding of a rejected IAM policy document. A rejection lists every finding, so the author fixes the document in one round trip.

object
code
required

The finding, e.g. INVALID_DOCUMENT, UNKNOWN_ACTION, ACTION_NOT_APPLICABLE.

string
statement
required

Index of the statement at fault; -1 for the document as a whole.

integer
>= -1
detail
required
string
action

The offending action, where one is at fault (ACTION_NOT_APPLICABLE).

string
errors

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.

Array<object>

One field’s problem, the stage-2 installer API’s shape. field is the field’s path, message what is wrong with it.

object
field
required

The field’s path, e.g. links[0].interface, disks[1].confirm, mgmt.address or role; empty for the whole request.

string
message
required
string
details

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
key
additional properties
any

Example

{
"issues": [
{
"code": "ACTION_NOT_APPLICABLE",
"action": "compute:CreateVpc"
}
]
}