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".
startrunstaktwerk agent supervise <bundle>detached: one process per bundle, holdingsupervise.lockand writing its state tosupervise.jsonin the bundle dir. It runs the engine with the same command line as the unit, incurrent, withPATH,RUST_LOG=infoand 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.
stopsends 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
sudoorsuprivilege), it raisesRLIMIT_RTPRIOto 90 andRLIMIT_MEMLOCKto unlimited, then runs as the owner of the install root;install-unit --useris 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 tolog.1..log.3;logsreads it. enableanddisablecreate and removebundles/<bundle>/enabled.install-unitwrites<root>/boot.sh, which starts the enabled bundles in the order of their names (stopstops all), each through its release’s engine, and an init entryS99taktwerkthat calls it in the first of/etc/rc.d,/etc/init.dthat exists (--unit-dirpicks another). On a read-only root the report’snotessay so: have the device’s own boot hook run<root>/boot.sh startas root.start-atruns a supervisor that sleeps for the delay on the monotonic clock, then starts the units;--cancelstops it,startstarts 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.
| command | changes | what |
|---|---|---|
info | architecture, 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> | yes | verify staging/<hash> against its manifest, move it to releases/<hash>, link its engine, create env if missing, switch current by one rename |
rollback <bundle> | yes | switch back to the release activated before the active one |
start <bundle> [--project <file>] | yes | select a project of the active release, then start the unit |
stop, restart, enable, disable <bundle> | yes | the 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] | yes | remove releases but the active one and the N (2) activated last before it, and staged uploads |
install-unit [--user U] [--unit-dir D] | yes | write 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>] | yes | arm 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 … | set | operate 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.