Telephone Load Testing Guide
Step-by-step instructions for running the telephone (IVR/DTMF) load test:
provisioning an election event with many voters, then driving many
simulated phone calls against it. The setup script provisions through step-cli; the runner starts independent ivr-cli processes against real Keycloak and Hasura services, without telephony infrastructure.
All commands below assume a terminal opened inside the dev container
(VS Code Dev Containers or GitHub Codespaces) — that's where cargo,
step-cli's dependencies (Hasura/Keycloak), and the trustee1/trustee2
containers are reachable.
Every command below writes its output under
packages/step-cli/scripts/telephone-load-test-output/ (gitignored) rather
than /tmp, so a run survives a container restart and stays easy to find
between steps.
Configuration
setup_telephone_load_test.py and run_telephone_load_test.py take no command-line arguments — every setting
lives in packages/step-cli/scripts/telephone-load-test-inputs/config/layers.yaml,
under the setup: / telephone_run: sections respectively. Online browser and k6 workloads use the voting load CLI.
config/ is gitignored (it holds real per-server credentials); copy the
tracked
layers.yaml.example
template there first:
mkdir -p packages/step-cli/scripts/telephone-load-test-inputs/config
cp packages/step-cli/scripts/telephone-load-test-inputs/layers.yaml.example \
packages/step-cli/scripts/telephone-load-test-inputs/config/layers.yaml
Edit that copy before each run instead of passing flags. A field left as
null falls back to the environment variable named in the comment beside
it (already exported in this repo's devcontainer).
Prerequisites
- The
keycloak,graphql-engine(Hasura),trustee1andtrustee2containers must be running (docker ps;docker start trustee1 trustee2if they aren't — the keys ceremony hangs without them). - Python 3 with PyYAML (already available in the devenv shell).
0. Build the CLIs
Both scripts look for their binary on PATH first, falling back to the
default release build path — so a release build is enough, no need to keep
using cargo run:
CARGO_TARGET_DIR=/workspaces/step/packages/step-cli/rust-local-target \
cargo build \
--manifest-path packages/step-cli/Cargo.toml \
--release \
--package step-cli
CARGO_TARGET_DIR=/workspaces/step/beyond/packages/rust-local-target \
cargo build \
--manifest-path beyond/packages/Cargo.toml \
--release \
--package ivr-cli
export PATH="/workspaces/step/packages/step-cli/rust-local-target/release:$PATH"
1. Stage 1 — provision the election event and voters
layers.yaml's setup: section already points at the tracked example
election event with voting_channel: TELEPHONE and 20 voters — run it as-is:
python3 packages/step-cli/scripts/setup_telephone_load_test.py
This imports the tracked example election event, generates 20 voters (all
placed in the same area, with unique numeric username/PIN/date-of-birth
each), runs the keys ceremony, publishes, and opens TELEPHONE voting.
Outputs land in setup.out_dir (telephone-load-test-output/run by
default): a top-level tenants.json index, and one tenant-<tenant_id>/
subdirectory per provisioned tenant holding that tenant's own summary.json
and voters CSV (with a single tenant, that's just one subdirectory).
By default Stage 1 provisions setup.tenant_id itself. Set
setup.new_tenants: N to instead create N brand-new tenants and import
the same election event into each — Stage 2 then places calls across every tenant tenants.json lists. Leaving tenant_id
unset defaults new_tenants to 1, so omitting it entirely provisions one
fresh tenant instead of reusing an existing one (creating a tenant needs
setup.keycloak_admin_user/keycloak_admin_password — a Keycloak
master-realm admin, distinct from admin_portal_user — to look up each new
tenant's freshly generated api-key-client secret).
A brand-new tenant starts blank — no trustees, and Keycloak/roles config
from the generic default template rather than tenant_id's own. For each
one, the script: exports tenant_id's Keycloak/roles config
(export-tenant-config), downloads it and re-uploads it so the new tenant
owns its own copy (documents are tenant-owned records — a document can't be
imported into a tenant that doesn't own it), imports that copy
(import-tenant-config), then copies tenant_id's registered trustees
(list-trustees / create-trustee) so the keys ceremony has trustees to
work with at all. None of this needs configuring — it's automatic whenever
new_tenants > 0.
Set setup.use_existing_tenants to a list of already-existing tenant IDs to
also provision this election event into, alongside tenant_id itself /
any new_tenants brand-new tenants — typically tenants a previous run's
new_tenants created (copy them straight from that run's tenants.json).
Unlike new_tenants, these are never created or cloned — the script
authenticates into each directly and provisions the election event, so every
one must already have its own registered trustees and Keycloak/roles config
in place (true for any tenant new_tenants itself created previously).
Looking up each one's api-key-client secret needs
keycloak_admin_user/keycloak_admin_password, same as new_tenants > 0.
The keys ceremony defaults to setup.ceremony_policy: AUTOMATIC: each
trustee's braid service still does its DKG round the same way, but nothing
needs to log in as trustee1/trustee2 to confirm it — the ceremony's
status flips to done on its own once every trustee's public key is on the
board, matching the Admin Portal's "automatic ceremony" option. Set it to
MANUAL to instead drive complete-key-ceremony as each configured
trustee, as the CLI always did previously.
Each run appends a random 5-character suffix to the election event's alias
(e.g. TECUMSEH - DATAFIX Test - K3F9Q) — the admin portal's election event
list renders alias, not name, so that's the field that needs the suffix to
actually be visible there. The full alias is printed at the end of the run
and recorded as election_event_alias in summary.json.
Using your own election event JSON instead of the tracked example works the
same way — just point setup.election_event_json at it in layers.yaml. If
it has more than one area, set setup.voter_area_name to pick which one
voters are generated into (null defaults to the first area).
Importing the election event also creates its own Keycloak realm
(tenant-<tenant_id>-event-<election_event_id>, printed as keycloak_realm
in summary.json), seeded with its own ivr-service/ivr-voting clients —
these are not in the tenant's realm alongside api-key-client. Since
every Stage 1 run creates a brand-new election event (and therefore a new
realm), fetch telephone_run.keycloak_ivr_service_client_secret/
keycloak_ivr_voting_client_secret from this realm after each run —
Keycloak admin console → the realm printed above → Clients →
ivr-service/ivr-voting → Credentials tab — rather than reusing a value
from a previous run's election event.
Match the IVR authentication and ballot
Telephone credentials must be numeric and no longer than eight digits. The setup starts usernames at 100 (setup.voter_username_start); use a RandomNumeric password policy with at most eight digits. The realm's /ivr-config endpoint defines the actual login fields, which may be voter ID and PIN or date of birth and PIN.
The setup opens the TELEPHONE voting channel explicitly. Opening only ONLINE does not make a voter eligible for a telephone call. All generated voters use setup.voter_area_name (the first area by default), because one DTMF ballot template must match the contests offered to every voter.
2. Get a DTMF template
packages/step-cli/scripts/dtmf-template.example.txt — the default for
telephone_run.dtmf_template — is already captured for the tracked example
election's first area and works as-is — skip to
step 3.
If you're testing a different election event JSON or
setup.voter_area_name, capture a new template: the ballot portion
(candidate numbering, confirm/submit keys) depends on that election's
contests. First get a phone_config.json and a running session store by
running Stage 2 once with any existing template (the calls themselves may
fail if the template doesn't match your election — that's fine, the side
effects are what you need):
python3 packages/step-cli/scripts/run_telephone_load_test.py
Then drive one call by hand, noting every prompt and keystroke:
PHONE_CONFIG_PATH=packages/step-cli/scripts/telephone-load-test-output/calls/phone_config.json \
beyond/packages/rust-local-target/release/ivr-cli \
--bundle dev \
--system-number +111111111111 \
--number +15550000000 \
--show-internal-state
Log in with the first row of the voters CSV. Transcribe the full keystroke
sequence into a copy of dtmf-template.example.txt (and point
telephone_run.dtmf_template at your copy), replacing the identifier/PIN
lines with {{VOTER_ID}}/{{PIN}} or {{DOB}}/{{PIN}} — check which
fields this realm's IVR flow expects first:
read -r -p 'Election realm from summary.json: ' IVR_REALM
read -rs -p 'ivr-service client secret: ' IVR_CLIENT_SECRET
TOKEN=$(curl \
--fail \
--silent \
--show-error \
--request POST \
--data-urlencode grant_type=client_credentials \
--data-urlencode client_id=ivr-service \
--data-urlencode "client_secret=$IVR_CLIENT_SECRET" \
"http://keycloak:8090/realms/$IVR_REALM/protocol/openid-connect/token" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
curl \
--fail \
--silent \
--show-error \
--header "Authorization: Bearer $TOKEN" \
"http://keycloak:8090/realms/$IVR_REALM/ivr-config"
unset TOKEN IVR_CLIENT_SECRET
(IVR_REALM is summary.json's keycloak_realm — the election event's own
realm, not the tenant's; see the note in step 1.
The client secret goes in telephone_run.keycloak_ivr_service_client_secret
in layers.yaml — or .devcontainer/.env.development for the local
devcontainer stack, which always targets the same seeded test election
event.)
3. Stage 2 — fan out the simulated calls
python3 packages/step-cli/scripts/run_telephone_load_test.py
This generates phone_config.json, renders one DTMF input file per voter
from the template, and fans out telephone_run.concurrency parallel
ivr-cli calls. A local valkey container is started automatically as the
session store if none is reachable (reused across runs; disable with
telephone_run.start_valkey: false). Results land in results.csv and
per-call logs under telephone_run.out_dir (telephone-load-test-output/calls
by default).
4. Stage 3 — clean up: delete the election event(s) and tenant(s)
cleanup_telephone_load_test.py automates this stage: it reads Stage 1's
tenants.json (every tenant it provisioned into — one, unless
setup.new_tenants/setup.use_existing_tenants was set) and each tenant's
summary.json, then for each (tenant_id, election_event_id) pair
re-authenticates step-cli against that tenant (a session is scoped to one
tenant at a time) and calls delete-election-event. Once every election
event is gone, it also deletes every non-bootstrap tenant (never the
bootstrap tenant itself — see below):
python3 packages/step-cli/scripts/cleanup_telephone_load_test.py
Unlike the other load-test scripts, this one takes command-line flags —
they scope how destructive a run is, which is a per-invocation choice, not
something that belongs in layers.yaml:
--events-only— delete election events only; leave every tenant realm in place, including onessetup.new_tenantscreated this run.--new-tenants-only— delete election events as usual, but only delete tenantstenants.jsonmarks"source": "new"— tenants Stage 1 reused viasetup.use_existing_tenants("source": "existing"), and any entry with nosourceat all (e.g. a hand-writtentenants.json), are left in place. This is the fieldsetup_telephone_load_test.pywrites per tenant to record whether it created that tenant this run or just provisioned an election event into an existing one — see the note onsetup.use_existing_tenantsabove.
With neither flag: delete every election event and every non-bootstrap tenant, as it's always done.
It reads the same setup: section of layers.yaml Stage 1 used. For the
bootstrap tenant (setup.tenant_id) it reuses the already-known
setup.keycloak_client_secret; for every other tenant, it looks up that
tenant's own api-key-client secret via
setup.keycloak_admin_user/keycloak_admin_password first — the same
Keycloak master-realm admin lookup setup_telephone_load_test.py did,
whether that tenant was created fresh or reused via
setup.use_existing_tenants.
delete-election-event calls the delete_election_event GraphQL mutation,
which queues an async task tearing down the election event's Postgres/Hasura
rows, its Keycloak realm, and its ImmuDB and document-store data — the
command blocks and polls until that task finishes (or fails/times out after
5 minutes), so a Success! means cleanup is actually done, not just queued.
To delete an event by hand, authenticate the CLI against its tenant, then run:
read -r -p 'Synthetic election event ID from summary.json: ' ELECTION_EVENT_ID
step-cli step delete-election-event \
--election-event-id "$ELECTION_EVENT_ID"
delete-tenant deletes a tenant outright — its Postgres/Hasura rows (trustees,
templates, election types, any tenant-level documents), its Keycloak realm,
and its remaining S3 documents — via the delete_tenant GraphQL mutation,
polled the same way as delete-election-event. It refuses to run while the
tenant still has any election events, and it's a super-admin-only action: the
caller must be authenticated as the bootstrap tenant (setup.tenant_id), not
as the tenant being deleted — matching how create-tenant itself is
authorized.
The tenant-delete role only needs to exist in the bootstrap tenant's own
Keycloak realm (never in the tenants being deleted), and is already assigned
to the admin/admin-light roles there in the dev container's default
realm import. Deployed environments provisioned via beyond's client-setup
chart don't seed this role yet (kept out deliberately, to avoid changing that
chart's realm export for every deployment), so before running cleanup against
such an environment, add the tenant-delete role to the bootstrap tenant's
realm by hand — Keycloak admin console → bootstrap tenant realm → Realm roles
→ create tenant-delete, then assign it to $ADMIN_PORTAL_USER's role (or
the role it inherits it from) — otherwise delete-tenant fails with an
authorization error. After authenticating the CLI against the bootstrap tenant, delete a disposable tenant with:
read -r -p 'Disposable tenant ID to delete: ' TENANT_ID
step-cli step delete-tenant \
--tenant-id "$TENANT_ID"
The bootstrap tenant itself is never deleted, even once its election event is gone — it's the persistent identity these scripts authenticate as, meant to be reused across runs, not a disposable one Stage 1 created.
Notes
- Each voter casts exactly one vote. Re-running Stage 2 against the
same
telephone_run.run_dirre-uses the same (already-voted) voters — every call logs in successfully but reports "voting is now complete" without casting a ballot, since there's nothing left for that voter to vote on. This is the system correctly rejecting a duplicate vote, not a failure. To place a fresh batch of calls, re-run Stage 1 to provision a new election event and voter set. - Re-running after a dev container restart: the auto-started
valkeycontainer is reused if it's already there (even if stopped), so you don't need to remove it manually between runs. - IVR client secrets are per-election-event, not per-tenant. Since Stage
1 provisions a new election event realm every run, a
keycloak_ivr_service_client_secret/keycloak_ivr_voting_client_secretthat worked for a previous run's election event will not work for a new one — re-fetch them from the new realm each time (see the note in step 1). With more than one tenant (setup.new_tenants > 1), the flattelephone_run.keycloak_ivr_service_client_secret/keycloak_ivr_voting_client_secretcan cover at most one of them — settelephone_run.tenant_ivr_secrets(keyed bytenant_id) for the rest.