Subsections of Choria Provisioner

Installation

We distribute an RPM or Docker container for the Provisioner package. Debian is not supported at present.

YUM Repository

For RPMs we publish releases but also nightly builds to our repositories.

Users of our Puppet modules will already have these repositories available.

Release

[choria_release]
name=Choria Orchestrator Releases
mirrorlist=http://mirrorlists.choria.io/yum/release/el/$releasever/$basearch.txt
enabled=True
gpgcheck=True
repo_gpgcheck=True
gpgkey=https://choria.io/RELEASE-GPG-KEY
metadata_expire=300
sslcacert=/etc/pki/tls/certs/ca-bundle.crt
sslverify=True

Nightly

Nightly releases are named and versioned choria-provisioner-0.99.0.20221109-1.el7.x86_64.rpm where the last part of the version is the date.

[choria_nightly]
name=Choria Orchestrator Nightly
mirrorlist=http://mirrorlists.choria.io//yum/nightly/el/$releasever/$basearch.txt
enabled=True
gpgcheck=True
repo_gpgcheck=True
gpgkey=https://choria.io/NIGHTLY-GPG-KEY
metadata_expire=300
sslcacert=/etc/pki/tls/certs/ca-bundle.crt
sslverify=True

Docker

There is a docker container choria-io/provisioner that has releases only.

Configuration

Configuring the Provisioner requires exact knowledge of your deployment needs, we suggest you start with first configuring servers by hand while exploring your needs, once the specific configuration file contents and security needs are defined those can be configured into the Provisioner system.

Deploying a Provisioner should not be the first thing you do.

General Requirements

Choria Broker

You must be running the Choria Broker and it will need a few extra configuration items.

JWT files or custom binaries

You can enable Provisioning mode in the standard Open Source Choria Server by placing a provisining.jwt in the right place. Those doing custom builds can also apply build-time defaults that has no external dependencies.

Helper Script

Provisioner calls into a user-supplier helper, written in a language like Ruby but any language can be used, to generate the per-node configuration properties.

Certificate Authorities

If you have a x509 based Choria deployment (the default) you will need to be able to generate or sign certificates for your nodes. To do this your CA needs to have a API that you can call to issue certificates.

There are other options requiring JWT files to be signed also if that is not possible.

Authorization Policies

In this mode typically nodes do not have any Policies controlling who can invoke Agents and Actions. You will probably want to write a Open Policy Agent policy that the Provisioner will deploy

Monitoring

We expose metrics to Prometheus, for in-depth monitoring you will need Prometheus or a compatible system.

Subsections of Configuration

Server Setup

The Choria Server has the required RPC Agent embedded and is ready to be used in a Provisioner managed network but it is disabled by default. Custom binaries can enable provisioning at compile time for an always-on experience.

Provisioning is enabled in the Open Source server by means of a JWT token that you create and place on the server. The JWT token holds all of the information the server needs to find it’s provisioning server and will present that token also to the provisioning server for authentication.

The token is signed using a trusted private key, the provisioner will only provision nodes presenting a trusted key.

Credentials

If you are using a x509 based setup you really just need any RSA Key pair to sign the JWT with:

$ openssl genrsa -out provisioning-jwt-signer-key.pem 2048
$ openssl rsa -in provisioning-jwt-signer-key.pem -outform PEM -pubout -out provisioning-jwt-signer.pem

The public file will be placed on all your brokers to enable provisioning.

For the Organization Issuer based deploys you would sign it using your Issuer.

Creating the JWT

Choria JWTs are creating using the choria jwt command, for provisioning specifically choria jwt provisioning.

Creating the JWT:

$ choria jwt provisioning.jwt provisioning-jwt-signer-key.pem --srv choria.example.net --token toomanysecrets
Saved token to provisioning.jwt, use 'choria jwt view provisioning.jwt' to view it

$ choria jwt provisioning.jwt
Unvalidated Provisioning Token x.jwt

                         Token: *****
                        Secure: true
                    SRV Domain: choria.example.net
       Provisioning by default: false
               Standard Claims: {
                                  "purpose": "choria_provisioning",
                                  "iss": "Choria Tokens Package v0.26.2",
                                  "sub": "choria_provisioning",
                                  "nbf": 1669805752,
                                  "iat": 1669805752,
                                  "jti": "2c99227346f641bbba34faf0a6991d05"
                                }

Here we create a provisioning.jwt that will instruct Choria to look for _choria-provisioner._tcp.choria.example.net SRV records to find the server to connect to.

Other options can be set for example to hard code provisioning URLs, username and passwords and more.

Version Hint

This table is correct for Choria 0.27.0 and newer which is due to ship early 2023. Review --help for your version.

OptionDescriptionDefaultRequired
signing-keyThe token must be signed using a private key, this can be a file with either RSA or ed25519 private key.yes
--[no]-insecureDuring provisioning the protocol security system cannot be active as no private credentials exist, this disables thatsince 0.27.0no
--tokenA basic shared secret that the provisioner must present to perform certain actionsno
--urlsA static, comma seperated, list of servers to connect to for provisioningno
--srvA domain name to query for SRV recordsno
--defaultEnables provisioning by default. Else requires plugin.choria.server.provision to be setfalseno
--registrationPath to a file to publish as registration data while in provisioning modeno
--factsPath to a file to use as fact source data while in provisioning modeno
--usernameA NATS user to use when connecting to the brokerno
--passwordPassword to use when connecting to the brokerno
--extensionsAdditional free-form data to embed in the JWT as JSON textno
--orgThe Organization this server belongs tochoriayes
--vaultUse Hashicorp Vault for signing the token, the signing-key is then the name of the vault keyfalseno
--protocol-v2Enables the choria security system and version 2 protocol. Set for Org Issuer based networksfalseno

When this file is placed in /etc/choria/provisioning.jwt and Choria starts without a configuration it will provision via these settings.

Choria also support provisioning plugins to resolve this information dynamically but this requires custom binaries and should in general be avoided.

Confirming

Without the JWT in place Provisioning is not enabled:

$ choria buildinfo
...
Server Settings:

    Provisioning Target Resolver: Choria JWT Resolver
           Supports Provisioning: false
           Provisioning JWT file: /etc/choria/provisioning.jwt
...

We can see it will look for the /etc/choria/provisioning.jwt file, lets move our newly created file there and try again:

$ sudo msg provisioning.jwt /etc/choria/provisioning.jwt
$ choria buildinfo
...
Server Settings:

    Provisioning Target Resolver: Choria JWT Resolver
           Supports Provisioning: true
           Provisioning JWT file: /etc/choria/provisioning.jwt
              Provisioning Token: *****
            Provisioning Default: false
                Provisioning TLS: true
      Default Provisioning Agent: true
         Provisioning SRV Domain: choria.example.net
...

Now provisioning is on with the settings we provided in the token.

Broker Setup

Choria Broker supports a concept called an Account or Organization, these are like a VLAN on your network switches.

Nodes partitioned into one Organization cannot communicate with nodes in any other Organizations unless special arrangements are made to facilitate that.

We use this feature to create an isolated network where unprovisioned servers wait for provisioning. When Choria Provisioner connects using fully verified credentials it gains access to these isolated Servers and can provision them.

TLS Based Provisioning TLS Based Provisioning

Here we show one Choria Broker Cluster with a Choria Account and a Provisioning Account active. Unverified connections enter the Provisioning Account automatically. The Provisioner connects with its fully verified credentials that identify it as a Provisioner and gains access to the unprovisioned servers.

Restrictions in the Choria Broker authentication layer ensures this separation is not optional and enforce control over what software gains access to the unprovisioned fleet.

Enabling the System Account

We suggest enabling the Choria Broker system account to allow introspection of nodes waiting to be provisioned and more.

Configure this in both your Broker and Client configurations.

plugin.choria.network.system.user = system
plugin.choria.network.system.password = system

Enabling the Provisioning Account

The Choria Provisioner will connect using its normal Certificate or JWT token which means it will appear just like any other client. To differentiate it we force it to connect to a specific user and password.

The password is unique per environment and should be configured in the broker:

plugin.choria.network.provisioning.client_password = provSecret

x509 Based Choria

If you are using an x509 based network you have to set the Public certificate that was used to configure servers.

plugin.choria.network.provisioning.signer_cert = /etc/choria/provisioning-jwt-signer.pem

Organization Based Choria

Version Hint

This applies only to Choria 0.27.0 and newer which is due to ship early 2023

If you are using an Organization Issuer you should already have the issuer configured and have used that issuer to sign the provisioning.jwt

plugin.security.issuer.names = choria
plugin.security.issuer.choria.public = 4ada2...cea4

Confirming

Once configured you can run the command choria broker server report conns --account provisioning to both confirm the account is active and it will show any connections made to this account. The Choria Provisioner will appear there as well as any machines being provisioned.

Provisioning account connections Provisioning account connections

Writing a Helper

The Helper is a script or program written in any language that is in charge of handling the steps that are unique to your environment.

Essentially it receives Server Metadata on its STDIN and writes a response to STDOUT that is used to configure the server.

See the sections below for sample Helper scripts.

Input

Let’s look at what you might receive as input on STDIN. The specifics will vary a bit between scenarios which we will call out in specific sections. The data you will get will all already have been validated. For example the JWT would have been parsed already and known to be valid.

{
  "identity": "24bd22cdb279.choria.local",
  "csr": null,
  "ed25519_pubkey": {
    "public_key": "2ddc906446a935aefde69ceee2beb3b1d85f264153720d9a752aa88771c0594c",
    "directory": "/etc/choria",
    "signature": "34c5c4d0564747b1d0577ddc9c4084de547235d56e2803a754c76ba4eabbf17e31453ebd9ca4b4a7dd5e25ccf57afd4138519de4666c81d3ee3cd5381cc15a0a"
  },
  "inventory": "{\"agents\":[\"choria_provision\",\"choria_util\",\"discovery\",\"rpcutil\"],\"classes\":[],\"collectives\":[\"provisioning\"],\"data_plugins\":[],\"facts\":{},\"machines\":[],\"main_collective\":\"provisioning\",\"version\":\"0.99.0.20221129\",\"upgradable\":true}",
  "jwt": {
    "cht": "s3cret",
    "chs": false,
    "chu": "nats://broker.choria.local:4222",
    "chpd": true,
    "extensions": null,
    "ou": "choria",
    "v2": true,
    "purpose": "choria_provisioning",
    "iss": "Choria Tokens Package v0.26.2",
    "sub": "choria_provisioning",
    "nbf": 1669809657,
    "iat": 1669809657,
    "jti": "3a4f9896e013498daeedbcb9a82fcd3c"
  }
}
KeyDescription
identityThe Choria Server identity being provisioned - typically FQDN
csrWould be non nil when the pki feature is enabled for obtaining x509 certificates
ed25519_pubkeyWould be non nil when the ed25519 feature is enabled
inventoryIs the JSON result of choria req rpcutil inventory this lets you find facts, version information and more about the server.
jwtIs the verified contents of the provisioning.jwt on the server when the jwt feature is enabled

Output

The response your helper should write to STDOUT is also in JSON format.

Here is an example that configures Choria Server and uses the jwt feature to enroll a node into an Organization Issuer based network.

{
  "defer": false,
  "msg": "Done",
  "certificate": "",
  "ca": "",
  "configuration": {
    "identity": "eb873ce040d7.choria.local",
    "loglevel": "info",
    "plugin.choria.server.provision": "false",
    "plugin.choria.middleware_hosts": "nats://broker.choria.local:4222",
    "rpcauthorization": "0",
    "plugin.choria.status_file_path": "/var/log/choria-status.json",
    "plugin.choria.submission.spool": "/var/lib/choria/submission",
    "plugin.security.issuer.names": "choria",
    "plugin.security.issuer.choria.public": "e72cba5268b34627b75c5ceae9449ad16d62f15f862c30d4e0e7d2588e2e6259",
    "plugin.security.provider": "choria",
    "plugin.security.choria.token_file": "/etc/choria/server.jwt",
    "plugin.security.choria.seed_file": "/etc/choria/server.seed",
    "plugin.choria.machine.store": "/etc/choria/machine"
  },
  "server_claims": {
    "exp": 157680000,
    "permissions": {
      "streams": true,
      "submission": true
    }
  }
}
Warning

The configuration must set plugin.choria.server.provision to disable provisioning, else the node will keep being reprovisioned forever.

Response keys used by scenarios:

KeyDescription
deferDefers the provisioning, this is a soft state meaning the server will come back and be retried later
shutdownIssues a shutdown on the server with exit code 0, systemd will not restart it.
msgA message to log on the Server to explain why it is being deferred or shut down
configurationA JSON Object of configuration items in key-value pairs, will be written to the server config
action_policiesA JSON Object of Action Policy policies in key-value pairs, where the key is an agent name
opa_policiesA JSON Object of Open Policy Agent policies in key-value pairs, where the key is an agent name or default

When using the pki feature used to enroll with a Certificate Authority:

KeyDescription
keyAn optional x509 private key that the server should use, will be encrypted using a unique one-time password in transit
certificateThe signed certificate in PEM format
caThe Certificate Authority public key in PEM format
ssldirWhat directory to store the key, certificate and ca in on the server

When using the jwt feature to create server JWT tokens for Organization Issuer based networks:

KeyDescription
server_claimsThe Choria Server token claims to base the server JWT on

When using the upgrades feature to in-place upgrade servers:

KeyDescription
upgradeThe version to upgrade the server to before provisioning

Enrolling nodes with a Certificate Authority

Most typically you have a Enterprise Certificate Authority or you made your own using something like cfssl.

In this mode Choria Server will generate a private key on its disk, create a CSR and your helper will receive the CSR in PEM format. In your helper you then simply interact with your CA to sign the CSR and respond with the signed Certificate and CA public key. This way once provisioned your server will be fully enrolled for mTLS.

To enable the Provisioner to request the CSR the pki feature needs to be enabled in the Provisioner Configuration.

The csr input field will then be a JSON Object with:

KeyDescription
csrThe PEM encoded CSR the node is sending
public_keyThe public part of the key the server created
ssldirThe directory the server created the private key in so it can be used in the generated configuration

Once you have this data you can use your CA API to enroll the node and get a signed certificate back. Simply put the resulting PEM data in the certificateand ca keys in the reply. You can set a SSL directory but typically just set ssldir to what was received in the input.

Info

A basic sample helper that enrolls in a cfssl based CA can be seen in cfssl-helper.rb

In general the Private key stays on the node and you do not need it. Some Certificate Authorities require the private key to be accessible when signing a request. Provisioner support that, if you generate a key in the provisioner and add it to the reply in the key JSON field a single use Shared Secret negotiated using Diffie-Hellman will be used to encrypt the key in transit.

I would not suggest ever to use a CA that requires you to transmit the Private Key during enrollment, it’s best to assume your CA is unusable at that point and consider a Organization Issuer based deployment.

Enrolling nodes with an Organization Issuer

In cases where a Certificate Authority is not available or it is operated in a way that makes it unsuitable for mTLS use you might opt to deploy Choria in an Organization Issuer based setup. The basic setup of that mode is out of scope for this document.

Version Hint

This applies only to Choria 0.27.0 and newer which is due to ship early 2023

To enrol nodes in an Organization Issuer based network you need to enable the ed25519 feature in the Provisioner Configuration.

Once enabled the ed25519_pubkey field will hold a JSON Object with these values:

KeyDescription
public_keyThe ed25519 public key unique to the server
directoryThe directory that will hold server.seed and where server.jwt will be saved to later
signatureA signature of the request made using the private key matching the public_key, this can be ignored as it would already be validated

Once you received these you can include a server_claims in your reply to give the server access to specific features in its JWT claims. The list here is correct for 0.27.0. For an up-to-date list see the Go Documentation for your version of Choria.

The claims can include permissions that have these properties.

PermissionDescription
submissionAllows the server to use Choria Submission
streamsAllows the server to access Choria Streams for example to read KV buckets from autonomous agents
governorAllows the server to access Choria Governor from autonomous agents
service_hostAllows the server to host Services

You can also use them to restrict it to a specific sub collective and more. Most values will default to sane defaults when not given.

Upgrading Servers

Choria Server can be upgraded in-place to a new version. This is done by overwriting the binary at run-time with one downloaded from a specifically prepared repository.

This can only be done during Provisioning and requires the upgrades feature to be enabled.

Version Hint

This applies only to Choria 0.27.0 and newer which is due to ship early 2023

Compatible nodes will have the upgradable key in the inventory received and will be set to true when upgrades are enabled. If you only want to support the latest nodes you can use this to determine if a upgrade is needed along with the version key in the inventory. On nodes that are too old set shutdown or defer.

You would have to configure the Provisioner with a repository location and set up a repository on a HTTP server as per the guidelines from go-updater.

With all of this in place you can add the upgrade key to the helper response that should just be a desired version like 0.28.0. Provisioner will then attempt to upgrade the node.

Configuration File

Provisioner is configured using /etc/choria-provisioner/choria-provisioner.yaml typically. It’s a YAML format file with a few required settings and a number of optional ones. Changes to the file requires the process to be restarted.

Choria Client Configuration

As the Provisioner connects to the Choria Broker as a client it needs a configuration that allows it access. Create /etc/choria-provisioner/choria.cfg with the following based on needs. These settings will augment those in /etc/choria/client.cfg.

x509 based Networks

plugin.security.provider = file
plugin.security.file.certificate = /etc/choria-provisioner/ssl/cert.pem
plugin.security.file.key = /etc/choria-provisioner/ssl/key.pem
plugin.security.file.ca = /etc/choria-provisioner/ssl/ca.pem

Obtain these certificates the same way you would obtain any other certificate, perhaps using choria enroll --certname provisioner.mcollective.

Organization Issuer Based Networks

Version Hint

This applies only to Choria 0.27.0 and Provisioner 0.15.0 and newer which is due to ship early 2023

In these setups you need a client JWT with --server-provisioner and --issuer set while creating the client JWT:

$ choria jwt keys /etc/choria-provisioner/signer.seed /etc/choria-provisioner/signer.public
$ choria jwt client /etc/choria-provisioner/signer.jwt provisioner_signer issuer \
     --public-key $(cat /etc/choria-provisioner/signer.public) \ 
     --server-provisioner \
     --validity 365d \
     --vault \
     --issuer

The /etc/choria-provisioner/choria.cfg would then have:

plugin.security.provider = choria
plugin.security.choria.token_file = /etc/choria-provisioner/credentials/signer.jwt
plugin.security.choria.seed_file = /etc/choria-provisioner/credentials/signer.seed
identity = provisioner_signer

Common Settings

These settings are required for all scenarios, below an example configuration followed by explanation:

workers: 4
interval: 1m
logfile: /dev/stdout
loglevel: warn
helper: /etc/choria-provisioner/provisioner/helper.rb
token: s3cret
site: testing
broker_provisioning_password: s3cret
jwt_verify_cert: e72cba5268b34627b75c5ceae9449ad16d62f15f862c30d4e0e7d2588e2e6259
jwt_signing_key: /etc/choria-provisioner/credentials/signer.seed
jwt_signing_token: /etc/choria-provisioner/credentials/signer.jwt

features:
  jwt: true
  ed25519: true
ItemDescriptionDefault
workersHow many concurrent helpers to call while provisioningnumber of cores
intervalHow often to perform a discovery against the network for new machines1m
logfileWhere to write the log
loglevelThe level to log at, debug, info, warn or errorinfo
helperPath to the helper script
tokenThe value of the token set using --token in the provisioning.jwt
siteA unique name for this installation, surfaced in monitoring data
monitor_portThe post to listen on for monitoring requests
broker_provisioning_passwordThe password configured in the broker plugin.choria.network.provisioning.client_password
features.jwtEnables fetching and validating provisioning.jwt, should almost always be truefalse
features.ed25519Enables JWT processing for Organization Issuer based networksfalse
features.pkiEnables x509 enrollmentfalse
features.upgradesEnables server version upgradesfalse

PKI / x509 Enrollment

When enabled the Provisioner will fetch a CSR from the node and ask the node to create a private key that stays on the node. The helper can then get the certificate signed and the signed certificate will be sent to the node.

To enable set features.pki to true.

Here we reference our x509 key and certificate

ItemDescriptionDefault
jwt_verify_certFull path to the public certificate used to sign provisioning.jwt
jwt_signing_keyFull path to our private key, also used in choria.conf

Organization Issuer based Enrollment

When enabled the Provisioner will sign and issue server JWTs with custom claims and signatures. No x509 steps will be done.

To enable set features.ed25519 to true.

Here we reference our JWT that gives us the right to provision and issue new JWTs

ItemDescriptionDefault
jwt_verify_certThe hex encoded public key of your Organization Issuer
jwt_signing_keyThe private ed25519 key, also used in choria.conf
jwt_signing_tokenThe JWT token, also used in choria.conf

Choria Server Upgrades

Choria Provisioner can upgrade Choria Servers using a go-updated repository.

To enable set features.upgrades to true.

Here we configure the updates repository and say how failures are handled

ItemDescriptionDefault
upgrades_repositoryURL to your updates repository
upgrades_optionalContinue provisioning even if upgrading failsfalse

Clustered Deployments

The Provisioner is generally fast enough to not need a cluster of active-active servers, so we support deploying multiple instances of Provisioner and using Leader Elections to elect one in the cluster as a leader that will be actively provisioning nodes.

To enable this the client needs access to Streams and Leader Elections so need --stream-user and --elections-user passed when creating its JWT.

Campaigning will be on a backoff schedule up to 20 second between campaigns, this means there can be up to a minute of downtime during a failover scenario, generally that’s fine for the Provisioner.

If a Provisioner was on standby and becomes leader it will immediately perform a discovery to pick up any nodes ready for provisioning.

Your broker must therefor have Choria Streams enabled.

ItemDescriptionDefault
leader_electionEnables active-standby clustersfalse

Fleet Management

Choria Server that is enabled for Provisioning expose a number of endpoints that can be used to interact with it in ways that is not otherwise possible.

Warning

These features are almost all extremely dangerous and should be used with great caution.

These abilities are not possible without enabling Provisioning in the server, so they are off by default. You should ensure your site security policies have adequate control over these actions.

Checking if upgrades are supported

Version Hint

This requires Choria 0.27.0 and newer which is due to ship early 2023

If you are running a new enough CLI and Server you can check if a running server supports version upgrades:

$ choria inventory 73ce620b5a21.choria.local
Inventory for 73ce620b5a21.choria.local

  Choria Server Statistics:

                    Version: 0.99.0.20221201 (upgradable during provisioning)
...

This will indicate if the JWT or custom build flags enable version upgrades and will be disabled by default, requiring the JWT was made with --upgrade option.

Restarting Servers

A fleet that is capable of provisioning will allow you to restart them over Choria RPC using CLI or other Client:

$ choria req choria_provision restart token=s3cret --batch 100 --batch-sleep 30

In this case the token is the same as supplied when creating the JWT in the --token argument. You can use any discovery filters and other options as shown here.

Servers will restart and continue as normal after a randomized sleep up to 10 seconds.

Initiating Provisioning

Once a fleet of nodes is deployed using Provisioner they will not again update their configuration unless instructed to or if they encounter a problem that might be fixed by provisioning again such as credentials expiring.

You can though initiate provisioning ad-hoc using the Choria CLI or other Client:

$ choria req choria_provision reprovision token=s3cret --batch 100 --batch-sleep 30

In this case the token is the same as supplied when creating the JWT in the --token argument. You can use any discovery filters and other options as shown here.

Servers will restart into provisioning mode after a randomized sleep up to 10 seconds.

Shutting down Servers

When provisioning is enabled the servers can be shut down remotely, this will trigger a exit code 0 shutdown that will not trigger a systemd restart.

This should be used with great caution, since, once shut down the machines will not return without manual intervention via reboot or service restart. Treat this like a Big Red Button to emergency shut down the service.

$ choria req choria_provision shutdown token=s3cret --batch 100 --batch-sleep 30

In this case the token is the same as supplied when creating the JWT in the --token argument. You can use any discovery filters and other options as shown here.

Servers will shut down after a short delay.

Monitoring

Watching the Provisioning Process

Choria Servers publish lifecycle events, these can be viewed using choria tool event --component provision_mode_server.

Servers will publish startup, shutdown and provisioned events that will be shown in the rolling display. These lifecycle events are published in JSON mode on the network and could be stored in a Stream for analysis.

$ choria tool event --component provision_mode_server
Waiting for events from topic choria.lifecycle.event.> on nats://broker.choria.local:4222
14:35:46 [startup] n1.choria.local: provision_mode_server version 0.26.15
14:36:30 [provisioned] n1.choria.local: provision_mode_server
14:36:31 [shutdown] n1.choria.local: provision_mode_server

Viewing servers waiting for provisioning

One can view the active connections in the Choria Broker, these are nodes waiting to be provisioned. Typically one would also see connections for the Provisioner in addition to these.

Provisioning account connections Provisioning account connections

Leader Election

One can determine the node that is currently the leader in the leader election:

$ choria election view provisioner
Election bucket information for CHORIA_LEADER_ELECTION

       Created: 01 Jan 01 00:00 +0000
       Storage: File
  Maximum Time: 1m0s
      Replicas: 3 on hosts broker1*, broker2, broker3
     Elections: 7

╭───────────────────────────────────────────────╮
│               Active Elections                │
├─────────────┬─────────────────────────────────┤
│ Election    │ Leader                          │
├─────────────┼─────────────────────────────────┤
│ provisioner │ provisioner1.choria.local       │
╰─────────────┴─────────────────────────────────╯

A re-election can be forced using choria election evict provisioner.

Prometheus Data

The Provisioner keep statistics in the Prometheus format, using this you can observe deployed counts, performance of individual RPC calls and more.

AAA Dashboard AAA Dashboard

All the statics have a site label allowing you to easily aggregate a global cluster of AAA Services.

StatisticDescriptions
choria_provisioner_rpc_timeHow long each RPC request takes
choria_provisioner_helper_timeHow long the helper takes to run
choria_provisioner_discoveredHow many nodes are discovered using the broadcast discovery
choria_provisioner_event_discoveredHow many nodes were discovered due to events being fired about them
choria_provisioner_discover_cyclesHow many discovery cycles were ran
choria_provisioner_rpc_errorsHow many times a RPC request failed
choria_provisioner_helper_errorsHow many times the helper failed to run
choria_provisioner_helper_shutdown_requestsHow many times the helper asked for a node to be shut down and it succeeded
choria_provisioner_discovery_errorsHow many times the discovery failed to run
choria_provisioner_provision_errorsHow many times provisioning failed
choria_provisioner_paused1 when the backplane paused operations, 0 otherwise
choria_provisioner_busy_workersHow many workers are busy processing servers
choria_provisioner_work_queue_entriesHow many nodes are on the work queue waiting to be provisioned
choria_provisioner_waiting_nodesHow many nodes are currently waiting to be provisioned
choria_provisioner_provisionedHow many nodes were successfully provisioned

We have a published Grafana Dashboard for this statistics.

Design

These pages cover the packages, the provisioning sequence, and the credential handling, for contributors and for auditors.

  • Code Map: How the source is laid out, what each package does, and how a server moves from unconfigured to running.

Subsections of Design

Code Map

Choria Provisioner is a single Go binary, about 2,400 lines across four packages. It finds Choria Servers that have no configuration, asks a site-supplied helper what their configuration should be, writes it over Choria RPC, and restarts them.

Snapshot

Generated 2026-08-19 against commit aca2b62 on branch main, with a clean working tree.

The provisioning flow

A freshly installed Choria Server with provisioning enabled connects to the broker’s provisioning collective and answers only the choria_provision agent. Provisioner is a client on that same collective. It finds such a server through broadcast discovery or a lifecycle event and puts it on the work queue. A worker then runs a fixed sequence of RPC calls against that one server: fetch its JWT, fetch its key material, fetch its inventory, ask the helper for a configuration, send the configuration back, and restart it. The helper handles certificate authorities, naming schemes, fact sources, and policy decisions. It is a program in any language that reads JSON on standard input and writes JSON on standard output.

Choria Serversprovisioning modeno configuration yetChoria Brokerprovisioning collectivelifecycle eventsProvisionerdiscover, queue, workN worker goroutinesstartup eventdiscoverRPCconfigure, restartHelperany language, JSON I/Ohost JSON outconfig JSON backCA or Issuerreached by the helper
Discovery and events both reach the same worker pool. Environment-specific work happens in the helper.

A startup log

A Provisioner that wins the election logs its discovery interval and worker count, followed by one line for each host admitted and one for each RPC issued:

INFO Choria Provisioner starting using configuration file /etc/choria-provisioner/choria-provisioner.yaml. Discovery interval 1m using 4 workers INFO Starting leader election against 'provisioner' WARN Became leader after winning election INFO Triggered a discovery after becoming leader INFO Looking for provisionable hosts INFO Adding n1.example.net to the provision list after discovering it INFO Adding n2.example.net to the provision list after receiving an event INFO Provisioning n1.example.net INFO Fetching JWT -> Fetching Inventory -> Configuring node -> Restarting node INFO Provisioned n1.example.net

Pages in this map

  • Architecture: Package layering, the import direction, and the boundaries that put site-specific logic in the helper program.
  • Discovery and the work queue: How a server is noticed, admitted to the queue, and picked up by a worker.
  • The provisioning cycle: What Host.Provision does to a server, which steps the feature flags gate, and what the delay flag controls.
  • The helper contract: The JSON exchange between Provisioner and site-specific logic, and how the child process is run.
  • Security and enrollment: JWT verification, the ed25519 challenge, CSR name checks, server token issuance, and how a private key is protected in transit.
  • RPC, retries, and upgrades: The wrapper every Choria call runs through, the per-action retry limits, and how an in-place version upgrade is decided.
  • Pausing and leader election: One boolean gates every outbound action. Only a Choria leader election sets it.
  • Reference and map: The command line flags, every source file and what it holds, the exported metrics, and a glossary.
Next

Architecture covers the package layering. Discovery and the work queue follows a server from first sighting to a worker.

Subsections of Code Map

Architecture

The source is four packages and about 2,400 lines.

Where it lives

main.go calls into cmd. Packages: cmd, config, hosts, host.

The packages

cmd
The fisk command line, flag parsing, and process startup. Loads both configuration files, builds the Choria framework, starts the Prometheus listener and the signal handler, then calls hosts.Process. One file, cmd/provisioner.go.
config
The YAML configuration, its defaults, and its validation. Also holds the pause flag that rpcWrapper and runHelper check before every outbound action. Files: config.go, pausable.go, stats.go.
hosts
Fleet-level scheduling. Broadcast discovery, the lifecycle listener, a work queue, the worker pool, the finisher, and leader election. Files: hosts.go, event.go, provisioner.go, election.go, stats.go.
host
Everything that happens to one server. The provisioning sequence, the RPC wrappers, the helper invocation, and RPM version comparison. Files: host.go, rpc.go, helper.go, version.go, stats.go.

cmd imports hosts, hosts imports host, and all three import config, which imports none of them. host does not import hosts, so host/host_test.go builds a Host from a config.Config and a logger with no scheduler involved.

cmdflags, choria framework, /metrics, signalshostsdiscovery + eventswork queue + workersleader electionfinisherhostProvision()rpcWrapperrunHelperJWT + keysCSR checksrpm versionsconfigYAML loaddefaultsvalidationPause / ResumeFlip / Pausedimported byall three, importsnone of themhosts.Process(ctx, cfg, fw)host.NewHost / h.Provision
cmd imports hosts, hosts imports host, and nothing imports upward, so host compiles and tests on its own.

The boundaries

The helper, the pause flag, and the generated RPC clients each change without touching the other two.

  1. The helper Provisioner marshals a Host to JSON, writes it to a child process on standard input, and unmarshals the reply into host.ConfigResponse (host/helper.go:38). The helper reaches certificate authorities, CMDBs, and naming services, in any language.
  2. The pause flag config.Config declares Pause, Resume, Flip, and Paused (config/pausable.go). Leader election calls Pause and Resume, and both rpcWrapper and runHelper check the flag before sending an RPC request or starting the helper process.
  3. Generated RPC clients host/rpc.go uses two clients generated in go-choria, provclient.ChoriaProvisionClient for the choria_provision agent and rpcutilclient.RpcutilClient for rpcutil. Every call to either goes through rpcWrapper, which holds the retries, timing, and pause check for both.

Package-level state in hosts

The hosts package declares its state as package variables rather than struct fields (hosts/hosts.go:24-33): the hosts map of identity to *host.Host, the buffered work and done channels, a mutex, and the logger, the Choria framework, and the configuration.

var (
	hosts = make(map[string]*host.Host)
	work  = make(chan *host.Host, 50000)
	done  = make(chan *host.Host, 50000)
	mu    = &sync.Mutex{}
	log   *logrus.Entry
	fw    *choria.Framework
	conf  *config.Config
	wg    = &sync.WaitGroup{}
)
One instance per process

hosts.Process writes these package variables on entry, so exactly one instance can run per process. That matches the deployment model, where a process runs one Provisioner against one site, and the scheduler functions take no receiver. Running two Provisioners in one binary requires moving this state into a struct.

Configuration and startup

cmd.run loads the Provisioner YAML from --config through config.Load, and the Choria client configuration from --choria-config, defaulting to choria.UserConfig(). cmd.run then overrides the Choria client configuration in code (cmd/provisioner.go:61-79): log level and log file come from the Provisioner configuration, and the collective is forced to provisioning for both Collectives and MainCollective.

Setting choria_insecure disables TLS, sets protocol.Secure to false, and forces the file security provider. Setting broker_provisioning_password switches the NATS credentials to user provisioner with that password. The broker’s dedicated provisioning account accepts that user.

config.Load fills defaults and rejects configurations that cannot work (config/config.go:64-142):

RuleBehavior
workers unsetDefaults to runtime.NumCPU().
lifecycle_component unsetDefaults to provision_mode_server.
lifecycle_component contains ., >, or *Rejected, since the value is interpolated into a NATS subject.
cert_deny_list emptyDefaults to four patterns that block privileged Choria certificate names.
features.pki and features.ed25519 both setRejected. The two enrollment models are exclusive.
features.ed25519 setImplies features.jwt.
server_jwt_validity unsetDefaults to one year.
interval below one minuteRejected.

Observability

config/stats.go, hosts/stats.go, and host/stats.go each register their metrics in an init function: the pause gauge, the fleet-level counters, and per-server timing and errors. Every metric carries a site label taken from the site configuration key, so a query across installations can sum or group by site. cmd.setupPrometheus serves them on /metrics when monitor_port is set.

Next

Continue to Discovery and the work queue to see how a server reaches a worker.

Discovery and the work queue

Broadcast discovery and lifecycle events both reach the same admission function, add. Everything after add is identical.

Where it lives

hosts: fleet-level scheduling. Files: hosts.go for the main loop, discovery, and the add and remove pair; event.go for the lifecycle listener; provisioner.go for the worker pool and the finisher.

Startup order

hosts.Process (hosts/hosts.go:36) runs for the life of the process. It stores the framework, configuration, and logger in package variables, opens a connector, and publishes a startup lifecycle event for the provisioner component so a fleet operator can see the instance start.

It then starts goroutines in a fixed order:

  1. Leader election, when enabled If leader_election is set, conf.Pause() runs first so the instance starts inert, then startElection begins campaigning.
  2. The lifecycle listener One goroutine subscribed to startup events for the configured component.
  3. The finisher One goroutine draining the done channel.
  4. The worker pool cfg.Workers goroutines, each running provisioner().

hosts.Process then sets every Prometheus counter and gauge to zero, so the full metric set appears on /metrics before the first server arrives. It runs discover(ctx) once, then blocks on a select over the interval ticker, the election trigger channel, and context cancellation.

Interval tickerbroadcast discoveryStartup eventslifecycle subscriptionadd()dedup and admitwork chanbuffered 50000workersN goroutinesdone chanbuffered 50000finishercalls remove()hosts mapidentity to HostisCurrent() drops stale entriesmembership
Both sources admit through add. A worker checks the map, not the queue, before acting.

Broadcast discovery

discoverProvisionableNodes (hosts/hosts.go:201) builds a filter for the choria_provision agent and runs a broadcast discovery against the provisioning collective:

bd := broadcast.New(fw)
nodes, err := bd.Discover(ctx,
	broadcast.Collective("provisioning"),
	broadcast.Filter(f),
	broadcast.SlidingWindow(),
	broadcast.Timeout(2*time.Second))

The timeout is a fixed two seconds and the window slides, so the call returns as soon as replies stop arriving rather than always waiting the full two seconds. Every returned identity becomes a new host.Host and goes through add.

discover (hosts/hosts.go:186) checks the pause flag first. When paused it logs, returns before sending the broadcast request, and leaves the cycle counter unchanged, so a standby instance only campaigns for the election.

Lifecycle startup events

listen (hosts/event.go:26) queue-subscribes to choria.lifecycle.event.startup.<lifecycle_component>, where the component defaults to provision_mode_server. config.Load rejects a component containing ., >, or *, because the value is interpolated straight into a NATS subject and those characters would widen the subscription.

Each message goes through handle, which checks the pause flag, parses the event, and returns the identity only for events of type lifecycle.Startup. listen therefore admits a server that has just booted into provisioning mode without waiting for the next discovery cycle. Broadcast discovery and the listener increment separate counters, choria_provisioner_discovered and choria_provisioner_event_discovered, so their relative contribution is visible in monitoring.

Admission and deduplication

add (hosts/hosts.go:153) takes the mutex, and no other function writes to the work channel.

For an identity already in the map, add compares the age of the new sighting against conf.IntervalDuration:

if time.Since(host.DiscoveredTime()) < conf.IntervalDuration {
	return false
}
removeUnlocked(host)

In that comparison, host is the newly constructed Host, whose discovered field NewHost sets to time.Now(). The comparison therefore measures the age of the sighting being processed, not the age of the entry already in the map.

Duplicates cost less than dropped nodes

A duplicate provision costs less than a node that never gets provisioned, so add re-admits on the older sighting. A duplicate is self-limiting, because the second attempt times out on its first RPC failure and Provision also refuses nodes that have been waiting too long. A dropped node stays unconfigured until the next event or discovery cycle.

add then writes to the buffered work channel in a non-blocking select. If the buffer is full, add logs a failure and deletes the identity from the map, so the map holds only entries that are on the queue.

Workers

Each worker (hosts/provisioner.go:15) blocks on the work channel. It then calls isCurrent, which looks the identity up in the map, before calling provisionTarget:

if !isCurrent(host) {
	continue
}

An entry can be on the queue but no longer in the map, which happens when removeAllHosts runs after a lost election. The worker skips the entry and does not send it to done, because removeAllHosts already deleted the identity.

The worker then calls provisionTarget, which increments choria_provisioner_busy_workers for the duration and delegates to host.Provision. The return value carries a delay flag alongside the error:

OutcomeWhat the worker does
ErrorIncrements choria_provisioner_provision_errors, logs, and sends to done immediately.
Success, delay falseSends to done immediately, so the host can be rediscovered on the next cycle.
Success, delay trueSends to done after 60 seconds via time.AfterFunc.

The 60 second delay leaves the node in the map while it restarts, so the next discovery cycle skips it. After an in-place version upgrade the flag is false instead, and the node is re-provisioned on the next cycle.

The finisher

One goroutine drains done and calls remove, which deletes the identity from the map and updates both the choria_provisioner_waiting_nodes and choria_provisioner_work_queue_entries gauges. Only the finisher calls remove, so workers never take the mutex for deletion and one goroutine writes both gauges.

Next

The provisioning cycle covers the RPC sequence a worker runs against a server.

The provisioning cycle

Host.Provision runs on a worker goroutine, holds the host’s mutex for the duration of the call, and returns a delay flag and an error.

Where it lives

host: everything that happens to one server. Main file: host/host.go, with Provision at host/host.go:78 and handleHostUpgrade at host/host.go:219.

The sequence

The feature flags gate individual steps. The order never changes.

Fetch JWTfeatures.jwtFetch ed25519features.ed25519Fetch inventoryalwaysFetch CSRfeatures.pkiCall helperalwaysUpgradefeatures.upgradesConfigurealwaysRestartalwaysDefer or shutdownhelper decidedRequeueupgraded, no delayProvisioned60 second hold
Defer, shutdown, and a completed upgrade all end the cycle before configure runs.

Checks before the first RPC

Provision checks provisioned and discovery age before it issues any RPC.

if h.provisioned {
	return true, nil
}

if !h.discovered.IsZero() {
	since := time.Since(h.discovered)
	if since > 2*h.cfg.IntervalDuration {
		return false, fmt.Errorf("skipping node that's been waiting %v", since)
	}
}

The provisioned short-circuit returns a delay, so the finisher holds a node that has already been through the sequence out of the map for a further 60 seconds.

The staleness check is the second half of the duplicate-provision trade-off described in Discovery and the work queue. Because the queue holds up to 50,000 entries, a host can wait long enough that the facts the helper would see no longer match the node. Provision refuses a host that has waited longer than two discovery intervals.

Gathering facts

Each fetch is a single RPC against one target, described in RPC, retries, and upgrades. fetchJWT, fetchInventory, and fetchEd25519PubKey each return early when the value they collect is already populated, so a retried Provision on the same Host does not re-fetch.

fetchInventory always runs. Besides the facts, it records h.version and h.upgradable from the reply, and the upgrade path requires both.

The helper’s answer

getConfig marshals the whole Host to JSON and runs the helper. Its reply, a host.ConfigResponse, can end the cycle:

defer
Returns an error carrying the helper's message. The node stays unconfigured. The finisher removes it from the map, and a later discovery cycle picks it up again.
shutdown
Issues a shutdown RPC to the node and returns a delay. The node exits with code 0, which systemd does not restart. When msg is empty, Provision logs a warning that carries no shutdown reason.

Provision copies the rest of the reply onto the Host: the configuration map, the CA, certificate, key, and SSL directory, the target upgrade version, and the two policy maps. The helper contract gives the full shape.

The upgrade interruption

When features.upgrades is on and the helper returned an upgrade version, handleHostUpgrade runs before configuration. It refuses to proceed if the inventory carried no version or the node reported upgradable as false, then compares versions with the RPM comparison in host/version.go.

The switch that consumes its result (host/host.go:189-202) handles these outcomes:

ResultEffect
No error, skipped trueVersions already match. Falls through and configures the node normally.
No error, skipped falseAn upgrade ran. Returns (false, nil) immediately, ending the cycle before configuration.
Error, upgrades_optional trueLogs a warning and configures the node on its old version.
Error, upgrades_optional falseReturns (true, err), so the finisher holds the node out of the map for 60 seconds.
Upgrade and configure never share a pass

An upgraded node returns a false delay flag so the next discovery cycle picks it up at once (“no delay so we reprov asap”). The node has already replaced its own binary and restarted into provisioning mode on the new version. Returning no delay leaves it eligible for the next discovery cycle, where it runs the full sequence again and this time reaches configure. A node is never both upgraded and configured in a single pass.

Configure and restart

configure sends the whole payload in one RPC: the configuration map as a JSON string, the token, the CA, certificate, key, SSL directory, the Provisioner’s ECDH public key, both policy maps, and the signed server JWT. It refuses to send an empty configuration map. If the CSR reply carried an SSL directory, that value overrides whatever the helper chose.

restart follows with a one second splay, and the node restarts into its new configuration. Provision sets h.provisioned to true after the restart returns, then returns (true, nil).

Next

The helper contract covers the JSON exchange. RPC, retries, and upgrades covers what happens inside each call.

The helper contract

Provisioner ships no knowledge of any particular site. It cannot name a node, reach a certificate authority, pick a broker, or decide which policies a machine should carry, because every one of those answers differs between deployments and most of them live in systems Provisioner has never heard of. The helper supplies them. It is a program in any language, named by the helper configuration key, that reads a JSON description of one server on standard input and writes that server’s configuration back on standard output.

The helper runs at the midpoint of the provisioning cycle, once per server. By the time Provisioner calls it, the fetch steps have already collected everything the node can say about itself: its verified provisioning.jwt, its inventory, and its CSR or ed25519 public key, depending on the enrollment model. The helper turns that description into a decision. It can return a configuration map, x509 credentials, a signed-token request, Open Policy Agent and Action Policy documents, and a version to upgrade to. It can also refuse: defer leaves the node unconfigured for a later cycle, and shutdown stops it. Provisioner takes whatever comes back, sends it to the node in a single configure call, and restarts the node.

Where it lives

host/helper.go, 131 lines. getConfig at host/helper.go:38 marshals the input, runDecodedHelper decodes the reply, and runHelper at host/helper.go:68 starts the child process and waits on it.

One exec per server

Pause gateno exec while pausedHost structidentity, csr, jwt,ed25519, inventoryhelper processany languageargv via shellquoteConfigResponse12 fields, alloptionalstdinstdoutcontext timeout: 10 seconds, not configurableFailureempty output or exit != 0helper stderr is discardedby os/exec, not logged
A fresh process per server, so no state carries over between calls.

What the helper receives

getConfig marshals the Host itself, so the input shape is exactly the struct’s exported fields (host/host.go:29-51). The JSON-tagged fields are the only ones the helper sees; every other field on Host is unexported and stays in the process.

identity
The Choria Server identity, typically the fully qualified domain name.
csr
A provision.CSRReply, non-null only when features.pki is enabled.
ed25519_pubkey
A provision.ED25519Reply, non-null only when features.ed25519 is enabled.
inventory
The rpcutil#inventory result. A JSON string, not a nested object.
jwt
The parsed and already-verified provisioning.jwt claims, non-null only when features.jwt is enabled.
Caveat

inventory is a string containing JSON, because fetchInventory assigns h.Metadata = string(j) from the RPC reply. A helper has to decode it a second time to reach the facts, the agent list, or the version. The identity value at the top level and the one inside the inventory come from different sources, so a helper that needs the identity should use the top-level field.

The token, the signing keys, and the helper path itself are not sent. A helper that needs a secret reads it from its own environment or files.

What the helper returns

The reply decodes into host.ConfigResponse (host/helper.go:23). Unknown keys are ignored and every field is optional, so a minimal helper answers with a configuration object alone.

type ConfigResponse struct {
	Defer          bool                 `json:"defer"`
	Shutdown       bool                 `json:"shutdown"`
	Msg            string               `json:"msg"`
	Key            string               `json:"key"`
	Certificate    string               `json:"certificate"`
	CA             string               `json:"ca"`
	SSLDir         string               `json:"ssldir"`
	ServerClaims   *tokens.ServerClaims `json:"server_claims"`
	Configuration  map[string]string    `json:"configuration"`
	ActionPolicies map[string]string    `json:"action_policies"`
	OPAPolicies    map[string]string    `json:"opa_policies"`
	UpgradeVersion string               `json:"upgrade"`
}

Provision checks defer and shutdown first, and either one ends the cycle. upgrade diverts to the upgrade path. Provision copies the rest onto the Host and sends it to the node in the single configure RPC. The provisioning cycle gives the order those checks run in, and Writing a helper gives worked examples in Ruby.

The child process

runHelper runs these steps in order.

  1. Timing starts first A prometheus.NewTimer on choria_provisioner_helper_time is deferred before anything else, so a timed-out helper still records its duration.
  2. The pause flag is checked A paused instance refuses to start the helper, and the error includes the helper path.
  3. A 10 second timeout is applied context.WithTimeout(ctx, 10*time.Second) wraps the whole call. No configuration key sets the value.
  4. The command string is split with shellquote shellquote.Split handles quoting, so helper can carry arguments, for example /opt/prov/helper.rb --site london. An empty result is rejected.
  5. Input is written from a goroutine The write to stdin runs concurrently with the read from stdout and closes the pipe on completion. Doing both in one goroutine would deadlock on any helper whose output exceeds the pipe buffer.
  6. Output is read to completion, then the process is waited on Reading before Wait is required: Wait closes the pipes.
  7. Zero bytes is an error A helper that exits cleanly without writing anything fails with "zero bytes received" rather than producing an empty configuration.
  8. The exit code is checked ProcessState.Success() must be true.
Caveat

runHelper sets a stdin and a stdout pipe but leaves Stderr nil, and os/exec connects a nil Stderr to the null device. Anything a helper writes to standard error is discarded and never reaches the Provisioner log. A helper that has a reason to report should return defer with a msg, which is logged, or write its own log file.

Provision counts the failures. It increments choria_provisioner_helper_errors when getConfig returns an error (host/host.go:134), and the successful shutdown path increments choria_provisioner_helper_shutdown_requests from host/rpc.go:169.

Next

Security and enrollment covers what happens to the key material that the helper returns.

Security and enrollment

Provisioner talks to servers that have no credentials yet. It checks every value it accepts from a node before using it, and signs every credential it returns with keys that the node cannot reach.

Where it lives

host: host.go holds validateJWT, validateCSR, generateServerJWT, and encryptPrivateKey. rpc.go holds fetchJWT, fetchCSR, and fetchEd25519PubKey. Key material paths come from config.Config.

Enrollment models

features.pki and features.ed25519 select mutually exclusive models, and config.Load rejects a configuration that sets both. features.jwt is independent, and enabling features.ed25519 turns it on implicitly.

FeatureWhat Provisioner fetchesWhat it gives back
jwtThe node’s provisioning.jwt.The node already holds this token, so Provisioner returns none.
pkiA CSR generated by the node.A signed certificate and CA from the helper.
ed25519The node’s ed25519 public key, with a signed nonce.A server JWT signed by Provisioner.

Provisioning token verification

Runs when features.jwt is set. Setting features.ed25519 sets it too, so this step also runs in Organization Issuer deployments.

validateJWT (host/host.go:405) refuses to run without jwt_verify_cert. os.Stat decides whether that value is a key file path or a hex ed25519 public key:

if _, err = os.Stat(h.cfg.JWTVerifyCert); os.IsNotExist(err) {
	pk, err = hex.DecodeString(h.cfg.JWTVerifyCert)
	claims, err = tokens.ParseProvisioningToken(h.rawJWT, ed25519.PublicKey(pk))
} else {
	claims, err = tokens.ParseProvisioningTokenWithKeyfile(h.rawJWT, h.cfg.JWTVerifyCert)
}

A path that exists on disk is read as a key file. Anything else is decoded as a hex ed25519 public key, the form of an Organization Issuer public key. validateJWT stores the parsed claims on h.JWT, and getConfig sends them to the helper as the verified jwt field.

The ed25519 challenge

Runs when features.ed25519 is set. features.pki deployments skip it, and config.Load rejects a configuration that sets both.

fetchEd25519PubKey (host/rpc.go:228) generates a nonce with choria.NewRequestID(), sends it in the gen25519 request, and verifies the node’s signature over it:

if !ed25519.Verify(pk, []byte(h.nonce), sig) {
	err = fmt.Errorf("invalid nonce signature")
	return
}

Without the nonce, a node could present any public key, including one belonging to a different machine, and receive a server JWT bound to that key. The signature proves the node holds the matching seed. The call also records the directory that the node created the seed in, which becomes the SSL directory in the generated configuration.

Server token issuance

Runs when features.ed25519 is set, immediately after the helper returns. It also needs jwt_signing_key, and server_jwt_validity or its one year default.

generateServerJWT (host/host.go:251) builds the claims in increasing order of precedence: built-in defaults, the configuration that the helper returned, and the helper’s explicit server_claims.

org
Defaults to choria. Overridden by server_claims.ou.
collectives
Defaults to mcollective. Becomes choria when the returned configuration sets plugin.security.provider to choria, then a comma-split of the collectives configuration key, then server_claims.collectives.
validity
Defaults to server_jwt_validity, or one year. A server_claims expiry is converted with time.Until.
permissions
Null unless server_claims.permissions is set.
additional publish subjects
Empty unless server_claims.additional_publish_subjects is set.

A computed validity of one hour or less is discarded and replaced with the configured default, which covers both an expiry already in the past and a claim that failed to parse.

Signing files are re-read for every server

When jwt_signing_token is set, generateServerJWT reads the signing token and the seed file from disk for every server. The code does this deliberately: “a bunch of redundant repeated reading happens here of the same files but I prefer to do that so just updating the secrets will update the running instance”. Rotating the issuer credentials takes effect on the next server rather than on the next restart. Provisioning a thousand servers therefore performs two thousand small file reads.

With a signing token present, the token must carry a TrustChainSignature claim, and claims.AddChainIssuerData attaches the chain before signing. tokens.SignTokenWithKeyFile signs the result, and the configure call sends it to the node as server_jwt.

CSR checks

Runs when features.pki is set. features.ed25519 deployments skip it, because no x509 certificate is issued.

validateCSR (host/host.go:435) parses the PEM that the node sent. The common name must equal the identity that Provisioner discovered, and no name on the request may match the denylist. validateCSR checks the common name and every DNS SAN.

The default denylist blocks the certificate names that Choria reserves for privileged clients:

cert_deny_list:
  - "\\.privileged.mcollective$"
  - "\\.privileged.choria$"
  - "\\.mcollective$"
  - "\\.choria$"

matchAnyRegex (host/host.go:468) treats a pattern wrapped in forward slashes as a bare regular expression and strips them, then matches with regexp.MatchString. Matching is unanchored, so a pattern without ^ or $ matches anywhere in the name.

Replacing cert_deny_list replaces the defaults

A node in provisioning mode is unauthenticated, so validateCSR is the only check on the name it receives, and that name can request a certificate with privileged access to the whole fleet. Setting cert_deny_list replaces the defaults rather than adding to them, so a custom list must repeat these four patterns.

Private key protection in transit

Runs when the helper returns a key, which no feature flag controls. It also needs features.jwt, because the server’s ECDH public key arrives on the jwt reply.

Some certificate authorities generate the key pair themselves rather than signing a CSR. For those, the helper can return a key, and it has to cross the network to a node that holds no credentials. encryptPrivateKey (host/host.go:354) uses an ephemeral Diffie-Hellman exchange over the two RPC calls that already happen.

Choria Servermakes an ECDH pairProvisionerholds the helper keychoria_provision#jwtprovisioning.jwt + ecdh_publicchoria.ECDHKeyPair()choria.ECDHSharedSecret(priv, serverPub)x509.EncryptPEMBlock, AES-256configure: encrypted key + provisioner ecdh_publicderives the same secret
Each secret covers one key on one node, and neither side writes it to disk.

The server’s ECDH public key arrives on the jwt reply as EcdhPublic, so key encryption requires features.jwt. Without it encryptPrivateKey fails with “private key received from helper but server did not start Diffie-Hellman exchange”. encryptPrivateKey stores Provisioner’s own public key on h.provisionPubKey, and the configure call sends it as EcdhPublic, so the node can derive the same secret and decrypt the PEM block.

x509.EncryptPEMBlock is deprecated in the standard library and the call carries a //lint:ignore SA1019 there is no alternative comment. The format is fixed by what Choria Server can decrypt.

Next

RPC, retries, and upgrades covers the wrapper that every one of these calls runs through.

RPC, retries, and upgrades

Every call Provisioner makes to a server goes through one wrapper, so rpcWrapper holds the retries, timing, error counting, and pause check for every call.

Where it lives

host/rpc.go, 404 lines. Every action calls rpcWrapper at host/rpc.go:58. host/version.go holds the RPM version comparison that the upgrade decision uses.

The shared client setup

rpcUtilClient and provisionClient (host/rpc.go:22 and host/rpc.go:40) build the generated go-choria clients for the rpcutil and choria_provision agents. Both narrow the client the same way before calling rpcWrapper:

client.OptionWorkers(1).OptionTargets([]string{h.Identity})

The client uses one worker and one target because a Host is one machine. The action name passed to rpcWrapper is formatted as agent#action, and that string becomes the rpc label on both choria_provisioner_rpc_time and choria_provisioner_rpc_errors, so a dashboard can show which call is slow or failing.

The retry loop

Pause checkbefore the loopbackoff.Forretry driverAttempt Npause, timer, callSuccessreturns nilRetrylog, then back offGive upcancel, rpc_errors + 1try > triesA pause taking effect mid-loop ends the call on the next attempt
The callback enforces the retry limit by canceling the context that runs the loop.

backoff.Default.For retries until the callback returns nil or the context ends, so the try limit is enforced from inside:

err := backoff.Default.For(tctx, func(try int) error {
	if try > tries {
		cancel()
		return fmt.Errorf("maximum tries reached")
	}

	if h.cfg.Paused() {
		cancel()
		return fmt.Errorf("provisioning is paused, cannot perform %s", action)
	}

	obs := prometheus.NewTimer(rpcDuration.WithLabelValues(h.cfg.Site, action))
	defer obs.ObserveDuration()

	return cb(tctx)
})

The pause flag is read twice: once before the loop and once on every attempt. A provisioner that loses an election mid-provision stops at the next attempt boundary rather than finishing the sequence against a node that another instance is now provisioning.

The timer is deferred inside the callback, so choria_provisioner_rpc_time records each attempt separately rather than the total across retries. choria_provisioner_rpc_errors increments once per wrapper call, after the loop gives up.

Retry limits per action

Each caller picks its own limit, and the values reflect how expensive a repeat is.

CallTriesReasoning
rpcutil#inventory5Read-only and cheap to repeat.
choria_provision#gen255195Read-only. A repeat generates a fresh nonce.
choria_provision#configure5The call that writes the configuration. Repeats are idempotent on the node.
choria_provision#jwt3Read-only.
choria_provision#restart3A repeat restarts a node that may already be restarting.
choria_provision#shutdown3A repeat targets a node that may already be shutting down.
choria_provision#release_update3A repeat downloads and rewrites the binary again.
choria_provision#gencsr1Each call makes the node generate a new private key.

Reply validation

Every action checks the response count first. It must be exactly one:

if res.Stats().ResponsesCount() != 1 {
	return fmt.Errorf("... received %d responses while expecting a response from %s",
		res.Stats().ResponsesCount(), h.Identity)
}

Zero means the node did not answer within the client timeout. More than one means two machines answer to the same identity, and the wrapper cannot tell which machine the configuration would reach, so it fails the call. EachOutput then checks ResultDetails().OK() and assigns to a captured err that the enclosing closure returns.

shutdown is the exception. It logs a non-OK result as a warning without setting err, and increments choria_provisioner_helper_shutdown_requests only on the OK path. The helper already rejected the node, so a failed shutdown confirmation does not fail the cycle.

The upgrade decision

handleHostUpgrade (host/host.go:219) runs before configuration and refuses the node when upgrades_repository is unset and upgrades_optional is off, when the inventory carried no version, or when the node did not report upgradable.

The comparison itself uses host/version.go, a vendored copy of go-rpm-version, MIT licensed, vendored because the upstream project is unmaintained. It implements RPM’s epoch:version-release parsing and the rpmvercmp segment comparison, including tilde handling and the rule that numeric segments sort higher than alphabetic ones.

cv := NewVersion(h.version)
tv := NewVersion(h.upgradeTargetVersion)

if !cv.Equal(tv) {
	err := h.upgrade(ctx)
	...
}
Version comparison is an equality test

handleHostUpgrade compares the two versions with Equal and acts on any difference. Version exposes GreaterThan and LessThan, and neither is called anywhere in the codebase. A helper that returns an upgrade value older than the node’s current version triggers a release update that moves the node backwards. Whether a version change is an upgrade or a rollback is therefore the helper’s decision. A helper that must prevent rollbacks compares the versions itself before setting the field.

NewVersion never returns an error. An epoch that does not parse as an integer silently becomes 0, which follows the upstream behavior.

When the versions differ, upgrade sends release_update with the configured repository, the token, and the target version, and Provision returns with no delay so the node is re-provisioned on the next cycle.

Next

Pausing and leader election covers the pause flag that gates every call on this page.

Pausing and leader election

Running several Provisioners against one broker would have them race to configure the same servers. Instead, every instance starts paused and the election winner resumes.

Where it lives

config/pausable.go holds the flag and its four methods. hosts/election.go calls Pause and Resume from the election callbacks. config/stats.go exports the flag as a gauge.

The pause flag

config.Config embeds a sync.Mutex and carries an unexported paused bool. Pause, Resume, Flip, and Paused guard it, and each sets choria_provisioner_paused before returning:

func (c *Config) Pause() {
	c.Lock()
	defer c.Unlock()

	c.paused = true
	c.setPauseStat()
}

config.Load sets the gauge to 0 at startup, so the metric exists before any election runs.

Every action that leaves the process reads the flag first:

ReaderEffect when paused
hosts/hosts.go:187 in discoverLogs and returns without sending the broadcast request.
hosts/event.go:63 in handleLogs and drops the lifecycle event.
host/rpc.go:59 in rpcWrapperFails the call before the retry loop starts.
host/rpc.go:74 inside the retry loopCancels the context and fails at the next attempt.
host/helper.go:72 in runHelperRefuses to run the helper.
The flag is checked inside the retry loop

rpcWrapper reads the flag inside the retry loop as well as before it, so a Provisioner that loses an election mid-sequence stops at the next attempt boundary. Without the inner check, an instance could carry on sending configuration to a node that the new leader has already started working on.

Flip is defined alongside the other three and has no caller anywhere in the repository. All four methods carry comments saying they implement backplane.Pausable, but this codebase does not import the backplane package. The four methods satisfy the interface for a consumer that this repository does not contain.

Election callbacks

Setting leader_election makes hosts.Process call conf.Pause() before it starts any goroutine, so the instance is inert from its first line of work. startElection (hosts/election.go:17) then registers two callbacks with a Choria Streams election named provisioner.

startupStandbypaused = 1no discovery or RPCLeaderpaused = 0provisions the fleetwins the electionloses the electioncampaigns, backoff up to 20sOn winningResume(), then send to discoverTriggerOn losingPause(), then removeAllHosts()
A standby instance holds connections and goroutines but performs no work until it wins.

The won callback resumes work and then writes to discoverTrigger, a channel of capacity one that hosts.Process selects on alongside the interval ticker. A new leader therefore runs a discovery immediately instead of waiting up to a full interval, so provisioning continues within seconds of a failover.

The lost callback pauses and calls removeAllHosts, which empties the map and then drains the work channel in a non-blocking loop until it is empty. A host already passed to a worker stays with that worker, but the worker’s isCurrent check rejects it before provisioning starts. Discovery and the work queue covers that check.

Campaigning uses backoff.TwentySec, so a failover can take up to a minute of standby time before a new leader starts working.

Failure handling in the election path

hosts.Process launches startElection in a goroutine, so its returned error is discarded. If fw.NewElection fails, the instance stays paused and logs nothing further.

elect.Start runs in a nested goroutine and calls log.Fatalf on error, ending the process rather than leaving the instance paused:

go func() {
	err := elect.Start(ctx)
	if err != nil {
		log.Fatalf("Leader election failed to start: %s", err)
	}
}()

startElection defers wg.Done() and returns once the election is registered, so its WaitGroup entry clears at setup rather than at shutdown.

Single-instance deployment

With leader_election unset, paused stays false for the life of the process and every gate passes. A single instance needs no election, and the documentation recommends that deployment unless high availability is required. Configuration File covers the operational side.

Next

Reference and map collects the file map, configuration keys, and metrics in one place.

Reference and map

Each table links to the page that explains the area it covers.

Where it lives

The whole repository, from main.go down. ABTaskFile, Rakefile, and packager/ hold the build tooling.

Command line

main.go calls cmd.Run, which builds a fisk application named choria-provisioner. The run command is the default, so choria-provisioner --config ... and choria-provisioner run --config ... are equivalent.

FlagCommandRequiredDefaultEffect
--debugGlobalNoOffForces the Choria log level to debug.
--configrunYesNoneNames the Provisioner YAML. Must be an existing file.
--choria-configrunNochoria.UserConfig()Names the Choria client configuration. Must be an existing file.
--pidrunNoUnsetWrites the PID, and removes the file on exit.

--version prints config.Version, which is 0.0.0 in a plain go build. The release build injects it through -ldflags, mapped in packager/buildspec.yaml to github.com/choria-io/provisioner/config.Version.

Source map

FileLinesHolds
main.go9The call to cmd.Run.
cmd/provisioner.go130Flags, both config loads, the Choria framework, /metrics, signal handling, and the PID file. Architecture.
config/config.go142The Config struct, Load, the defaults, and the validation.
config/pausable.go48Pause, Resume, Flip, and Paused. Pausing and leader election.
config/stats.go18The choria_provisioner_paused gauge.
hosts/hosts.go223Process, the package state, broadcast discovery, add, remove, and isCurrent. Discovery and the work queue.
hosts/event.go82The connector and the lifecycle startup subscription.
hosts/provisioner.go84The worker loop and the finisher.
hosts/election.go51The leader election setup and its two callbacks.
hosts/stats.go66Nine fleet-level counters and gauges.
host/host.go483The Host struct, Provision, JWT and CSR validation, server JWT issuance, and key encryption. The provisioning cycle and Security and enrollment.
host/rpc.go404rpcWrapper and every Choria RPC action. RPC, retries, and upgrades.
host/helper.go131ConfigResponse and the child process. The helper contract.
host/version.go210The vendored RPM version comparison.
host/stats.go42Five per-server metrics.
host/host_test.go238Ginkgo specs for generateServerJWT, validateCSR, and encryptPrivateKey.
tools.go18Build-tagged tools imports that pin the Ginkgo CLI, excluded from normal builds.

Key types

config.Config
config/config.go:25. The parsed YAML plus derived durations, the config file path, and the pause flag. Passed by pointer to every package.
host.Host
host/host.go:29. One server. Five exported fields form the helper's input. The remaining fields hold state that the RPC sequence fills in.
host.ConfigResponse
host/helper.go:23. The helper's reply. Twelve optional fields.
host.Version
host/version.go:21. An RPM epoch, version, and release triple with a Compare method.

Exported metrics

Every metric carries a site label taken from the site configuration key. Three init functions register them, one per package.

MetricTypeExtra labelsRegistered in
choria_provisioner_pausedGaugeNoneconfig/stats.go
choria_provisioner_discoveredCounterNonehosts/stats.go
choria_provisioner_event_discoveredCounterNonehosts/stats.go
choria_provisioner_discover_cyclesCounterNonehosts/stats.go
choria_provisioner_discovery_errorsCounterNonehosts/stats.go
choria_provisioner_provision_errorsCounterNonehosts/stats.go
choria_provisioner_provisionedCounterNonehosts/stats.go
choria_provisioner_busy_workersGaugeNonehosts/stats.go
choria_provisioner_work_queue_entriesGaugeNonehosts/stats.go
choria_provisioner_waiting_nodesGaugeNonehosts/stats.go
choria_provisioner_rpc_timeSummaryrpchost/stats.go
choria_provisioner_rpc_errorsCounterrpchost/stats.go
choria_provisioner_helper_timeSummaryNonehost/stats.go
choria_provisioner_helper_errorsCounterNonehost/stats.go
choria_provisioner_helper_shutdown_requestsCounterNonehost/stats.go

The rpc label is the agent#action string, for example choria_provision#configure or rpcutil#inventory. Monitoring describes what each metric measures.

Configuration keys

Configuration File gives the full operational description. The following table gives the struct field and the reader for each key.

YAML keyFieldRead by
workersWorkershosts.Process, to size the worker pool.
intervalInterval, IntervalDurationhosts.Process for the ticker, add for the dedup window, Provision for the staleness check.
helperHelperrunHelper, split with shellquote.
tokenTokenNewHost, which copies it to Host.token for every choria_provision action.
siteSiteEvery metric, as the site label.
lifecycle_componentLifecycleComponentlisten, as the startup event subject.
logfile, loglevelLogfile, Loglevelcmd.run, which copies them onto the Choria configuration.
choria_insecureInsecurecmd.run, which disables TLS and forces the file security provider.
monitor_portMonitorPortcmd.setupPrometheus.
broker_provisioning_passwordBrokerProvisionPasswordcmd.run, which sets the NATS user to provisioner.
cert_deny_listCertDenyListvalidateCSR, through matchAnyRegex.
jwt_verify_certJWTVerifyCertvalidateJWT, as a file path or a hex public key.
jwt_signing_keyJWTSigningKeygenerateServerJWT.
jwt_signing_tokenJWTSigningTokengenerateServerJWT, re-read for every server.
server_jwt_validityServerJWTValidity, ServerJWTValidityDurationgenerateServerJWT.
upgrades_repositoryUpgradesRepoupgrade, as the release_update repository.
upgrades_optionalUpgradesOptionalProvision, in the upgrade failure branch.
leader_electionLeaderElectionhosts.Process, to start startElection.
features.jwtFeatures.JWTProvision, to gate fetchJWT and validateJWT.
features.pkiFeatures.PKIProvision, to gate fetchCSR and validateCSR.
features.ed25519Features.ED25519Provision, to gate fetchEd25519PubKey and generateServerJWT.
features.upgradesFeatures.VersionUpgradesProvision, to gate handleHostUpgrade.

rego_policy is declared on Config as RegoPolicy and read nowhere. OPA policies reach a node through the helper’s opa_policies reply instead.

Choria RPC actions used

AgentActionPurpose
rpcutilinventoryFacts, agent list, version, and the upgradable flag.
choria_provisionjwtThe provisioning.jwt and the server’s ECDH public key.
choria_provisiongen25519An ed25519 public key and a signature over a nonce.
choria_provisiongencsrA CSR generated by the node.
choria_provisionrelease_updateAn in-place binary upgrade.
choria_provisionconfigureConfiguration, credentials, and policies in one call.
choria_provisionrestartRestart into the new configuration.
choria_provisionshutdownExit with code 0, which does not trigger a systemd restart.

Glossary

fleet node
A machine running Choria Server. Provisioner's unit of work.
provisioning collective
The sub-collective that an unconfigured server joins. Provisioner forces both collectives and main_collective to this value.
provisioning.jwt
A token placed on a node that enables provisioning mode, carries the broker address, and holds the shared token.
helper
The site-supplied program that turns a server's identity and inventory into a configuration.
Organization Issuer
The ed25519 key at the root of a certificate-authority-free Choria deployment. Provisioner signs server tokens that chain to it.
server JWT
The token that Provisioner issues for a node in an Organization Issuer deployment, replacing an x509 certificate.
lifecycle event
A JSON event that Choria components publish on choria.lifecycle.event.>. Provisioner subscribes to the startup ones.
leader election
A Choria Streams primitive that names one instance in a cluster as active.
paused
The flag that a standby instance sets. Every outbound action checks it.
splay
A random delay that a server applies before acting on a restart or shutdown, spreading the load across a fleet.
site
A name for one installation, used as a label on every metric so a dashboard can aggregate across installations.
Next

The Code Map overview returns to the start. Writing a helper gives the operator’s view of the helper contract.