Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Devices and bundles

What a deploy puts on a device and how it runs there: the bundle file, the release manifest, the layout under the install root, the units, the supervisor where systemd does not run, and the agent’s commands. The workflow is in Deploying to devices.

Bundle

A bundle is a folder with one or more project files, the model packages they use and optional companion processes, described by taktwerk-bundle.toml at its top:

name = "plant"                          # lowercase letters, digits, `_`, `-`; at most 64
projects = ["sim.toml", "closed-loop.toml"]  # the first is selected on a device's first activation
exclude = ["records", "campaigns"]      # left out, with everything below

[[companion]]                           # a process of its own, started and stopped with the engine
id = "bridge"
command = "bin/bridge"                  # relative to the bundle
args = ["--port", "9000"]
env = { LOG = "info" }

Every regular file below the folder belongs to the bundle, except hidden entries (a name starting with .) and what exclude names. A link to a file inside the folder is carried as that file; a link out of the folder, or to a directory, is refused. Project and command paths use letters, digits and ._/+- only.

A bundle is checked per target architecture (aarch64, x86_64, arm): every project file parses, every model package it references lies inside the bundle (no path escapes it) and has a library for the architecture (lib/<arch>/ of a raw package, binaries/<platform>/ of an FMU, packed or extracted), and every companion’s command is in the bundle.

Manifest

The manifest lists the bundle, the taktwerk version that runs it, the architecture and every file with its size and sha256. Its canonical form is one line of JSON, keys in this order, files sorted by path:

{"format":1,"bundle":"plant","taktwerk":"0.8.1","arch":"aarch64","files":[{"path":"bin/bridge","size":1024,"sha256":"…","exec":true}]}

exec marks a companion’s command, installed executable. The sha256 of these bytes, in lowercase hex, names the release. The agent refuses a manifest that is not canonical, whose hash is not the release’s name, or whose paths leave the bundle.

Device layout

<root>/bin/taktwerk-<version>                    engine binaries
<root>/bundles/<name>/staging/<hash>/            an upload: the files, and .taktwerk/manifest.json
<root>/bundles/<name>/releases/<hash>/           activated releases
<root>/bundles/<name>/releases/<hash>/.taktwerk/  manifest.json, engine -> bin/taktwerk-<version>
<root>/bundles/<name>/current -> releases/<hash>
<root>/bundles/<name>/history                    activated releases, oldest first
<root>/bundles/<name>/records/                   the engine's file recorders
<root>/bundles/<name>/env                        selected project, http address, secrets (0600)
<root>/bundles/<name>/enabled                    without systemd: started at boot
<root>/bundles/<name>/log, log.1 .. log.3        without systemd: the units' output
<root>/bundles/<name>/supervise.lock, .json      without systemd: the supervisor's lock, state
<root>/boot.sh                                   without systemd: starts the enabled bundles

The first activation creates env; later deploys leave it alone, and the device owner adds secrets (for password_env) and RUST_LOG there:

TAKTWERK_PROJECT=sim.toml              relative to the release
TAKTWERK_HTTP=127.0.0.1:8480           a loopback port per bundle, from 8480 up
TAKTWERK_HTTP_TOKEN=<64 hex digits>    the http connector's write token

Units

agent install-unit writes one template unit, taktwerk@.service; the instance is the bundle’s name. It runs as the owner of the install root (or --user), reads env, and starts in current:

ExecStart=<root>/bundles/%i/current/.taktwerk/engine run ${TAKTWERK_PROJECT} --http ${TAKTWERK_HTTP} --http-writes --record-dir <root>/bundles/%i/records
Restart=on-failure
RestartSec=2
RestartSteps=5
RestartMaxDelaySec=60
LimitRTPRIO=90
LimitMEMLOCK=infinity

A failed engine restarts after 2 s, the delay growing to 60 s (RestartSteps needs systemd 254; an older one restarts every 2 s), and is never given up (StartLimitIntervalSec=0): an engine that fails at init because its PLC is not up yet comes back once it is.

The release’s engine link picks the taktwerk version, so the binary switches with current. The engine serves its http connector on loopback only (the binary needs the http feature); a host reaches it through an SSH port forward. A companion is its own unit, taktwerk-<bundle>.<id>.service, with PartOf= and After= the engine’s; a drop-in taktwerk@<bundle>.service.d/companions.conf makes the engine’s unit want it. Rerun install-unit after a release changes its companions.

Without systemd

On a device without systemd (a BusyBox userland with init scripts, perhaps a read-only root and a persistent data partition for the install root) the agent drives its built-in supervisor instead, through the same commands, JSON and unit names, so the host and the workbench work unchanged. It is picked when /run/systemd/system is missing; --supervisor systemd|builtin overrides. agent info reports "supervisor": "builtin".

  • start runs taktwerk agent supervise <bundle> detached: one process per bundle, holding supervise.lock and writing its state to supervise.json in the bundle dir. It runs the engine with the same command line as the unit, in current, with PATH, RUST_LOG=info and the env file, then every companion.
  • A unit that fails (non-zero exit, a signal other than SIGHUP, SIGINT, SIGTERM, SIGPIPE) restarts after 2 s, doubling to 60 s, back to 2 s after a run of 60 s, and is never given up. A cleanly ended engine stops the companions and the supervisor.
  • stop sends the supervisor SIGTERM; it stops the companions, then the engine, each with SIGTERM and SIGKILL after 30 s. A killed supervisor takes its units with it.
  • Started as root (the boot script, or a sudo or su privilege), it raises RLIMIT_RTPRIO to 90 and RLIMIT_MEMLOCK to unlimited, then runs as the owner of the install root; install-unit --user is refused. Started as that owner, the real-time limits stay as they are.
  • The units’ output goes to bundles/<bundle>/log, a line each with the time and the unit, rotated at 1 MiB to log.1 .. log.3; logs reads it.
  • enable and disable create and remove bundles/<bundle>/enabled. install-unit writes <root>/boot.sh, which starts the enabled bundles in the order of their names (stop stops all), each through its release’s engine, and an init entry S99taktwerk that calls it in the first of /etc/rc.d, /etc/init.d that exists (--unit-dir picks another). On a read-only root the report’s notes say so: have the device’s own boot hook run <root>/boot.sh start as root.
  • start-at runs a supervisor that sleeps for the delay on the monotonic clock, then starts the units; --cancel stops it, start starts at once instead. Like the transient timer, it does not survive a reboot.

Agent

taktwerk agent <command> --root <dir> [--yes] [--supervisor systemd|builtin]

Every command but connect prints one JSON document on stdout:

{"agent_api":1,"command":"activate","ok":true,"bundle":"plant","release":"…","applied":false,"plan":["…"]}
{"agent_api":1,"command":"activate","ok":false,"error":{"code":"unit-running","message":"…"}}

A failure exits 1, a usage error 2. Error codes: usage, invalid, not-found, unit-running, conflict, verify-failed, arch-mismatch, ci-refused, no-systemd, systemd, supervise, engine, io. Mutating commands print their plan and change nothing without --yes; --yes is refused when CI is set.

commandchangeswhat
infoarchitecture, glibc, os-release, systemd, supervisor, free bytes under the root, engines in bin/, bundles
status [<bundle>]releases, staged uploads, active release, selected and available projects, http address, unit states
manifest <bundle> [<hash>]the active (or named) release’s manifest
activate <bundle> <hash>yesverify staging/<hash> against its manifest, move it to releases/<hash>, link its engine, create env if missing, switch current by one rename
rollback <bundle>yesswitch back to the release activated before the active one
start <bundle> [--project <file>]yesselect a project of the active release, then start the unit
stop, restart, enable, disable <bundle>yesthe engine unit; companions follow it
logs <bundle> [--lines N]journal lines of the engine and its companions (without systemd: the bundle’s log)
records <bundle>the files below bundles/<bundle>/records/: path, size, modification time and, for a closed file, the SHA-256 its run’s SHA256SUMS lists; temporary files left out
gc <bundle> [--keep N]yesremove releases but the active one and the N (2) activated last before it, and staged uploads
install-unit [--user U] [--unit-dir D]yeswrite the template, companion units and drop-ins, remove stale ones, reload systemd; needs root. Without systemd: boot.sh and its init entry
start-at <bundle> --in D | --at T | --cancel [--project <file>]yesarm a transient timer taktwerk-start@<bundle>.timer (without systemd: a sleeping supervisor) that starts the unit once, or cancel it; needs root
supervise <bundle> [--after-ms N]the built-in supervisor, run by start and start-at
engine <bundle> status|read|watch|set …setoperate the running engine through its http connector (Operating a running engine)
connect <address>copy stdin to a TCP connection to a loopback address and back to stdout; the host’s pipe to an engine’s http connector through wsl.exe; no --root, no JSON

activate and rollback refuse while the bundle’s unit runs. activate refuses a release for another architecture and reports, as problems, missing, changed or extra files, a missing bin/taktwerk-<version> and a running unit; a dry run says ready when there are none. Files an upload leaves out that the active release holds unchanged are taken from it, so a host sends only what changed.