-
Notifications
You must be signed in to change notification settings - Fork 0
Tutorial 9 Advanced
← 8. Observe in the supervisor GUI · Tutorial index
The earlier chapters deployed a single app onto a small rig. This chapter is the operator/release-engineer reference for running Theia at scale over time:
- 9.1–9.4 Higher-arity deployment — one master + N workers, the machine-vs-role identity split, and how a single manifest fans out to a heterogeneous fleet.
- 9.5–9.8 Semantic versioning — what PATCH / MINOR / MAJOR mean for an app SWP, and the one-command way to cut each.
-
9.9–9.11 Config migration — the rules for changing an app's
.artinterface or its config without bricking a running board.
These three are one story: as arity grows and versions churn, the framework needs a stable identity per board and a disciplined way to evolve the interface. Read it once end-to-end; come back to a section when you cut a real release.
A deployment's arity is how many machines it spans. Chapter 4 showed arity-1 (everything on one board) and a 2-machine split. The rule generalizes:
One
master, Nzonalworkers. Themasteris the coordinator — it hosts the single per-cluster etcd, the deployment-wide singletons (per,sm,phm,com,vucm), and the mender gateway. Every other board is azonalworker: the minimal per-board FC set (ucm+shwa) plus a mender agent that reaches the server through the master's proxy. One master + as many workers as your rig has.
The framework runtime ships this shape as a role-based rig you consume (you
don't author it): manifest/services/rig.py exports RIG (master only) and
MULTI (master + workers). A Distribution's roles pick which materialize onto
which boards.
# arity-1: the single-master runtime (the common case)
theia manifest services
# arity-N: master + named workers
theia manifest services --attr MULTIThis is the single most important idea in a multi-board deploy. There are three identities, and conflating them is the classic mistake:
| Identity | What it is | Unique? | Used for |
|---|---|---|---|
| role |
master / zonal — the deployment identity |
No (N workers share zonal) |
which manifest slice colony provisions; whether the board runs etcd / a mender gateway vs agent |
| machine name |
master / compute / frontal — the runtime identity |
Yes | how com, the GUI, and rtdb/tdb address one specific board |
| hostname | the OS hostname | not guaranteed | never used as an identity — informational only |
The rule: role is the deployment identity; the machine NAME is the runtime identity. Two workers can share the role
zonal, but they must have distinct names (compute,frontal). Never use a role as a machine name — a rig with two machines both namedzonalis rejected at serialize time (§9.4).
Why it matters: com on the master aggregates every board's supervisor tree into
one view. To keep compute and frontal as distinct subtrees — and to let an
operator type rtdb ps compute — each needs a unique name. The role (zonal) can't
disambiguate them; the name can.
Each machine gets a stable cluster index at serialize time: the master is 0,
workers are 1, 2, … in canonical order. The supervisor's own TIPC control node
binds at type=0x80020001, instance=<machine_index> — so com discovers a
supervisor at instance N and maps N → machine_index → the unique name.
The name→index map is written into machines.json (the one manifest com
references):
machine_index is authoritative — it wins over whatever a board's supervisor
reports about itself, so a machine name is correct even mid-upgrade. On a deployed
board, machines.json is staged into /opt/theia/config/, and the launcher points
com at it (THEIA_MACHINE_MANIFEST=/opt/theia/config). Provisioning does both for
you; you never wire it by hand.
Verify the identity end-to-end — from the master, list the cluster:
rtdb machines
# INST MACHINE PRESENT HOST
# 0 master yes board-a
# 1 compute yes board-b
# 2 frontal yes board-c
rtdb ps compute # scope to ONE board by its unique name
rtdb ps 2 # …or by its instance numbertheia manifest runs the TIPC address-collision gate, then three machine-identity
invariants. A violation fails the serialize with a clear message — so a broken
manifest never reaches a board:
- Machine names are unique. (The name is the runtime identity.)
-
machine_indexis sequential from 0 —0, 1, 2, …, no gaps. - Index 0 is the master (the etcd coordinator).
theia manifest services --attr MULTI
# serialize-manifest: machine NAMES must be unique — … Duplicated: ['zonal'].
# Give each board a distinct name (role master/zonal is the deployment
# identity, NOT the name).If you author your own multi-machine rig, give each worker a distinct name= and
its role=Explicit("zonal"); the index + validation are automatic.
The runtime/base is installed once and held fixed; your app SWP is exchanged freely on top of it. Semver tells you how freely:
| Bump | Example | Meaning | A free swap? |
|---|---|---|---|
| PATCH | 1.0.0 → 1.0.1 |
bug fix, no interface change | Yes — deploy at will |
| MINOR | 1.0.0 → 1.1.0 |
compatible, additive change | Yes |
| MAJOR | 1.0.0 → 2.0.0 |
.art interface change (a proto / port / message contract) |
No — needs a migration + a runtime pin |
The dividing line is the
.artinterface. If the change is invisible at the.artlevel (you fixed a handler bug, tuned a constant), it's a PATCH/MINOR — a free overlay swap on the same runtime. If you changed a message, a port, or a node address in.art, it's a MAJOR: the wire contract moved, so it needs an explicit migration and it pins the runtime it was built against.
theia release-swp <app> --patch mints the next patch of an app: it reads the app's
latest published SWP, increments the PATCH, and builds + publishes the new artifact
to the package plane. That new artifact is exactly what a Ground Station Rollout
advances a device group onto.
# 1.0.0 already published → this cuts 1.0.1 and pushes it, abi-keyed
theia release-swp counter --patch --s3 http://10.0.0.99:9000
# theia release-swp: counter 1.0.0 → v1.0.1 (patch bump)
# published → s3://theia-swp/user-software/theia-rig/counter/1.0.1-amd64/-
--from V— bump from an explicit base instead of "latest on S3". -
--to V— set an explicit target version (skips the auto-bump). - The abi (
amd64,bookworm-arm64, …) is baked into the artifact name, so a per-role Distribution resolves the right binary for each board — "partial per machine".
A PATCH ships no migration. On-device it's a clean overlay: the app's FC binaries are swapped, the supervisor re-reads its tree, done.
A MAJOR crosses the .art interface, so it is refused unless you acknowledge it
with --migrate:
theia release-swp counter --to 2.0.0 --s3 http://10.0.0.99:9000
# ✗ 2.0.0 crosses a MAJOR boundary … Re-run with --migrate to confirm.--migrate makes two things mandatory, because a major is not a free swap:
-
A runtime pin —
--requires-runtime <V>. A major app depends on exactly one runtime (its ABI/proto are pinned at build time). The Ground Station deploy gate refuses to install a pinned app onto a board whosebase_versiondiffers ("update the base first"). Refused if empty. -
A migration file —
--migration <path>, else the conventionalapps/<app>/migrations/v<from>-to-v<to>.py. If none exists an empty no-op stub is auto-created for you to edit — a migration always ships, even if it's a no-op, so the upgrade is explicit and reviewable.
theia release-swp counter --to 2.0.0 --migrate \
--requires-runtime 0.2.2-amd64 --s3 http://10.0.0.99:9000
# theia release-swp --migrate: created empty migration stub
# apps/counter/migrations/v1-to-v2.py — EDIT it, then re-run.Edit the stub, re-run, and the migration is packed into the SWP (and synced to
s3://theia-swp/.../migration/) as a first-class part of the artifact.
The three release facts to keep straight:
PATCH / MINOR → free overlay swap, same runtime, no migration
MAJOR (--migrate) → runtime pin (requires_runtime) + a shippable migration/
The runtime pin is enforced twice — at a direct app publish and at a Rollout — so a major SWP can never land on the wrong runtime. That's what makes "exchange the app freely on a fixed runtime" safe: the framework knows which swaps are free and which aren't.
A MAJOR often needs to move on-device config or state across the interface break — rename a key, split a message, seed a new default. That is what the migration file is for. It runs on the board during install, before the new binaries take over:
ArtifactInstall on the board:
1. unpack the new SWP
2. run migration/v1-to-v2.py <THEIA_ROOT> ← against the STILL-OLD release
3. overlay the new FC binaries
4. merge the executor subtree + reload the supervisor
Running the migration first, against the old release, is deliberate: it can read the pre-upgrade config and rewrite it for the new interface. If the migration exits non-zero the install aborts and Mender rolls back — a broken migration never leaves a half-migrated board.
The stub is a plain Python script — migrate(theia_root) invoked with the install
root:
"""Migration counter v1 → v2 (MAJOR / .art interface change)."""
import json, sys, pathlib
def migrate(theia_root: str) -> None:
cfg = pathlib.Path(theia_root) / "config" / "counter.json"
if not cfg.is_file():
return # nothing installed yet — no-op
data = json.loads(cfg.read_text())
# v2 renamed `limit` → `max_count`; carry the operator's value across.
if "limit" in data:
data["max_count"] = data.pop("limit")
cfg.write_text(json.dumps(data, indent=2))
if __name__ == "__main__":
migrate(sys.argv[1] if len(sys.argv) > 1 else "/opt/theia")Rules for a good migration:
- Idempotent. It may run more than once (a retried install); running it twice must be a no-op the second time — check before you transform.
- Tolerant of "not there yet." A fresh board may not have the old config — return cleanly, don't raise.
- Fail loud on a real problem. A genuinely un-migratable state should exit non-zero — that aborts + rolls back, which is safer than a corrupt config.
- Config, not code. It moves data; it never patches binaries (the SWP overlay does that).
The migration is app-plane only. It never touches the runtime/base — that plane is re-provisioned, never rolled, and carries no migration. Two consequences:
- If your change needs a new runtime (a platform ABI bump), that's a base re-provision through colony — do it first, then deploy the app major that pins it (the runtime pin from §9.7 is what sequences this correctly).
- A PATCH/MINOR has no migration by construction. If you find yourself wanting to migrate config for a "minor" change, it wasn't minor — the interface moved, so cut it as a MAJOR.
Arity (§9.1) was about machines. Multiplicity is the same idea one level down: N instances of the same node type, in one process. Same code, same TIPC type, distinct instance — a worker pool.
You express it in .art with prototype (attribute-replacement — each clone is
the base node, re-addressed to its own instance):
node atomic CounterNode { tipc type=0xd0010001 instance=0 config CounterConfig … }
// nine more clones at the same type, instances 1..9
node atomic Counter1 prototype CounterNode { tipc type=0xd0010001 instance=1 }
node atomic Counter2 prototype CounterNode { tipc type=0xd0010001 instance=2 }
// …
composition CounterProc {
prototype CounterNode counter0 on process P1 // instance 0 — the base
prototype Counter1 counter1 on process P1
// …
}
Two things multiplicity gives you, and they use the instance differently:
1. A node knows its own instance. main resolves each clone's instance and
hands it to the node (GenServerBase::tipc_instance()), so handler code can act on
which clone it is:
void CounterNode::init(CounterNodeState&) {
const uint32_t inst = this->tipc_instance(); // 0..9 — this clone
// key THIS clone's config as "counter/<instance>"
}2. Per-instance config. Each clone owns its config in per under
<component>/<instance> (counter/0, counter/1, …). A change to counter/3
notifies only clone 3 — per reads the instance straight from the key and casts
ConfigUpdated to that exact instance. No fan-out, no clone reading another's
config. (Omit the instance and it's a single-instance node, the common case.)
Addressing a clone. TIPC addresses a node by
(type, instance). Send to a specific instance to hit one clone; several ports co-bound at the same(type, instance)are round-robined by the kernel — that's how you build a load-balanced worker pool (bind the pool at one address) vs an addressable set (bind each at its own instance). Multiplicity gives you both shapes.
For test/demo, drive a running node straight from the shell — pack a JSON payload into the node's proto message and send it over TIPC:
theia cast CounterNode Inc --data '{"n": 5}' # send to the base
theia cast CounterNode Inc --data '{"n": 100}' --instance 3 # ONE clone (inst 3)
theia call CounterNode Get --instance 5 # call → prints reply-
--instance Ntargets clone N;--machine Mshifts the instance by a board's machine index (the per-board clone on a/Ndeploy). -
castis fire-and-forget;callblocks for the reply and prints it as JSON. - No
--instancesends to the node's base address (round-robined if the pool is co-bound).
It resolves <node> + <msg>/<op> from your workspace's .art, so any node you
model is reachable — a one-liner instead of a probe script or an rf test.
The in-repo
tutorial_wscarries the 10-CounterNodecomposition above as the worked example: build it, thentheia cast CounterNode Inc --instance 3and watch[counter3] Inc (inst 3) → count=…in its log.
Every earlier chapter used a bare workspace (theia init): the supervisor +
your own apps, no ARA platform services. That is the normal app-developer setup —
your app reaches the platform (config via per, state via sm, …) over TIPC at
runtime, and those services run where the platform runs (a provisioned board,
or a local docker-compose rig), NOT inside your app workspace.
theia init --with-services is the advanced, single-slice case: it symlinks
the framework's service FCs into your workspace so theia start brings up the
whole platform (com/log/per/sm/ucm/shwa + your apps) as one supervised tree on
one TIPC namespace. Use it only when you deliberately want the app and the
platform co-located on a single TIPC slice — e.g. an all-in-one dev box or a CI
smoke test — rather than the normal split where the platform is a separate deploy.
theia init --with-services # symlinks system/services → $THEIA_ROOT
theia manifest bootstrap
theia install bootstrap # stages supervisor + all service FCs
theia start # the full platform + your apps, one treeTwo things to know:
-
One TIPC namespace = one platform. host-network docker containers share a single TIPC nametable, so a
--with-servicesworkspace and a docker-compose platform rig on the same host collide at the service addresses. Run one or the other on a given slice — or isolate them with a per-rig TIPC cluster id (Machine.tipc_cluster_id). The common pattern is: the compose rig owns the platform slice, and your app workspace does a baretheia initand joins that slice, rather than starting a second platform. -
perneeds etcd;nmneedsCAP_NET_ADMIN. A local--with-servicesstart without etcd (or netadmin) will seeperretry /nmfail. Either pointperat a running etcd, or keep those services defined but down so the rest of the tree still comes up clean.theia installdeep-merges a per-machine override onto the staged supervisor tree, so drop adeploy/config/<machine>/executor.jsonin your workspace:{"children":[{"name":"services_sup","children":[ {"name":"per","run_on_start":false}, {"name":"nm", "run_on_start":false}]}]}Re-run
theia installand those FCs stay defined but not booted (the supervisor logsrun_on_start=false — defined, not started at boot), whilecom/sm/the rest start normally.
You now have: the higher-arity model (one master + N uniquely-named workers, the
role-vs-name split, and the serialize-time invariants that keep identity clean); the
semver rules for an app SWP (PATCH/MINOR are free swaps, MAJOR pins a runtime and
ships a migration); the on-device config-migration contract (runs first, against
the old release, idempotent, aborts on failure, app-plane only); and node
multiplicity (N clones of one node, each knowing its instance + owning its
per-instance config, poked from the shell with theia cast/call).
Next: you have finished the tutorial. Revisit Chapter 5 to bind a specific runtime + SWP version into a Distribution, and Chapter 6 to deploy it across a multi-board fleet.