OT demo cell: end-to-end verification
Audience: operator · Profile:
demo· Read this when: a cell is deployed and you want to prove every part of it works, layer by layer, before showing it to anyone.
Part of OT demo cell.
E2E verification checklist
Written for GCP (the first cloud); on AWS/Azure substitute that cloud's deploy child
(ec2_deploy / azure_deploy), destroy endpoint, functional account
(passwordsafe_vm_functional_account_aws / _azure), managed-system address shape
({instance-id}:{region} / tenantId/subscriptionId/resourceGroup/vmName) and
gateway size key (see the sizing guard). On AWS also
verify the cell landed in the private subnet (no public IP on the child job) and,
for Password Safe over SSM, that aws_ssm_endpoints_enabled is on or the subnet
otherwise reaches the SSM control plane. As of 2026-08-25 the AWS and Azure slices
have not been E2E-verified live — this checklist is the script for that pass.
The ot-sim image, the FUXA seed, the extra protocol sims and the Purdue rules have
not been exercised on a live bake or cell either: they are covered by unit and
structural tests, and the sims themselves were run and read against real clients
outside the image. The k3s runtime is the newest of these: the air-gapped install,
Docker staying beside it, the image side-load and the identity reset are held by
tests/test_ot_k3s.py and by the bake's own smoke test, and nothing more. Steps 2a, 4a
and 10 below are their first live pass; OT_RUNTIME=docker is the fallback if the bake
fails on a platform the script has not met.
- Settings:
pra_enabledon;bt_api_host/bt_client_id/bt_client_secret/bt_jump_group_name/bt_jumpoint_nameset;gcp_jumpoint_machine_type=e2-medium(delete an existing gateway VM so it recreates);gcp_vm_nat_enabledoff; Password Safe registration on with the GCP functional account. - Bake
ot-sim; it appears in the OT tab's image picker. Watch the bake log forinstalling k3s …, CoreDNS becoming ready, every baked image found in containerd, the five ports answering the smoke test, and eitherFUXA project seededor theNOT seededwarning. - 2a. On the deployed cell (Shell Jump):systemctl status ot-simisactive (exited),kubectl -n ot-sim get podsshowsot-plc,ot-hmi,ot-opcua,ot-enipandot-s7Running on the node's own IP (-o wide),docker psanswers (Docker is there, the plant is not in it), and the Web Jump opens FUXA on a project that already has thePLCconnection and its four tags.kubectl get nodesnames this cell, not the build VM, andkubectl -n kube-system get podsis healthy with no image pulls pending — that is the air-gapped install doing its job. - Deploy a cell with several protocols ticked and the Jump Group + Gateway
pickers set to the cell's region; the parent job completes; the child holds Shell
Jump id + private IP; one tunnel jump per ticked protocol appears, each named
ot-<cell>-<protocol>, all in the picked Jump Group (not the configured default), and each carrying an OT-cell comment rather than "k8s API tunnel". The cell shows wired only once every one of them exists. - PRA rep console: Shell Jump SSH works; Web Jump renders FUXA (recorded); a Modbus
client (mbpoll / QModMaster) through the tunnel at
127.0.0.1:502reads holding register 0 incrementing every second. Then deploy (or add a standalone tunnel for) each other vendor and confirm the same four values move: - Siemens — python-snap7db_read(1, 0, 8)against127.0.0.1:102. The pure-Python server logs a COTP framing warning on some handshakes; the read is what matters, not that line; - Rockwell —pylogixRead("Counter")against127.0.0.1:44818(CIP tag names are case-sensitive); - OPC UA — UaExpert againstopc.tcp://127.0.0.1:4840, browseObjects/Plant.
python scripts/ot/verify_tunnels.py does all four in one pass and distinguishes
"no listener" from "listener, no answer" from "answers but frozen" — see
OT protocol clients on Windows.
- 4a. The cluster, through PRA. With the cell deployed with Kubernetes API
(k3s) ticked: the jump item ot-<cell>-k3s exists, and with it started,
kubectl --kubeconfig <the cell's /var/lib/ot-sim/kubeconfig-via-tunnel.yaml>
-n ot-sim get pods answers from the rep machine, with no certificate error
(k3s's certificate names 127.0.0.1). Stop the jump and the same command fails to
connect: the cluster has no other way in.
5. Password Safe: managed system projectId/zone/instanceName exists; the mirror
system <cell>-pravault exists with account <cell>-adminuser; the pair shows
under adminuser's Synced Accounts; rotate adminuser and watch the change
propagate to the mirror.
6. PRA: the Vault account <cell>-adminuser exists, is check-out-able in the rep
console / /login, and is offered for injection when starting the cell's Shell
Jump; after the rotation in step 5, checkout returns the NEW credential and SSH with
it succeeds.
6a. The plant's own agent. Deploy a cell with Register in Entitle ticked
and a broker image picked. The parent job shows: token minted → broker deployed →
cell deployed → PRA wiring → both zones applied → agent install queued.
The zones are readable from the cloud's own CLI — gcloud compute firewall-rules
list, aws ec2 describe-security-groups --group-names <cell>-ot-zone
<broker>-dmz-zone, or az network nsg rule list -g <rg> --nsg-name <cell>-ot-zone
— and on AWS confirm the cell's instance carries <cell>-ot-zone and nothing
else, since a zone beside a permissive group restricts nothing. On Azure the row
to look for is the outbound Deny: without it the cell reaches the internet
despite having no public IP.
gcloud compute firewall-rules list shows the two zones from
Who brokers identity in the plant. From the
broker (Shell Jump): kubectl -n entitle get pods is Running and the install job's
probe passed. From the cell: every curl fails. Then request access in Entitle and
confirm the ephemeral account appears on the cell — that is the agent reaching it
from inside the plant — and that SSH with it through the Shell Jump works and expires
on its own. Destroy: both VMs, both rule sets, the agent token and the integration go.
7. Negative test: set the gateway to e2-micro → a new cell fails fast with the sizing
remedy in the job error (not a mid-session OOM). With a Gateway override picked, the
same deploy proceeds (guard skipped, noted in progress).
8. Per-tunnel Re-wire: delete one of the cell's tunnel jumps in PRA, press
Re-wire, and confirm only that protocol is recreated (the others are untouched
and not duplicated). Re-wire again with nothing missing and confirm it completes
without provisioning anything.
9. Destroy the cell → every tunnel jump gone from PRA (check each protocol, not
just the first), the Web Jump gone, the Vault checkout account gone, the mirror + PS
system off-boarded, VM deleted, gateway reaped only once nothing else references it.
10. Back-compat: destroy a cell deployed before this change — its metadata has the
singular ot_tunnel_* keys and no ot_tunnels list — and confirm its tunnel is
still torn down. (A cell in that state also Re-wires cleanly: the existing tunnel is
adopted into the list rather than provisioned a second time.)
11. Expiry: with the timer enabled, expires_at is stamped on the child row; a reaped
cell cleans up identically to a destroyed one — including every tunnel.
12. Purdue rules (GCP, optional): with ot_purdue_firewall_enabled on, deploy a
cell (or Re-wire an existing one) and check gcloud compute firewall-rules list
shows its three <cell>-ot-* rules. Then: Shell Jump, Web Jump and the tunnel all
still work; curl to the internet from the cell fails even with
gcp_vm_nat_enabled on; and a destroy removes all three rules.
13. Standalone tunnels still work alongside: the cell's own tunnels cover the
protocols it was deployed with, so use a standalone tunnel for what it was not —
e.g. DNP3 to real gear, or a second local port for a protocol already brokered.
Two tunnels cannot listen on the same local port on one rep machine at once.