Skip to content

Tutorial 5 Distribution

ibnHatab edited this page Jul 4, 2026 · 1 revision

4. Write a rig (rig.py) · Tutorial index · 6. Provision a board + deploy from the Ground Station


Chapter 5 — Local install, run, and the distribution model

You now have an app (.art + C++) and a rig (manifest/single/rig.py). This chapter closes the local dev looptheia installtheia starttheia stoptheia clean — and then maps those artifacts onto the distribution model that the fleet uses for OTA.

5.1 The local install loop

Four commands form the local inner loop:

command what it does
theia install <target> serialize the rig → per-machine JSON; bazel-build; stage install/<machine>/
theia start launch the staged supervisor (daemonized, pidfile)
theia stop SIGTERM the supervisor → waits for clean shutdown
theia clean remove install/ + dist/manifest/; --bazel also runs bazel clean

These are symmetric: install and clean are inverses; start and stop are inverses. Run them from your workspace root.

theia install single          # build + stage install/central/
theia start                   # supervisor up, log → install/central/supervisor.log
# … work, probe, restart …
theia stop                    # graceful shutdown
theia clean                   # wipe install/ + dist/manifest/
theia clean --bazel           # also bazel clean (full rebuild next time)

5.2 What theia install produces

theia install single runs three steps in sequence:

Step 1 — serialize the rig. artheia serialize-manifest manifest.single.rig writes the per-machine JSON manifests under install/manifest/central/:

install/manifest/
  machines.json              { "machines": ["central"], "rig": "single", … }
  central/
    machine.json             arch, os, cores, etcd, machine_states
    execution.json           process list (names → bazel targets + start_cmd)
    executor.json            the full supervised process tree (for the supervisor)
    service.json, application.json
    config/
      executor.json          collocated copy the supervisor reads at runtime
      <fc>.json              per-FC params defaults (from params{} in .art)
      config-defaults.json   first-boot etcd seed (from config{} in .art)

The config/ sub-tree is key: params and config defaults are captured at gen-manifest time and baked into the manifest module (PROCESS_PARAMS / PROCESS_CONFIG_DEFAULTS sidecars). serialize-manifest emits them directly — no backtrack into .art at install time.

Step 2 — bazel build. Reads execution.json for the binary set; builds only what's needed. Framework binaries (supervisor) come from the deb install (/opt/theia/bin) in deb-mode, so only your own //apps/... are compiled.

Step 3 — stage. Copies config/ verbatim from the manifest, stages the supervisor and FC executables under install/central/, and applies setcap (same caps a real Ansible deploy uses).

The result:

install/central/
  supervisor                 the supervisor binary (setcap'd)
  current/                   → symlink to releases/local/
  releases/local/
    bin/
      p1                     your FC executable
  config/
    executor.json            the supervisor tree (read via THEIA_SUPERVISOR_MANIFEST)
    p1.json                  params defaults for p1's nodes
    config-defaults.json     first-boot etcd seed

5.3 What theia start does

theia start launches the staged supervisor as a background process:

theia start                   # → "supervisor up (pid …) — log: install/central/supervisor.log"

The supervisor:

  1. reads config/executor.json for the process tree,
  2. starts each child with THEIA_CONFIG_DIR=config so the runtime config singleton finds config/<fc>.json,
  3. writes a pidfile at install/central/supervisor.pid.

Check the log and the process tree:

tail -f install/central/supervisor.log
tdb apps                      # the supervised tree (requires a running supervisor)
tdb ps                        # flat Linux-ps view

5.4 Config and params at runtime

Two kinds of per-FC data land in config/:

Params (config/<fc>.json) — static deploy knobs, read once at boot by the runtime config singleton. They come from params {} blocks in your .art:

// config/p1.json  (generated from params{} in CounterNode — empty here)
{ "nodes": {}, "package": "system.apps" }

For the tsync service, whose nodes have GPS/PTP knobs:

// config/tsync.json
{ "nodes": { "tsync_ctl": { "gps_baud": 460800, "poll_ms": 100,  },  } }

Config defaults (config-defaults.json) — the first-boot etcd seed for config {} (runtime-observable protobuf config). Seeded on theia start via gen-config-defaults:

// config-defaults.json
{ "configs": { "counter": { "digest": "cfg_…", "values": { "step": 1, "label": "counter" } } } }

After seeding, per stores these in etcd. You can inspect or change them live via tdb set-config without restarting.

Per-machine overrides. For a split rig where the same FC runs on both machines with different knobs (e.g. tsync as GPS-master on central, GPS-slave on compute), place a hand-edited JSON in deploy/config/<machine>/<fc>.json. serialize-manifest deep-merges it on top of the .art defaults at manifest time — no code changes needed.

5.5 The deploy chain (manifestdistrelease*) and two deploy paths

The local loop is the dev path. For the fleet, the chain is manifestdistrelease*, with one build verb (dist) that behaves differently for a runtime manifest vs an app manifest:

verb input output deploy path
theia manifest <target> rig.py dist/manifest/ — per-machine JSON prerequisite for everything below
theia dist <target> dist/manifest/ runtime manifest → dist/debian/theia-{runtime,services}_*.deb; app manifest → dist/manifest/<host>/<host>.deb (build only)
theia release <target> --s3 URL dist/manifest/ runtime .debs → S3 theia-runtime/<ver>-<abi>/ colony installs the runtime plane
theia release-swp <app> dist/manifest/ + bazel dist/apps/<app>/<rig>-<ver>-<abi>.mender Mender OTA installs as overlay

One build verb, two modes. theia dist <target> keys on the manifest's machines.json apps list. A runtime manifest (apps: [] — e.g. the framework services rig) is associated with the runtime Deb set and builds theia-runtime + theia-services; an app manifest (apps: [...] — your single rig) has no association and packs one self-contained .deb per machine. --arch A[,A…] overrides every machine's arch, so one runtime manifest releases for several arch/os in a single call. (This is the build that the old build-only misnomer theia release --arch used to do.)

Two deploy paths, still independent:

  • Ansible path (app .debtheia orchestrate in the colony repo): the self-contained per-machine .deb from theia dist <app-target>, SSH-copied and dpkg -i'd. Direct provisioning of known boards — no S3 needed.
  • Mender/OTA path (theia release-swp → S3 → Mender): a .mender overlay of only your app FCs, installed on top of the running runtime (supervisor + services untouched). Day-2 updates and fleet OTA.

The Mender path requires a running runtime base provisioned by colony (which installs the runtime plane theia release <services-target> --s3 publishes); the Ansible path is self-contained.

5.6 Build + publish the runtime plane (theia dist services / theia release services)

The runtime plane (supervisor + ARA services) is built once per runtime version — the platform team's responsibility, not the app developer's day-to-day. It comes from the framework's role-based services manifest (apps: []), so theia dist associates it with the runtime Deb set:

# from the theia framework checkout:
theia manifest services              # the role-based runtime manifest (central/zonal)
theia dist services --arch host      # → dist/debian/theia-{runtime,services}_*amd64.deb
# or:  theia dist services --arch rpi4    # bookworm-arm64
# or:  theia dist services --arch jetson  # focal-arm64

Output: dist/debian/theia-runtime/, dist/debian/theia-services/. Publish them to the S3 runtime plane with theia release (the push counterpart to theia dist):

theia release services --arch host --version 0.2.2 \
    --s3 http://<minio-host>:9000
# → s3://theia-runtime/0.2.2-amd64/theia-{runtime,services}_*.deb

theia release <target> --s3 builds via theia dist <target> then aws s3 cps the runtime .debs to theia-runtime/<ver>-<abi>/. The runtime_build key in a Distribution ("0.2.2-amd64") references this versioned upload verbatim; colony reads it at provisioning time to know which .debs to install on a fresh board.

Two verbs, symmetric with the app plane: theia release <services-target> pushes the runtime plane (colony installs it); theia release-swp <app> pushes the app plane (Mender overlays it). Same shape, different bucket + consumer.

5.7 Build your app bundle for direct deploy (theia manifest + theia dist)

The Ansible path takes your app from a rig all the way to a .deb that Ansible SSH-copies and dpkg -i's:

theia manifest single         # → dist/manifest/ (the per-machine JSON)
theia dist single             # → dist/manifest/central/central.deb

theia dist <target> reads dist/manifest/machines.json for the machine list (the <target> name is required — it validates the manifest is for the right rig), looks up each machine's arch from machine.json, and builds //dist/manifest:<host>_pkg (a cross-compiled .deb per host). The .deb carries only your app FCs — the supervisor and ARA services are part of the runtime base installed by colony:

$ dpkg-deb -c dist/manifest/central/central.deb
./opt/theia/bin/p1

The .deb is staged next to the machine's manifest JSON — exactly where orchestrate.yml expects it.

# then deploy to a known board (from the `colony` repo — the deploy adapter):
colony orchestrate             # Ansible: copies + dpkg -i, sets up systemd unit

Provision/orchestrate moved to the colony repo (the deploy adapter). theia emits the per-machine bundle via manifest/dist; colony deploys it.

This path does not touch Mender. It is the right choice for a controlled lab board that you can SSH into directly.

5.8 Build + publish your Software Package (theia release-swp)

The Mender/OTA path builds a .mender artifact carrying only your app FCs — the supervisor and services are never touched. It reads dist/manifest/ (from theia manifest) for the process set, builds every composition's executable, and packs them:

theia manifest single                          # prerequisite: emits dist/manifest/

theia release-swp apps --swp-version 1.0      # build-only, no S3
# → dist/apps/apps/single-1.0-amd64.mender

theia release-swp apps --swp-version 1.0 \   # build + publish to S3
    --fleet theia-rig \
    --s3 http://<minio-host>:9000
# → s3://theia-swp/user-software/theia-rig/single/1.0-amd64/
flag meaning
apps the apps/<app> package (your cluster's home in //apps/apps/)
--swp-version your app semver — the install dir + artifact name
--fleet the Mender device-type / hardware-capability class (S3 key)
--arch board target host/rpi4/jetson — picks abi; default host
--s3 MinIO/S3 endpoint; omit to build the .mender locally only

The artifact is named <rig>-<ver>-<abi>.mender (rig name from machines.json, not the app package name) — so single-1.0-amd64.mender for the single rig. This is the SWP build identifier (swp_build) the Distribution references.

Key points:

  • No .deb step. The SWP is a Mender overlay (theia-swp module type), not a .deb. The supervisor + services .debs are the runtime base, installed by colony at provisioning time — the SWP overlays only your FCs on top.
  • Multi-composition. release-swp packs all compositions in the cluster into one overlay. The apps cluster here has one composition (CounterProcp1); the demo's cluster bundles four.
  • arity / roles / on from the manifest. release-swp reads dist/manifest/machines.json for these — no hand-written metadata.
  • Per abi for heterogeneous fleets. An rpi4(bookworm)+jetson(focal) split rig needs two builds: --arch rpi4 and --arch jetson.

5.9 Assemble the Distribution

A Distribution is the deployable bundle: a name + version + a list of roles, each resolved to {abi, runtime_build, swp_build}. The invariant: role.abi == runtime_build's abi == swp_build's abi. arity = len(roles).

The operator creates one in the Ground Station UI (Distributions view: pick the app → per-role pick abi + runtime build + SWP build). Behind the UI it is a single API call:

// POST /api/planes/distributions
{
  "name": "single", "version": "1.0",
  "roles": [
    { "role": "central", "abi": "amd64",
      "runtime_build": "0.2.2-amd64", "swp_build": "single-1.0-amd64" }
  ]
}

A split (arity-2) Distribution lists two roles:

{
  "name": "split", "version": "2.0",
  "roles": [
    { "role": "central", "abi": "amd64",
      "runtime_build": "0.2.2-amd64", "swp_build": "split-2.0-amd64" },
    { "role": "compute", "abi": "amd64",
      "runtime_build": "0.2.2-amd64", "swp_build": "split-2.0-amd64" }
  ]
}

Stored at s3://theia-distributions/<name>/<version>/index.json. The Ground Station is stateless — every entity is an S3 artifact.

5.10 Why Distribution is the right unit

One Distribution deploys a whole heterogeneous fleet in one click: it resolves each role to a board whose abi matches, then fans out per role — the runtime build to colony (the base), the SWP build to Mender (the app overlay). Runtime↔app compatibility is satisfied by construction (you bound them in the Distribution), so there is no deploy-time guesswork.

Distribution "split" 2.0
  ├─ role central (amd64) → runtime 0.2.2-amd64 + swp split-2.0-amd64
  └─ role compute (amd64) → runtime 0.2.2-amd64 + swp split-2.0-amd64
        ↓ deploy
        for EACH role: colony(base) + Mender(app) to its assigned board

You now have: the local install loop (install / start / stop / clean), a clear picture of the four release verbs and two deploy paths (Ansible vs Mender), and the Distribution model that ties runtime + SWP together for fleet OTA.

Next: Chapter 6 — Deploy from the Ground Station, where you bring up a rig, enrol it through the GS, and deploy your Distribution onto it.


4. Write a rig (rig.py) · Tutorial index · 6. Provision a board + deploy from the Ground Station

Clone this wiki locally