ATC documentation
atc ships software to robots over SSH. You push a versioned bundle or an OCI
image, the device checks every byte against its digest, and you switch to it
when you’re ready. If the new version misbehaves, you switch back. The device
keeps recent versions on disk, as many as its retention settings allow, so a
rollback to one of them downloads nothing.
New here? Start with Onboarding. You install a released
atc binary, with no repo to clone and nothing to build, and ship your first
verified update in six steps.
| Doc | Type | Read it when you want to… |
|---|---|---|
| Onboarding | Quickstart | Install the released atc CLI and ship your first update. Start here |
| Deploy your first update | Quickstart | Ship an update end to end from a released atc. Covers devices without systemd, containers included, and has a troubleshooting table |
| Logging in | Quickstart | Log a person in to a control plane with atc auth login. Covers the none, token, local and WorkOS modes, several profiles, logout and common errors |
| atc.toml reference | Reference | Write an atc.toml, the default manifest. Covers the schema and every lifecycle-hook detail, from state and phase to @self, @bundle and the environment |
| atc.kdl reference | Reference | Write an atc.kdl, the opt-in alternative to atc.toml. Lists every node you can write today, what atc config lint checks, and what a push still refuses |
| Private CA trust | Guide | Point atc and atcd at a private CA, so a device can enroll with a self-hosted, air-gapped or lab control plane |
| Remote access without a VPN | Guide | Reach a robot behind NAT or an egress-only firewall through the reverse tunnel built into atcd. No mesh VPN, no extra agent, and one config block you can delete |
| Lifecycle hook cutover | Runbook | Find out whether an existing commit.* hook has to move, now that the version pointer flips earlier, inside activate |
| Modules today | Reference | Ship a module, an executable the device runs after a delivery commits, and see which parts of the module vocabulary work today |
| Shared-library resolution | Runbook | Fix a delivered binary that dies with error while loading shared libraries when the library is right there in the tree |
| Device location | Guide | Put a device on the console map. A program on the device sends its position with atcd attr set-location, and four settings decide how often atcd passes it on |
| Error codes | Reference | Look up an ATC-Exxxx code that atc printed |
| Migrating from Mender or balena | Guide | Move an existing OTA setup onto atc. Concept maps, device and CI steps, and a cutover checklist for each |
| Verifying the binary | Reference | Check that the atc or atcd you run is the one we released, with its sha256 digest and the ed25519-signed release index |
Also useful:
- The one-page command cheatsheet, also as a PDF.
- The source, the issue tracker and the contributor docs are in the agency-code repository.
- Agency Tool Company builds
atc. Our site says what we do and how to reach us.
Onboarding: install atc and ship your first update
Who this is for: you have a device (a robot, a Linux box, a container) and you
want to deliver software to it from your own machine using the atc CLI. You do
not need this repository, and you do not build anything from source —
you install a released, signed atc binary and go. This is the fast path from
zero to one real, verified update. It assumes you can use a terminal and SSH, and
nothing else about atc.
Working in this codebase (contributing)? Start at building-from-source.md instead. That path builds the binaries from source.
Mental model
atc— the controller. Runs on your machine (laptop / CI). It delivers artifacts to a device over SSH. This is what you install below.atcd— the daemon. Runs on the device. It owns a content-addressed store at/var/atcand applies updates atomically. On a systemd device,atcinstalls it for you, or the device installs it withatcd install(Path A below). On a device without systemd as PID 1,atcd install --no-serviceseeds it with no unit, or you stage it by hand (Path B).- Every push is versioned and atomic: bytes are staged first, then the active
version is flipped only when you ask with
--switch. Rollback is just a flip back to a prior version — nothing is rebuilt or re-downloaded. - You declare what to ship in an
atc.tomlmanifest and runatcfrom that directory.
The steps below are sequential — run them in order.
Two pages cover this journey. This one is the complete path: both device types, lifecycle hooks, container images, rollback, and the traps each step hides. deploy-your-first-update.md is the short version of the same journey, with a troubleshooting table. Read one of them, not both.
1. Install atc (on your machine)
curl -fsSL https://atc.dev/install.sh | sh
This drops the atc binary at ~/.local/bin/atc. If that directory isn’t on
your PATH, add it before anything else — otherwise the next command fails with
atc: command not found:
export PATH="$HOME/.local/bin:$PATH" # this shell only
If each step runs in a fresh shell (CI, a script, a new terminal), this export
won’t carry over. In an interactive shell, add the line to
~/.bashrc/~/.profile now so every later atc invocation finds the binary.
But a non-interactive runner (CI, a headless script — bash invoked as
sh -c …) sources neither file, so there the reliable fix is to set PATH
explicitly in each step, or just call the binary by its full path
($HOME/.local/bin/atc). Inside one script a single export at the top holds
for the rest of that script. It is per-step runners, such as CI jobs or separate
sh -c invocations, that need it repeated.
Then sanity-check:
atc --version
The one-liner does two things: it fetches the atc binary over TLS (verifying
its sha256 digest against the index — transport + integrity), then runs
atc upgrade as its final step. That self-update re-checks the
signed index — now verifying an ed25519 signature over it against a key
pinned inside the binary (cryptographic authenticity) — and provisions a
bundled skopeo into the atc-store, the tool atc shells out to for OCI
artifacts (atc oci import/push; used only if you ship a container image,
see the aside in step 3). So the
provisioned skopeo … line you see scroll past during install is expected, not
a stray side effect, and the ed25519 check has already run by the time the
one-liner exits — it is not a separate step you trigger later. (It’s
best-effort: a network or cross-arch hiccup, or a custom ATC_BASE_URL, skips
it with a note — re-run atc upgrade yourself to provision skopeo.)
Full trust story: verifying-the-binary.md.
The install script honors a few env vars —
ATC_BIN_DIR(where to install),ATC_TRACK(which release track to fetch),ATC_BASE_URL(which index origin to fetch from). These configure this install only; they don’t change where a lateratc upgradeself-update pulls from.Put each variable after the pipe, on
sh. The script reads it there. To install from thenightlytrack:curl -fsSL https://atc.dev/install.sh | ATC_TRACK=nightly sh
ATC_TRACK=nightly curl … | shsets the variable forcurlonly. The script does not see it and installsstable, with no error.
Which release track should you use?
stableis the default and the right answer for a first install. Thenightlyand internaldevtracks, version pinning, and the token thedevtrack needs are all in Appendix: release channels, at the end of this page. Skip it for now.
2. Bootstrap the daemon onto the device
Every command from here on uses deploy@device as the SSH target. Replace it
with your own login user and the device’s address, for example
pi@192.168.1.5, or with a Host alias from ~/.ssh/config.
Preflight — which path applies to you. atc bootstrap installs atcd as a
systemd service, so systemd must be PID 1 on the device. Check that first —
it decides which of the two paths below you take:
# accept-new: this is the doc's FIRST contact with the device. Without it the
# host-key prompt hangs a non-interactive shell (details in the note below).
ssh -o StrictHostKeyChecking=accept-new deploy@device 'ps -p 1 -o comm=' # systemd → Path A ; anything else → Path B
This asks what runs as PID 1 (the init process), not whether systemd is
installed. A box can carry the systemd tools yet boot a different init — e.g.
sshd as PID 1 in a container — and that’s Path B.
- Prints
systemd→ Path A (theatc bootstrapcommand just below). - Prints anything else — a bare container, a minimal image → Path B: don’t run
the bootstrap command (it aborts with
ATC-E0419, installing only the systemd unit); use the foreground recipe in Path B instead.
Don’t infer the path from the OS or distro — a perfectly ordinary-looking
Debian/Ubuntu host can still be Path B if it runs without systemd as PID 1 (e.g.
inside a container-based sandbox). Always run the ps check; never assume from
how the box looks.
First SSH to a fresh device? The initial connection triggers a host-key trust prompt that hangs a non-interactive run — it fires before any sudo or password prompt. The robust fix is to set
StrictHostKeyChecking=accept-newfor the host in~/.ssh/config— it seeds the key on first connect no matter how the device is reached. You can instead pre-seed withssh-keyscan device >> ~/.ssh/known_hosts(bare host, nodeploy@), butssh-keyscanignores~/.ssh/configaliases and talks straight to DNS on port 22 — so for a device reached through a config alias, aProxyJump, or a forwarded port it fails withgetaddrinfo … name resolution(or silently returns nothing). Ask ssh what an alias resolves to —ssh -G <alias> | grep -E '^(hostname|port) '— thenssh-keyscan -p <port> <real-host>. That covers a renamed host or a forwarded port, but notProxyJump:ssh-keyscantakes no proxy option and reads no config, so it dials the target directly and either hangs or grabs the wrong key. For a device behind a jump host, useaccept-new— the one approach that works whatever the config does.Concretely: with
Host myrobot/HostName 192.168.1.1/ProxyJump jumphostin your config,ssh myrobotreaches the device through the jump host butssh-keyscan myrobottries to resolvemyrobotin DNS and fails, because it reads neither the alias nor theProxyJump.ssh -o StrictHostKeyChecking=accept-new deploy@myrobot …takes the same routesshalready takes, so it works.
Path A — systemd device
atc installs and starts atcd on the device over SSH:
atc bootstrap atcd install --yes --non-interactive deploy@device
Because you installed atc with the one-liner (no repo, no locally-built
daemon), atc auto-downloads the arch-matched atcd from the same signed
release origin and verifies it (ed25519 signature + sha256) before it is ever
copied to the device. This works automatically for linux-x86_64-musl and
linux-aarch64-musl devices; for any other device arch (32-bit ARM, RISC-V, …)
supply a binary yourself with --from <path/to/atcd>.
--enroll: enroll the device with a control plane in the same command. The install mints a single-use enrollment token and stages it on the device. No file on your machine holds the token. The server and org come from--serverand--org, else the repo’satc.toml[server]table, else youratc auth login. To use a token minted ahead withatc org enroll-token(one token for many devices, or a token for another person), pass--enrollment-token-file <path>instead.--yes(-y) skips the confirmation prompt;--non-interactiveis key-only SSH with no prompts. Use both in automation — a sudo or SSH password prompt hangs in a non-TTY shell. Passwordless sudo + key auth on the device is strongly preferred.--setup-rootless: delivered podman in one command. Without it, the install leaves delivered podman not set up.atcd ctl podman envthen refuses withno_recorded_identity, andatc shellhas no podman onPATH. With the flag, bootstrap runs three steps after the install, in this order:atcd upgradeas the store owner,atcd ctl setup-rootless --provision, andatcd ctl setup-rootless --apply. The owner comes fromUser=in the unit, so a--rootinstall updates as root. Thesetup-rootlesssteps install setuidnewuidmapandnewgidmaphelpers, so the flag is opt-in and nothing else runs them for you. It sets up the account that the unit runs hooks as (atc-hookson a new install). Name another account with--rootless-user <name>. A lifecycle hook gets the delivered engine only when it runs as that account. With--rootthere is no hook account, so you must pass--rootless-user. A failed step stops the run and names the step. The install stays in place, and you run the same command again. Each step is safe to repeat.- A slow or flaky link. When the SSH connection fails, bootstrap tries
the step again, up to 5 attempts per step and 120 s of waiting per run. It
prints one
bootstrap: ssh to … retryingline for each attempt. A step that changes the device runs again only when ssh never reached the device. If the connection drops while such a step runs, bootstrap stops withATC-E0303and says that the step possibly ran. Run the same command again. A dropped link never removes an install that already started. - The boot-confirm guard. The install adds a guard that systemd runs before
each
atcdstart. It counts the starts. When a newatcdversion fails to reach a healthyreadystate on more starts than the guard’s limit (ATCD_BOOT_CONFIRM_LIMIT), the guard rolls back to the previous version. Later sections use this name for it. The guard writes to theatcdjournal, so read it withssh deploy@device 'sudo journalctl -u atcd'. A rollback shows asatcd-boot-confirm: <N> attempts > <limit> — rolling back to prior atcd, thenatcd-boot-confirm: reverted to prior atcd.
You do not name the device. A hostname, an IP address and
localhostare all valid targets.atcdnames itself, so the name does not depend on how you reached the box. It uses a word pair and a 6-character hex suffix (fluffy-kitty-3fa9c1), made from a hash of the hardware identity that bootstrap reads during install. The same hardware gets the same name back after a reflash. A box with no usable hardware identity gets a word pair made from its device id instead. A box that already has a name keeps it.
ATC-E0419withno systemdmeans the device has no systemd as PID 1. The message prints a foreground recipe, which is Path B below.
Path A on the device: atcd install
Use this when the device installs atcd itself, for example from a
provisioning script, a first-boot job or Ansible. It needs no atc anywhere.
The device needs curl, OpenSSL 3, awk, and sha256sum or shasum.
Without systemd as PID 1, the script installs with --no-service (Path B).
Run on the device:
curl -fsSL https://atc.dev/install-atcd.sh | sudo sh -s -- --socket-owner deploy
The script downloads the atcd for the device’s CPU. It checks the ed25519
signature on the release index and the sha256 of the binary. Then it runs
atcd install with your arguments. If a check fails, it stops before it runs
atcd.
atcd install does the same install as atc bootstrap atcd install, and it
takes these flags:
| Flag | What it does |
|---|---|
--socket-owner USER | The account that pushes, which gets the control socket. Default: the user that ran sudo |
--root | Run atcd and its hooks as root, as atc bootstrap atcd install --root does. Needs --socket-owner |
--company NAME | The telemetry tenant. Writes the daemon.company setting, which telemetry carries as atc.company |
--server URL | Enroll the device against the control plane at URL |
--enroll-token FILE, --ca FILE | The enrollment token file and a PEM bundle for the control plane. Both need --server |
--store-root DIR | The store for a new install. Default: /var/atc. An existing install keeps its store. Missing parent directories are made as /var is: owned by root, mode 0755 |
--from BIN | The atcd binary to install. Default: the atcd that runs the command |
--config FILE | A TOML file with the same keys, spelled as the flags (socket-owner = "deploy"). A flag wins over the file |
--no-service | No systemd: seed the store and the guard, write no unit, and print the start command. install-atcd.sh adds it when PID 1 is not systemd |
--no-link | Do not make, change or check the /usr/local/bin/atcd link. The store saves this choice. See “More than one store on a device” below |
--link | Point the /usr/local/bin/atcd link at this store, and clear a saved --no-link |
--check | Report what would change, and change nothing |
--json | Print one JSON line: {"changed", "actions", "device_name", "version"} |
More than one store on a device. An install that changes the device
points /usr/local/bin/atcd at its own store, so the install that ran last
owns the link. A link to the atcd of another store is not a difference.
--check reports the symlink action only for no link, a file in place of
the link, or a link that names no file. --link also reports it when the link names
another store.
To add a second store and keep the link where it is, pass --no-link. That
install does not make the link, change it, check it, or remove it on a
rollback. Its rollback also keeps the boot-confirm guard, because the first
install uses it too. It removes the atc shell sudoers file only when that
run made the file.
A --no-link install with hook isolation writes the atc shell sudoers file
only when the file is absent or holds the grant of its own accounts alone.
Any other file stays as it is. When that file does not grant the accounts of
the new store, atc shell of it exits 4. atcd install, --check included,
prints a kept /etc/sudoers.d/atc-shell line for it. --check reports no
action for it, because no run changes it. A path there that is not a regular file stops
the install.
The store saves --no-link as an empty no-link directory in the store root.
It stays across a reboot and an atcd update. A later atcd install of that
store keeps the choice with no flag. atc bootstrap atcd upgrade of that
store does not point the link at it, and atc bootstrap atcd uninstall of it
keeps the link and the guard.
The uninstall removes the sudoers file only when two things are true. The
file holds the grant of that store, and no other atcd store on the device
uses the same accounts. A store that saved --no-link keeps the file when
the uninstall keeps the accounts, as --keep-store does. When the uninstall
deletes the accounts, it removes their grant too. A store with no hook
isolation never removes it.
The prompt lists the file as removed only when the run removes it. When the prompt cannot read a fact, its line names every condition.
--check reports the no_link action when the saved choice differs from the
run’s: --no-link on a store with no saved choice, or --link on a store
with one. --link removes the saved choice and takes the link.
atc bootstrap atcd install --no-link and --link pass the flag to
atcd install on the device:
sudo atcd install --no-link --store-root /var/atc2 \
--set daemon.service_name=atcd2 --socket-path /run/atcd2/atcd.sock
For atc to reach that store, give it a link of its own, and set
[daemon] remote_path in the repo’s atc.toml to that link. Do not set
remote_path to the store’s binary itself: atc bootstrap atcd upgrade points
remote_path at that binary, and would replace the binary with a link.
sudo ln -s /var/atc2/channel/agency/atcd/delivery/current/atcd/atcd /usr/local/bin/atcd2
atcd install is safe to run again. On a device that already has the correct
install, it changes nothing. On a damaged install, it repairs only the
difference, so the repair is the same command. The exit code tells you the
result:
| Exit | Meaning |
|---|---|
| 0 | The device has the correct install. It had it already, or this run made it |
| 1 | The install failed, or it refused before it changed anything. A fresh install is rolled back |
| 2 | Bad flags, a bad file that a flag names, or not run as root. Nothing ran |
| 3 | --check found a difference. Nothing changed |
atcd install refuses, with exit 1, on a device without systemd unless you
pass --no-service (see Path B). It also refuses when a systemd drop-in sets
ExecStart for the unit, because the install cannot own a command that a
drop-in replaces. The message names the drop-in file. Remove it, then run the
install again.
install-atcd.sh exits 1 if a download or a check fails. Otherwise it exits
with the atcd install code. These variables change what it fetches. Put them
after sudo, as sudo env ATC_TRACK=nightly sh -s -- …, because sudo drops
your environment.
| Variable | What it does |
|---|---|
ATC_TRACK | The release track. Default: stable, or the track in the script URL |
ATC_VERSION | Refuse unless the track serves this version |
ATC_BASE_URL | The index origin. Default: https://atc.dev/agency |
ATC_LOCAL_DIR | Air-gapped: read index.json, index.json.sig and atcd-<target> from this directory, with no network |
ATC_DOWNLOAD_ONLY | Write the verified atcd to this path and exit. Nothing is installed |
ATC_CHANNEL_TOKEN | The token for the dev and pr-<number> tracks |
Each track also has its own copy of the script, which defaults to that track:
https://atc.dev/agency/nightly/install-atcd.sh. The copy at
https://atc.dev/install-atcd.sh changes only on an official release.
With Ansible. One task covers a normal run and --check mode. In check
mode it passes --check, so atcd install changes nothing and exits 3 on a
difference:
- name: Install atcd
become: true
ansible.builtin.shell: >-
set -o pipefail &&
curl -fsSL https://atc.dev/install-atcd.sh |
sh -s -- --json --socket-owner deploy {{ '--check' if ansible_check_mode else '' }}
args:
executable: /bin/bash
check_mode: false
register: atcd_install
changed_when: >-
atcd_install.rc == 3 or
(atcd_install.rc == 0 and (atcd_install.stdout | from_json).changed)
failed_when: atcd_install.rc not in [0, 3]
check_mode: false lets the task run in check mode, which is safe because it
then passes --check. Keep pipefail. Without it, a failed download feeds
sh an empty script, which exits 0. To install a binary that is already on
the device, run atcd install --json … in place of the curl pipe.
Name the device
atcd keeps the device name in <store_root>/device_name. On a default (root)
install that is /var/atc/device_name. A rootless install uses its own store
root. The file holds one name as plain text. atcd trims surrounding whitespace.
atcd ignores an empty value, an IP address and a loopback name such as
localhost.
atcd resolves the name at every start and again when it enrolls. The enrol request carries the resolved name. atcd picks the first match in this list:
- The name the server returned after a
--replacesreinstall or a rename. - An
atc.deviceseed from a unit an older bootstrap wrote. - The file
<store_root>/device_name. - A name made from the hardware digest, such as
adjective-animal-xxxxxx. - A word pair made from the device id.
atcd writes the winning name back to the file when the file is missing or differs. On a first start with no file, atcd therefore writes the hardware-derived name. To choose the name, write the file before the atcd unit first starts. Do this in the factory image or in a first-boot step. The store directory must exist.
sudo install -d /var/atc
printf '%s\n' robot-07 | sudo tee /var/atc/device_name
This works for the default store root. The install refuses a store root other
than /var/atc that already holds files and is not an atcd store.
The hardware digest comes from the DMI UUID or the device-tree serial. Containers on one host share the host UUID. Cloned VM images keep the UUID of the source. Devices like these derive the same name, so set the file on each one.
The server keeps the name a device enrolled with. If you edit the file later,
only the name in telemetry changes. After a --replaces reinstall or a rename,
the name from the server comes first, so an edit changes nothing.
To rename a device after it enrolled, use the console or the CLI. In the console, open the device and select Rename. On the command line, run:
atc fleet device rename <device-id> robot-07
The server refuses a name that another device in the org has. It also refuses
an empty name, an IP address and a loopback name such as localhost. The
device picks up the new name at its next check-in. Its diagnostic reports use
the new name at once. Its telemetry uses it from the next start of atcd.
If another device in the org already holds the requested name, the server
enrolls the new device as <name>-<last 6 hex digits of its device id>. It
returns a warning in the enrol reply. atcd logs that it was renamed and saves
the new name. Telemetry reports the new name from the next atcd start. The run
that enrolled keeps the name it started with. A retry of the same enrolment is not renamed, and a --replaces
reinstall is not renamed. To keep the identity of a reflashed box, use
atc bootstrap atcd install --replaces <device>.
Path B — not systemd (containers, minimal images)
On the device, with atcd install --no-service. It seeds the store and
installs the boot-confirm guard, writes no unit, and calls no systemctl. It
has the same --check, --json and exit codes as Path A. install-atcd.sh
adds the flag itself when PID 1 is not systemd:
curl -fsSL https://atc.dev/install-atcd.sh | sudo sh -s -- --socket-owner deploy
This line runs on the device, so the device needs curl, OpenSSL 3, awk,
and sha256sum or shasum. Without curl the installer does not start, and
only the device shell reports that curl is not found. When the installer starts, it stops
and names the first other tool that is missing. A device without these tools
takes the by-hand flow below.
Nothing supervises atcd here. On success, the install prints the one command
that starts it in the foreground, and --json carries it as start_command.
Run that command under your own supervisor (the container entrypoint, for
example).
From your machine, by hand. atc bootstrap won’t stage atcd here (it aborts at the systemd preflight), so
you stage it by hand. Two steps: fetch the arch-matched atcd onto your
machine, then start it on the device in the foreground.
1. Fetch atcd from the release origin (the same signed index the installer
used — this is exactly what bootstrap does for you on a systemd device). Run on
your machine; needs jq. Run it with bash, not sh. The block uses a
bash array (auth=()), which dash rejects as a syntax error.
# Pick target to match the DEVICE's CPU (not your machine's) — a wrong-arch
# push looks identical in `ota status` later, so get it right here:
case "$(ssh deploy@device 'uname -m')" in
x86_64) target=linux-x86_64-musl ;;
aarch64|arm64) target=linux-aarch64-musl ;;
*) echo "unsupported device arch — copy your own atcd to deploy@device:/tmp/atcd" >&2; exit 1 ;;
esac
# Read the track your atc came from. Every release prints it in `atc --version`.
# `atc version --json` first shipped in 0.1.5. On 0.1.4 and older it fails with
# "unrecognized subcommand 'version'", so this line parses `--version` instead.
track=$(atc --version | sed -n 's/.*track \([a-z][a-z]*\)).*/\1/p')
: "${track:?could not read track from 'atc --version'. Set it by hand, e.g. track=nightly}"
# The internal `dev` track is token-gated; without the header a dev-stamped atc
# gets a bare 403 here. Send it ONLY for that track and only to atc.dev, mirroring
# what `atc` itself does — a credential should never ride a request that does not
# need it.
auth=()
if [ "$track" = dev ] && [ -n "${ATC_CHANNEL_TOKEN:-}" ]; then
auth=(-H "X-Atc-Channel-Token: $ATC_CHANNEL_TOKEN")
fi
idx=$(curl -fsSL "${auth[@]}" "https://atc.dev/agency/$track/index.json")
url=$(printf '%s' "$idx" | jq -r --arg t "$target" '.artifacts."agency/atcd"[$t].url')
dig=$(printf '%s' "$idx" | jq -r --arg t "$target" '.artifacts."agency/atcd"[$t].digest' | sed 's/^sha256://')
# `jq -r` prints the string "null" for a missing key rather than failing, and the
# next line would then try to fetch a host named `null` ("curl: (6) Could not
# resolve host: null") — a confusing error for a track that simply has no atcd
# for this arch. Check before fetching:
case "$url" in
''|null) echo "no atcd published for $target on track $track" >&2; exit 1 ;;
esac
# Keep these three chained. `sha256sum -c` reports a mismatch only by EXIT
# STATUS, so as separate lines a tampered download still reaches the scp.
curl -fsSL "$url" -o atcd && \
echo "$dig atcd" | sha256sum -c && \
scp atcd deploy@device:/tmp/atcd
The device has
curland OpenSSL 3? Fetch on the device instead.install-atcd.shalso checks the signature on the index, which the block above does not. It picks the device’s CPU itself, andATC_DOWNLOAD_ONLYwrites the verified binary and installs nothing, so do not pass it arguments here. To install as well, run the script with noATC_DOWNLOAD_ONLY: it adds--no-serviceitself (see the start of Path B).track=$(atc --version | sed -n 's/.*track \([a-z][a-z]*\)).*/\1/p') ssh deploy@device "curl -fsSL https://atc.dev/install-atcd.sh | ATC_TRACK=$track ATC_DOWNLOAD_ONLY=/tmp/atcd sh"For the
devtrack, also setATC_CHANNEL_TOKENin the remote command.
Need a specific
atcdversion, not the track’s current one? The block above always resolves whatever the track publishes today, which is what you want for a normal bootstrap. To pin an older build — testing an upgrade or reproducing a customer’s version — take the binary from that release instead of the track index. Releases are tagged by date (nightly-2026-08-12), and each one attachesindex.jsonalongside the binaries:tag=nightly-2026-08-12 # the release you want # $target is the DEVICE's target from the case statement above, e.g. # linux-aarch64-musl — the same wrong-arch trap applies here. gh release download "$tag" -R Agency-Tool-Company/agency-code \ -p "atcd-$target" -p 'index.json' -p 'index.json.sig' mv "atcd-$target" atcdVerify the index signature before you trust its digest. The digest below only proves that
atcdmatches whatindex.jsonclaims, so an index an attacker also replaced would still sayOK. Check the ed25519 signature first with the pinned pubkey, exactly as verifying-the-binary.md describes (it carries the current key and the OpenSSL alternative):atc sign verify index.json index.json.sig <pinned-pubkey-hex> && \ dig=$(jq -r --arg t "$target" '.artifacts."agency/atcd"[$t].digest' index.json \ | sed 's/^sha256://') && \ echo "$dig atcd" | sha256sum -c && \ scp atcd deploy@device:/tmp/atcdThe
&&chain is load-bearing, not style.atc sign verifyandsha256sum -cboth report failure through an exit status (VERIFY FAILED: …, exit 1), not by stopping your shell.set -edoes not cover this: it has no effect on commands pasted into an interactive shell, which is how an operator runs this. Run them unchained and a rejected signature falls through to the digest check against the attacker’s index, then copies the substituted binary to the device.Then continue with step 2 below — nothing else about Path B changes. Note that Path A’s
--from <path>flag is abootstrapoption, and Path B runs nobootstrapcommand at all, so a pinned version here is purely a matter of which bytes youscp.
scp reads ~/.ssh/config exactly as ssh does, so aliases, Port, and
ProxyJump all resolve — but a -o flag you passed on an earlier ssh command
line does not carry over. A successful PID-1 check with inline
-o StrictHostKeyChecking=accept-new has already written the device key to
known_hosts, so this later scp finds it and does not prompt. If that check
never completed — you skipped it, or it failed before connecting — this scp is
the first contact and stops on the host-key prompt. Pass the option again
(scp -o StrictHostKeyChecking=accept-new …), or set it in ~/.ssh/config once
and stop repeating it.
The index URL derives the track from atc --version (which prints
… track <name>)), so atcd always comes from the exact track your atc was
installed from — controller and daemon can’t drift apart, whatever the
installer’s current default track is.
2. Install and start it on the device. Two rules the controller depends on:
the binary must live at /usr/local/bin/atcd (the fixed path atc probes),
and atcd must run as the same user you SSH in as (start it without
sudo so it owns the socket — a root-owned socket makes every later atc
command fail Permission denied).
For the simplest copy-and-paste flow, run the whole block as one SSH
command. The final setsid … & line backgrounds and detaches atcd; it also
works if you already ran the setup lines separately, so don’t start a second
copy; two daemons contend for the same socket.
On a container device, plan for restarts before you run it: /run is tmpfs —
it does not survive a container restart, so the mkdir + atcd run lines
must run on every container start. Bake them into your entrypoint, not a
one-time setup script.
ssh deploy@device '
sudo install -m 0755 /tmp/atcd /usr/local/bin/atcd
sudo mkdir -p /var/atc /run/atcd
sudo chown -R "$(id -un):$(id -gn)" /var/atc /run/atcd
# no sudo on the next line — atcd must own the socket (the second rule above)
setsid /usr/local/bin/atcd run \
--store-root /var/atc --socket /run/atcd/atcd.sock \
</dev/null >/tmp/atcd.log 2>&1 &
'
Release builds log nothing, so an empty /tmp/atcd.log is normal. It is not a
sign that the daemon failed to start. Confirm it came up before you continue:
ssh deploy@device 'pgrep -x atcd >/dev/null || { echo "atcd is NOT running" >&2; exit 1; }'
From here the device behaves like any bootstrapped one — skip the bootstrap
command and continue below. Nothing here needs jq or python3 on the device.
The later steps that do say so where they need it. The hook script in step 3
needs only awk, which every base image carries. The OCI arch check in step 5
needs jq, which a minimal image omits, so expect to install it there.
Started it with
sudoby mistake? Then root owns the socket and the very nextatccommand fails withPermission denied— the error names the socket, not the cause, so it’s easy to misread as a config problem. Stopping it takessudotoo: a plainpkill atcdas the SSH user cannot signal a root-owned process and exits non-zero having done nothing, so the stray daemon keeps the socket and the retry fails identically.ssh deploy@device 'sudo pkill -x atcd; sleep 1; pgrep -x atcd || echo "stopped"'Confirm it printed
stoppedbefore restarting — then re-check thesetsidline for a straysudoand start it again without one. Never start a second copy while the first is alive: two daemons on one/var/atccontend for the same socket and store.
Restarting atcd (Path B). There is no unit to systemctl restart, so a
crashed or wedged daemon is a manual stop-then-start. pgrep -x atcd is the
liveness check; if it is already dead, skip straight to the setsid line in step
2 above. To bounce a running one:
ssh deploy@device '
sudo pkill -x atcd || true # sudo: it may be root-owned; non-zero = none running
sleep 1
pgrep -x atcd && { echo "still running — not starting a second" >&2; exit 1; }
sudo mkdir -p /run/atcd && sudo chown -R "$(id -un):$(id -gn)" /run/atcd
setsid /usr/local/bin/atcd run \
--store-root /var/atc --socket /run/atcd/atcd.sock \
</dev/null >/tmp/atcd.log 2>&1 &
'
atc ota status deploy@device
The /run/atcd re-create matters after a container restart, not a plain
daemon crash. /run is tmpfs, so the socket dir is gone on a fresh container
even though /var/atc survives. Never start a second copy while the first is
alive. Two daemons contend for the same socket and store.
Upgrade atc first — both paths. atc and atcd exchange a protocol
generation at handshake, and accept only an exact match. A stale atc therefore
cannot push to a daemon you just upgraded, and the reverse is refused too. So
update your own binary before you touch the device:
atc upgrade
atc --version # confirm the new version and track
Only a push checks that generation. atc ota status and atc ota ls use a
separate handshake, so they still answer across a push-wire skew. A
clean status therefore proves the daemon is up, not that a push will pass.
A push wire mismatch warning means the pair cannot push until you upgrade
the named binary.
Rollback carries the full skew rule, the warning, and the error
a mismatch prints.
Upgrading atcd later (Path A). On a systemd device the controller does the
whole swap for you:
atc bootstrap atcd upgrade --yes --non-interactive deploy@device
atc ota status deploy@device # confirm the new daemon answers
It ships a verified atcd, restarts the unit so the daemon boots the new
version (boot-confirm-guarded), and waits for it to come back. --track <name>
aligns the device to a track other than your atc binary’s own. Downgrades are
always permitted, so this also puts a device back on an older revision.
The new binary comes from ATCD_LOCAL_BIN or a local build when one is present,
otherwise the signed release origin. The command therefore needs either a local
binary or network access. It is deliberately not taken from the device’s own
store. The daemon can write there, and the privileged step must not execute
bytes the daemon controls. The store at /var/atc is untouched.
Upgrading atcd later (Path B). atc bootstrap atcd upgrade is Path A only —
it restarts a systemd unit and chowns the store to the daemon’s own user, so on a
Path B device it fails (no unit, and no such user to chown to). Replace the
binary by hand instead. Re-run 1. Fetch atcd from the release origin above
to fetch the new binary, then:
ssh deploy@device '
set -e
# sudo: the running daemon may be root-owned (see the sudo note above), and an
# unprivileged pkill would fail silently and leave it holding the socket.
sudo pkill -x atcd || true # non-zero simply means none was running
sleep 1
# Explicit exit, not `! pgrep`: under `set -e` a negated command is a tested
# condition, so a surviving daemon would NOT abort the script.
if pgrep -x atcd; then echo "atcd still running — not starting a second" >&2; exit 1; fi
sudo install -m 0755 /tmp/atcd /usr/local/bin/atcd
# same rules as first start: /run is tmpfs, and no sudo on atcd itself
sudo mkdir -p /run/atcd && sudo chown -R "$(id -un):$(id -gn)" /run/atcd
setsid /usr/local/bin/atcd run \
--store-root /var/atc --socket /run/atcd/atcd.sock \
</dev/null >/tmp/atcd.log 2>&1 &
'
atc ota status deploy@device # confirm the new daemon answers
The store at /var/atc is left alone: the upgrade is a binary swap, not a store
migration. One exception — if the old daemon had been running under sudo, its
store writes are root-owned and the unprivileged daemon can’t read them. Hand the
store back before starting:
ssh deploy@device 'sudo chown -R "$(id -un):$(id -gn)" /var/atc'.
Verify the daemon is reachable — both paths. After Path A’s bootstrap or Path B’s foreground start, confirm the controller↔daemon handshake (the authoritative proof it’s up):
atc ota status deploy@device
An (empty) store listing means the controller can reach the daemon. A
just-started daemon can miss the first 5 s ping and return
ATC-E0308: … ping … timed out — that’s a cold-start race, not a failure.
Re-run the same command once and it answers instantly.
3. Declare what to ship — atc.toml
Work in a directory you own (not this repo). Scaffold a minimal manifest:
mkdir -p ~/first-deploy && cd ~/first-deploy
atc ota init # writes a minimal atc.toml (one [[bundle]])
atc ota init writes a scaffold: one [[bundle]] named after
the current directory with source = "dist", and the rule about where writable
state goes. It also carries the lifecycle-hook and long-running-service blocks
as comments.
Pass --full to also write the compiled-binary block, or read it in
docs/operating/atc-toml.md. Pass --format kdl to
write an atc.kdl instead. It won’t write beside an existing atc.toml or
atc.kdl. This walkthrough doesn’t
use the scaffold’s values — its bundle name (app), source directory
(app_bundle), and the live commit hook that step 5 verifies all differ. Don’t
reconcile the two: replace the generated atc.toml wholesale with this
complete example, which ships a tiny bundle (a directory of files) and drops
a marker file when the update commits:
[[bundle]]
name = "app"
source = "app_bundle" # a directory you stage: payload + scripts/
[bundle.lifecycle.hook.commit.exit] # fires after "app" commits active
script = "@self/scripts/mark.sh" # @self = this artifact's root on the device
timeout_secs = 60
This marker hook reports that the delivery is durable, which is what
commit.exit is for. A hook that brings the new version into service, such as
starting a unit or swapping containers, belongs at activate.exit. That is the
first gate after the flip, so the gap where the device points at the new version
without running it stays short.
If you stage without activating (--no-switch, or no --switch while
autoswitch is off), no activate.* hook runs. Nothing is activated, so a
restart at activate.exit does not restart the running version. See
lifecycle-hook-cutover.md.
source resolves against the directory holding atc.toml, not against your
current directory. That still holds under -c/--config, where the manifest can
live anywhere. An absolute path is not rejected: it is taken as written, so the
push works on your machine and breaks on anyone else’s. Keep source relative.
A path that resolves to nothing fails with
ATC-E0504: bundle source not found: <path>.
Check the shared libraries of each binary you stage. A binary that links
libraries from the build machine fails on the device. It reports error while loading shared libraries. Run file <binary> first, then ldd <binary>, on a
machine that does NOT hold the build tree.
file comes first because the artifact may not be a binary. An autotools or
libtool build writes a wrapper shell script under the binary’s own name, and
leaves the real ELF under .libs/. ldd reports nothing useful about that
wrapper, so a check that starts at ldd reads as clean and stages the wrapper.
The glibc risk runs one way. glibc keeps older binaries working, so a binary
built against an older glibc than the robot’s runs there. Only the reverse needs
a static link: a newer build machine, an older robot. Run ldd --version on
both machines to see which case you have.
atc ota push answers that question for you when it can. It reads the robot’s
glibc over the ssh connection it already holds, and it compares that against
the release each staged binary binds. A robot that meets the release says
nothing at all. A robot below it REFUSES the push (ATC-E0469), and names both
numbers. Pass --allow-glibc-skew when the workload runs under a glibc the
probe cannot see, inside a container or a chroot on the robot.
A push that cannot read the robot’s glibc says nothing, because it measured
nothing. Run ldd --version on the robot yourself then.
A static link is the reliable answer. The flag depends on the toolchain, and none of them is a guarantee on its own:
- autotools: configure with
--disable-shared --enable-static. That governs the project’s OWN libraries. It does not make libc static, so the binary can still link glibc dynamically. AddLDFLAGS=-static(or libtool’s-all-static) when you want libc in the binary too. - Go: build with
CGO_ENABLED=0 go build. A plaingo buildlooks static and often is not. The standard library pulls cgo in for DNS lookups and foros/user, which links the build machine’s glibc.go buildprints no warning about it. Two consequences to know. A program that genuinely needs cgo will not build this way. And the pure-Go resolver reads/etc/hostsand/etc/resolv.confdirectly, so an exoticnsswitch.confbehaves differently. - Rust: build for a
musltarget, such asx86_64-unknown-linux-musl.
Then check the result rather than trusting the flag. file should report
statically linked, and ldd should report not a dynamic executable. If
ldd still lists libraries, the flag did not do what you wanted.
Copying the libraries ldd names into source is NOT sufficient on its own.
The loader must also be told to look beside the binary, and a RUNPATH covers
only the direct dependencies of the object that carries it. Read
shared-library-resolution.md before you take
that route.
Delivered files are read-only at runtime. The device materializes every
bundle file 0o440, or 0o550 when the source file is executable. No delivered
file keeps a write bit. A program that writes beside its own binary works in
local development and fails after the deployment. Keep writable state under
$HOME, or under a path you pass in an environment variable.
The directories that hold those files are not read-only. They keep the daemon user’s own mode, so a manual write over ssh as that user can still succeed.
Do not predict which way a write goes. Two inputs decide it and neither is a
guarantee. [daemon] hook_run_as_user picks the account a hook runs as,
defaulting to atcd’s own uid. The directory’s mode comes from atcd’s umask when
the store was built, so a permissive umask leaves it group-writable, and
atc-hooks is in the store group. The same program can take EACCES on one
device and succeed on the next. Only the file modes are a guarantee.
A write beside the binary does not follow the pointer. Each version lands in a directory of its own, so a file written there stays with the version that was current when it was written. Retention prunes it later, and a rollback to that version brings it back. Do not read “the next push moves on” as “the data is gone”.
There is no version field. The version is assigned at push time (step 4).
A manifest still carrying version = is a hard error.
The device decides what stays on disk. Two device settings control it:
storage.delivery_keep_n(default 5) keeps the newest labeled deliveries on each channel. A delivery is one committed push. It keeps the versions it installed, so each of these deliveries is a rollback target.storage.version_keep_n(default 0) sets the total number of versions to keep for each artifact. Versions that a kept delivery installed count toward this total, and they stay even when they exceed it. The device removes the least recently used of the other versions first.
The active version is always kept. The device prunes in a housekeeping sweep,
so you never prune by hand. To change a setting, run
atc ota settings set deploy@device storage.delivery_keep_n=10. The change
takes effect when atcd restarts.
The hook script below parses its context with awk. Every base image carries
it, busybox and debian:stable-slim included. Do not use python3 in a hook:
a minimal image such as debian:stable-slim omits it, and so does the meta-atc
image. A hook that calls a missing python3 exits nonzero. atcd reads that as
a veto: it rolls the whole delivery back and reports the push as failed.
Do not use jq in a hook. Many device images omit it. atcd also pins the
hook PATH to the standard system directories, so a hook sees only what the
image already installed. A hook that calls a missing jq gets an EMPTY value
rather than an error. It writes a broken file and still exits 0, and the push
then reports the hook as successful.
Now create the bundle and its hook script:
mkdir -p app_bundle/scripts
echo "hello from atc" > app_bundle/payload.txt
cat > app_bundle/scripts/mark.sh <<'SH'
#!/usr/bin/env bash
set -euo pipefail
# Lifecycle hooks run with a CLEARED environment. The delivery context arrives
# as ONE JSON variable, ATC_HOOK_CONTEXT. There is no ATC_VERSION or any other
# scalar to read a delivery field from. Parse the JSON with awk.
#
# `ctx` reads one dotted field. A second argument gives the value to use when
# the field, or an object above it, is null. With no second argument, a field
# under a null object prints "". An unknown key or a null value exits nonzero,
# and `set -e` then stops the hook, so no step acts on an empty value.
ctx() {
awk -v path="$1" -v nargs="$#" -v dflt="${2-}" '
function fail(why) { print "ATC_HOOK_CONTEXT." path ": " why > "/dev/stderr"; exit 1 }
function find(t, k, j, c, d, q) {
k = "\"" k "\":"
for (j = 1; j <= length(t); j++) {
c = substr(t, j, 1)
if (q) { if (c == "\\") j++; else if (c == "\"") q = 0 }
else if (c == "{") d++
else if (c == "}") { if (--d == 0) return 0 }
else if (c == "\"") { if (d == 1 && substr(t, j, length(k)) == k) return j + length(k); q = 1 }
}
return 0
}
BEGIN {
s = ENVIRON["ATC_HOOK_CONTEXT"]; n = split(path, key, ".")
for (i = 1; i <= n; i++) {
if (substr(s, 1, 4) == "null") { print dflt; exit 0 }
if (substr(s, 1, 1) != "{") fail("not an object")
p = find(s, key[i])
if (p == 0) fail("no such field")
s = substr(s, p)
}
if (substr(s, 1, 4) == "null") { if (nargs < 2) fail("null"); print dflt; exit 0 }
if (substr(s, 1, 1) != "\"") fail("not a string")
for (j = 2; j <= length(s); j++) {
c = substr(s, j, 1)
if (c == "\"") { print v; exit 0 }
if (c == "\\") { j++; c = substr(s, j, 1); if (c != "\"" && c != "\\" && c != "/") fail("holds a control escape") }
v = v c
}
fail("not a string")
}'
}
name=$(ctx artifact.name)
sha=$(ctx version.new_sha)
echo "atc update $name @ $sha ran" > /tmp/atc-app-ran
SH
chmod +x app_bundle/scripts/mark.sh
If a commit hook exits nonzero (a missing reader, a script error), atcd treats it as a veto and automatically rolls the whole delivery back to the previously-active version, reporting the push as failed — a bad hook can’t strand the device half-updated. That is why the hook above fails loudly instead of writing an empty marker.
What the hook receives. $ATC_HOOK_CONTEXT is one JSON document with stable,
always-present keys (an absent value is JSON null, never a missing key):
| Path | Meaning |
|---|---|
artifact.name, artifact.kind | artifact name; "bundle" or "oci" |
version.new_sha | version being activated (always set) |
version.old_sha | previously-active version (null on first install) |
state, phase | e.g. commit / exit |
trigger | push, user_activate (a switch/rollback), … |
Read a field with the ctx helper above, e.g. ctx version.old_sha. The full
ATC_HOOK_CONTEXT schema and hook reference is in
atc-toml.md — ATC_HOOK_CONTEXT.
PATH is pinned, not inherited. “Cleared environment” includes PATH: your
shell’s PATH never reaches the hook. atcd exports a fixed, absolute one —
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
— so a hook shelling out by bare name resolves only standard system locations.
Anything your hook calls (curl, jq, a tool in /opt) must already be
installed on the device and reachable from that PATH; otherwise call it by
absolute path. So the hook above reads its context with awk, which every
base image carries. It does not use jq or python3, which an image may omit.
This is deliberate. An inherited PATH would let a writable
directory on the daemon’s environment decide what a hook executes. HOME, USER,
LOGNAME, and SHELL are set from the hook user’s passwd entry, as in a login
session.
The full set a hook gets. ATC_HOOK_CONTEXT carries the delivery context,
and it is the only place to read delivery data. The rest of the environment is
fixed:
| Variable | Value |
|---|---|
ATC_HOOK_CONTEXT | the delivery context, as one JSON document |
PATH | the pinned absolute path above |
HOME, USER, LOGNAME, SHELL | the hook user’s passwd entry |
XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS | the hook user’s session bus, so a hook can run systemctl --user without sudo |
ATC_DELIVERY_CONTEXT | a delivery bookend hook only (delivery.pre / delivery.post), and only when the hook user can read the file. The document is always on stdin too |
Nothing from your shell reaches the hook. atc ota push --hook-env KEY=VALUE
injects one extra variable for one push, and atc ota switch --hook-env does
the same for one switch.
Which user is that? It depends on which path you took in step 2:
- Path A (
atc bootstrap, systemd): bootstrap creates anatc-hooksuser and bakes--hook-run-as-userinto the unit. Hooks run asatc-hooks. That user is in the daemon’s group, so it can read the 0750 store but cannot corrupt it. You do not create this user by hand. - Path B (manual foreground atcd): no hook user is configured, and no
atc-hooksuser exists. Hooks run as the user that launched the daemon, which is your SSH login user. Do not pass--hook-run-as-userhere. A hook uid switch needs a root orCAP_SETUIDdaemon, which the un-sudo’d foreground recipe deliberately avoids.
The hook runs on both paths. The paths differ in which uid it gets, not in whether it fires. Full reference: atc-toml.md.
Two limits on what a hook can do with systemd. Both come from the device or from the sandbox, not from your hook.
systemctl --userneeds two packages the device may not ship. On Debian or Ubuntu, installlibpam-systemdanddbus-user-session. Withoutlibpam-systemd,loginctl enable-lingerstarts no user manager and eachsystemctl --usercall answersFailed to connect to bus. Withoutdbus-user-sessionthere is no/run/user/<uid>/bus, so theDBUS_SESSION_BUS_ADDRESSin the table above names nothing, and a hook that speaks D-Bus fails.atc bootstrap atcd installreads the device and warns when either one is absent. atcd installs and runs without both.- A hook writes its units under its own home, not under
/etc. atcd’s unit setsProtectSystem=full, which each hook inherits. So/usr,/boot, and/etcare read-only to it. The hook user’s home is inReadWritePaths, so put unit files in~/.config/systemd/user/and drive them withsystemctl --user. That is the pattern the isolated hook user exists for. It holds no sudo, so a root-owned system unit is out of reach from a hook either way.
Your program prints no version of its own
Some programs carry one version and no more. An autotools C project is the
common case. jq --version prints the jq release, and the project has no second
version to bump per push. You do not need one. atc owns the version label, and
you set it with --version at push time (step 4). atc ota status reports the
live label, so “which version is on the device” never depends on the program.
Add an on-device version marker only when the DEVICE itself must report the answer, such as from a log line or a health endpoint. Two patterns work, and neither one patches the program:
-
Stage a
VERSIONfile beside the binary. Write the same string you pass to--versioninto the source directory before each push. The file ships with the payload, so it lands in the version’s own directory on the device. A hook runs in that versioned directory, so it can read the file beside itself. Give the file one writer, your build script, so the file and the push label cannot drift.Do not treat the hook’s directory and
atc shell -a <name>as one place.atc shell -a <name>entersdelivery/current/<name>, which is the ACTIVE version. A hook runs in the versioned tree of the version the push installs. After a stage-only push the two differ. The hook reads the newVERSIONfile, and the shell still reads the old one.ATC_HOOK_CONTEXT.current_roothands a hook the active pointer when it wants that one instead. -
Ship a wrapper script and point the service at it. The wrapper prints a labelled line, then runs the real binary. Use this when something already starts the program and you cannot add a file read.
Either way, print the label with the value. Bare version output makes a poor marker, for the reason step 5 gives.
Shipping a container image instead?
A [[bundle]] ships a directory. To ship a container image, import it into
atc’s local store first, then declare an [[oci]] artifact next to (or instead
of) the bundle:
atc oci import alpine:latest # pull from a registry into atc's store
# or import what your local engine already holds:
# atc oci import --docker my-app:latest (--podman for podman)
The registry pull goes through skopeo, which reads your machine’s
/etc/containers/registries.conf. On a host whose config is still in the v1
format, that pull fails with a registries.conf format error before atc sees the
image. You don’t need to fix the host config — pull the image with your local
engine and import from there instead:
docker pull alpine:latest && atc oci import --docker alpine:latest
[[oci]]
name = "app"
source = "alpine:latest" # the ref you imported
platform = "linux/amd64" # REQUIRED; match the DEVICE's CPU, not your machine's
Shipping an image instead of a bundle? Then that block is the whole file.
There is no [[bundle]] to keep:
[[oci]]
name = "app"
source = "alpine:latest"
platform = "linux/amd64"
platform is required because an image ref can carry several architectures,
and atc must know which one belongs on the device. It is the same wrong-arch
trap as the target note in step 2: a wrong value looks fine at push time.
Take the value from the device, not from your machine. ssh deploy@device 'uname -m' prints x86_64 for linux/amd64 and aarch64 for linux/arm64.
OCI names the arch differently from the kernel, so linux/x86_64 and
linux/aarch64 are not the values to use.
Pushing and activating then work exactly like the bundle flow in steps 4-5, and
[[oci]] artifacts take lifecycle hooks the same way a bundle does (under
[oci.lifecycle.*]), so a commit.exit hook can verify an image just as
mark.sh verifies the bundle.
Verifying which platform landed is the one step that differs: the ELF-byte
check in step 5 reads a single binary, and an image has no equivalent. The arch
lives in the image’s config blob, two hops from the version dir’s index.json
(index → the single manifest blob → its config.digest). Walk it on the device:
ssh deploy@device '
d=/var/atc/channel/local/default/delivery/current/app
m=$(jq -r ".manifests[0].digest" "$d/index.json" | cut -d: -f2)
c=$(jq -r ".config.digest" "$d/blobs/sha256/$m" | cut -d: -f2)
jq -r ".architecture, .os" "$d/blobs/sha256/$c"
'
That prints the arch and OS baked into the image that actually landed — expect
amd64 on an x86-64 device, arm64 on aarch64, and your laptop’s arch here is
the wrong-platform bug.
This check needs jq on the device, and nothing else in this guide does.
The hook script reads its context with awk for that reason. So install
jq before you run the block above, and only for this check:
ssh deploy@device 'sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y jq' # Debian/Ubuntu
# Alpine: sudo apk add jq · Fedora/RHEL: sudo dnf install -y jq
Don’t hand-parse these blobs with grep/sed instead: the config digest sits in
a "config" object that also holds a "digest" key for every layer, so a
line-oriented match picks up a layer digest and the walk dies on a missing blob.
atc ota status and atc ota ls --artifacts deploy@device confirm the version
activated. For an OCI-only delivery, their Arch column reads unknown: the
column reports the first ELF arch found among the active delivery’s members,
and OCI members are not ELF files on disk. Check an OCI payload’s arch from its
image config instead.
Full [[oci]] schema and OCI lifecycle hooks: atc-toml.md.
Shipping a module instead?
A bundle and an OCI artifact are payloads the device unpacks. A module is
an executable the device also runs, each time a delivery ships it — including
a re-push at an unchanged SHA. A delivery that carries a module forward
untouched does not re-run it. Write start to be safe to run again. Modules
are declared in atc.kdl rather than atc.toml, and much of the module
vocabulary is still refused at push time, so read
modules-today.md before you design around one. A repo you
can copy, with an e2e test that pushes it to a real container, is
examples/heartbeat-module/.
Optional: run the whole push on your own machine first
atc ota push --local starts a throwaway atcd on this machine and pushes to
it. It needs no device, no SSH and no root, and it runs the real daemon. So a
manifest and a hook that pass here ran against the real executor. Use it to
check atc.toml and mark.sh before you involve the device.
Get an atcd for this machine first. The installer places only atc, so a
fresh machine has no atcd and the push stops on ATC-E0463. One command
provisions one, and it needs no device:
atc bootstrap atcd fetch
It reads the same signed release origin atc upgrade reads. It checks
the ed25519 index signature and the sha256 artifact digest. Then it writes the
binary where --local looks for one: beside your atc when that atc is a
plain binary, and ~/.local/bin otherwise. It also says whether --local can
reach the result, and names the fix when it cannot.
Add --track <track> to follow another channel, or --source <name> to fetch
from a private release mirror registered in sources.toml. Both resolve the
way atc upgrade resolves them. Then:
atc ota push --local --switch --local-root /tmp/atc-local-test
Give --local-root an absolute path, and keep it short. A relative path
makes every hook fail to spawn (spawn failed: No such file or directory), and
the failure vetoes the push. The root also holds atcd’s Unix socket, and a
socket path over 103 bytes is refused with ATC-E0463.
- Linux only. The released
atcdassets are Linux x86_64 and aarch64 binaries. On any other hostatc bootstrap atcd fetchrefuses and says so. --localneeds anatcdfor this machine.atc bootstrap atcd fetchis the short way.atcalso accepts one you supply: put it next toatc, put it onPATH, or name it withATC_LOCAL_ATCD. The Path B fetch block in step 2 downloads one too, but pick your OWN target there, not the device’s.- Pass
--switchor--no-switch. The command refuses to choose.--switchruns the activate and commit hooks.--no-switchstages, which runs the download and install hooks and then stops. --local-root <DIR>keeps the run: the store,atcd.log, and each hook’s stdout and stderr underhooks/. Without the flag--localuses a temporary directory and removes it. A directory that already holds something else is refused.- This is not a sandbox. Hooks run under your uid, in your home, against your
services.
--localrelocates the store and nothing else. - Push the same content twice into one
--local-rootand the daemon treats it as already present. No hooks run at all. Change something between runs.
Your hook writes /tmp/atc-app-ran on this machine, so read it here:
cat /tmp/atc-app-ran
4. Push and activate
Every artifact by default — a bare atc ota push pushes every artifact
declared in atc.toml. (Omit --version and the push gets an auto-generated
<host>-<timestamp>-<entropy> label.) Give the push
a version and add --switch to activate it:
atc ota push --version v0.1.0 --switch deploy@device
-
You don’t have to be in the manifest directory.
atcdiscoversatc.tomlby walking up from the current directory, so any subdirectory of~/first-deployworks. Pass-c/--config <FILE>to point at a manifest elsewhere and skip that search entirely. -
--only <name>(repeatable) narrows the push to a subset of artifacts; a bare push (no--only) pushes all of them. -
-t app@v0.1.0is shorthand for--only app --version v0.1.0. -
Without
--switchthe version is staged on the device but it keeps running the old one. -
The push log shows the
commit.exithook firing. The log prints one line per hook, when it finishes. For a hook that prints nothing on success, like themark.shabove, which only writes a marker file, the log shows:hook logs: <cache-path> ✓ commit.exit app scripts/mark.sh 8ms i commit.exit produced no outputThe log prints
hook logs:once per push, under thepush-startedline. Under--non-interactive, which prints nopush-startedline, it prints above the first hook that records output to disk. A failed hook reads✗and its exit state, for exampleexit 3ortimed out.The interactive view draws the same hook as
└ ✓ commit.exit app scripts/mark.shunder theCommitrow, and nameshook logs: <cache-path>once, under the first line of the push.<cache-path>is$XDG_CACHE_HOME/atc/hooks/<timestamp>-<target>/, or~/.cache/atc/hooks/<timestamp>-<target>/whenXDG_CACHE_HOMEis unset. It holds one<state>.<phase>__<hook-run-id>.stdout/.stderrpair per hook run. These are captured on your machine byatc, not on the device. Recording is on by default for every push;--capture-hooks <DIR>pins a different directory, and--jsonis the one mode that records nothing.The
✓ commit.exitline is your confirmation thecommit.exithook ran and succeeded — it’s the one stable signal to grep for. A failed hook prints✗and the reason instead:exit 3,signal 9,timed outorspawn error. Each hook run also carries a unique opaque id, which the recorded filenames use. Don’t string-match against that. Thehook logs:andilines are normal, not a warning: atc captures every hook’s stdout/stderr to disk by default, and notes when a hook was silent. The hook runs after the atomic version flip, exactly as the[bundle.lifecycle.hook.commit.exit]comment says. -
atc ota status’s lifecycle log names the same hook by its journal label,commit/pre_hook_exit, which does not match thecommit.exityou wrote inatc.toml. That’s the daemon’s internal journal name for the gate, andpre_means “about to run the exit hook”, not “before the commit”.commit.exit,commit.post, andcommit/pre_hook_exitall name the same gate;commit.enter/commit.premap tocommit/pre_hook. The push log usescommit.exit. Match on✓ commit.exit, never on the journal label. -
Did the push stop with
ATC-E0460: atc and atcd are incompatibleorATC-E0016: unsupported protocol versionbefore any payload moved? An olderatccan showATC-E0443: store: push failedfirst, with theATC-E0016nested below it. Thenatcand the device’satcdspeak different push wires. This happens on a device that an older release bootstrapped. Upgrade the binary that the error names. Rollback carries the full rule.
5. Verify it landed
Prove it with commands — don’t assume:
atc ota status deploy@device # active version should now be v0.1.0
ssh deploy@device 'grep "^atc update app @" /tmp/atc-app-ran' # hook marker + content
ssh deploy@device 'od -An -tx1 -j18 -N2 /usr/local/bin/atcd' # ARCH that landed (see below)
If ota status shows v0.1.0 current and the marker line is present, the
update is live. Grep the marker’s content (not just cat it) — a hook that ran
with a broken parse could leave an empty file that a bare cat would still show.
Label every version string you check. A bare version marker is a common
cause of a verify step that reads as a failure. A program’s own --version
often prints 1.0.0 and nothing else. That string holds no stable word to
anchor a grep, so one grep cannot separate three cases:
- the new version ran
- an older version ran
- the command printed nothing at all
Print a label beside the value instead. The label proves the command ran, and the value proves which version ran:
ssh deploy@device 'echo "myapp-version=$(myapp --version)"'
Then check the label and the value together:
ssh deploy@device 'echo "myapp-version=$(myapp --version)"' \
| grep -q 'myapp-version=v0.1.0'
A command that fails prints the label with an empty value. The paired grep
rejects that line, so a broken run cannot read as a pass. Use the same pattern
for a hook marker file: write label=value, never the value alone.
Confirm the architecture that shipped. ota status reports version and sha,
atcd --version reports atcd <semver> with its existing prefix,
and atcd version (the subcommand,
which also takes --json) reports semver, git sha, and build date. None of them
report target arch, so a wrong-arch binary (e.g. a
linux-x86_64-musl build on an aarch64 device) looks identical there. Read the
deployed binary’s ELF header directly — byte 18 is e_machine, the CPU the
binary was built for:
ssh deploy@device 'od -An -tx1 -j18 -N2 /usr/local/bin/atcd'
# 3e 00 → x86-64 · b7 00 → aarch64 (ELF e_machine, little-endian)
e_machine is a little-endian 16-bit field, so the two bytes read
low-byte-first: 3e 00 is 0x003e (62, x86-64) and b7 00 is 0x00b7 (183,
aarch64). Bytes in the other order would mean a big-endian ELF, which no device
here targets.
od is coreutils, so this works even on the stripped, file-less container
images Path B targets. If file is installed it’s friendlier —
ssh deploy@device 'file /usr/local/bin/atcd' prints
… x86-64 … statically linked — but it’s absent on minimal images, so don’t
lead with it. Don’t fall back to uname -m: it reports the device’s CPU, not
the binary’s arch, so it can’t catch a wrong-arch push onto a matching-CPU host
— the ELF-byte check above is the real proof. (Default daemon path is
/usr/local/bin/atcd; adjust if you set [daemon] remote_path.)
Where your files land on the device. In the default (delivery-pinned) mode, point on-device services at the authoritative delivery pointer — atc flips it atomically on every switch and rollback:
/var/atc/channel/local/default/delivery/current/<name>
6. Rollback
Rollback is a flip back to an earlier version, so it needs at least two on the
device — if you’ve only pushed v0.1.0 so far, repeat step 4 with
--version v0.2.0 first. List what’s on the device, then switch:
atc ota ls deploy@device # list on-device versions
atc ota switch deploy@device <older-version> # re-activate an earlier one
switch needs a current atcd. Whole-delivery switch is its own wire
operation, so an atcd that predates it doesn’t recognize the request and the
switch fails with ATC-E0455: atcd is too old for the whole-delivery switch.
Nothing was activated — the old daemon refuses before touching the store — so the
device is still on the version it was already running. The fix is forward only:
upgrade atcd on the target (atc bootstrap atcd upgrade deploy@device on a
systemd device, or the Path B binary swap in step 2), then re-run the switch.
Downgrading atc to match an old daemon is not supported.
An old atc against a newer atcd is refused too. The push wire is a single
generation per build. atc and atcd exchange a protocol version at handshake
and accept only an exact match. A push across a skew in either direction is
therefore rejected before anything transfers. There is no
“old-client-still-works” window for a push. status and ota ls still answer,
as the paragraphs below show.
Keep the pair in step: upgrade atc with atc upgrade, and
the device’s atcd by Path A or Path B above.
The refusal happens at the push’s first stage, before any payload transfers, and it names the binary to upgrade:
- A current
atcagainst an olderatcdfails withATC-E0460: atc and atcd are incompatible, followed byupgrade this daemon (atcd)and the fix commandatc bootstrap atcd upgrade <target>. - An older
atcagainst a neweratcdfails withATC-E0016: unsupported protocol version, followed byupgrade the pushing client (atc). A release that predatesATC-E0460wraps it inATC-E0443: store: push failed.
atc ota status does not refuse a skewed pair. Status and ota ls use the
attach handshake, which has its own version and does not move with the push
wire. Measured: an atc 0.1.3 (atc/5) read status and listed deliveries from
an atcd 0.1.5 (atc/6) with no error, then failed its first push with
ATC-E0016.
A newer atcd reports its push wire in the status reply, and atc ota status
then flags a skew itself. The output ends with
warning: push wire mismatch: this atc speaks atc/N, the daemon speaks atc/M.
and a fix: line that names the binary to upgrade. --json carries the same
answer as "push_wire": {"atc", "atcd", "match"}. An atcd that predates the
field reports "atcd": null and "match": null, and prints no warning. For
that daemon, compare the Daemon column of atc ota status deploy@device with
atc --version. The same release always speaks the same wire. Different
releases may still match, so a push is the final answer.
A switch re-runs the target version’s activate and commit lifecycle hooks
(with trigger: "user_activate" in $ATC_HOOK_CONTEXT). They are not “fire once
at first install.” The order is activate.enter, deactivate.enter,
deactivate.exit, the pointer flip, activate.exit, commit.enter,
commit.exit. The two deactivate cells run the declaration of the OUTGOING
version, which is the one the switch leaves.
Your marker is rewritten on rollback, carrying that version’s sha. A hook that
restarts a service or runs a health check fires on the rollback too. The store itself is only a pointer flip — nothing is rebuilt or
re-downloaded — but the hooks do run.
Prove the rollback the same way you proved the push (step 5) — one verification per delivery, whether it arrived by push or by switch:
atc ota status deploy@device # active version is the older one again
ssh deploy@device 'grep "^atc update app @" /tmp/atc-app-ran' # marker rewritten with THAT version's sha
.version.new_sha is the artifact’s content sha, not the delivery version
string. It tracks the payload, so it changes only when the payload changes.
Roll back to a version whose payload is byte-identical and the marker gets
rewritten with the same sha it already held. An unchanged marker is therefore
not evidence that the hook was skipped. To prove a re-run for any payload,
make the hook append instead of overwrite, or record a timestamp with the sha:
echo "atc update $name @ $sha ran at $(date -Is)" >> /tmp/atc-app-ran
The ✓ <state>.<phase> hook line in the push output is the authoritative
signal that the hook ran; the marker proves it did its work.
Pass a bare delivery version (v0.1.0) — in the default delivery-pinned mode
that re-activates the whole delivery. The name@version form (or -a <name>) is
a per-artifact escape hatch, valid only when assignment_mode = per_artifact;
under the default it is rejected with ATC-E0454: switch refused: … device is delivery-pinned.
Open the hatch with atc ota assignment-mode:
atc ota assignment-mode deploy@device per_artifact --for 1h # lease the opt-in
atc ota status deploy@device # MODE reads per-artifact
atc ota assignment-mode deploy@device delivery_pinned # back to the default
--for is the safer form. The daemon re-reads the mode at the instant it
activates. An expired lease therefore refuses the next lone switch, and the
device returns to the delivery-pinned posture on its own. Omit --for and the
override has no expiry.
The ota switch argument order is <TARGET> <VERSION> — host first,
version second. A reversed form like atc ota switch v0.1.0 deploy@device
parses v0.1.0 as the hostname and fails with
ATC-E0303: could not resolve hostname.
Appendix: release channels
| Track | Who it’s for | Cadence |
|---|---|---|
stable | Everyone. The curl | sh default and the channel unstamped local builds follow. | Manually dispatched official releases |
nightly | Release candidates, ahead of stable. | Daily, from the tip of dev |
dev | Agency developers only. Tracks dev closely. | Per merge that touches the binaries, debounced ~45 min |
dev gets you the tip of dev within about an hour of a merge, instead of
waiting for the nightly.
Installing a specific version
A track always serves its current release: https://atc.dev/agency/<track>/index.json
is overwritten in place on every publish, and the install script takes
ATC_TRACK but no version pin. So the curl | sh one-liner cannot give you
v0.1.2, and neither can the Path B fetch script below.
Pinned stable and nightly versions come from the GitHub release page
instead. Those releases keep their own binaries, so prior versions stay
available:
gh release list -R Agency-Tool-Company/agency-code --exclude-pre-releases # stable releases
gh release download v0.1.2 -R Agency-Tool-Company/agency-code \
-p 'atc-linux-x86_64-musl' -p 'SHA256SUMS'
grep 'atc-linux-x86_64-musl$' SHA256SUMS | sha256sum -c # verify before running
chmod +x atc-linux-x86_64-musl
./atc-linux-x86_64-musl --version # atc 0.1.2 (6b17235ca …, track stable)
A pinned release prints the wire generation it speaks as a trailing
[wire atc/N]. Releases older than that banner print no such segment.
v0.1.2 above is one of them.
The push wire accepts only an exact match, so a pinned atc older than the
device’s atcd cannot push to it. See An old atc against a newer atcd is
refused too in step 6.
A bannerless binary does not tell you its generation, and the release notes do not state one. For a stable release, use this list. It was read from each tag’s source and checked against the published binaries:
| Stable release | Push wire | --version banner |
|---|---|---|
v0.1.1 to v0.1.3 | atc/5 | none |
v0.1.4 and later | atc/6 | [wire atc/6] |
A bannerless nightly may speak either generation. The banner arrived after the
atc/6 bump, so its absence proves nothing. Do not use
atc ota status deploy@device as the probe: status uses a separate handshake
and answers across a push-wire skew. A push is the probe that answers. A mismatch is refused at the push’s first stage, before
any payload transfers. If the versions match, the push uploads and stages a
real delivery. With autoswitch = true in atc.toml, it also activates it.
Use this probe only when you intend to push that delivery.
Assets are named <binary>-<target> for the same targets the index carries
(atc/atcd × linux-x86_64-musl, linux-aarch64-musl; atc also
darwin-arm64), alongside SHA256SUMS and the signed index.json. Pick the
target matching the machine that will run the binary. For atcd that is
the device, not your laptop.
Nightly builds are published as pre-releases (dropped by --exclude-pre-releases
above); list them with a bare gh release list.
The internal dev channel is the exception. Its per-merge publishes record only
a lightweight tag, with no release object and no assets, to keep the Releases
page clear. So gh release download has nothing to fetch for a dev build. Pin a
nightly release instead, or build the binary yourself from the tag’s commit.
This is the path for testing CLI/daemon compatibility against an older release.
Note that a pinned binary is outside the self-update flow: a later
atc upgrade moves it back to the track’s current release.
Opting in
The dev track is internal and token-gated: the index is served only to requests
carrying X-Atc-Channel-Token, which atc reads from ATC_CHANNEL_TOKEN.
# 1Password → Software vault → item "atc-dev-channel" → field "token"
export ATC_CHANNEL_TOKEN=… # put this in your shell profile
atc bootstrap track dev # persists in sources.toml
atc upgrade
On a machine with no atc yet, the installer takes the same variable:
export ATC_CHANNEL_TOKEN=…
curl -fsSL https://atc.dev/install.sh | ATC_TRACK=dev sh # or ATC_TRACK=pr-<number>
The installer reads the token only from the environment, and it sends the token
only to https://atc.dev/agency/dev/ and /agency/pr-*/. With no token, it
stops before the first download and names ATC_CHANNEL_TOKEN.
The value must carry no trailing newline or surrounding whitespace. The edge
compares the header byte-for-byte and atc sends what you exported verbatim —
it rejects a blank token but never trims one — so a stray newline from a copy or
a $(cat file) produces a 403 while the 1Password value looks perfectly
correct. The current value is 64 hex characters — nothing enforces that length,
it is just how this one was minted, so treat the count below as a fingerprint of
today’s token rather than a rule:
printf '%s' "$ATC_CHANNEL_TOKEN" | wc -c # 64 for the current token
What is actually required is that the value match the deployed one exactly, with no leading or trailing whitespace.
atc --version should now print track dev.
To go back: atc bootstrap track stable && atc upgrade. Use
atc bootstrap track, not a one-shot --track — the flag does not clear a
track recorded in sources.toml, so the next bare update would return to
dev.
Five things that will confuse you otherwise
No token means 403 on every update. atc names ATC_CHANNEL_TOKEN in the
error rather than surfacing a bare HTTP status, so it should be obvious. A 403
with the token set is one of two things: a stale value (re-pull from
1Password) or stray whitespace in it (check the byte count above). The second is
the likelier one and the harder to see, since the 1Password UI shows nothing
wrong.
Version numbers barely move. Dev builds are 0.1.3-rc.<minute>.<sha>, all
sharing one base until the next official release. Self-update compares the
content DIGEST, not just semver (the channel is published rolling), so you do
get new builds. The version string is just not how you tell them apart. Use the
sha.
No GitHub releases. Each publish records a bare git tag (dev-<date>-<sha>)
and nothing on the Releases page. Deliberate: per-merge prereleases nobody
consumed are what got this channel retired the first time. Release notes live at
the URL the index carries.
Not every merge publishes. A docs-only or terraform-only merge does not trigger a release, and a burst of merges collapses into one. Expect roughly one release every couple of hours on a busy day, not one per merge.
The macOS atc arrives after the Linux binaries. Each dev release
publishes the index twice. The first publish carries only the Linux targets, so
a device gets its binaries without a wait for the macOS runner. When the native
macOS build finishes, the release republishes the index with the
darwin-arm64 entry. Between the two publishes, the index has no
darwin-arm64 atc. On a Mac, an update in that window fails with
release index has no artifact for package agency/atc on target darwin-arm64.
The gap is usually one or two minutes. It is longer when the macOS runner is
queued or the macOS build is slow. The timeout-minutes of the macOS build
(build-atc-macos-release.yml) and of the release-macos job
(release.yml) set how long each job can run.
If you see that error on the dev track, wait a few minutes and run
atc upgrade again. If the error stays, open the newest dev-release run in
GitHub Actions and read the status of two jobs. These are
build atc (macOS / Apple Silicon, after the Linux publish) and
republish index with macOS atc. If either job is queued or in progress, wait
for it. If either job failed, the index has no darwin-arm64 until the next
dev release. Use nightly meanwhile, or build atc locally with
AGENCY_BACKEND=local ./agency build -c opt //crates/atc-cli:atc.
Before you rely on it
The gate covers the index, not the binaries: those live in the shared content-addressed blob prefix and stay publicly readable by digest. It keeps customers off the channel rather than keeping the bytes secret.
Rotating the token is a tofu apply plus an env update everywhere, with no
overlap window — it is baked into the CloudFront function, which cannot read a
secrets store. It is a long random value precisely so it does not need a
schedule.
Going deeper
- building-from-source.md — the same journey but building
atc/atcdfrom source via./agency(the contributor / CI path). - deploy-your-first-update.md — end-to-end quickstart with the no-systemd / container recipe and a troubleshooting table.
- atc-toml.md — the
atc.tomlmanifest schema and the full lifecycle-hook (ATC_HOOK_CONTEXT,@self/@bundle, state/phase) reference. - lifecycle-hook-cutover.md — which gate each hook
belongs at, and what to check if you already have
commit.*hooks in the field. - verifying-the-binary.md — independently verify the
atc/atcdbinary you’re running is genuine (sha256 + ed25519-signed index). - console-config-publish-readiness.md: run this checklist before you promise a customer config publish from the web console. Until it is green, use the CLI path above.
For an ATC-Exxxx error, see
docs/development/error-codes.md.
Deploy your first update
A self-contained operator path from nothing to a shipped, verified update on a
device: install atc, bootstrap the on-device daemon, declare what to ship,
push it, and roll back if you have to.
You do not need any of the .claude/skills/ material, Notion, or an external URL
to follow this. Every command below was run against the real CLI; discover any
subcommand’s full options with atc <cmd> --help.
Two pages cover this journey. This one is the short version, and it ends with a troubleshooting table. onboarding.md is the complete path: both device types, lifecycle hooks, container images, and the traps each step hides. Read one of them, not both.
Mental model (30 seconds)
atc is a controller CLI you run on your machine. It delivers artifacts to a
device over SSH. A daemon, atcd, runs on each device and manages a
content-addressed store at /var/atc. Every push is versioned and atomic:
artifacts are staged, then the active version is flipped only when you ask
(--switch). Rollback is just flipping back to an older version.
You declare what to ship in atc.toml and run atc from that directory.
What you need
- A controller machine with
atcinstalled (Step 1). You do not need a checkout of this repo. - A device you can SSH into (key auth preferred) with passwordless sudo
for the one privileged bootstrap step.
--non-interactiveforces key-only SSH. - The device’s CPU arch must be
aarch64orx86_64(ssh user@device uname -m). Bootstrap probes it and picks the matchingatcd; anything else needs a binary supplied with--from. - The device’s init system (
ssh user@device 'ps -p 1 -o comm='). If PID 1 is notsystemd, skip Step 2 and use the no-systemd recipe instead. Check PID 1, not for asystemctlbinary —systemctlcan be present (and even answer) on devices where systemd is not running.
Step 1 — Install atc
atc is the controller CLI you run on your own machine:
curl -fsSL https://atc.dev/install.sh | sh
export PATH="$HOME/.local/bin:$PATH" # the installer drops atc here
atc --version
The installer verifies what it downloads (sha256 against a signed release index) before writing it. See verifying-the-binary.md to check that yourself.
You do not need to install atcd, the device daemon, by hand. Step 2
downloads the arch-matched, signature-verified atcd and installs it for you.
atc reads atc.toml from the directory you run it in.
Building from source instead? If you have a checkout of this repo and want the binaries you built, follow building-from-source.md. It covers the
./agencybuild path, then rejoins this guide at Step 2.
Step 2 — Bootstrap atcd onto the device (one-time)
Bootstrap probes the device’s arch, downloads the matching atcd from the same
signed release origin atc self-updates from, installs it into the store at
/var/atc, and enables a systemd service:
atc bootstrap atcd install --yes --non-interactive user@device
# → bootstrap: atcd started on user@device (socket /run/atcd/atcd.sock)
# → ✓ atcd <version> installed and running on user@device
The downloaded atcd goes through the same verification as a self-update: the
ed25519 signature over the release index is checked against the pinned trust
keys, and the binary’s sha256 against the signed digest, before anything is
written or copied to the device.
There is exactly one atcd on the device: the store-managed binary the
systemd unit execs, at
/var/atc/channel/agency/atcd/delivery/current/atcd/atcd. /usr/local/bin/atcd
is a symlink to it.
So atcd --version reports the version that symlink resolves to, which is not
always the version the daemon RUNS. atcd upgrade re-points current, and the
symlink follows it, but the daemon keeps serving the old process until a
restart. In that window atcd --version reports the STAGED version. The reply
from atcd upgrade names the running version, which it reads from the daemon
on its socket.
Any target form works: a hostname, an IP address or localhost. You do
not name the device. atcd names itself from a hash of its hardware identity,
or from its device id when the hardware has none.
To ship a specific binary instead (one you built, or a patched build), pass
--from ./path/to/atcd — it must match the device’s arch. --from is explicit
intent, so a bad path is an error rather than a silent fall back to the
download.
Verify the daemon is up and the controller can reach it:
ssh user@device 'systemctl is-active atcd' # → active
atc ota status user@device # controller ↔ daemon; lists the (empty) store
A status (or any other command) can occasionally fail with
ATC-E0308: ping ... timed out after 5s — a transient SSH-connection timeout,
common on the first contact after starting atcd but possible on any contact.
Retry once; it succeeds immediately (see Troubleshooting).
Want the device to install itself, with no
atc? For provisioning scripts and Ansible, runatcd installon the device:curl -fsSL https://atc.dev/install-atcd.sh | sudo sh -s -- --socket-owner user. It is safe to run again, and--checkchanges nothing. Flags, exit codes and an Ansible task: onboarding.md, Path A on the device.
No systemd on the device? (a bare container, a minimal image) bootstrap aborts up front with
ATC-E0419. Skip to No-systemd / container devices for the foreground recipe, then come back to Step 3.
Step 3 — Declare what to ship (atc.toml)
atc ota init scaffolds a minimal manifest; or write it by hand. The smallest
valid atc.toml ships one bundle (a directory tree — your built app plus any
service files / hooks):
[[bundle]]
name = "app" # on-device identity; unique across artifacts
source = "dist" # directory to ship — must exist at push time
Put your built application under source (here, ./dist/). There is no
version field — the version is a label assigned at push time (Step 4). A
manifest is validated when you push; a missing source directory fails with
ATC-E0504: bundle source not found.
Bundles can also carry lifecycle hooks — scripts run on the device at specific lifecycle states — see atc-toml.md for the hook syntax and the full manifest reference.
(For container images use a [[oci]] block with a source registry ref and
atc oci import/oci build first — see atc oci --help.)
Step 4 — Push and activate
A bare push sends every declared artifact (the default); narrow to a subset with
--only <name> (repeatable). A push stages by default; add --switch to make
it the active version. The version label comes from --version (or
-t name@version):
atc ota push --version v0.1.0 --switch user@device
atc ota push --dry-run user@device uploads and mutates nothing, but its
report is terse: it previews only peripheral reconciliation — printing
peripheral reconcile: no removals, or a table of peripherals it would remove.
It does not summarize the bundles, versions, or bytes a real push would send.
Step 5 — Verify
atc ota status user@device # current + prior version and daemon health
atc ota ls user@device # all on-device versions
atc ota netcheck user@device # connectivity / daemon-reachability preflight
atc ota status showing your version as current means the update shipped and is
live. The device table at the top reads:
- STORE — the on-device store root (
/var/atc). - DAEMON — the version of the atcd running on the device.
- MODE — how versions are assigned:
delivery-pinned(the default and the normal mode for push-based delivery — the device tracks one pinned delivery across all artifacts) orper-artifact(each artifact resolved independently). Change it withatc ota assignment-mode <target> per_artifact, which takes an optional--for <DURATION>so the opt-in expires on its own. - CHANNEL (in the artifact tables) — the channel each artifact tracks, e.g.
local/default.
Where your files land on the device: the active version’s files are
materialized at
/var/atc/channel/local/default/artifacts/<name>/versions/<sha256>/. In the
default delivery-pinned mode the stable pointer is not a per-artifact
current symlink — it is the delivery pin:
/var/atc/channel/local/default/delivery/current/<name>, a symlink that resolves
(through the active delivery dir) to that artifact’s active versions/<sha256>/
dir and is flipped atomically on every switch/rollback. Point on-device services
at delivery/current/<name> for a stable path. (A per-artifact
artifacts/<name>/current symlink is written only in per-artifact MODE, never
in the delivery-pinned default — see the MODE row above.)
Emergency rollback
Flip the active version back to an older one. Push output does not tell you the
previous version — find it with atc ota ls, then switch. The signature is
atc ota switch <TARGET> <VERSION> — host first, version second:
atc ota ls user@device # find the previous version to revert to
atc ota switch user@device v0.1.0 # re-activate it on user@device
(Order matters: a version-first form like atc ota switch v0.1.0 user@device is
parsed as “host v0.1.0” and fails ATC-E0303: could not resolve hostname.)
Anti-rollback floor (opt-in)
A device can be told to refuse a delivery older than the one it already
accepted. That is the anti-rollback floor. It is OFF by default. Turn it on per
segment, with policy in the deployment doc:
{
"root": {
"policy": { "anti_rollback": true },
"children": [
{ "kind": "rule", "name": "canary",
"when": { "all": [ { "attr": "custom.tier", "op": "eq", "value": "canary" } ] },
"policy": { "anti_rollback": false } }
]
}
}
The root value is the deployment default. A segment that sets nothing inherits
it. A segment that sets anti_rollback overrides it for itself and for every
segment below. Read the resolved value back before you roll out:
atc fleet device resolve <DEVICE_ID> --channel stable
# anti-rollback armed
The server cannot turn it off
The device reads the bit as an arm-only ratchet. The bit can arm the floor.
It can never disarm one. A server that sends false to a device that already
armed does nothing. This is deliberate: the floor defends against a compromised
server, so the server must not hold its off switch.
To disarm a TEST robot, delete the floor on the device itself:
ssh user@device 'rm -rf /var/atc/.atc/delivery-floors/local/default'
The last two path components are the channel’s <namespace>/<name>. The device
arms again on the next stamped delivery it accepts.
Limit: legacy devices never arm
The bit rides the desired-state object the server publishes for each device.
An atcd that predates that object reads its desired state from
POST /v1/checkin, and the server no longer serves that route. Such a device
receives no channel policy and never arms. The deployment cannot change that.
Upgrade the device to arm it.
Health timeout of a multi-host device
A device with several hosts switches to a new delivery on every host at once.
After the switch, each host must confirm the new delivery within the health
timeout. If a host does not confirm in time, the server rolls back every host.
The default is 15 minutes. If your devices take longer to reboot, set
health_timeout_secs in the segment policy:
{ "root": { "policy": { "health_timeout_secs": 2400 } } }
The value is from 1 to 86400 seconds. The server refuses a publish with any
other value. It inherits like anti_rollback. The server also raises the value
to twice the longest check-in interval of the hosts, so that one late check-in
cannot cause a rollback.
No-systemd / container devices
atc bootstrap installs atcd as a systemd service. On a device whose PID 1
is not systemd (a bare container, a minimal image), bootstrap stops up front
with ATC-E0419 and prints a foreground recipe. Here is the full end-to-end
version of that recipe.
Critical: the binary must live at /usr/local/bin/atcd on the device. In
this no-systemd recipe that path is a real binary you install, not the
symlink-into-the-store the systemd bootstrap creates — there is no store-managed
current/atcd to point at, because nothing seeds or manages a store here. The
controller’s liveness check and auto-bootstrap reach the daemon by SSH-exec’ing
/usr/local/bin/atcd attach (the [daemon] remote_path default). A daemon
started from any other path (e.g. /tmp/atcd) is running and healthy, yet
atc ota status / netcheck report the daemon as not present — because the
controller probes the fixed path, not the running process. Install it there.
Equally critical: run atcd as the same user you SSH in as. The controller
SSH-execs atcd attach as your SSH login user (the user in user@device),
and the socket defaults to 0660 owned by the user that started the daemon. So if
you start atcd as root (sudo atcd run) but SSH in as a non-root user, every
atc ota status / push fails with connect to atcd at /run/atcd/atcd.sock: Permission denied (os error 13) — the daemon is up and healthy but unreachable.
(Worse, that permission error is currently masked as ATC-E0401 atc.toml is malformed or an ATC-E0415 release-download 404 — see Troubleshooting.) The
recipe below starts atcd without sudo so the socket is owned by your login
user. Only steps 1–2 (install the binary, create+chown the dirs) need sudo.
On the device (the user you SSH in as, with passwordless sudo for steps 1–2):
# 1. Install the device-arch atcd at the path the controller probes. Bootstrap
# needs systemd. `atcd install --no-service` can seed a store instead (see
# onboarding.md, Path B); this recipe stages the binary by hand. Fetch it
# with install-atcd.sh. It checks the signature on the release index and
# the binary's sha256, and ATC_DOWNLOAD_ONLY makes it install nothing.
# Set TRACK to the track that `atc --version` prints on your machine.
# curl -fsSL https://atc.dev/install-atcd.sh | ATC_TRACK=$TRACK ATC_DOWNLOAD_ONLY=/tmp/atcd sh
# It needs curl, OpenSSL 3, awk, and sha256sum or shasum on the device.
# Without them, use the fetch
# block in onboarding.md (Path B), or scp up a binary you built yourself.
sudo install -m 0755 /tmp/atcd /usr/local/bin/atcd
/usr/local/bin/atcd version # arch check — 'exec format error' means the
# binary is for another CPU
# 2. Create the store root and the socket's parent dir (atcd does NOT create
# them — on the systemd path the unit's RuntimeDirectory= does), and hand
# both to your login user so the un-sudo'd daemon in step 3 can write them
# and owns the socket the controller attaches through. Use chown -R:
# if atcd ever ran as root it left root-owned subdirs a shallow chown misses.
sudo mkdir -p /var/atc /run/atcd
sudo chown -R "$(id -un):$(id -gn)" /var/atc /run/atcd
# 3. Start the daemon headless (NO sudo) so the SSH session returns AND the
# socket is owned by your login user. setsid + redirecting all three streams
# detaches it; a plain `nohup ... &` leaves the SSH channel hanging open.
setsid /usr/local/bin/atcd run \
--store-root /var/atc --socket /run/atcd/atcd.sock \
</dev/null >/tmp/atcd.log 2>&1 &
# Fresh-device sanity check: if the ssh gets wrapped in a `timeout` (so it exits
# 124/143 rather than returning), `DAEMON_UP` on stdout is a quick signal the
# daemon came up. This is only trustworthy on a FIRST bootstrap — on a re-run an
# OLD atcd may still hold the socket, so both the socket test and `pgrep -x atcd`
# can pass before (or even without) the new daemon binding. If you're re-running,
# stop the old daemon first (`sudo pkill -x atcd; sudo rm -f /run/atcd/atcd.sock`)
# before the start line above. The AUTHORITATIVE proof either way is the
# controller handshake in the next step (`atc ota status`), which only succeeds
# when the controller can actually attach to the live socket.
for i in $(seq 1 10); do
[ -S /run/atcd/atcd.sock ] && pgrep -x atcd >/dev/null && { echo DAEMON_UP; break; }
sleep 1
done
Release/opt builds log nothing by default, so an empty /tmp/atcd.log is
normal — use pgrep -x atcd as the liveness check. To debug startup, prefix
the run line with RUST_LOG=info.
Running atcd as your login user makes the socket owned by the identity the
controller attaches as — the only setup that works out of the box. If you must
run the daemon as a different user than you SSH in as (e.g. a dedicated
atcd service account), grant that login user the socket with
--socket-owner <login-user>, or add the login user to the daemon user’s
group. Do not reach for --socket-mode 0666. That mode opens an admin control
socket to every local process.
Started this way, lifecycle hooks run as atcd’s own uid — your SSH login
user (the atc-hooks default in atc-toml.md applies to the
systemd/bootstrap install, not this recipe). --hook-run-as-user <user> cannot
work here: switching the hook uid needs a root (or CAP_SETUID/SETGID) daemon,
and atcd refuses a hook user that resolves to its own uid. On this recipe, omit
the flag.
A hook on this recipe cannot drive systemctl --user either, because the
device has no user session. For a hook that must reach a root-owned file, use a
supplementary group for the daemon’s user, then restart atcd. See path 1 in
Root work on a device with no user session.
Path 2 in that section needs systemd as PID 1, so it does not apply here.
/runis tmpfs — it does not survive a container restart./run/atcdand the running daemon are both gone after the container restarts, so steps 2 and 3 (mkdir -p /run/atcd+ theatcd runstart) must run on every container start — bake them into your image’s entrypoint / init script. For a durable socket dir that survives restarts, put it under the (persistent) store root, e.g.--socket /var/atc/run/atcd.sock(and set the matching[daemon] socket_pathin youratc.tomlso the controller probes the same path).
The controller then talks to it exactly as a bootstrapped device — no bootstrap
needed, plain SSH:
atc ota status user@device # controller SSH-execs /usr/local/bin/atcd attach
atc ota push --version v0.1.0 --switch user@device
The first contact after starting atcd commonly exceeds the 5s ping window and
fails with ATC-E0308: ping ... timed out — the recipe worked; retry once and
it succeeds immediately.
If atc ota status reports the daemon as absent here, check pgrep -x atcd
on the device (and /tmp/atcd.log, if you started it with RUST_LOG) and
confirm the binary is at /usr/local/bin/atcd (step 1).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| pushed but nothing changed on device | no --switch (staged only) | re-push with --switch, or atc ota switch user@device <version> |
ATC-E0303: could not resolve hostname v0.1.0 | reversed ota switch args | order is <TARGET> <VERSION>: atc ota switch user@device v0.1.0 |
ATC-E0419 on bootstrap, message says no systemd | device has no systemd as PID 1 | use the no-systemd recipe |
ATC-E0308: ping ... timed out after 5s — can follow any command (first contact after starting atcd, or e.g. a status right after a successful push) | transient: an SSH connection / host-key exchange exceeded the fixed 5s ping window; it does not mean the daemon died | retry once — it succeeds immediately; if it persists, atc ota netcheck confirms reachability |
daemon running but status/netcheck say “not present” | atcd not at /usr/local/bin/atcd | sudo install -m755 /tmp/atcd /usr/local/bin/atcd and restart it |
ATC-E0420: atcd is up, but its control socket is not reachable (or a raw Permission denied (os error 13) connecting to atcd.sock) | daemon started as a different user (e.g. sudo, or a --root bootstrap) than your SSH login user | run atcd without sudo (step 3). On a --root bootstrap install, re-run atc bootstrap atcd install --root <user>@<host> so the unit grants the socket to that account, or set [daemon] socket_owner in atc.toml (atc.kdl has no such key). On a default install, add the login user to the atc group (sudo usermod -aG atc <user>), then retry after the shared SSH connection closes (2 minutes idle) |
ATC-E0470: atcd on <host> is installed, but the SSH login user cannot run it (the shell reports /usr/local/bin/atcd: Permission denied) | a --root install left the store root /var/atc in a group your SSH login user is not in, so that user cannot traverse to the atcd binary the symlink points at | re-run atc bootstrap atcd install --root <user>@<host>, which puts the store root in that account’s group; or on the device chgrp $(id -gn <user>) /var/atc |
ATC-E0401: atc.toml is malformed / ATC-E0415 404 from atc.dev, but atc.toml is valid | an older build masking the socket Permission denied above — it couldn’t reach the daemon and fell back to a download | fix socket ownership (row above); on current builds this surfaces directly as ATC-E0420, not a config/download error |
| bootstrap: “arch does not match remote” | the --from atcd is for the wrong arch | drop --from and let bootstrap download the matching atcd, or supply one built for the device’s uname -m |
ATC-E0504: bundle source not found | [[bundle]] source dir missing | create the source dir (e.g. dist/) with your built artifact |
| command hangs with no output | sudo/SSH password prompt in a non-TTY | use passwordless sudo + key auth, or --non-interactive |
Next steps
- atc-toml.md. The full manifest schema and the lifecycle-hook reference.
examples/heartbeat-module/. A repo that ships a module: an executable the device runs after the delivery commits, rather than a payload it only unpacks. Its README says what the example proves and what it cannot do. modules-today.md carries the whole map.
Getting started: human identity (atc auth login)
This is the quickstart for a person authenticating to an atc-server
control plane with the atc CLI — as opposed to ../development/building-from-source.md, which
walks through shipping software to a device. It assumes an atc-server is
already running somewhere and you know its URL; if you’re standing up the
server itself, see docs/runbooks/workos-identity-setup.md (WorkOS) or
docs/runbooks/local-identity-setup.md (WorkOS-free).
Mental model
A server exposes exactly one of four auth methods, discovered automatically — you never configure the CLI to match it:
| Server mode | What atc auth login does |
|---|---|
none (anonymous, trusted-network) | Records a local profile; nothing to authenticate |
static_token | Prompts for (or reads via --token-stdin/--token-file) a shared bearer token |
local_token | Same as above, but the token is a per-user JWT an operator minted for you |
workos_device | Opens a browser approval flow (RFC 8628 device authorization) |
atc never accepts a token on the command line — only an interactive prompt,
stdin, or a file. That’s deliberate: a token on argv ends up in shell history
and ps.
First login
atc auth login --server https://atc.example.com
- If the server is
workos_device:atcprints a URL and a short code, then opens the URL in your default browser. Approve in the browser, andatcfinishes automatically once you approve — no need to press anything locally.atcdoes not open the browser over SSH, on Linux with no graphical display, or with--no-browser(orATC_NO_BROWSER=1). Then open the printed URL yourself. If the browser cannot start, the login continues and prints the reason. - If the server is a token mode:
atcprompts for the token on your terminal (input hidden, never echoed). Get the token from whoever administers the server (a shared admin token, or one minted for you personally inlocalmode). - If the server is
none: you’re done immediately — there was nothing to authenticate.
A successful login activates a profile (default name default) that
every later atc command uses automatically. Confirm it:
atc auth status
Working with more than one server
Name a profile per server so you can switch between them:
atc auth login --server https://staging.example.com --profile staging
atc auth login --server https://prod.example.com --profile prod
atc auth profiles # see everything you have stored
atc auth use staging # switch which one is active
Everything after login (push, ota status, etc.) uses whichever profile is
active — there’s no need to repeat --profile on every command.
Logging out
atc auth logout
For a WorkOS login this actually revokes the session server-side (not just a
local delete) — the CLI confirms the revocation before wiping local state. If
it can’t confirm (network blip, server hiccup), it says “logout incomplete”
rather than silently claiming success; run atc auth logout --retry to resume.
Full recovery paths (including the un-revocable case) are in
docs/runbooks/workos-identity-operations.md.
When something goes wrong
| Symptom | What’s happening | Fix |
|---|---|---|
| “profile is already bound to a different organization” | You’re re-logging in with --org different from the profile’s existing binding | atc auth remove <profile> first, then log in fresh |
| “has an unfinished logout” refusing a new login | A prior atc auth logout didn’t confirm revocation (quarantine) | atc auth logout --profile <name> --retry, or see the operations runbook |
Login works but ota/admin commands still 401 | A stale ATC_SERVER_TOKEN env var is shadowing your stored profile | atc auth status flags this — unset ATC_SERVER_TOKEN |
| Commands reach another server or org than your login | An exported ATC_SERVER_URL or ATC_SERVER_ORG comes before your login and atc.toml | unset ATC_SERVER_URL ATC_SERVER_ORG |
| No browser available (headless box) | The workos_device flow needs a human to approve somewhere | Approve from any device/browser — the printed URL isn’t tied to the machine running atc. atc skips the browser launch itself when it finds no display or an SSH session |
| Keyring backend errors | No keyring daemon on this host | atc auth login --credential-store file |
Full day-to-day operations (session revocation, incident recovery, mode
transitions) live in docs/runbooks/workos-identity-operations.md.
See also
docs/runbooks/workos-identity-setup.md— stand up a WorkOS-backed server.docs/runbooks/workos-identity-operations.md— login/logout deep dive, quarantine recovery, session revocation.docs/runbooks/workos-identity-rotation.md— key/JWKS rotation (plus the fail-closed readiness kill switch: a signed lease or the explicitATC_WORKOS_READINESS_INSECURE=1opt-out) (server operators).docs/runbooks/local-identity-setup.md— the WorkOS-freelocalmode.
atc.toml reference
atc.toml declares what you ship to a robot and how it behaves across the
delivery lifecycle. One file feeds both delivery planes: atc ota push sends it
over SSH, and atc ota publish --deliver and atc channel deliver embed the same hook tables in the signed
manifest a polling device fetches for itself. This page documents the
lifecycle-hook schema, because it is the part the parser validates most
strictly and the part that has caused the most field confusion.
The schema below is derived from the parser (
crates/atc-lifecycle/src/config.rs), which is the source of truth. If this doc and the parser ever disagree, the parser wins — file a docs bug.
There is no [[module]] table. A repo declares modules in atc.kdl, and
no TOML syntax reaches one. What a declared module can and cannot do is mapped,
with citations, in
modules-today.md; a working repo is
examples/heartbeat-module/.
Which file atc reads. With no -c, atc reads the nearest atc.toml
or atc.kdl in the current directory or a parent. When one directory holds
both, atc reads atc.kdl and prints a note that names the atc.toml it did
not read. Set ATC_KDL_CONFIG=0 to read the atc.toml instead. The full rule
is in Where atc finds the file.
What a bundle’s source ships
[[bundle]]
name = "myapp"
source = "dist"
source names a directory. atc ships every file and every resolvable symlink
under it, subdirectories included, at any depth. The two exceptions are the
bundle’s exclude patterns and Python bytecode, both described below. Nothing
declares that layout, and every push reads the directory again.
Three things the walk does not carry:
- An empty directory has no entry of its own, because a directory is implied by the paths under it.
- A FIFO, socket or device node is skipped, and the push still succeeds. A bundle that needs one must have a hook create it on the robot.
- A dangling symlink is a hard error, not a skip. The push stops with
ATC-E0510and names the link, because a broken link shipped verbatim fails confusingly on the device. An unbuiltnode_modules/.binis the usual source. If the robot does not need that directory, add it toexclude.
Leave paths out with exclude
[[bundle]]
name = "myapp"
source = "dist"
exclude = ["**/.git", "**/*.log", "tmp"]
Each pattern is a glob that matches the path relative to source. * stays
inside one directory, so *.log matches only at the top of source. **
crosses directories, so **/*.log matches at any depth. A directory that
matches is left out with everything under it, and the walk does not enter it.
So a dangling symlink inside an excluded directory does not stop the push.
A pattern that can never match stops the parse with ATC-E0531. That is an
empty pattern, a leading or trailing /, a . or .. segment such as
./build, or a glob that does not compile.
Python bytecode is left out by default
The push leaves out every __pycache__ directory and every .pyc and .pyo
file. The robot cannot use them. The push does not keep a file’s modification
time, and CPython checks a timestamp-based .pyc against the time of its
source. So on the robot CPython rejects the .pyc and compiles the source
again. On a read-only store it cannot write the result either.
When the default left something out, atc ota push prints one line:
atc: note: bundle web: left out 2 Python bytecode files. Set `ship_python_bytecode = true` to ship them.
Set ship_python_bytecode = true on the bundle only if you build hash-checked
bytecode (python -m compileall --invalidation-mode checked-hash). CPython
accepts that form whatever the file times are.
In atc.kdl, both keys are children of the bundle node:
bundle myapp {
source "dist"
exclude "**/.git" "**/*.log"
ship-python-bytecode #true
}
On any other artifact kind, loading the file fails with ATC-E0776.
Point source at a clean build output
Apart from exclude and the bytecode default, nothing filters the tree. So a
directory you also work in ships what your tools left there. A checkout used as
source ships its whole .git history, and a test run leaves .pytest_cache.
Nothing fails on the robot. The push still sends the bytes, and the store holds them for every version it keeps.
atc ota push names what it found before it uploads:
atc: warning: bundle web: 2 staged files are in a tool cache: .pytest_cache/v/cache/nodeids, .pytest_cache/v/cache/lastfailed.
atc: The robot never reads a tool cache. The store holds these bytes for every version it keeps.
atc: Point `source` at a clean build output. Delete the cache directory before the push.
It reports two kinds: a tool cache (.mypy_cache, .pytest_cache,
.ruff_cache, .tox, node_modules/.cache) and a .git repository. Each
warning names up to three paths and counts the rest. The push continues: a
bundle that ships one of these on purpose is legitimate, and a refusal would
break it. To leave one out for good, add it to exclude.
So reorganizing inside source costs no atc.toml edit. A flat dist/myapp
that becomes dist/bin/myapp plus dist/share/VERSION needs no change here,
and the next push carries both new directories. atc ota push --dry-run
previews the upload size per artifact, though not the paths it would send.
The dist directory that excludes itself
A build that stages into ./dist/ usually copies the project tree first. An
unanchored exclude then removes more than the staging directory:
rsync -a --exclude=dist ./ ./dist/ # WRONG: drops every nested dist/
rsync -a --exclude=/dist ./ ./dist/ # anchored: only the top-level one
rsync matches an unanchored pattern against every path component, so
--exclude=dist also drops node_modules/<pkg>/dist/. Several npm packages
keep their runtime files there. The bundle still validates and still pushes,
and the program fails on the device with a missing module.
Anchor the pattern with a leading /, or stage into a directory whose name
appears nowhere else in the tree.
A binary that reads files beside itself
A delivery path carries a content hash, so it changes on every version. A
bundle that ships bin/ and share/ together must resolve share/ from the
directory of its own executable, and must ask the platform for that directory:
| Language | Take the directory this way |
|---|---|
| Go | filepath.Dir(exe), after os.Executable() |
| Python | os.path.dirname(os.path.realpath(__file__)) |
| Rust | std::env::current_exe()?.parent() |
| Shell | cd -P -- "$(dirname -- "$(readlink -f -- "$0")")" && pwd |
Do not assemble the path by string concatenation, such as exe + "/../share".
That form breaks as soon as a caller reaches the program through a symlink or a
relative path. It breaks on the device, not on the build machine.
The shell row resolves the link first. $0 is the path the caller typed, so a
wrapper reached through a convenience symlink would otherwise look for share/
beside the LINK. readlink -f also resolves delivery/current. That lands on
the version directory the program runs from, where bin/ and share/ are
siblings.
A hook is a different case. A hook runs in a sibling of delivery/, so it must
read current_root out of ATC_HOOK_CONTEXT. See “current_root: the path a
service unit must name” below.
A third-party binary whose version you do not own
A cloned upstream project bakes its own version into its --version output, and
a pin to one commit cannot change it. The bundle version and the program version
are two labels, and atc owns only the first.
atc ota push --version v1.0.0 sets the bundle version. That is the label
atc ota ls reports and atc ota switch takes, and it is independent of what
the shipped program prints.
To report it from the bundle as well, ship a VERSION file beside the binary
and a wrapper that reads it:
dist/
bin/jq # the upstream binary, untouched
bin/jq-wrapped # the wrapper below
share/VERSION # v1.0.0, written by your build
#!/bin/sh
# bin/jq-wrapped - report the bundle version, then hand off to upstream.
set -eu
self=$(readlink -f -- "$0")
here=$(cd -P -- "$(dirname -- "$self")" && pwd)
if [ "${1-}" = "--bundle-version" ]; then
cat "$here/../share/VERSION"
exit 0
fi
exec "$here/jq" "$@"
Write share/VERSION from the build step that passes --version to atc ota push, so the two labels cannot drift.
Shipping a compiled binary
This section is about compiled binaries. A node or python bundle ships source, so skip it.
Run file on the artifact, then ldd. An autotools or libtool build writes a
wrapper shell script under the binary’s own name, and leaves the real ELF
under .libs/. ldd tells you nothing useful about that wrapper, which is why
file comes first. Configure with --disable-shared to get one
self-contained binary instead.
ldd then lists what the binary loads at start. The robot must carry every
library it names, or the process dies with error while loading shared libraries.
A second failure reads differently. A library that resolves by soname, but is not the one you built against, loads without complaint. The process then dies at symbol-binding time:
/app/bin/myapp: symbol lookup error: /app/bin/myapp: undefined symbol: jv_free
The file was found, so the “cannot open shared object file” message never
appears. A non-static autotools build reaches this often: the real ELF under
.libs/ links the soname, and the build host already carries an older copy of
it. The binary runs on that host and dies on the robot, or the reverse.
readelf -d <file> | grep NEEDED names the sonames it will accept.
The toolchain decides how you link statically, and no flag is a guarantee.
For autotools, configure with --disable-shared --enable-static. That governs
the project’s own libraries, not libc, so add LDFLAGS=-static when you want
libc in the binary too. For Go, build with CGO_ENABLED=0 go build. A plain
go build looks static and often is not, because the standard library pulls
cgo in for DNS lookups and for os/user. For Rust, build for a musl target.
Check the result rather than trusting the flag. file should say statically linked, and ldd should say not a dynamic executable.
The glibc risk runs one way. glibc keeps older binaries working, so a
binary built against an older glibc than the robot’s runs there. Only the
reverse needs a static link or vendored libraries: a newer build machine, an
older robot. Run ldd --version on both machines to see which case you have,
rather than deploying to find out.
atc ota push reads the staged binaries. It refuses a binary that binds a
newer glibc than the robot reports, and it warns about a libtool wrapper
script. It says nothing about shared libraries, because a dynamic link is the
right answer when the robot carries them.
Loader search order, $ORIGIN rpaths, and an install.exit hook that refuses
a tree whose binaries cannot resolve:
shared-library-resolution.md.
A device with several hosts
A robot can carry several computers, each with its own atcd, for example a
nav computer and a compute computer. Each computer is a host, and its
role is its job in the robot. Set a host’s role when you install atcd:
atc bootstrap atcd install --role nav.
Give each artifact for one role a role. An artifact with no role goes to
every host. The [device.hosts] table maps each role to the SSH target of its
host:
[[bundle]]
name = "planner"
source = "dist/planner"
role = "nav"
[[oci]]
name = "team/vision"
platform = "linux/amd64"
role = "compute"
[[bundle]]
name = "common"
source = "dist/common"
[device.hosts]
nav = "ops@nav-14"
compute = "ops@compute-14"
A role is 1 to 63 of a-z, 0-9, - and _, and starts with a letter or a
digit. atcd refuses an artifact whose role is not its own.
atc ota push --device --version v2 moves every host to v2 as one cutover:
- It reads each host, and refuses a host whose role is not its key in
[device.hosts]. A host that cannot be reached stops the push before any host changes. - It pushes
v2to each host with no switch, with only the artifacts for that host’s role and the artifacts with no role. - It switches every host to
v2at the same time. Each switch runs theactivateandcommithooks. - If one host fails, or does not finish its switch within
--health-timeout(default15m), the push rolls back. Every host that runsv2switches back to the version that every host ran before. A new device, whose hosts share no earlier version, has nothing to roll back to. The error then names each host that runsv2.
--device needs --version or -t, because a second run finds the cutover by
its label. For the same reason it refuses --overwrite: push changed content
under a new label. The push keeps no state. If it stops in the middle, run the same
command again: it reads each host and finishes the cutover. A host that cannot
go back to the old version is named in the error, with the atc ota switch
command that moves it.
A host that reaches the robot through a gateway needs a ProxyJump entry in
your ssh_config. Only a host whose atcd reports the device-cutover-v1
capability can take part. An older atcd is refused with a message to upgrade
it. atc.kdl spells an artifact role (role "nav") and this table
(device { hosts { nav "ops@nav-14" } }), so atc ota push --device works from
either file.
Files signed with each version: attachments
A [[bundle]] or [[oci]] table can name files to keep with each version
that atc ota publish uploads. Examples are a wiring diagram or a test
report. Devices never download them.
[[bundle]]
name = "field-config"
source = "dist"
attachments = ["docs/wiring.txt", "reports/bench.txt"]
- Each path is relative to the manifest and stays inside its directory: no
absolute path and no
... The file is stored under its file name, which must be 1 to 128 ofA-Z a-z 0-9 . _ -and start with a letter or a digit. Two files with one name are refused. atc ota publishreads each file before it uploads anything. It refuses a file over 256 KiB, and more than 32 files for one artifact.- The hash of each file is signed into the version’s metadata, so it is part
of the version’s identity. A changed file is a changed version, and needs
a new
--version. - The publish uploads each file after it creates the version. The server takes only bytes with the signed hash, and nobody can delete the file. A rerun uploads the files again, so it finishes an interrupted publish.
- A delivery does not carry the hashes, so devices see no change.
atc ota attach ls --version <artifact>@<version>lists the files. A signed name that has no upload yet showsnot uploaded.
atc.kdl has no spelling for attachments yet, so a manifest that uses them
stays atc.toml.
[ownership]
atc ota publish --deliver reads this table. One manifest owns the delivery
config of a server channel: its hooks, workloads, peripherals, state_dirs and member roles.
Another manifest that ships artifacts into that channel defers to the owner.
The ownership block of atc.kdl is the same table.
[ownership]
id = "robot-base"
owns = ["stable", "beta"]
defers = { canary = "robot-core" }
| Key | Meaning |
|---|---|
id | The name of this manifest. A delivery to a channel in owns carries it, signed. owns needs an id |
owns | The server channels whose delivery config this manifest owns |
defers | A map of channel to owner id. This manifest defers that channel to the manifest with that id |
The channels are server channels, the --channel of atc ota publish. They
are not the [channel] table that atc ota push targets. An id and an owner id follow the rules
of an artifact name. A file with no [ownership] table makes no claim.
atc ota push, atc ota publish and atc config lint refuse these tables:
| Code | Refused table |
|---|---|
ATC-E0506 | owns with no id, or an id or owner id that is not an artifact name |
ATC-E0538 | A channel in owns or defers that cannot be a server channel name: empty, longer than 64 bytes, ., .., current, or a character outside A-Z a-z 0-9 . _ -. A / is the usual cause: a server channel name has no namespace |
ATC-E0539 | A channel that the table both owns and defers: it is in owns and in defers, or defers names the table’s own id |
atc config lint also warns when owns names a channel more than once
(ATC-E0588). That changes no delivery.
The manifest does not list its server channels. So the lint cannot find a
channel name that the org does not have. atc channel owner <channel> shows the owner that the server keeps for a channel.
Release through atc-server
tells what the server does with the claim.
Lifecycle hooks
A lifecycle hook runs a script on the robot at a specific (state, phase) during a delivery. The table key is exactly:
[<scope>.lifecycle.hook.<state>.<phase>]
The .hook. segment is required. Omitting it (e.g. [lifecycle.commit.exit])
is rejected at push time — it does not silently wire nothing.
Body keys
| Key | Required | Type | Notes |
|---|---|---|---|
script | yes | string | @self/<path>, @<name>/<path>, or an absolute /path |
timeout_secs | no | int | any u32; 0 disables the timeout (hook runs unbounded); omit for the 30s default (negative is rejected). atc imposes no ceiling — a hook that copies a multi-GiB image needs a budget no fixed number could set, and 0 already meant unbounded, so a cap forbade only honest finite values |
allow_missing | no | bool | true: if the absolute script does not exist on the device, skip the hook and continue. Applies on every delivery. See A hook script that the artifact installs |
allow_missing_initial | no | bool | true: the same skip, but only when the device has no earlier version of this artifact. A later delivery with a missing script fails as usual |
timeout_secs = 0 is push-only. A signed (pull-plane) delivery carrying it is
refused on the device — the daemon stays on the prior delivery and reports
hook_config_invalid — because an unbounded pre-flip hook wedges the poller with
no retry budget to fall back on.
No other keys are allowed in a hook body. A stray run / command / exec
(or any other key) is rejected. A [delivery.hook.*] or [lifecycle.hook.gc.*]
bookend takes script and timeout_secs only.
A hook script that the artifact installs
If the artifact can carry the script in its own tree, use @self instead.
Before the install, a @self hook is the active version’s own hook, and a
first delivery runs none
(Which version’s script @self runs). This
section is for a script that must live at a fixed system path.
Sometimes the artifact itself installs the script its hook runs. For example,
a .deb installs /usr/lib/agent/pre-download.sh, and the download.enter
hook runs that path. On the first delivery to a device the file does not
exist yet. Without a policy, the hook fails with ENOENT and atcd refuses the
delivery that would install the script.
Set allow_missing_initial = true for this case:
[[bundle]]
name = "agent"
source = "dist"
[bundle.lifecycle.hook.download.enter]
script = "/usr/lib/agent/pre-download.sh"
allow_missing_initial = true
The first delivery of agent to a device skips the hook. Each later delivery
runs it. If the script is missing on a later delivery, the hook fails the
delivery as usual, because the package that owns it is already installed.
Set allow_missing = true to skip a missing script on every delivery.
The rules:
- Set one key or the other. A hook that sets both is refused, even when one of
them is
false. - Both keys need an absolute
script. On@self/…or@<name>/…they are refused, because atc places those trees itself. - Only a missing file is skipped. A script that exists but has no
+x, a path that escapes its allowed root, and every other pre-spawn check still fail the hook. - “No earlier version” means the device has no prior version of this
artifact. The uninstall gate and
atc ota run-hookare not deliveries, soallow_missing_initialnever skips there.allow_missingdoes. - A
[[peripheral]]hook refuses both keys. A peripheral hook is the flash, and a skipped flash would still record the peripheral as flashed. - A symlink at the path whose target does not exist is not a missing script. It fails the hook.
- A stage hook (top-level
[lifecycle.hook.*]) takesallow_missingonly. It belongs to no one artifact, so it has no first delivery. - A skipped hook passes its gate. The push output prints a
→ <state>.<phase> <script> skipped (script missing)line for it.atc ota statuslists it in aSkipped Hookstable, and thelifecycle_logrows ofatc ota status --jsonshow the outcomeskipped_missing. Thelifecycle.hookspan carriesoutcome="skipped_missing". - A device whose atcd predates these keys ignores them, and a missing script fails the hook as before.
States and phases
<state> | meaning |
|---|---|
check | observation-only probe (never mutates) |
download | bytes are being fetched to the device |
install | the new version is being laid down |
activate | the new version goes live. Its two gates straddle the pointer flip |
deactivate | the outgoing version stands down. Runs nested inside activate |
commit | the delivery is durably recorded, after the flip |
rollback | a failed delivery is being reverted |
failure | terminal failure sink |
uninstall | an old version is about to be deleted by housekeeping |
<phase> | meaning |
|---|---|
enter | before the state’s work |
exit | after the state’s work succeeds |
enter refuses, exit rolls back. On a push, on a pulled delivery and on
an atc ota switch, a hook that exits nonzero at enter refuses the step. The state’s work never
started, so atc undoes nothing. It runs no rollback.*, no failure.*, and no
activate.* for the outgoing version. current does not move, and the push or
switch reports refused, the device did not change. A hook that exits nonzero at
exit answers after the work, so atc rolls the delivery back.
commit.enter is the one exception. It runs after the pointer flip, so a
refusal there rolls back like an exit refusal. delivery.pre refuses with no
ladder, as before. A switch crosses two of these gates, activate.enter and
deactivate.enter. See
Which of these run on an atc ota switch.
A refusal is clean only when every hook that ran at that gate exited nonzero.
Some gates run more than one hook, for example a deactivate.enter with a
stage hook and one hook per member. If one of them passed, it may have done its
work, so atc rolls back as before. A hook that times out, is killed, or cannot
start also rolls back, because it may have stopped part way.
A stage hook that refuses at activate.enter or deactivate.enter stops that
gate, on a push and on a pulled delivery. No artifact hook runs beside it at
that gate. A stage hook that exits nonzero there stays a clean refusal. One that
times out, is killed, or cannot start still rolls back. At deactivate.enter
the outgoing version’s hooks do not run, so atc records no teardown. Before
issue #7215 a push still ran the artifact hooks at both gates.
Write an enter hook so that it changes nothing when it exits nonzero. Its
refusal tells atc that the device is as it was.
enter and exit are the only two. There is no error phase to declare —
nothing dispatches one, at any state, so <state>.error is rejected at push.
The authoritative list is atc_lifecycle::State::ALL
(crates/atc-lifecycle/src/state.rs).
The order they run in
check.enter → check.exit
→ download.enter → download.exit
→ install.enter → install.exit
→ activate.enter (may the new version go live?)
→ deactivate.enter (the outgoing version stands down)
→ deactivate.exit
→ [the current pointer flips]
→ activate.exit (the new version confirms it is live)
→ commit.enter → commit.exit
deactivate runs nested inside activate, which puts every step that can fail
ahead of the one destructive step. A veto at activate.enter has torn nothing
down.
A name shared across versions
Read this order before you write a hook that claims a name the device holds only once. Examples: a systemd unit name, a TCP port, a lock file, a PID file. Every version claims the same name. The order above decides who holds it at the end.
Start the service at activate.exit. Never at activate.enter.
activate.enter runs before the outgoing version’s deactivate.*. A start
placed there is stopped again by the version it replaces. The push then reports
success with the device on the new version and the service down. activate.exit
runs after both deactivate gates, so the order pays the stop back.
Make the start a restart, not an enable --now. deactivate.* reads the
OUTGOING version’s declaration. So the hook that stops the service is the one
already on the device. Add the pair in v2 and push it over a v1 that declares
none, and nothing stops v1. systemctl --user enable --now then exits 0 against
a unit that is already active, does not restart it, and leaves v1’s process
holding the name. The delivery commits green, atc ota status names v2, and the
robot runs v1. A first deploy dispatches no deactivate.* at all, because there
is no outgoing version to tear down.
Stop the service at deactivate.enter or deactivate.exit. Both run before
the flip, and the incoming activate.exit runs after them. So a stop there needs
no test for “am I still the current version”. The order answers that question.
To ask “may this version stand down now?”, put the stop at deactivate.exit.
Then deactivate.enter is free for that check, and it runs before anything
stops.
Know what a refusal there does. On a push, on a pulled delivery and on a
switch, a refused deactivate.enter leaves the device as it was: the outgoing service keeps
running, and nothing restarts it. A push does not try again, so push again when
the device is ready. A poll tries again on the next poll. To ask the poller to
retry sooner or later, exit 21 instead; see
How long a pulled delivery defers.
A refused deactivate.exit starts the unwind ladder, because the stop already
ran. The ladder runs the outgoing version’s activate.* again to bring the
service back.
A worked pair of hooks is in Worked example: a long-running service.
The unwind track is rollback.enter → rollback.exit, and failure.* after that
if a rollback hook itself fails.
A rollback restores the delivery that the vetoed one displaced. A first
delivery displaced nothing, so a veto after its flip removes delivery/current
instead. The push footer then reports “no earlier delivery to restore”, and a
service unit that starts from that pointer fails until the next successful
push. Give such a unit a ConditionPathExists= line; see
install_unit.sh in the worked example.
The pointer flip used to happen between commit.enter and commit.exit. If
you have existing commit.* hooks, read
the hook cutover runbook: they all still run, but
three gates changed which side of the flip they sit on.
uninstall.* is not on this graph at all. The periodic housekeeping sweep and
atc ota rm both dispatch uninstall.enter then uninstall.exit for each
version they are about to reclaim, and delete only if both return 0. Both run before the deletion,
because an uninstall.exit script lives in the tree that is about to go.
Which of these run on a pulled delivery
atc ota publish --deliver and atc channel deliver --lifecycle-config author
the same three hook tables atc ota push does (artifact, stage, delivery) from
the same atc.toml, using the same parser, and sign them into the manifest. A polling device re-validates those bytes against
the manifest signature before it runs anything, but the declaration it runs is
yours, unchanged.
All fourteen forward cells now run on a pulled delivery. A pulled delivery
walks delivery.pre, then per member check.enter → check.exit → download.enter → download.exit → install.enter → install.exit, then
activate.enter → deactivate.enter → deactivate.exit → [flip] → activate.exit → commit.enter → commit.exit → delivery.post. That is push’s order and the whole
FORWARD pull walk. The unwind ladder is a separate walk. It is not narrower
either.
Read this before you publish to a pull-fed fleet. Until now those six cells
were accepted, signed, delivered, and never run. Your channel may already publish
a check.*, download.* or install.* hook. A device that upgrades past this
change starts running it. Check what those scripts do first.
@self resolves the same way on a pull as on a push. At check.*,
download.* and install.enter the hook is the ACTIVE version’s own, with
that version’s atc.toml entry and script. From install.exit on, it is the
incoming version’s
(Which version’s script @self runs).
check.*, download.* and install.* cross one gate per member, on both
planes. They have no delivery-wide barrier, so they run at artifact scope only.
A top-level [lifecycle.hook.check.enter] table is refused at publish, and a
device that somehow receives one warns about it on
atcd.pull.undispatched_hook_total. Every other state runs at both scopes.
A member the device already holds crosses check.* and neither of the other
two. It fetches nothing and places nothing, so a download.* there would say
bytes arrived when none did. Push makes the same skip, member by member, for a
version the device holds when that member’s check.exit runs, active or not
(#7217). The push transfer itself does not change.
delivery.pre now runs before any byte is fetched. That is where push runs
it. On this plane it used to run after the poller staged every member. Two things
follow.
A hook that read the incoming version’s files in delivery.pre no longer finds
them. Move that work to install.exit or activate.enter.
A poll whose download then FAILS has run delivery.pre already. So a channel
stuck on an unfetchable blob re-runs the hook at poll cadence. The same failure
on a push costs one operator-driven attempt.
The delivery bookend closes on every ending that ends the walk.
delivery.post runs at the end of a pulled delivery whose delivery.pre
passed. That holds for a staging failure, for a refusal before the flip, for a
refusal after it, and for a spent retry budget. Push pairs the two bookends the
same way. A delivery.pre that refuses or defers opens nothing, so the poller
runs no delivery.post for it.
One case is neither. A poll superseded between its hooks and its commit leaves
the delivery RESUMABLE, so it runs no delivery.post of its own. The poll that
finishes the delivery runs it. If that delivery is never advertised again, its
delivery.pre stays unpaired.
A deferred poll is not an ending, so it runs no delivery.post either. The
poller has a retry scheduled for the same delivery. So a hook that exits 21 at
activate.enter, after delivery.pre passed, leaves that delivery.pre
unpaired for that poll. The poll that ends the delivery closes the bookend: the
commit, a refusal, or an operator-set retry cap that runs out. Do not read the
pair as 1:1 across a chain of deferred polls.
A delivery.post that refuses still runs once. The bookend that reports a
failure can be the failing hook. The poller records that refusal, keeps the
reason the walk already reported, and starts no second unwind.
What a refusing hook costs is not identical on the two planes. A hook that refuses after the flip un-flips the delivery on both. The prior delivery becomes the active one again, and the incoming version’s modules are drained.
The compensation ladder then runs, on all three planes. It dispatches
rollback.enter, then the compensating activate.* for the outgoing version,
then rollback.exit. If a gate refuses, the compensating activate.* does not
bring the outgoing version back, or the take-back cannot restore the prior
delivery, it also dispatches failure.enter and failure.exit. The
compensating activate.* runs no stage-scope hook. So if a stage
deactivate.* stopped the workload and no forward stage activate.* passed
after it, the workload stays down, and failure.* follows. The
ladder’s own cells honour no --skip-hook and no --hook-env: an operator flag
may not suppress a recovery hook.
If the compensating activate.enter refuses, its activate.exit does not run.
That is the rule for every refused enter gate (#6389): the state’s work did
not start, so its exit has nothing to close. It is a refusal only when every
hook that ran exited non-zero, and every member that deactivate.* stopped ran
an activate.enter hook. Otherwise activate.exit still runs. A member with no
activate.enter did not refuse, and its activate.exit may be what restarts
it. After a timeout, a signal or a dispatch fault, the device state is not
known.
A ladder that completes ends the delivery rolled_back. A ladder that does not
complete ends it failed, and failure.* runs. Each member’s push row
(pushes) takes the same status. This applies to a push whose pointer flip
landed, and to a pull whose flip landed. It also applies to a push or a pull
that a hook refused before the flip, when the ladder brought back members that
deactivate.* stopped. A push that fails before any teardown has nothing to
roll back, so it ends failed. On a pull, a compensation before the flip that
does not bring the outgoing version back runs failure.*. If a member of that
version declares no activate.*, nothing can bring it back, so the delivery
ends failed and the channel accepts the next pulled delivery (#8055).
Otherwise, for example after a refusal by the outgoing version’s own
activate.enter, the delivery stays in_progress, and the next poll or the
next start tries again.
Before #6750 and #6939, a ladder whose hooks
refused could still report rolled_back, and a clean ladder before the flip
reported failed. Before #6977, a pull ladder ran no compensating activate.*
after the flip, and a dirty pull ladder before the flip still ended
rolled_back.
The compensating activate.* names the version it restores as
version.new_sha. So a hook that pins or marks new_sha acts on the version
that comes back. On the push ladder version.old_sha is the failed version. A
pull compensation, a switch unwind and a boot recovery name the restored
version in both fields. So do not tear down old_sha unless it differs from
new_sha. On rollback.* and failure.*, new_sha is still the failed
incoming version.
An unwind hook cannot stop the unwind. A refusal at any of those four gates is recorded and the take-back continues, because the ladder is already the failure path. This differs from a forward gate, where a refusal is a veto.
Before #3298 the poller dispatched none of the ladder, so a rollback.* hook on
a pull-fed device parsed, published without error, and never fired.
A hook that refuses before the flip at an exit gate is compensated on both
planes, in the poll or push that heard the refusal. If the outgoing version was
already torn down, the poller re-runs that version’s activate.* to bring the
workload back, under the auto_rollback_veto trigger. A refused enter gate
owes no compensation, because nothing was torn down (#6389).
Nothing bounds a permanent pre-flip refusal on the pull plane. A veto writes no
retry-budget row, so the next poll dispatches the whole pre-flip half again. A
deactivate.exit hook that refuses forever therefore stops and starts the
outgoing version once per poll. Each of those polls also runs that version’s two
activate gates. A deactivate.enter hook that refuses forever stops nothing: the
poll ends at the refusal. A defer (exit 21) is not bounded either, by design
(#6431): see
How long a pulled delivery defers.
No forward state runs on one plane only. check.*, download.* and
install.* used to. The poller fetched and staged bytes without walking them, so
such a hook parsed, published without error, and never fired. It fires now
(#3412 §1). See Which of these run on a pulled
delivery for what changes on a fleet
that already publishes one.
Read your peripheral hooks before you upgrade a pulling fleet. An unattended
device on a firmware channel writes to hardware on a poll. No operator is
present. ATC_HOOK_CONTEXT.trigger reads pull on those runs. It read push
on every peripheral hook before this change, on both planes.
Peripheral firmware converges by pull. A bundle a peripheral subscribes to
used to be refused at the commit with ATC-E0156. That refusal told the
operator to use atc ota push. The poller now runs the flash ladder push runs.
It admits the firmware-payload.toml and parks every subscribed peripheral
BEFORE the version flip. Then it runs each peripheral’s commit.exit and
records last_flashed_sha after it. A flash hook that refuses before the flip
leaves the device on the prior version.
A peripheral subscription is device-local and names no channel. The ladder
used to read subscriptions in the channel the delivery lands on. Every operator
route writes the seeded local/default channel, so the read returned no rows on
any other channel. No pulled delivery flashed anything. Every subscription read
now keys on the peripheral and the artifact. So the ladder finds the operator’s
subscription whichever channel the delivery lands on. atc ota peripheral subscribe and atc ota peripheral apply still take no channel argument.
A peripheral hook must be idempotent. A poll that flashes and then loses its commit re-flashes on the next poll. A re-poll that skipped the flash would flip the pointer over hardware still holding the prior firmware. A crash between the two does the same. Push has the same rule for the same reason: a re-push of a member that is not yet active re-runs the ladder. Write the flash so that running it twice against one payload sha is the same as running it once.
The ladder runs only where the device can run it: pull_plane_hooks_enabled = true and a signature-verified release. Anything else refuses with ATC-E0156
and leaves the prior firmware live. That covers the flag off and an unsigned
release. Pull never flips firmware it did not flash.
A release does NOT have to declare a lifecycle hook of its own. It used to. That
was a limitation rather than a rule. A release can carry the [[peripheral]]
declarations of its source file, hooks included, in the signed manifest. A
release that carries none flashes with the device’s fallback: the
[[peripheral]] blocks of atcd’s --config file, which atcd imports once into
its store at the first start of this release. The old condition refused such a
channel.
Such a release now converges. The device resolves the walk the ladder hangs off when a member of the release has a subscribed peripheral. It dispatches no delivery hook, because the release declares none.
That refusal could not fire on a pulled delivery while the subscription read was channel-scoped. It fires now. Such a delivery stops instead of flipping a subscribed member unflashed.
--skip-hook and --hook-env reach no pull cell, and that includes peripheral
hooks. This is deliberate, not an omission. Both flags scope to one operator
command: atc ota switch --skip-hook commit.exit skips that gate for that switch
only. The next command runs the hook
again. A poll has no operator on the other end. There is no invocation for an
override to scope to.
A pull-plane override has two possible homes, and neither one is safe. In the
signed manifest, a skip would let whoever holds the publish key disarm a
device-owned safety gate. The device already refuses that shape of privilege for
root hooks: the daemon.pull_plane_root_hooks setting in atcd’s store is
device-owned for that reason, and the manifest cannot set it.
In atcd’s settings store, a skip would be a standing disarm that no fleet-side
surface reports. A device-local --hook-env is worse. The daemon applies
extra_env after every framework variable. A standing entry would therefore
override ATC_TRIGGER for every later delivery on that device.
To stop hook dispatch on one pull-fed device, run atcd settings set daemon.pull_plane_hooks_enabled=false on the device. Read the comment on that key first. A device that
ran with the key off can wedge a channel when you turn the key back on.
One smaller difference: timeout_secs = 0 is refused on the pull plane. See
Body keys.
uninstall.* runs on both planes. A version carries its own declaration in an
.atc/lifecycle.pb sidecar next to its files, and both planes write one. Push
writes it at that artifact’s install.exit. The poller writes it when it commits
the delivery that activates the version.
On a pulled version the poller writes a sidecar only when three things hold. The delivery is signature-verified, it carries hook declarations, and the device has pull-plane hooks enabled. An unsigned or legacy delivery declares no hooks at all, so there is nothing to write.
One further case has no sidecar even when all three hold. The server can ask a device to keep a version resident without activating it. Such a version gets its declaration only when a later delivery makes it active, so housekeeping reclaims it silently if it never becomes active.
Read the daemon log if a teardown does not run. The poller warns whenever it commits a delivery whose declared hook config it could not read back.
The sidecar feeds more than the sweep, so expect two other hooks to start
running. A version’s own declaration is also what a later ota switch reads for
its activate.* hooks, and what a push reads for the outgoing version’s
deactivate.* hooks. Before this, a pulled version answered “no declaration” to
both, and both were skipped. Now they run, and a deactivate.* hook that refuses
can veto the push that displaces a pulled version. That is the intended
behaviour on both planes. Check your deactivate.* and activate.* declarations
before you publish to a pull-fed fleet.
How long a pulled delivery defers
A hook that exits 21 on a pulled delivery defers the activation. The device
stays on its running delivery and reports deferred, which the server reads as
in progress. A deferring device is not a failed one, so it does not count
toward a rollout’s failure rate.
The poller tries again for as long as both of these stay true:
- the hook keeps exiting 21, and
- the server keeps the delivery as the device’s desired state.
It stops when the hook exits 0 (the delivery activates), or when the server
advertises a different desired state. A refusal (a veto) ends the deferral
too and reports failed, but a pre-flip veto is not final: the next poll runs
the walk again. A new
desired state is a new delivery, and the poller applies it at once. A deferral
survives an atcd restart, because its state is in the device store.
A deactivate.* hook can stop the running workload before a later gate
defers. The poller then starts that workload again in the same poll, so the
device runs its prior delivery while it waits. The next attempt stops it
again, once per attempt.
A deferring hook can set the wait. To do this, print a line
retry-after: <seconds> on stdout before you exit 21, for example
retry-after: 120. The key is case-insensitive, and the first such line
counts. The poller waits that many seconds, plus up to 10 % jitter, but never
longer than hook_retry_max_secs. A value of 0, or one that is not a whole
number, is ignored. When several per-artifact hooks defer at the same gate, the
largest value wins, so no hook runs again before the time it asked for.
Without a retry-after: line, each retry waits longer than the one before. The
first wait is hook_retry_default_secs (default 30 s), and each later wait
doubles it, up to hook_retry_max_secs (default 300 s). Every deferral counts
as an attempt, with a hint or without one. So after a hinted deferral, the next
deferral without a hint takes the next backoff step, not the first. Once the
backoff reaches the ceiling, a deferral that lasts all day runs the hook about
once every five minutes.
A retry that is due runs at the next check-in, so the wait is never shorter than the check-in interval.
Each retry walks the delivery again from the start. The hooks that run before
activate.enter, such as download.exit and install.*, run again on every
attempt, for as long as the deferral lasts. Write them so that a second run is
safe.
The [daemon] keys hook_retry_max_attempts and hook_retry_max_total_secs
are unset by default. Set one to make the device give up: when the cap runs out
the poller stays on the running delivery, reports exhausted, and stops
retrying that delivery. A cap of 0 gives up on the first defer; to remove a
cap, unset the key. Before #6431 these caps defaulted to 10 attempts and
3600 s.
atcd exports the histogram atcd.pull.hook_defer_age (Prometheus:
atcd_pull_hook_defer_age_seconds). It records one sample each time a
deferral defers again, and the sample is how long that deferral has lasted.
Its wait label tells what set the next wait: hint for a retry-after:
line, backoff for no line.
Which of these run on an atc ota switch
A switch is a third walk. It re-points the device at a delivery already in the
store, so it moves no bytes. atc ota switch <version> walks delivery.pre -> activate.enter -> deactivate.enter -> deactivate.exit -> [flip] -> activate.exit -> commit.enter -> commit.exit -> delivery.post. That walk drops
one thing a push does, and it is a gap rather than a carve-out.
No check.*, download.* or install.*. Those three states put bytes on
the device. A switch moves none, so it crosses none of them. This is the one
walk that still omits them: the pull walk dispatches all six (#3412 §1).
A clean refusal at activate.enter or deactivate.enter leaves the device as
it was, as on a push: no rollback.*, no failure.*, and no activate.* for
the outgoing version. Any other refusal at activate.enter or after it enters
the unwind ladder described above, in the same order push and pull run it. One
case still unwinds: a clean deactivate.enter refusal whose teardown record
merged into one an earlier switch left standing. That earlier switch still owes
the outgoing workload a restart, so this refusal runs the ladder and tries the
restart again. If the restart fails, the record stands for the next boot. A
stage hook that refuses before the record is written leaves that earlier debt
as it was. A switch does not run delivery.post after a refusal.
deactivate.* DOES run, from the outgoing version’s declaration. It did not
until #3698. A switch owned no durable record of a teardown. So a daemon that
died between the teardown and the flip left the outgoing workload stopped, with
nothing able to start it again. The switch now writes that record before the
first teardown hook. A boot re-runs the outgoing version’s activate.* from it.
All three walks dispatch the same deactivate.* cells. They also dispatch the
same rollback.* and failure.* cells: check.*, download.* and install.*
stay the switch’s gap, for the reason above.
This changes what a device does. A fleet that switches versions starts running
deactivate.*hooks that never ran there before. So does a rollback, because a rollback IS a switch. Read thedeactivate.*declarations of the versions already on your fleet before you roll out an atcd carrying this.A refused teardown VETOES the switch, and the device stays on the version it was already running. The switch un-does nothing, because it had not yet switched. A clean
deactivate.enterrefusal stops there. A refuseddeactivate.exit, or adeactivate.enterrefusal that is not clean, runs the compensation: the outgoing version’sactivate.*is dispatched again, so write both halves to tolerate running twice.A version whose members declare no
activate.*cannot be brought back that way. A teardown that refuses there leaves those workloads stopped until an operator pushes, and the reply says so by name.
A refused switch now runs your
rollback.*scripts, and yourfailure.*scripts when the recovery itself fails. A cleanactivate.enterordeactivate.enterrefusal runs neither. Those hooks ran on a push and on a poll and never on a switch, so a fleet that rolls back starts running a script that has never run there. Read them before you roll out an atcd carrying this.rollback.enterruns before the device is put back,rollback.exitafter, and both run twice if you retry a switch that keeps refusing — write them to tolerate that.failure.enterandfailure.exitfire only when the outgoing version’s workload is still stopped after the recovery ran, which is the one condition worth paging on.
Stage-scope hooks DO run, at each of the six gates. The
scope table says a stage hook runs once per delivery, at that barrier. That holds on a switch as it does on a push and a poll. So a top-level
[lifecycle.hook.activate.exit] runs on the switch that makes the delivery
live. The switch reaches activate.enter, deactivate.enter,
deactivate.exit, activate.exit, commit.enter and commit.exit. A stage
hook at rollback or failure runs only if the switch enters that ladder — a
refusal at activate.enter or after it, except a clean refusal at
activate.enter or deactivate.enter — and the ladder’s cells honour neither
--skip-hook nor --hook-env.
The stage arm changes what a device does too. A fleet that switches versions starts running stage hooks that never ran there before. Read your top-level
[lifecycle.hook.*]tables as well. A non-zero exit at any of the six gates VETOES the switch. A stage hook that passed atactivate.enterordeactivate.entermakes a later member refusal there not clean, so that refusal still unwinds. After the flip a veto un-switches the device back to the version it came from.Only a delivery committed by an atcd new enough to archive the table has one to read. A delivery committed before that runs no stage hook on a switch until the next push or poll re-commits it.
atc ota switch -a <artifact> is a fourth walk, narrower again. It activates one
artifact and not a delivery, so it mints a synthetic single-member delivery. The
device must also be in per_artifact assignment mode to accept -a at all.
The -a form runs the per-artifact arm and nothing else. It walks
activate.enter -> deactivate.enter -> deactivate.exit -> [flip] -> activate.exit -> commit.enter -> commit.exit. So it runs no delivery.pre, no
delivery.post and no stage hook at any gate, at a forward gate or at an unwind
cell. It crosses the same six gates the whole-delivery form crosses, and it walks
the same unwind ladder when one of them refuses. Read every rule above as the
<version> form’s rule. The -a form is the carve-out from the bookends and
from the stage arm, and from those two only.
That is a property of the TARGET and not of the walk. The bookends and the stage
arm both read the target delivery’s .atc/delivery.pb archive. No push or poll
ever committed the synthetic delivery, so it has no archive, and it declares no
delivery-scope or stage-scope hook. To read the originating delivery’s archive
instead would run one delivery’s hooks over another delivery’s activation. The
whole-delivery form runs both arms because its target IS a committed delivery.
deactivate.* is the arm the two forms share, and it did not run on -a
until #4211. It reads the OUTGOING version’s own .atc/lifecycle.pb sidecar,
which every version carries, so it is a property of the version being replaced
and not of the synthetic target. Everything the deactivate.* note above says
holds here: a refused teardown vetoes the activation, the device stays on the
version it was already running, and a refusal that is not a clean
deactivate.enter refusal dispatches the outgoing version’s activate.* again
first. A daemon that dies between the teardown and the flip is
covered by the same durable record. That record names the synthetic delivery as
its target, so a boot that finds the pointer already on it reads the walk as
finished and clears the record.
This changes what a device does. A fleet that runs
atc ota switch -aon aper_artifactdevice starts runningdeactivate.*hooks that never ran on that form before. Read thedeactivate.*declarations of the versions already on your fleet before you roll out an atcd carrying this.
The unwind ladder brackets that compensation. #4257 is where it started to
run on -a. A refused -a activation dispatches rollback.enter, then the
compensation that brings the outgoing version back, then rollback.exit. If a
rollback hook itself fails, it also dispatches failure.enter and
failure.exit. A take-back that could not restore the device does the same.
These cells read the TARGET version’s own sidecar, which every version carries. So they run at the per-artifact arm, like every other cell of this walk. A hook there cannot veto anything. The unwind track IS the failure path. A non-zero exit is recorded, and the ladder walks on.
One ordering differs from atc ota switch <version>. On that form
rollback.enter runs while the pointer still names the target. On -a the
pointer is already back, because putting it back is what tells this walk’s
background task the activation was refused. Nothing may be awaited before that.
So an @name/... cross-ref in a -a rollback.enter hook resolves against the
outgoing version.
A refused
atc ota switch -anow runs yourrollback.*scripts. It runs yourfailure.*scripts too, when the take-back did not restore the device. These are the same cells a push and a poll already dispatch. So a fleet that declares them for either of those planes starts running them on this form too. Both cells bracket the workload restart, and the pointer is back before either of them runs.
Which hooks run after atcd crashes during a push
If atcd dies during a push, for example on a power loss, the next start of atcd
finishes the delivery. What it does depends on where the push stopped. Here n
is the version that was active before the push, and v is the pushed version.
| Where atcd stopped | current after the restart | Hooks that run on the next start |
|---|---|---|
Before deactivate.* (check, download, install) | n | None. The delivery is failed. |
After n’s deactivate.* ran, before the pointer flip | n | Each activate.* phase that n declares |
After the pointer flip, before commit (for example during v’s activate.exit or commit.*) | n, reverted | The rollback of an activate.exit failure, once. See below. |
During v’s delivery.post, after commit | v | Each activate.* phase that v declares |
Every hook in the table reads daemon_restart_recovery in
ATC_HOOK_CONTEXT.trigger. That is how a hook, and an operator reading
atc ota status, tells a crash from a hook that refused: a refusal’s rollback
reads auto_rollback_veto. Write an activate.* hook so that it can run again
on a version that is already active, because these re-runs do exactly that.
A crash after the pointer flip is treated as an activate.exit failure.
atcd puts current back on n, then runs these hooks in this order:
v’srollback.enter.n’sactivate.enterandactivate.exit, ifn’sdeactivate.*ran during the push. Ifactivate.enterrefuses,activate.exitdoes not run. The refusal rule is the one above: every stopped member ran anactivate.enterhook, and each one exited non-zero.v’srollback.exit.- If a hook in steps 1 to 3 exited non-zero,
v’sfailure.enterandfailure.exit.
If every hook passes, the delivery is rolled_back. If one does not, the
delivery is failed. atcd runs this rollback at most once. If atcd dies again
during the rollback, the next start marks the delivery failed and runs no
hook. atcd never pushes v again by itself, because an activate.exit that
crashes the device would then crash it on every start. To try v again, push
it again.
The delivery-wide delivery.post hook, stage-scope hooks and the workload
restart step do not run during this rollback.
A pulled delivery that a hook refuses after the flip gets the same rollback
from the next start if atcd dies after the un-flip and before n’s
compensating activate.* ran. If atcd dies during that compensation, the next
start marks the delivery failed and runs no hook.
A live push whose activate.exit refuses ends with the same hooks and the same
status (#6750).
After a crash before the flip, n’s hooks set the delivery status:
- If every member that went down runs an
activate.*hook and no hook exits non-zero, the delivery isrolled_back. - If a hook exits non-zero, or atcd cannot dispatch the hooks, the delivery
stays
in progressand atcd tries again on its next start.v’sfailure.enterandfailure.exitrun, ifvdeclares them. - If a member that
n’sdeactivate.*stopped declares noactivate.*, atcd cannot restart it. The delivery isfailed, andv’sfailure.*run, ifvdeclares them.
While atcd runs these hooks, it refuses a new push or pull into that channel.
Which state’s hooks come from which version
Almost every hook runs the declaration of the delivery arriving. Two do not.
| State | Reads the declaration of | From |
|---|---|---|
deactivate | the outgoing version | on push and on atc ota switch <version>, that version’s own .atc/lifecycle.pb sidecar; on pull, the signed declaration the poller retained when it installed that version |
uninstall | the version being deleted | the same sidecar, which both planes write |
| everything else | the incoming delivery | the manifest you just sent or published |
So a deactivate.* hook you add today does not run on today’s delivery. It runs
on the next one, when the version you ship now becomes the outgoing version. An
atc ota switch that leaves that version reaches it too. A first deploy traverses
deactivate and dispatches nothing, because there is no outgoing version to
tear down. So does a delivery that flips nothing:
a stage-only push, a re-push of the version already active, or a pulled delivery
re-cut over unchanged bytes. The rule is the same on both planes.
A push that flips nothing also runs no activate.*, because it activates
nothing. That covers a stage-only push and a re-push of the active version. A
pulled re-cut still runs activate.*, because it moves current to the new
delivery. An atcd older than v0.1.5 did run activate.* there; see
Behavior changed.
A pulled re-commit of the delivery already active runs no activate.*,
because it moves nothing. A revision that only adds present-only
deliveries makes that re-commit. So does the first poll after an atcd restart,
when the last walk finished. A restart that resumes an unfinished walk still
runs activate.exit.
An atc ota switch to the delivery already active runs no activate.* and
no deactivate.*. It moves nothing, so it activates nothing. commit.* and
the delivery bookends still run. An atc ota switch -a that repeats the
per-artifact activation already current also runs no activate.* and no
deactivate.*. An -a of a version that is live under a whole delivery still
runs activate.*, because the pointer moves to a new delivery.
Scope
| Scope | Table | Runs |
|---|---|---|
| Artifact | [bundle.lifecycle.hook.<state>.<phase>] | once per artifact, in push-list order |
| Stage | [lifecycle.hook.<state>.<phase>] | once per delivery, at that state’s barrier |
| Sweep bookend | [lifecycle.hook.gc.enter] / [lifecycle.hook.gc.exit] | once per housekeeping sweep for each channel whose active delivery declares it |
The Stage row holds on all three walks. An atc ota switch <version> runs the
stage arm at each of the six gates it crosses, and crosses no other. The
atc ota switch -a <artifact> form runs no stage arm at all. See Which of these
run on an atc ota switch.
check.*, download.*, install.* and uninstall.* have no stage scope. The
first three are artifact-major. The daemon crosses one gate per artifact, not one
per delivery, so a stage table there would parse and never run. Push preflight
rejects all four rather than accept one silently.
Declare them per artifact instead. That is where they run on a push and on a
poll alike, once each, in push-list order. A pull walk reached none of the three
until #3644; it dispatches all six cells now
(atcd_pull::pull_poller::walk_acquire), and the rest of this page says so at
“Which of these run on an atc ota switch”. The atc ota switch walk is the
one that still reaches none of them, because it moves no bytes.
The refusal above is about SCOPE and not about a plane. A check, download or
install cell declared at STAGE scope is rejected at preflight on every plane,
and the device warns about each such cell once per delivery. It still runs it
zero times.
uninstall.* is a separate case. It is dispatched per artifact of each version
being removed, read from that version’s own sidecar. There is no per-version
stage table for a delivery-wide cell to read. Put cleanup for one version in an
artifact-scope uninstall.* hook; see Per-version cleanup:
uninstall.*. gc.* brackets a whole sweep
and names no version.
So the stage-scope states are activate, deactivate, commit, rollback and
failure.
The daemon runs the same check over a signed manifest it pulls. Any invalid prong refuses the whole delivery. So a delivery published with one of these tables before the rule tightened stops activating on a pulling device. It used to activate with the hook silently skipped. Move the table under the artifact and publish again. The device cannot be repaired locally.
That move buys a pulling fleet what it buys a pushing one. The delivery activates again, and the hook runs. The pull walk reaches all three states at artifact scope (#3412 §1). Neither plane has a delivery-wide barrier at any of them, which is why the top-level table is refused rather than supported.
A carve-out worth knowing about. A stage-scope deactivate.* is legal and it
runs, but it reads the incoming manifest, not the outgoing version’s. Stage
hooks are delivery-wide and bound to no artifact, while the outgoing world is
per-version, so there is no delivery-wide outgoing table to read from. If you
want a teardown veto over the version already running, declare it at artifact
scope.
Declare it at artifact scope for a second reason. The durable teardown record
above covers the artifact-scope teardown only. A stage-scope deactivate.* can
stop a workload, and a crash after it leaves nothing on the device that says so.
That is true on all three planes.
Per-version cleanup: uninstall.*
Some work must follow one version off the device. Example: a hook loaded an OCI
image into podman storage, and the tag must go when atc deletes that version.
Declare that cleanup as an artifact-scope uninstall.* hook, not as gc.*:
[[bundle]]
name = "myapp"
source = "dist" # ships scripts/release_image.sh
[[oci]]
name = "myapp-image"
source = "registry.example.com/myapp:1.4"
[oci.lifecycle.hook.uninstall.exit]
script = "@myapp/scripts/release_image.sh"
timeout_secs = 60
Both removal paths run uninstall.*: the housekeeping sweep and atc ota rm.
gc.* runs on the sweep only, and it names no version.
What the hook receives. atc runs the gate once for each version it removes,
before it deletes the version directory. On atc ota rm the delivery is
already gone at that point, so the gate holds back the version and nothing
else. ATC_HOOK_CONTEXT holds:
| Field | Value at uninstall.* |
|---|---|
push_id | <name>@<sha> of the version being removed. It is not a delivery id |
artifact | the kind and name of the artifact that owns the version |
version.new_sha | the version being removed |
version.old_sha | null |
self_root.new | the directory of the version being removed. It still exists |
self_root.old | null |
current_root | the artifact’s delivery/current/<name> pointer. It names the ACTIVE version, not the one being removed |
trigger | gc, on the sweep and on atc ota rm alike |
The hook runs in the directory of the version being removed. That is true for
a @self script and for a @<peer> cross-ref.
A non-zero exit keeps the version. A non-zero exit, a timeout, or a script
that cannot start withholds the removal. exit 21 is a refusal here too, not a
defer. The housekeeping sweep asks again while the artifact has an active
version. After that, the next atc ota rm or purge in the channel asks. So a
cleanup hook that fails on every run keeps its version on disk for ever.
If the cleanup is best effort, report the failure and end the script with
exit 0:
#!/bin/sh
# uninstall.exit on myapp-image. Release the podman tag of the version that
# atc removes. ctx() is the helper from "How hooks run".
sha=$(ctx version.new_sha) || exit 0
podman rmi "localhost/myapp-image:$sha" > /dev/null 2>&1 ||
printf '%s\n' "release_image.sh: no tag for $sha" >&2
exit 0
The tag must be the one your load hook wrote. This example assumes the image’s
install.exit ran podman tag <image-id> "localhost/myapp-image:$sha" with the
same version.new_sha. A different tag makes podman rmi miss, and the script
still exits 0.
Ship both artifacts in one push. An OCI version holds an image layout and
none of your scripts, so its hook names a script that a bundle ships. A
@myapp/... ref to a bundle in the same push is accepted. An uninstall.* hook
runs at GC, after the push, so it puts no order on the push. See “Same-push
dependencies” under “Script references”.
At uninstall.* and gc.*, @<peer> finds the ACTIVE version, not a copy
kept with the image. No push is in flight when GC runs. So @myapp resolves
to the myapp version that is active in the channel when the hook runs. The
delivery pointer delivery/current/myapp names the same version. For an
uninstall.* hook, two cases leave no script to run, and each one keeps the
image version:
- The active
myappversion does not ship the script. An older version that you switched back to is the usual cause. myappis not in the active delivery, for example because you removed it first.
So ship the script at one fixed path in every bundle version, and remove the
image versions before the bundle. To remove that dependency too, install the
script at a fixed path outside the store, such as
/usr/local/libexec/myapp/release_image.sh. atc does not manage or roll back
that file.
gc.enter / gc.exit
The housekeeping sweep’s bracket. Declared at the top level under
[lifecycle.hook.gc.*], because that is where you expect to write a hook, and
carried in .atc/delivery.pb alongside the [delivery.hook.*] bookends, because
a sweep bracket has a bookend’s shape and lifetime rather than a state’s. An
artifact-scoped [bundle.lifecycle.hook.gc.*] is rejected at push.
[lifecycle.hook.gc.enter]
script = "@ops/scripts/pause_reclaim.sh" # @self is forbidden here
timeout_secs = 30
gc.enter is your opt-out. A non-zero exit skips that sweep’s delivery GC and
per-artifact version GC, and nothing else. The partial-transfer, orphan-staging,
stream-cache and pull-partial reapers always run, whatever gc.enter says.
Those reclaim disk to stop the device filling up, and a wedged operator hook
that could block them would be a worse failure than the one the opt-out prevents.
gc.exit is reporting only. The sweep is over by the time it runs, so its exit
code gates nothing.
Each channel runs its own gc.* bookend. The sweep is device-wide, but the
declaration belongs to a delivery. So the sweep runs the bookend once for each
channel whose active delivery declares it. On a device with one channel, that is
once per sweep. On a device with agency/atcd beside your own channel, a hook
declared in both deliveries runs twice in one sweep. At gc.enter, a non-zero
exit from any one of them skips the delivery GC and version GC for the whole sweep.
A @<peer>/... script in gc.* resolves to the peer’s ACTIVE version when the
sweep runs, the same rule as uninstall.*.
atc ota rm runs no gc.* hook. The pair brackets the housekeeping sweep
only. rm is an operator command, not a sweep. It runs uninstall.enter and
uninstall.exit for each version it removes, and it skips both gc.* gates.
So a gc.enter opt-out does not stop an rm.
gc.exit does not run after the rm. It runs after the uninstall pass of a
later sweep, and only on a sweep whose gc.enter let that pass run. For cleanup that must run when one
version goes, use uninstall.*. That gate runs on both removal paths.
uninstall.* and gc.* both write a lifecycle_log row. A dispatched
uninstall.enter or uninstall.exit is journalled like any other hook. A
dispatched gc.* bookend is journalled under the id of the active delivery that declares it, with
scope and trigger both gc and the uninstall cell it brackets. A refusal
at any of the four also goes to the daemon log and the
atc.housekeeping.uninstall_veto span. An uninstall.* refusal reads
uninstall.<phase> refused for …; withholding its removal this sweep.
What a gc.* hook reads. A sweep is not a delivery, so the bookend context
names no artifact and no version. ATC_HOOK_CONTEXT holds:
push_id: the id of the active delivery whose[lifecycle.hook.gc.*]declaration runs. A sweep has no session of its own, so this can name a delivery pushed long before the sweep.stateuninstall,phaseenterorexit, andtriggergc.nullforartifact,version,self_root.new,self_root.oldandcurrent_root.
An uninstall.* hook also reads state uninstall and trigger gc. Its
artifact is not null, which is how a shared script tells the two apart.
ATC_DELIVERY_CONTEXT carries hook_scope gc as well.
[lifecycle.hook.gc.error] and uninstall.error are both rejected at parse,
as is every other .error cell: enter and exit are the only gates anything
dispatches, so a .error cell would parse, push green, and never run. You get
an error naming the two phases that do dispatch, rather than a hook that
silently never fires.
Progress hooks: reporting DURING a state
Every hook above fires at a barrier. [lifecycle.progress.<state>] fires
between two, on a cadence you set, while the state’s work runs:
[lifecycle.progress.download]
script = "@ops/scripts/report-progress.sh"
interval_secs = 5
The script gets two extra environment variables on top of the usual hook
environment: ATC_PROGRESS_PCT, the percentage at that tick, and
ATC_HOOK_POINT, which reads download.progress.
Read ATC_HOOK_POINT, not ATC_HOOK_CONTEXT.phase. progress is not a
phase, so the context document carries the placeholder "phase":"enter", the
same placeholder a delivery bookend carries. No gate waits on a progress hook.
Rules, all enforced at atc ota push:
| Rule | Why |
|---|---|
download is the only state | It is the only state with work long enough to report on. Every other state is rejected by name |
interval_secs is required and 1 or more | A cadence of zero can never fire |
| Top-level only | The cadence task reads the delivery’s stage table; a copy under [[bundle]]/[[oci]] would never fire |
@self is forbidden | The hook is bound to no artifact, and mid-download the version tree does not exist yet |
It runs on push and on pull. atc ota push and a polled delivery both
tick at the declared interval while a member transfers. atc ota push refuses
to send the table to a device whose atcd predates the feature.
The two planes differ in two places:
- The exit code. On pull, a non-zero exit cancels the delivery. The member in flight stops, later members are not staged, and nothing activates. A timeout, a signal or a spawn failure does not cancel. On push, the exit code is logged and nothing acts on it.
- The first tick. Pull waits one full interval after a member’s transfer starts. Push arms its timer when the push starts, so the first tick in a transfer lands somewhere inside the first interval. A push tick that comes before the daemon knows the member’s total size runs no script, so the first run can come one interval later.
What it does NOT do, today:
- It runs once per member. The task brackets one artifact’s transfer. A three-bundle delivery runs the hook three times over, not once across the delivery.
- A slow script does not queue. A tick that arrives during a live run is skipped. Nothing is killed for being slow.
- It does not run a script in a member that is not installed yet. A tick
resolves
@<name>/<path>against the channel’s installed<name>. On the channel’s first delivery of<name>, that tree does not exist until the delivery commits, which is after every download. So atcd skips the hook and logs the skip, once per push or once per pulled member. For a first delivery, use an absolute path or a member that the channel already holds.atc ota pushaccepts the script, because later deliveries run it.
Script references
| Form | Resolves to | Scope rules |
|---|---|---|
@self/<path> | a file inside this artifact’s tree | only valid in artifact-scoped tables. Before the install, the ACTIVE version’s tree (see below) |
@<name>/<path> | a file in another artifact (by name, any kind): for an artifact hook during a push that also carries <name>, usually the version this push installs (see “Same-push dependencies”); otherwise the version active when the hook runs | valid everywhere; a progress hook needs <name> installed |
/abs/path/on/device | a system-installed path on the robot | outside atcd’s rollback guarantee; no .. / trailing / |
@self is forbidden in top-level (stage-scope) [lifecycle.hook.*] tables,
because a stage hook runs once across all artifacts and isn’t bound to one tree.
Use an artifact-scoped table ([bundle.lifecycle.*] / [oci.lifecycle.*]) for
@self.
A stage hook’s @<name> must name an artifact in the same push or an artifact
that is active in the channel. If it names neither, atc ota push fails at
preflight, before any blob moves:
stage hook `commit.exit`: script `@sidecar/…` names no artifact in this push or active in channel `local/default`
Which version’s script @self runs
A version’s own scripts run only after that version is installed. Before that,
an artifact hook’s @self/<path> belongs to the version that is ACTIVE on the
device. This is the version that the delivery replaces. A rollback and a failure
report belong to the version that the unwind restores, which is that same
version.
At those gates the hook is the active version’s own hook. Its table entry comes
from the active version’s atc.toml: whether the hook exists, its
timeout_secs and its <path>. Its script comes from the active version’s
tree.
| Gate | A @self/<path> hook is |
|---|---|
check.enter, check.exit | the active version’s |
download.enter, download.exit | the active version’s |
install.enter | the active version’s |
install.exit | the incoming version’s |
activate.enter, activate.exit | the incoming version’s |
commit.enter, commit.exit | the incoming version’s |
rollback.enter, rollback.exit | the restored version’s (the active one) |
failure.enter, failure.exit | the restored version’s (the active one) |
deactivate.*, uninstall.* | the outgoing version’s |
An absolute-path hook and a @<name>/<path> hook name no version tree of the
artifact. At check.*, download.*, install.enter, rollback.* and
failure.*, they keep the INCOMING atc.toml. At deactivate.* and
uninstall.*, they use the outgoing version’s declaration, as a @self hook
does.
What follows from the rule:
- A guard that a release adds. A
@selfguard that release N+1 adds atcheck.*,download.*orinstall.enterruns first at the upgrade AFTER N+1, from the copy that N+1 installed. It does not run on N+1’s own delivery. - A release that needs its own prerequisite. Declare it as an absolute-path
or
@<name>hook. It runs on that release’s delivery, the first delivery included. - A guard that a release removes or changes. It still runs on that release’s delivery, with the active version’s script, path and timeout.
- Two hooks at one gate. The active version can declare a
@selfhook at a gate where the release declares an absolute or@<name>hook for the same artifact. Then both run: the active@selfhook first, then the release’s hook. If either one refuses, the delivery is refused. A release cannot switch off the guard of the version that it replaces, and it keeps its own prerequisite. - A first delivery. The device has no active version, so no
@selfhook runs at these gates and none writes a journal row. An absolute or@<name>hook still runs. - An unwind with no version to restore. A first delivery that is refused
after the switch runs no
@selfhook atrollback.*orfailure.*. - A version installed before atcd kept declarations. If the active
version’s declaration cannot be read, no
@selfhook runs at these gates. The push journalsdeactivate_config_unavailablefor that member. - A missing script. The active version’s own
@selfscript must be in its own tree. If the tree does not hold it, the hook fails, as at every other gate. It vetoes atcheck.*,download.*andinstall.enter. It marks the unwind dirty atrollback.*andfailure.*.
[[bundle]]
name = "agent"
source = "dist"
# Runs the ACTIVE version's guard before any byte of the new version moves.
# A release that adds this table runs it first at the next upgrade.
[bundle.lifecycle.hook.download.enter]
script = "@self/hooks/pre-download.sh"
# A prerequisite of THIS release. It runs on this release's own delivery.
[bundle.lifecycle.hook.check.exit]
script = "/usr/local/libexec/agent/battery-ok.sh"
A pulled delivery that declares no hooks still runs the active delivery’s
hooks: its @self guards at these gates and its deactivate.* hooks.
atc ota run-hook is not a delivery, and this table does not apply to it. It
runs the hook that the ACTIVE version declares, from that version’s own tree,
at every gate. It cannot run the guard of a version that is not active yet.
Without -o, if no artifact declares the cell, it runs the active delivery’s
stage hook. With -o <name>, it never runs a stage hook.
The hook runs in the active version’s tree, and ATC_HOOK_CONTEXT says so. At
these gates self_root.old is the active (or restored) version’s tree, which holds the
script and is the working directory. self_root.new names the incoming tree
only if that tree is already on the device, and the hook must not run scripts
from it. version.old_sha is the active version, and version.new_sha is the
incoming one. On a first delivery the hook does not run.
A device whose atcd does not advertise active-hook-table-v1 reads the
INCOMING atc.toml for a @self hook at these gates. It skips a script that the
active tree does not hold.
Requires atc and atcd from nightly-2026-10-08 or later. No stable
release has this rule yet: 0.1.5 does not. A stable atc refuses @self at
download.enter itself, before it connects to the device. It prints
self-dependency at download_enter is not allowed (circular), then
ATC-E0401: atc.toml is malformed.
On a stable release, move that hook to install.exit, activate.* or
commit.*, where @self is the incoming version’s tree in every release. Or
give it an absolute script path that is already on the device before the first
push. allow_missing_initial is also newer than 0.1.5, so a stable release
cannot skip a missing script.
On the daemon side, the rule needs the self-active-tree-v1 capability. An
older atcd refuses @self at download.enter and fails a @self hook at
check.*. At download.exit, install.enter and rollback.*, it runs the
incoming script. A @self hook at failure.* also requires
the self-failure-active-tree-v1 capability. An atcd that has only
self-active-tree-v1 runs the incoming script at failure.*.
So atc ota push
refuses to send such a declaration to a daemon that does not advertise the
capability, and names the capability and the gates. A pulled delivery has no
handshake. Upgrade every device on a channel before you publish such a
declaration to it.
Same-push dependencies
One push can carry an artifact and the artifacts it depends on. A dependency
is a requires entry or an @<name>/... hook script. atc orders the push so
that each dependency that an order can satisfy resolves. atcd refuses the two
that no order satisfies: a cycle, and a check.* hook that names another
artifact of the push.
Which version @<name> names. When <name> is in the same push, an
artifact hook’s @<name> resolves to the version of <name> that this push
installs, at check.* through commit.*. That is true before and after the
flip, on a first deploy and on an upgrade. Other hooks resolve it to another
version, the same release @self names there:
deactivate.*, and theactivate.*that a rollback runs again, run the OUTGOING version’s declaration.@<name>names the version active before the push.rollback.*andfailure.*restore the earlier version.@<name>names the version of<name>they restore, or none on a first deploy. The incoming<name>may never have been placed: adownload.exitveto unwinds before the install.uninstall.*andgc.*run at GC, after the push.@<name>names the version active when the hook runs.- Delivery bookends, stage hooks and progress hooks resolve through the delivery pointer, as before.
An @<name> or a requires entry that names the artifact itself is not a
same-push dependency. @<name> keeps resolving to its active version at
check.* through commit.*, as before, so the artifact must be active, or
atcd refuses with missing dependency. Use @self for the tree this push
installs.
When <name> is not in the push,
@<name> resolves to its active version, and <name> must be active, or atcd
refuses with missing dependency: <name>.
The order. A push downloads its artifacts one at a time, in the order it
sends them. Every state after install.* runs once all of them are installed.
So atc ota push sends each artifact after the same-push artifacts that it
requires, and after the artifacts that its download.* hooks name. Other
artifacts keep the order of atc.toml.
An install.* hook runs after every download, so its @<name> resolves in any
order. So does an @<name> at a later cell, for example activate.enter or
uninstall.exit.
A push cannot order a check.* hook. A push runs check.* for every
artifact before it downloads any. So a check.* hook that names another
artifact of the same push finds nothing, in any order, and atcd refuses it.
Move the hook to download.* or install.*, or push the other artifact first.
A pulled delivery stages in the same order. A device that pulls a delivery
takes each artifact through check.*, download.* and install.* before the
next, and stages an artifact after the artifacts it depends on. So on a pull, a
check.* hook can name another artifact of the same delivery.
Both planes refuse a cycle, because no order can install a after b and
b after a. A push and a pull report the same message. A device refuses a
pulled cycle before it downloads anything, stays on its current release, and
reports the rollout failed. An atcd from before this check can have installed
such a delivery, or started to and parked it on a hook retry. The device keeps
the first and can finish the second. The message:
same-push dependency cycle: a -> b -> a. No order of the push installs each artifact after the ones it depends on. Remove one dependency.
Older versions. An atc that predates the ordering sends artifacts in
atc.toml order. atcd cannot reorder a push, so it refuses one that lists an
artifact before its dependency: artifact "a" is listed before "b", which it depends on. Upgrade atc. An atcd that predates the ordering refuses every
same-push dependency with depends on b which is in the same push — split into separate pushes. Upgrade atcd, or split the push as it says.
How hooks run
Write hooks so a second run is harmless — including a second run that OVERLAPS the first. atcd will not re-run a hook for a delivery it knows it already started, but it can lose that knowledge: if the daemon restarts partway through an activation, a retry starts the delivery again from the top. The same applies if
atc ota switchreconnects after a drop early enough that it has not yet seen any progress from the daemon.The first instance may still be running. Hooks are spawned into their own process group (
process_group(0)), so they do not receive signals sent to atcd. atcd kills that group when a hook exceeds its owntimeout_secs, but a daemon that is SIGKILLed, OOM- killed, or panics runs no cleanup at all — the hook is reparented and keeps going. A retry can therefore put two instances of the same script on the device at once.Requires a daemon whose unit grants
CAP_KILL. Withhook_run_as_userset (the default), the hook runs as a different uid than atcd, and POSIX allowskill(2)only from root or a matching uid. A unit rendered before that capability was granted therefore takesEPERMon both SIGTERM and SIGKILL:timeout_secsstill fires on time, and the hook still runs to completion while being reported as a timeout. Re-bootstrap (atc bootstrap atcd install) to re-render the unit;systemctl show atcd -p AmbientCapabilitiesshould listcap_kill. See #3242.Sequential safety is not enough for that. Do both:
Idempotent steps, so a re-run is a no-op:
mkdir -pnotmkdir; write to a temp path andrename(2)over the target rather than appending; check whether a unit is already enabled before enabling it; make “already done” an exit-0 path, not an error.Mutual exclusion, so two instances cannot interleave: take an
flockon a well-known path for the duration of the hook — blocking with your own bound, e.g.flock -w 60 /run/atc/myhook.lock -c '…'. Wait for the lock, then do the work (your idempotent steps make the second pass cheap) and report your own real outcome; if the wait expires, exit non-zero, because at that point you genuinely do not know whether the other instance is progressing or wedged.Bound the lock wait yourself — do not rely on
timeout_secs. A hook declaringtimeout_secs = 0runs unbounded (that is the documented rule), so a plain blockingflockbehind an orphan that never exits stalls the deployment forever instead of failing it.-wis what makes the failure mode legible on every configuration.Do not
flock -nand exit 0 on losing the race. Exit 0 tells atcd the hook succeeded and the lifecycle advances — but the instance holding the lock is a leftover from a dead daemon that may still fail or leave its work half-finished. You would be reporting completion for work you neither did nor observed. Exiting non-zero is no better: it fails a deployment that may well be fine. Blocking is the only option whose exit code describes something the hook actually knows.Prefer single atomic operations over read-modify-write sequences; two concurrent read-modify-writes lose an update no matter how idempotent each looks alone.
A hook that only works on a clean, solitary first run will eventually fail a deployment on a device that rebooted at the wrong moment.
Background and the design decision behind this:
docs/internal-site/src/design/2026-07-27-switch-wait-and-resume-design.md(“Accepted limit: one window can still run hooks twice”).
atcd exec’s the script directly — there is no sh -c wrapper. The file
needs the executable bit and a shebang; a script without +x is rejected
on the device just before it runs (not executable: mode=…). Neither is
pre-checked when you author the manifest or at push preflight — a bad script
only surfaces once its hook fires during a delivery — so chmod +x and check
head -1 for the shebang in source before pushing. An absolute script that
does not exist fails the hook with ENOENT, unless the hook sets
allow_missing or allow_missing_initial
(A hook script that the artifact installs).
Those keys never excuse a script that exists without +x.
For an ARTIFACT-scope hook, the working directory is the tree that the @self
script came from, if that directory exists. For most cells this is the new
version. At check.*, download.* and install.enter it is the ACTIVE
version, and at rollback.* it is the restored version, which is the same one
(Which version’s script @self runs).
For deactivate.*, for uninstall.*, and for the ladder’s compensating
activate.*, it is the OUTGOING version. Those cells read their declaration
from that version too. So a hook that ships data beside itself always gets the
copy of its own version, even through a relative path.
Two shapes have no declaring version, and they keep the old rule. They run in
the artifact’s new version directory, or in the script’s own directory when
there is none. A @<peer>/... cross-ref names a script in another artifact’s
tree. An absolute path names a script the operator owns.
A STAGE-scope or DELIVERY-scope hook is bound to no artifact. @self is refused
there, so such a hook has no version tree at all. Its working directory is the
script’s own directory.
The hook does not inherit atcd’s environment. The env is cleared, PATH is
pinned to /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin, and
atcd exports the whole delivery context as a single JSON variable,
ATC_HOOK_CONTEXT (see ATC_HOOK_CONTEXT below), plus the
hook user’s login environment (HOME/USER/LOGNAME from the passwd entry;
SHELL is the resolved bash on PATH, not the passwd login shell, which
stays nologin; plus XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS). Parse the
context with awk:
set -eu
# Read one dotted field out of ATC_HOOK_CONTEXT. A second argument gives the
# value to use when the field, or an object above it, is null. With no second
# argument, a field under a null object prints "". An unknown key or a null
# value exits nonzero, so a typo stops the hook instead of giving "".
ctx() {
awk -v path="$1" -v nargs="$#" -v dflt="${2-}" '
function fail(why) { print "ATC_HOOK_CONTEXT." path ": " why > "/dev/stderr"; exit 1 }
function find(t, k, j, c, d, q) {
k = "\"" k "\":"
for (j = 1; j <= length(t); j++) {
c = substr(t, j, 1)
if (q) { if (c == "\\") j++; else if (c == "\"") q = 0 }
else if (c == "{") d++
else if (c == "}") { if (--d == 0) return 0 }
else if (c == "\"") { if (d == 1 && substr(t, j, length(k)) == k) return j + length(k); q = 1 }
}
return 0
}
BEGIN {
s = ENVIRON["ATC_HOOK_CONTEXT"]; n = split(path, key, ".")
for (i = 1; i <= n; i++) {
if (substr(s, 1, 4) == "null") { print dflt; exit 0 }
if (substr(s, 1, 1) != "{") fail("not an object")
p = find(s, key[i])
if (p == 0) fail("no such field")
s = substr(s, p)
}
if (substr(s, 1, 4) == "null") { if (nargs < 2) fail("null"); print dflt; exit 0 }
if (substr(s, 1, 1) != "\"") fail("not a string")
for (j = 2; j <= length(s); j++) {
c = substr(s, j, 1)
if (c == "\"") { print v; exit 0 }
if (c == "\\") { j++; c = substr(s, j, 1); if (c != "\"" && c != "\\" && c != "/") fail("holds a control escape") }
v = v c
}
fail("not a string")
}'
}
sha=$(ctx version.new_sha)
state=$(ctx state)
name=$(ctx artifact.name)
old=$(ctx version.old_sha "") # null on a first install
artifact.* and version.* are empty in a hook bound to no artifact. A
stage hook, a delivery.* bookend and a gc.* bookend get null for
artifact and version. The helper stops at that null, prints "" or its
second argument, and exits 0. A per-artifact hook always has a non-empty
artifact.name, so [ -z "$(ctx artifact.name)" ] is true only in a hook
bound to no artifact.
A ctx copied from an older version of this page exits 1 with not an object
on those reads. Under set -eu, a delivery.pre that does so vetoes the
delivery, and a gc.enter skips the sweep. Paste the helper above in its place.
A stage hook and a gc.* bookend can read push_id, state, phase and
trigger. A delivery.* bookend gets placeholders in state and phase
(commit, enter), so read its real phase from ATC_DELIVERY_CONTEXT. Its
trigger is real, and it matches ATC_DELIVERY_CONTEXT: pull on a pulled
delivery, user_activate on a switch. Before #7223 it read push on every
plane.
Use awk, not jq or python3. The meta-atc device image carries
neither of them. atcd pins the hook PATH to the standard system directories,
so the hook sees only what the image already installed. awk is on every base
image, busybox included.
The helper handles the fields atcd writes: strings, nulls and nested objects.
It decodes \", \\ and \/. It refuses any other escape, such as a
control character, and names the field.
A hook that reaches for a missing reader gets an EMPTY value, not an error. It
writes a broken file while the push reports success. set -eu plus the null
check above turn that silent case into a failed state.
There are no discrete ATC_PUSH_ID / ATC_NEW_VERSION_SHA / ATC_STATE /
ATC_PHASE / … variables — those were removed. Every field lives inside
ATC_HOOK_CONTEXT. (There is likewise no ATC_VERSION; read
.version.new_sha.)
Hook user: hooks inherit atcd’s own uid unless hook isolation is
configured. On the bootstrap/systemd path it always is: bootstrap creates the
atc-hooks user and bakes --hook-run-as-user into the unit (from
[daemon] hook_run_as_user, default atc-hooks) — a separate user in the
daemon’s group that can read the 0750 store but cannot corrupt it, and drives
its own systemctl --user without sudo. A manually started atcd (the
no-systemd foreground recipe) has no hook user configured — no atc-hooks
user exists on such a device — so hooks run as whatever user launched the
daemon (your SSH login user). Neither --hook-run-as-user <user> nor
[daemon] hook_run_as_user can isolate them on that recipe: switching the
hook uid requires a root (or CAP_SETUID/SETGID) daemon, which the un-sudo’d
foreground recipe deliberately avoids — set neither there (see the no-systemd
section of
deploy-your-first-update.md). A hook user that
resolves to the daemon’s own uid is rejected at startup, never silently
accepted.
A hook that must read a root-owned file, a K3S kubeconfig for example,
reaches it through a supplementary group on atc-hooks. Do not reach for
hook_run_as_user = "root". atcd resolves that group set at every spawn, so the
grant needs no ATC change and no daemon restart. The recipe and its cost are in
the atcd non-root runtime model.
That is why writing hook_run_as_user into the file is what turns isolation
on for a hand-launched daemon, and leaving it out is not (#3366). The key’s
DEFAULT reaches only atc bootstrap atcd install, which creates the account and
grants the unit the capabilities before it bakes the flag. A hand-launched
atcd run --config <file> applies the key when the file states it, and keeps
hooks on its own uid when the file does not — so a config written for a
bootstrap install does not turn a foreground daemon into one whose every hook
dies in its pre-exec. --hook-run-as-user still wins over the file, and atcd
logs a warning when it overrides a different stated value.
[daemon] socket_mode reads the same way: --socket-mode wins, then the
file, then 0660. So does [daemon] socket_owner: --socket-owner wins, then
the file, then no grant at all.
Socket owner: the daemon’s own user owns the control socket. The controller
opens it by SSH-exec’ing atcd attach as your SSH LOGIN user, so those two
accounts must line up. Under the default install they do. The installer adds
the login user to the daemon’s group, and the group bits of the 0660 socket
then reach it.
Under run_as_user = "root" there is no such group. An operator may not join
the root group, so the login user takes ATC-E0420 on every command while
the daemon stays healthy.
[daemon] socket_owner closes that gap. atcd chowns the socket to the named
account right after bind, and grants exactly that one uid. atc bootstrap atcd install --root <user>@<host> fills the key in from the target’s own user@.
The same install also puts the store root (/var/atc, mode 0710) in that
account’s primary group, so the account can traverse to the atcd binary the
remote_path symlink resolves into; without that grant every atc ota verb
fails with ATC-E0470.
State it yourself when your target names no user, or when the account that runs
atc is not the account the daemon expects.
Do NOT reach for socket_mode = "0666" instead. That mode hands an admin
control socket to every local process on the device.
atcd REFUSES to start when the named account does not resolve, or when the
chown fails. A grant that quietly did not happen leaves a socket nobody can
open, behind a systemctl status atcd that still reads green.
On timeout_secs expiry the hook’s whole process group gets SIGTERM, then
SIGKILL after a 2-second grace.
A hook that calls setsid() leaves that process group, so no signal reaches
it. atcd therefore also puts each hook in its own cgroup (v2) and kills that
cgroup on the same ladder. A process cannot leave its cgroup, and every
descendant inherits it. The cgroup is the backstop. The process group stays the
first move.
That backstop needs a cgroup subtree atcd may write to. The daemon probes for one at startup and says which mechanism is in force:
lifecycle hook bound: cgroup v2 at /sys/fs/cgroup/system.slice/atcd.service/atc-hooks …
lifecycle hook bound: process group only (…)
The second line is not an error. atcd runs in containers and under atcd run
with no systemd, and a hook still runs and is still bounded by its process
group there. It says only that a hook which daemonizes can outlive its
timeout_secs. To get the backstop on a device installed before #3562,
re-bootstrap (atc bootstrap atcd install). The regenerated unit carries
Delegate=. Do not add the directive by hand: bootstrap rewrites the unit
whole, so a hand edit is erased by the same command that would apply it. Where
there is no systemd, point ATC_HOOK_CGROUP_ROOT at a writable cgroup-v2
directory instead. The same answer reads across a fleet as the
atc.lifecycle.hook_cgroup_delegation metric. See #3321.
With timeout_secs = 0 there is no expiry:
the hook runs until it exits or the delivery preempts it, so a wedged hook
wedges the delivery. Use it only for work whose duration you genuinely cannot
bound (a long flash, a large migration).
File modes: the packer records each source file’s mode, but the device
materializes every bundle file read-only (0o440), adding u+x,g+x only
when the source file had any execute bit. So only the exec bit survives the
trip. Run chmod +x on your hook script in source before pushing. Write,
“other”, and setuid/setgid bits are always stripped.
Directory modes are not part of that guarantee. The directories that hold
delivered files keep the daemon user’s own mode, so a manual write as that user
can still succeed. Do not predict which way a write goes. hook_run_as_user
picks the account (see Hook user above), and the directory’s mode comes from
atcd’s umask when the store was built. A permissive umask leaves the directory
group-writable, and atc-hooks is in the store group, so even a non-owning hook
can write. Neither input is a guarantee, so only the file modes are.
Keep writable state in a directory that the bundle declares with state_dirs.
See Writable state: state_dirs below.
A write beside the binary also does not follow the delivery pointer. Each version lands in a directory of its own, so the file stays with the version that was current when it was written. Retention prunes it later, and a rollback to that version brings it back.
Phase names in the journal: each state’s journal labels are the six
internal sub-phases (pre_hook → hook_done → pre_side_effect → side_effect_done → pre_hook_exit → hook_exit_done). An enter hook runs at
<state>/pre_hook; an exit hook at <state>/pre_hook_exit. The push output
names the hook as you wrote it, so a commit.exit hook reads commit.exit
there. The pre_ means “about to run this hook”, not “before the state”.
| You write | Journal shows | Runs |
|---|---|---|
commit.enter | commit/pre_hook | before the state’s work |
commit.exit | commit/pre_hook_exit | after the state’s work succeeds |
commit.error | — | rejected at parse — see below |
Only enter and exit gate hook dispatch, so those are the only two that
surface as a pre_hook* phase in the journal. Match on the hook line,
✓ commit.exit, not on the journal label.
<state>.error is not a declaration point at any state. Nothing dispatches
an error gate — not for commit, not for any other state — so a .error cell
would parse, push green, and never run. atc ota push rejects it and names the
two phases that do dispatch, enter and exit.
It does not name a replacement cell, because which states you may use depends
on the scope you declared at. deactivate.exit is valid under
[bundle.lifecycle.*] and refused under [[peripheral]]. Pick enter or
exit on a state your scope supports.
At artifact and stage scope that is usually <state>.exit, or the unwind hooks
rollback.* and failure.*, which run after a delivery fails. Under
[[peripheral]] those are not all available — failure.* and
rollback.exit are refused there too, and rollback.enter is the peripheral’s
one unwind hook. See the plane table below.
Which states a [[peripheral]] may declare. A peripheral’s hooks run on a
shorter ladder than an artifact’s, and atc ota push refuses a cell the ladder
never reaches. The whole plane:
| State | enter | exit |
|---|---|---|
check | runs, before the flash | runs, before the flash |
download | rejected | rejected |
install | runs | runs |
deactivate | rejected | rejected |
activate | rejected | rejected |
commit | runs | runs, after the flip |
rollback | runs, on unwind | rejected |
failure | rejected | rejected |
uninstall | rejected | rejected |
Use check.enter, check.exit or install.enter to refuse a flash before it
starts. All three run before any byte reaches the peripheral, and a non-zero
exit from any of them stops the delivery with nothing written. check.enter is
the earliest of the three. Declare the rejected states at artifact or stage
scope instead.
Writable state: state_dirs
A bundle declares the writable directories its program needs. atcd creates them
before every install.* and activate.* hook, so a check.* or download.*
hook of a first delivery runs before they exist. They persist across versions.
[[bundle]]
name = "myapp"
source = "dist"
state_dirs = ["data", "cache"]
In KDL, the key is state-dirs "data" "cache" on the bundle node.
Rules. Each entry is one path segment: not empty, not . or .., and with
no /. A list must not name one directory twice. Only a bundle may declare
state_dirs. A bad entry fails the load with ATC-E0533, at atc ota push
and at atc channel deliver. A device checks a signed list again and refuses
a bad one.
Location. The root is <hook-home>/.local/state/atc/<bundle>/, where
<hook-home> is the home directory of the hook user (see Hook user above).
Each entry is a subdirectory of that root. atcd creates the root and each entry
as the hook user, with mode 0700. The step does not depend on a hook: a bundle
with no hooks gets its directories too.
Not in the delivery tree. atcd does not link, mount or copy the root into
any version’s directory. A data/ that a program creates beside its own binary
is a plain directory of that version, and the next version starts with an empty
one. Give the program the root path: a hook reads ATC_STATE_DIR and passes it
on, for example in the systemd unit that an install hook writes. Run the
program as the hook user. No other account can open the 0700 root, so a
program that you start over ssh as a different user cannot use it.
A bundle with no hooks. A one-shot CLI that you run by hand has no hook to
pass the path on. Run it through atc shell instead:
atc shell user@robot -a myapp -- sh -c './myapp --data "$ATC_STATE_DIR/data"'
atc shell -a <bundle> runs the command as the hook user, in the bundle’s
current version. When atc.toml declares state_dirs on that bundle, it also
sets ATC_STATE_DIR to the same root that a hook gets. The program can read the
variable itself, or take the path as an argument. The single quotes stop your
local shell from expanding the variable. Without state_dirs, the variable is
unset.
The shell sets the variable only when the root exists in the home of the user
it runs as. With hook isolation off, hooks run as atcd’s own user, and the
shell becomes atc-hooks. The root is then in another home, so the shell
leaves ATC_STATE_DIR unset and prints the path it looked for on stderr.
What a hook sees. A hook of that bundle gets ATC_STATE_DIR, the root, and
a state_dirs map in ATC_HOOK_CONTEXT from each name to its full path. A hook
that atc ota run-hook, an uninstall or a recovery starts gets the same values.
These read the root on disk, because the request carries no list.
Shared across channels. The root is keyed by bundle name alone. Two channels that ship a bundle of one name share its state. This is decided behaviour, not an accident.
Deletion. atc ota rm deletes the root when it removes the last installed
version of that bundle name on every channel. A version switch, keep_n pruning
and the housekeeping sweep never delete it. A failed delete does not fail the
removal. atcd logs it and counts it in atcd.state_dirs.remove_failures.
Version skew. On a push, an atcd without the bundle-state-dirs-v1
capability cannot create the directories. atc ota push prints a warning for
each bundle with state_dirs. On the pull plane, the list rides on the signed
delivery manifest and there is no handshake. An atcd that predates the field
ignores the list and creates nothing. So atc channel deliver prints a note for
each bundle with state_dirs.
ATC_HOOK_CONTEXT
Every hook receives one atc variable, ATC_HOOK_CONTEXT, holding a compact JSON
object. Field presence is stable: artifact, version, self_root.new,
self_root.old, current_root, and peripheral are always present. JSON
null means “absent”, so a parser never has to special-case a missing key.
A hook bound to no artifact gets null for the whole artifact and version
values: a stage hook, a delivery.* bookend and a gc.* bookend. When
version is an object, version.old_sha is always present.
{
"push_id": "01J…", // session id: a ULID on push,
// "pull-activate-<delivery-id>" on pull,
// "manual-run-hook-<conn-id>" for `atc ota run-hook`
"artifact": { "kind": "oci", // "oci" | "bundle"; the whole object is
"name": "tempo" }, // null for a hook bound to no artifact
"version": { "new_sha": "abc…", // version being installed/activated; the whole
"old_sha": null }, // object is null like `artifact`. old_sha: the
// previously-active version, or null on first install
"state": "install", // any name from the states table above
"phase": "enter", // enter | exit
"trigger": "push", // push | pull | user_activate | gc | manual_run_hook | …
"artifacts_dir": "/var/atc/…/artifacts", // artifacts umbrella dir on the device
"self_root": { "new": "/var/atc/…/versions/abc…", // new version's root dir, or null
"old": null }, // previous version's root dir, or null.
// At check.*, download.*, install.enter,
// rollback.* and failure.*, @self and the
// working dir are "old"
"current_root": "/var/atc/…/delivery/current/tempo", // stable pointer, or null
"peripheral": null // peripheral-flash context, or null
}
Read fields with awk, through the ctx helper in
How hooks run. The document arrives in the
ENVIRONMENT, not on stdin, so ENVIRON["ATC_HOOK_CONTEXT"] is the read; a
per-artifact hook gets /dev/null on stdin. The same call works from #!/bin/sh
and from #!/usr/bin/env bash. The schema is defined in
crates/atc-lifecycle/src/hook_context.rs, which is the source of truth.
current_root: the path a service unit must name
current_root is the artifact’s delivery/current/<name> pointer. atc flips it
atomically on every push, switch and rollback, so a systemd ExecStart that
names it follows every later version with no edit. Take the field; never derive
the path yourself:
set -eu
# Print one dotted field of ATC_HOOK_CONTEXT, or the second argument when
# it or an object above it is null. With no second argument, a null field
# exits 1 and a field under a null object prints "". A missing key exits 1.
ctx() {
awk -v path="$1" -v nargs="$#" -v dflt="${2-}" '
function fail(why) { print "ATC_HOOK_CONTEXT." path ": " why > "/dev/stderr"; exit 1 }
function find(t, k, j, c, d, q) {
k = "\"" k "\":"
for (j = 1; j <= length(t); j++) {
c = substr(t, j, 1)
if (q) { if (c == "\\") j++; else if (c == "\"") q = 0 }
else if (c == "{") d++
else if (c == "}") { if (--d == 0) return 0 }
else if (c == "\"") { if (d == 1 && substr(t, j, length(k)) == k) return j + length(k); q = 1 }
}
return 0
}
BEGIN {
s = ENVIRON["ATC_HOOK_CONTEXT"]; n = split(path, key, ".")
for (i = 1; i <= n; i++) {
if (substr(s, 1, 4) == "null") { print dflt; exit 0 }
if (substr(s, 1, 1) != "{") fail("not an object")
p = find(s, key[i])
if (p == 0) fail("no such field")
s = substr(s, p)
}
if (substr(s, 1, 4) == "null") { if (nargs < 2) fail("null"); print dflt; exit 0 }
if (substr(s, 1, 1) != "\"") fail("not a string")
for (j = 2; j <= length(s); j++) {
c = substr(s, j, 1)
if (c == "\"") { print v; exit 0 }
if (c == "\\") { j++; c = substr(s, j, 1); if (c != "\"" && c != "\\" && c != "/") fail("holds a control escape") }
v = v c
}
fail("not a string")
}'
}
root=$(ctx current_root)
[ -n "$root" ] || { echo "install_unit.sh: current_root is null" >&2; exit 1; }
The set -eu line and the emptiness test are part of the recipe. A reader that
is absent from the device gives an empty $root, and the hook then writes a
broken ExecStart and exits 0. The push reports the hook as successful.
It is a path, not a directory the field checked. At install.exit on a
first install, nothing under delivery/ exists yet. That is exactly the hook
which writes the unit file. So write the value out, and do not test it first.
self_root.new is the field filtered by existence, and current_root is not.
Do not read the incoming version through it before the flip. On an upgrade,
current_root names the OUTGOING version at every cell up to the flip,
install.exit included. A test such as [ -x "$root/bin/myapp" ] there
inspects the old tree. To inspect the version being installed, read
self_root.new. An @self hook at install.exit also runs in the incoming
tree, so a path relative to its working directory reads that tree too.
Do not compute it from the hook’s working directory. A hook runs in
artifacts/<name>/versions/<sha>, which is a sibling of delivery/, not a
path under it. Counting levels up from $PWD and stripping from /delivery/
both produce a plausible string that resolves to nothing.
current_root is null for a peripheral-flash hook. That plane’s
artifacts_dir is the artifact’s own version dir, not the channel umbrella. No
channel pointer can be named from it, and a firmware flash has no service
pointer to follow.
What each field names at each cell
The table gives, for each delivery cell of an ARTIFACT-scope hook, whose
declaration runs, which tree a @self script runs in, and which version each
field names. It uses three words for two versions:
- incoming: the version that the delivery or the switch brings.
- outgoing: the version that was active before it.
- active and restored: the outgoing version again. It is “active” before the install, and “restored” on the unwind ladder, which brings it back.
| Cell | trigger | @self hook: declaration, tree and working directory | Absolute or @<name> hook: declaration | version.new_sha | version.old_sha | current_root names |
|---|---|---|---|---|---|---|
check.enter, check.exit | push, pull | active | incoming | incoming | outgoing | outgoing |
download.enter, download.exit, install.enter | push, pull | active | incoming | incoming | outgoing | outgoing |
install.exit | push, pull | incoming | incoming | incoming | outgoing | outgoing |
activate.enter | push, pull, user_activate | incoming | incoming | incoming | outgoing | outgoing |
deactivate.enter, deactivate.exit | push, pull, user_activate | outgoing | outgoing | incoming | outgoing | outgoing |
activate.exit, commit.enter, commit.exit | push, pull, user_activate | incoming | incoming | incoming | outgoing | incoming |
rollback.enter, rollback.exit | auto_rollback_veto | restored | incoming | incoming | outgoing | see below |
activate.enter, activate.exit | auto_rollback_veto | outgoing | outgoing | outgoing | incoming | see below |
failure.enter, failure.exit | auto_rollback_error | restored | incoming | incoming | outgoing | see below |
How to read the columns:
- A declaration is the
atc.tomlthat decides whether the hook exists, itstimeout_secsand its script path. “active” and “restored” name the version that was active before this delivery, so they are the outgoing version’s declaration. A@selfhook at those cells is that version’s hook: a release cannot add, remove or change it there. - A
@self/<path>script joins the tree of the version whose declaration it is. That tree is also the hook’s working directory. A@<name>/...or an absolute script names no version tree. That hook runs inself_root.newif that directory exists, else in the directory of the script. - The
triggercolumn gives the value the hook reads. It does not say which walk dispatches the cell. A switch runs nocheck,downloadorinstall. A push that flips nothing runs noactivateand nodeactivate; see Behavior changed. - On a first deploy there is no outgoing version.
old_shaandself_root.oldarenull, no@selfhook runs at an “active” or “restored” cell, and nodeactivate.*runs.
A stage hook (top-level [lifecycle.hook.*]) is bound to no artifact. It gets
null for artifact, version and current_root, and it reads the INCOMING
manifest at every cell, deactivate.* included. A peripheral-flash hook gets
null for current_root.
The table pins the version pair that a PUSH dispatches. Three other callers
pass a different pair to the compensating activate.*:
- A pulled delivery, an
atc ota switchand a boot recovery pass the restored version as bothnew_shaandold_sha. Only a push passes the failed version asold_sha. - A boot recovery runs the same cells as the compensation, under the trigger
daemon_restart_recovery, with the outgoing declaration in the outgoing tree.
uninstall.* and atc ota run-hook are not deliveries. An uninstall.* hook
runs the declaration of the version being deleted, in that version’s tree.
atc ota run-hook runs the declaration of the version that is active now, in
that version’s tree, at every cell. It gives new_sha as that version,
old_sha as the version before it, and trigger as manual_run_hook.
Without -o, if no artifact declares the cell, it runs the active delivery’s
stage hook, which gets null version fields.
version.new_sha has three meanings
| Where | new_sha names |
|---|---|
A forward cell (push, pull, user_activate) | the incoming version |
The compensating activate.* (auto_rollback_veto) | the version that the unwind restores |
rollback.* and failure.* | the incoming version, which failed |
So new_sha names the failed version at rollback.* and the restored version
one step later, at the compensating activate.*. To tell a rollback from a
forward activation, read trigger. Do not compare new_sha with the version
that current_root names:
case "$(ctx trigger)" in
auto_rollback_veto | daemon_restart_recovery) action=restore ;;
*) action=forward ;;
esac
The compensating activate.* runs the outgoing version’s own script, so a
version’s activate.* must also bring that same version back after a failed
upgrade. Write it to run twice without harm.
What current_root resolves to
current_root is <channel>/delivery/current/<artifact>. delivery/current
is a symlink to the active delivery’s directory. In that directory,
<artifact> is a symlink with the relative target
../../artifacts/<artifact>/versions/<sha>. So sha below is the sha of the
version that current names at that moment:
root=$(ctx current_root)
sha=$(basename "$(readlink -f "$root")")
The last column of the table says which version that is at each forward cell.
Before the flip it is the outgoing version. After the flip it is the incoming
version. On a first deploy the link does not exist before the flip, so
readlink -f prints nothing. Test [ -e "$root" ] before you read the sha.
On the unwind ladder:
- At
rollback.enter,current_rootstill names the version thatcurrentheld when the hook refused. After the flip, that is the incoming version.atc ota switch -ais the exception: it puts the pointer back first. - At the compensating
activate.*, atrollback.exitand atfailure.*, it names the restored version if atc put the pointer back. If that revert failed, or a newer delivery took the channel, it names whatevercurrentnames then. Read the link. Do not assume. - A first deploy refused after the flip has no version to restore, so atc
removes
delivery/current.
A progress hook ([lifecycle.progress.download]) gets the pointer of the
artifact being DOWNLOADED, not of the artifact that ships the script. Its
new_sha is the version being downloaded. Its old_sha, self_root.new and
self_root.old are null.
An outgoing version with no deactivate.* hooks
Warning: no artifact hook stops the outgoing version when it declares no
deactivate.*hook.deactivate.*reads the OUTGOING version’s declaration, not the incoming one. A version shipped before you added the hook declares none. So does a version installed by an atcd too old to keep the declaration. On a push, a pull or a switch away from such a version, a workload that only your hooks start and stop keeps running while the incomingactivate.exitruns.
Two things can still stop it: an incoming stage-scope deactivate.*, and the
stop that atc runs for a workload that atc itself manages.
So write the incoming activate.exit to take over a workload that still runs.
Use systemctl --user restart, not enable --now. See
A name shared across versions.
A refusal later in that delivery has no teardown to undo either. If no
member’s deactivate.* ran, the unwind runs no compensating activate.*.
Behavior changed: a push without --switch runs no activate.*
Changed in atcd v0.1.5 and nightly-2026-10-02 (#6361, 2026-10-01). A push
that flips nothing dispatches no activate.* and no deactivate.*, at any
scope. Two pushes flip nothing: a push without --switch (stage-only), and a
re-push of the version already active. An artifact-scope commit.* does not
run either. A stage-scope commit.* still runs.
Before that change, such a push ran activate.enter and activate.exit with
current still on the old version. So an activate.exit hook written for that
behavior often carries a guard: exit early when new_sha is not the version
that current_root names. On atcd v0.1.5 and later, a forward activate.exit
runs only after the flip, so that guard never fires there. Keep it only while a
device runs an older atcd.
Do not move the same guard to activate.enter. There it fires on every
upgrade, because current_root still names the outgoing version.
What a hook does not receive
ATC_HOOK_CONTEXT is the only atc variable. atc shell -a <name> also sets
ATC_ARTIFACTS_DIR, ATC_ARTIFACT_KIND, ATC_ARTIFACT_NAME, ATC_SELF_ROOT
and ATC_NEW_SELF_ROOT for interactive work, and it starts you in
delivery/current/<name>. A hook receives none of those. It starts in the
versioned tree instead. So a script written in that shell fails on its first
real push with unbound variable. Debug against the document, or against
atc ota run-hook, which dispatches through the real hook path.
A hook’s exit status is its last command’s
There is no separate success signal. The shell’s usual rule applies, and a
non-zero status vetoes the delivery. A diagnostic hook that ends in a find
over one unreadable directory therefore fails a push whose real work already
succeeded, and the reported failure names the hook rather than the find. End
any script that reports rather than gates with an explicit exit 0.
Worked example: a commit.exit hook
Drop a marker file once a bundle has been committed (made active) on the robot:
[config]
autoswitch = true # commit/switch on push without an explicit --switch
[[bundle]]
name = "myapp"
source = "dist" # directory ATC chunks + sends
# Runs AFTER myapp is committed (made active) on the device.
[bundle.lifecycle.hook.commit.exit]
script = "@self/scripts/mark_committed.sh" # path inside myapp's own tree
timeout_secs = 60
scripts/mark_committed.sh lives inside the bundle source (dist/), so it is
shipped with the artifact and resolved via @self. It must be chmod +x with a
shebang in dist/ — see How hooks run.
A marker is a report, so commit.exit is the right gate for it. If the script
were instead the thing that brings myapp into service, activate.exit is the
better home: it is the first gate after the pointer flips, so the window where
the device points at the new version but is not yet running it stays as short as
possible. See the hook cutover runbook.
With multiple [[bundle]] entries, a [bundle.lifecycle.hook.*] table binds to
the [[bundle]] defined immediately above it (TOML array-of-tables
semantics) — keep each bundle’s hooks directly under that bundle.
Worked example: a long-running service
This is the pattern behind “ship a bundle and keep it running across reboots”.
atc has no [service] key and starts no process of its own. A systemd unit does
that, and three lifecycle hooks install it and drive it.
Bootstrap already prepares the device for the rootless half of this. It creates
the atc-hooks user, gives it the home directory /var/lib/atc-hooks, and runs
loginctl enable-linger for it. Linger is what starts that user’s systemd
manager at boot with no login, so a --user unit the hooks enable comes back
after a reboot. Every hook already receives XDG_RUNTIME_DIR and
DBUS_SESSION_BUS_ADDRESS for that manager, so the scripts below need no sudo
and no root daemon.
The declaration
[[bundle]]
name = "myapp"
source = "dist"
# Write the unit and reload systemd BEFORE the pointer flips. A unit file that
# systemd refuses fails the delivery here, instead of after the robot is live.
[bundle.lifecycle.hook.install.exit]
script = "@self/scripts/install_unit.sh"
timeout_secs = 60
# Start the new version. activate.exit is the first gate after the flip.
[bundle.lifecycle.hook.activate.exit]
script = "@self/scripts/start_service.sh"
timeout_secs = 60
# Stop the outgoing version. This declaration runs on the NEXT delivery, when
# the version you ship now becomes the outgoing one.
[bundle.lifecycle.hook.deactivate.enter]
script = "@self/scripts/stop_service.sh"
timeout_secs = 60
dist/scripts/install_unit.sh
The unit points at delivery/current/myapp, not at this version’s own directory.
delivery/current is the pointer atc flips atomically on every push, switch and
rollback. A unit pinned to one version’s SHA cannot follow it.
The hook reads that path out of ATC_HOOK_CONTEXT.current_root rather than
hard-coding it. The same script then works on any channel and any store root.
It also never walks up from its own working directory, which is a sibling of
delivery/. See current_root.
ConditionPathExists= guards the one case where the pointer is absent. A
rollback restores the earlier delivery, and the pointer follows it. A first
delivery has no earlier one, so a veto on it removes delivery/current. With
Restart=always and no condition, the unit then restarts every few seconds into
“No such file or directory”. The launcher’s one useful log line is buried under
the repeats. The condition makes systemd skip the start and report the
unit as inactive instead. The next successful push creates the path, and the
activate.exit hook starts the unit as usual.
#!/bin/sh
# install.exit — pre-flip. The new tree is on the device and readable, and
# `current_root` still points at the OLD version (or at nothing, on a first
# install). Either way the path is the one the unit must name.
set -eu
# awk, not jq or python3: the device image carries neither, and a missing
# reader gives an EMPTY root rather than an error. `set -eu` and the test below
# make that case stop the hook.
# Print one dotted field of ATC_HOOK_CONTEXT, or the second argument when
# it or an object above it is null. With no second argument, a null field
# exits 1 and a field under a null object prints "". A missing key exits 1.
ctx() {
awk -v path="$1" -v nargs="$#" -v dflt="${2-}" '
function fail(why) { print "ATC_HOOK_CONTEXT." path ": " why > "/dev/stderr"; exit 1 }
function find(t, k, j, c, d, q) {
k = "\"" k "\":"
for (j = 1; j <= length(t); j++) {
c = substr(t, j, 1)
if (q) { if (c == "\\") j++; else if (c == "\"") q = 0 }
else if (c == "{") d++
else if (c == "}") { if (--d == 0) return 0 }
else if (c == "\"") { if (d == 1 && substr(t, j, length(k)) == k) return j + length(k); q = 1 }
}
return 0
}
BEGIN {
s = ENVIRON["ATC_HOOK_CONTEXT"]; n = split(path, key, ".")
for (i = 1; i <= n; i++) {
if (substr(s, 1, 4) == "null") { print dflt; exit 0 }
if (substr(s, 1, 1) != "{") fail("not an object")
p = find(s, key[i])
if (p == 0) fail("no such field")
s = substr(s, p)
}
if (substr(s, 1, 4) == "null") { if (nargs < 2) fail("null"); print dflt; exit 0 }
if (substr(s, 1, 1) != "\"") fail("not a string")
for (j = 2; j <= length(s); j++) {
c = substr(s, j, 1)
if (c == "\"") { print v; exit 0 }
if (c == "\\") { j++; c = substr(s, j, 1); if (c != "\"" && c != "\\" && c != "/") fail("holds a control escape") }
v = v c
}
fail("not a string")
}'
}
root=$(ctx current_root)
[ -n "$root" ] || { printf '%s\n' "myapp: current_root is null" >&2; exit 1; }
# An ALLOWLIST, not a list of the characters that happen to break today. The
# artifact-name validator permits every one of them, so this is the only gate.
case $root in
*[!A-Za-z0-9/._+:@%\ -]*)
printf '%s\n' "myapp: unsafe character in the delivery path" >&2; exit 1 ;;
esac
# `%` is inside the allowlist, because a real path can hold one. Double it, or
# systemd reads a specifier: a name holding `%n` resolved to the UNIT name.
esc=$(printf '%s' "$root" | sed 's/%/%%/g')
unit_dir="$HOME/.config/systemd/user"
mkdir -p "$unit_dir"
cat > "$unit_dir/myapp.service" <<UNIT
[Unit]
Description=myapp
# A vetoed first delivery removes delivery/current. Without this line the
# unit restart-loops on "No such file or directory" until the next push.
ConditionPathExists=$esc/bin/myapp
[Service]
ExecStart="$esc/bin/myapp"
Restart=always
[Install]
WantedBy=default.target
UNIT
systemctl --user daemon-reload
One allowlist, and one escape. An enumeration of the characters that break
is the wrong shape here. It is only ever as complete as the last person to look.
Two rounds of review found %, then "/\/newline, then asked about $. The
allowlist above ends that: a character must be added to it to reach the unit
file.
% is the one allowed character that still needs work. A real path can hold
one, and systemd expands it. Measured with systemd-analyze verify: a path
holding %n resolved to …/pct<unit-name>/…, which names no directory.
Doubling it to %% resolved the literal path.
$ needs no escaping, which is the counter-intuitive part. systemd expands
no variable in the FIRST argument of a command line. The executable is that
argument. Measured the same way, with Environment=HOME=/pwned set. A path
holding $HOME resolved to the literal $HOME path. $$HOME resolved to a
literal two-dollar path, so doubling would have broken a path that worked.
In an argument AFTER the executable a $ does expand, and $$ is the escape
there. The ExecStart above has no such argument, and $ is outside the
allowlist, so the question does not arise for a delivered path.
The allowlist is deliberately stricter than systemd. Measured over 26
characters, systemd fatally rejects only a single quote and a tab in an
executable path. The message is “Executable path contains special characters”.
It treats `, ;, &, | and the rest as literal, because it runs no
shell, so those would work. They are refused here anyway. A bundle named a;b
is a typo far more often than an intention, and a narrow allowlist is the
version of this rule that cannot rot.
validate_artifact_name permits every character named above. It rejects an
empty name, /, :, NUL, ./.., the reserved oci and .atc, and an
over-long name. It rejects nothing else, so all of these are pushable today.
Tightening it is tracked in #4293. Until then, this guard stands between a
pushed name and a generated unit file.
install.exit is the right place to refuse. The hook has written no unit yet,
so a non-zero exit vetoes the delivery with nothing installed, and it leaves
the running service untouched.
The heredoc is unquoted (<<UNIT, not <<'UNIT') so $esc expands. Every
other $ in the TEMPLATE expands too, and the shell gets there before systemd
does. So a variable you want SYSTEMD to expand needs a backslash: write
\$MAINPID, which reaches the file as $MAINPID. $$MAINPID does not work
here, because the shell reads $$ as its own PID and writes that instead.
The interpolated path needs none of that. $esc is a variable VALUE, and the
shell does not rescan what it substitutes. So the path is written once, exactly
as the allowlist and the sed left it.
dist/scripts/start_service.sh
Use restart, not enable --now. enable --now exits 0 against a unit that is
already active, and starts nothing. So a delivery whose outgoing version declared
no deactivate.* keeps the old process alive under a green push. See
A name shared across versions.
#!/bin/sh
# activate.exit — post-flip. delivery/current already names this version.
set -eu
systemctl --user enable myapp.service # survive the next reboot
systemctl --user restart myapp.service # always replace the running process
systemctl --user is-active --quiet myapp.service
The is-active line is the hook’s own check. restart reports success for a
start job that is only queued, so without it a service that starts and dies
still commits a green delivery.
dist/scripts/stop_service.sh
#!/bin/sh
# deactivate.enter — pre-flip, and the incoming activate.exit runs after it.
set -eu
systemctl --user stop myapp.service
The stop needs no test for “is my version still the current one”. The order
guarantees the answer: deactivate.* runs inside activate, and the incoming
version’s activate.exit runs after both deactivate gates.
The three scripts bind to the versions that ship them
A hook is resolved out of the version tree its declaration came from.
@self/scripts/stop_service.sh means that version’s copy of that file. So
stop_service.sh here is not the script that stops the version in this push.
It is the script that stops the version already on the robot, and that copy
shipped with it.
Editing atc.toml or a script therefore reaches the deliveries after this
one, never the way out of a version already installed. Two consequences bite in
practice:
- The pair does nothing on the push that first adds it. The outgoing version
declared no
deactivate.*, so nothing stops the old process, which is whystart_service.shmustrestartrather thanenable --now. - A relabelled push of unchanged content still runs the old
stop_service.sh. That copy can be wrong: it can stop a unit the incoming version just started. Fixing the file does not fix that push. Push a version carrying the corrected script, then push again.
The push output does not tell you whose copy ran. A hook line reports the
declared ref, @self/scripts/stop_service.sh, which reads the same for every
version. So a stale deactivate.enter looks exactly like a current one. Reason
it out from the order above, not from the output.
A wrapper that sources someone else’s environment
A ROS node, a Python venv, and most other environments need a small wrapper.
The wrapper sources a setup script, then execs the program. Point ExecStart
at the wrapper, not at the program.
Give that wrapper a #!/bin/bash shebang, not #!/bin/sh. On a device
image /bin/sh is dash. Catkin’s install/setup.bash and ROS 2’s
/opt/ros/<distro>/setup.bash both need bash. They read $BASH_SOURCE to find
their own directory, and they use bash-only syntax elsewhere. Under dash they
report Bad substitution or builtin: not found, and neither names the shell.
Source setup.bash, not setup.sh. This is a second fault with its own
symptom, and the right shebang does not fix it. A POSIX shell cannot tell a
sourced script its own path, so colcon’s install/setup.sh carries the prefix
of the machine that built it. The robot has no such directory, and the script
says so: The build time path '<path>' doesn't exist.
atc ota push catches neither mistake. It ships your script and never reads
it, so the push reports success and the unit is broken.
Ship a ROS 2 install space built without --symlink-install. With that
flag, colcon gives an ament_python package an editable install. Its
install/<pkg>/lib/python3.X/site-packages/ then holds a <pkg>.egg-link and
no .py files. The .egg-link holds an absolute path to build/<pkg> on the
build machine, and that directory links into src/. The bundle holds neither
directory.
The console script under install/<pkg>/lib/<pkg>/ looks the package up by
its metadata, and the .egg-info with that metadata also stays in build/. So
the push succeeds, and the node fails on the robot with
importlib.metadata.PackageNotFoundError or ModuleNotFoundError.
Run a plain colcon build for the install space you ship. A
~/.colcon/defaults.yaml with symlink-install: true under build: sets the
flag on every build, so check that file too. atc ota push warns when a bundle
carries a .egg-link, or a __editable__ pointer from pip install -e. It
does not refuse the push.
Do not set -u in that wrapper. ROS 2’s /opt/ros/<distro>/setup.bash
reads AMENT_TRACE_SETUP_FILES without setting it, and a venv activate does
the same with variables of its own. Under nounset the wrapper dies before your
program starts, and the failure names a variable you never wrote. set -e alone
is fine.
#!/bin/bash
# Sourced environment first, strict mode after it. Never around it.
set -e
. /opt/ros/humble/setup.bash
exec "$(dirname "$0")/../bin/my_node"
Checking a --user unit’s state
The unit belongs to the hook user’s manager, not to yours. So
systemctl --user status myapp.service, run over ssh as your login user,
answers Unit myapp.service could not be found. The push can be correct, and
the service can run. Ask with the hook identity:
atc shell robot@device -- systemctl --user status myapp.service
atc shell reconstructs the hook environment, XDG_RUNTIME_DIR and
DBUS_SESSION_BUS_ADDRESS included. A plain sudo -u atc-hooks systemctl --user ... sets neither variable, and answers Failed to connect to bus. Set
XDG_RUNTIME_DIR yourself where atc shell is not available:
sudo -u atc-hooks env XDG_RUNTIME_DIR="/run/user/$(id -u atc-hooks)" \
systemctl --user status myapp.service
Reading a --user unit’s logs
journalctl --user -u myapp.service, run as atc-hooks over
atc shell robot@device, can answer No journal files were found while the
unit is actively logging. The user session’s journal is not always reachable
from a shell that never logged in. The records still reach the system journal,
where each one carries the user unit name. Read them from outside:
sudo journalctl _SYSTEMD_USER_UNIT=myapp.service _UID="$(id -u atc-hooks)" -f
Name the two fields. journalctl also has a --user-unit= flag, which matches
on the caller’s own session, so a root shell needs the explicit form above. For
every unit of that user at once, ask for the manager instead:
sudo journalctl -u "user@$(id -u atc-hooks).service" -f
An empty journal is not a dead service. systemd gives the unit a pipe, not
a terminal. A Python or C runtime buffers stdout in blocks on a pipe. A line
then sits in the buffer until the buffer fills or the process exits. A rospy
node that prints one line per message can show one startup line for hours. It
handles every message correctly.
Which answer applies depends on how the node writes the line.
-
print()and a bare write reach stdout, and nothing else. SetEnvironment=PYTHONUNBUFFERED=1in the unit. The journal then gets each line as the node writes it. For a C program that the unit starts, wrap the command instdbuf -oL.stdbufcannot change a Python program, because Python sets its own buffering. -
rospy.loginfoand the other framework loggers reach the framework’s own files instead. ROS writes them under~/.ros/log/<run_id>/. Read them as the hook user:atc shell <target> -- sh -c 'ls -t ~/.ros/log/'Quote the command. The tilde must expand on the device, and your own shell expands an unquoted one first. Sort by time rather than reading
latest. Thelatestsymlink is aroslaunchconvenience, so a node the unit starts directly can leave none.
A node can use both, and each line reaches one of the two places. So read both before you call the service broken.
A ROS 1 roslaunch service logs almost nothing there for a second reason.
roslaunch redirects each node’s output into its own files under
~/.ros/log/<run_id>/. So output="screen" never reaches systemd’s capture.
Read ~/.ros/log/latest/ for the node’s own logs. The journal carries only
roslaunch itself. An unbuffered stdout does not change that.
A service that needs time to stop
At shutdown, systemd stops the hook user’s systemd --user manager
(user@<uid>.service). The manager then stops its own units. A unit cannot
outlive its manager. So the manager’s TimeoutStopSec caps the stop time of
every hook-launched user service, whatever the unit’s own TimeoutStopSec
says.
Ubuntu and Jetson L4T set that cap to 5 s. They ship
/usr/lib/systemd/system/user@.service.d/timeout.conf with
TimeoutStopSec=5. A service that saves state on SIGTERM and needs longer is
killed before the save ends.
By default, atc bootstrap atcd install raises the cap to 180 s for the hook
user only.
It writes /etc/systemd/system/user@<uid>.service.d/zz-atc-hook-user-stop-timeout.conf,
where <uid> is the numeric uid of the hook user. The file name sorts after
timeout.conf, and systemd applies the drop-in that sorts last. The install
then reads the value back with systemctl show. It prints a warning when
another drop-in still sets a different value.
Other users’ managers, and the
user@.service template, keep their values. A daemon-reload applies the
change to a running manager, so the hook user’s manager does not restart.
Bootstrap writes no drop-in when hooks run as the daemon user, as with
--root. atc bootstrap atcd upgrade does not write it. Run
atc bootstrap atcd install again on a device installed before this change.
atc bootstrap atcd uninstall removes the file when it removes the hook
user: on a purge, when no other atcd store uses that user. It removes only a
file whose first line is the bootstrap header. --keep-store keeps the hook
user, and the file with it.
The install records the uid that it wrote the drop-in for, in
/etc/atc/<service>.hook-user-stop-timeout-uid. The hook user can change in
three ways:
--set daemon.hook_run_as_user=<user>names another hook user.- Hooks run as the daemon user again.
- The account is deleted and created again with a new uid.
Then the next install, and a purge, remove the drop-in of the recorded uid. They remove only that one path, and only a regular file with the bootstrap header. They keep it when the record of another atcd unit names that uid. They never remove a drop-in of any other uid.
--keep-store keeps the drop-in and the record. A purge keeps the record
while the drop-in it names stays. Three limits remain:
- A failed first install rolls back the unit, but it keeps the drop-in and the record, as it keeps the hook user.
- A device installed before the record has none. Its next install writes one. If that install also changes the hook user, the drop-in of the former uid stays. Remove it by hand.
- Two atcd units can share one hook user. If the other unit was installed before the record, it has no record that names the uid. A change of the hook user of this unit then removes the shared drop-in. The next install of the other unit writes it again.
Two flags of atc bootstrap atcd install change the drop-in:
--hook-user-stop-timeout <SECS>writes the drop-in with that value. The value must be from 10 to 3600. Above 30 min,poweroff.targetstops waiting (JobTimeoutSec=30min) and powers the host off.--no-hook-user-stop-timeoutremoves the drop-in, then runssystemctl daemon-reload, so the vendor’s value applies again. A drop-in that sorts after it still applies. It removes only a file whose first line is the bootstrap header, and it keeps a symlink at the path. With no file, it changes nothing.
The two flags conflict, and each one refuses --root. When hooks run as the
daemon user, there is no hook user’s manager. Then a flag changes nothing, and
the install prints a note that says so.
The flags are not saved. Each install sets what its own flags say. An install
with no flag writes the 180 s drop-in again, also after
--no-hook-user-stop-timeout. So give the flag to every install of the device.
atc bootstrap atcd upgrade does not read the flags, and it does not change the
drop-in. A repair by atc ota push of an existing unit does not change the
drop-in either. When atc ota push installs atcd on a device for the first
time ([bootstrap].auto), the install writes the 180 s drop-in.
Check your units against the value you set:
- Set the unit’s
TimeoutStopSecto the longest stop you expect. The cap must be at least that value plus a margin of about 30 s. - A unit with
ExecStop=can take about twice itsTimeoutStopSec. systemd applies the timeout to eachExecStop=command and again to the wait after SIGTERM. - Units that are not ordered against each other stop in parallel. The slowest ordered chain counts, not the sum.
If a unit needs more than 180 s, use --hook-user-stop-timeout. You can also
add a drop-in for the hook user that sorts after
zz-atc-hook-user-stop-timeout.conf, for example
zzz-local-stop-timeout.conf. The install keeps your value. It prints the
warning only when it runs its script, and an install that finds nothing to
repair runs none. atcd install reads the file, not the value systemd
holds.
With or without a flag, the install keeps a regular file at the drop-in path
that bootstrap did not write. --no-hook-user-stop-timeout also keeps a
symlink there. An install that writes a value, with or without the flag,
refuses a symlink and stops. The install names a kept file only when it runs
its script.
These points are yours to check. atc does not and cannot verify them:
- Late or network mounts.
user@<uid>.servicestops before localfstabmounts unmount. A unit that writes to a network mount, an automount or a late mount at stop needs ordering that atc does not add. - Other users’ managers. The drop-in covers only the hook user. A workload that runs in another user’s manager keeps that manager’s timeout.
- Container stop timeouts.
podman stopsends SIGKILL after 10 s by default, whatever systemd allows. A unit that runs a container must raise it:podman run --stop-timeout,podman stop -t, orStopTimeout=in a Quadlet file.
systemctl --user needs more than linger
Bootstrap enables linger, which is necessary and not sufficient. A rootless
systemd --user manager also needs dbus installed, and cgroup delegation for
user slices. A nested container usually has neither. Docker-in-Docker is the
common case, and a device image built that way behaves the same. The manager
then fails with status=219/CGROUP, however correct the unit is. A missing
dbus on its own gives Failed to connect to bus.
A hook cannot fix either. The next section gives the two supported paths for such a device.
Root work on a device with no user session
This section is for a device with no dbus or no systemd --user manager. On
such a device a hook cannot drive systemctl --user. Use one of two supported
paths, in this order.
- Give the hook user a supplementary group. Use this path when the hook
must read or write a root-owned file, socket, or device node. Add the hook
user (
atc-hooksby default) to a group that owns the resource. Then give that group the access. When the hook user differs from the daemon user, atcd reads the group set at every spawn, so you need no daemon restart. When hooks run as the daemon’s own user, as on the hand-launched no-systemd recipe, they inherit the daemon’s groups. Then restart atcd after the group change. The recipe, with the K3S kubeconfig as the example, is in the atcd non-root runtime model. - Ship a root-owned system unit in a
.deb. Use this path when the work must run as root, for example a service that writes to/usror/etc. This path needs systemd as PID 1, for bootstrap and for the unit itself. Bootstrap the device withatc bootstrap atcd install --root. atcd then runs as root, and its hooks inherit root. The hooks install the.deband start its unit withsystemctl. The complete producer tree isexamples/deb-systemd-service/.
A root hook user under a non-root daemon is refused by design. atc refuses
hook_run_as_user = "root" while run_as_user is not root. The error is
ATC-E0506. This is a decision, not a gap (#4785). There is no opt-in that
lifts it. To run hooks as root, run atcd as root with path 2.
A sudoers rule that lets the hook user call sudo is not a supported path for
this device shape. atc does not install, test, or check such a rule.
Ubuntu 24.04 and user namespaces
Ubuntu 24.04 sets kernel.apparmor_restrict_unprivileged_userns=1. With that
setting, a binary needs an AppArmor profile that grants userns before it can
use an unprivileged user namespace. Without one, the kernel confines the process
to the unprivileged_userns profile and strips its capabilities.
Ubuntu ships such profiles for podman, buildah and crun. It ships none for
skopeo. So podman works for the hook user, and skopeo copy fails with
unshare: Operation not permitted. atc oci import from a registry runs
skopeo, so a first push can fail this way while every rootless check passes.
Install a profile for skopeo. It is Ubuntu’s podman profile with the name and the path changed:
sudo tee /etc/apparmor.d/skopeo > /dev/null << 'EOF'
abi <abi/4.0>,
include <tunables/global>
profile skopeo /usr/bin/skopeo flags=(unconfined) {
userns,
include if exists <local/skopeo>
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/skopeo
A file under /etc/apparmor.d loads again at boot. atcd ctl setup-rootless --user <name> reports check R6 as WARN while the sysctl is 1 and the kernel
lists no skopeo profile in /sys/kernel/security/apparmor/profiles. It also
warns when the file changed after the kernel loaded it, and when the file does
not attach to /usr/bin/skopeo. The file alone is not a pass. A profile the parser refused, or that nobody loaded, leaves skopeo
failing. The delivered agency/podman bundle needs no extra step:
setup-rootless --provision installs /etc/apparmor.d/atc_podman for it.
Do not set the sysctl to 0. That lifts the restriction for every unprivileged binary on the host, and it hides the missing profile instead of adding it. Ubuntu 20.04 and 22.04 have no such sysctl and need no profile.
What the push checks after activate.exit
A hook can exit 0 and still leave a unit that does not run. So after a push
that activates a bundle, atc ota push asks the hook user’s
systemctl --user about the unit of that bundle. It polls every half second
for up to 10 seconds. A unit counts as running when it stays active for 2
seconds with no restart. A slow starter therefore does not fail the first poll.
A unit that is not running gives a WARNING in the push summary. The warning
names the unit and its last state, for example failed, activating (auto-restart) or restarting. The push still succeeds, the exit code does
not change, and atc reverts nothing. --json carries the same list as
inactive_units.
The push finds the unit in one of two places:
- An
atc.kdlsystemd <name> { from <bundle>; unit "<unit>" }workload. The push checks that unit when it activates<bundle>. If systemd does not know the unit, the warning reportsnot-found. - Otherwise, the unit named
<bundle>.service, which is the name this example uses. The push checks it only when the hook user’s manager has it loaded. A bundle with no such unit gets no check and no warning.
The check says nothing when it cannot run: a push over --socket, or a device
with no user session. No poll starts with less than 2 seconds of the budget
left. When a later poll fails, the push still reports each unit an earlier
poll saw failed, inactive, not-found or restarting. It drops a unit last
seen activating, because that unit may be a slow starter.
What this example does not cover
- A root-owned system unit, and a
.deb. Useexamples/deb-systemd-service/, a complete producer tree with its own e2e test. That path needsatc bootstrap atcd install --root, because a.debwrites to/usrand/etc, and the unprivileged hook user cannot. - A device image with no user-session support.
systemctl --userneedslibpam-systemdanddbus-user-session. Bootstrap probes for both and reports what it found. On a device that has neither,systemctl --useranswersFailed to connect to bus. For the supported paths on such a device, see Root work on a device with no user session. - The
systemd <name> { unit ... }workload syntax. It parses and it persists, but nothing starts it. See modules-today.md. Hooks are the mechanism that works today.
Debugging hooks
Hook stdout/stderr is recorded on the controller once the hook is
dispatched: one <state>.<phase>__<hook-id>.{stdout,stderr} pair per hook run,
under $XDG_CACHE_HOME/atc/hooks/<timestamp>-<target> (~/.cache/atc/hooks/…
by default — the exact path is printed at push start). Pass
--capture-hooks <DIR> to pin a specific directory; recording is unavailable
only under --json.
spawn_error means the exec itself failed — the hook never ran, so there
is no exit status. The OS error is still reported: the CLI prints it as a
[stderr] line on the failure replay, and it is written to the hook’s
.stderr capture file. Look for spawn failed: Exec format error or
spawn failed: Permission denied. The same text is also available after the
fact, base64-encoded in stderr_tail_b64, from atc ota status --json.
Triage the usual causes on the device:
- missing or bad shebang (
ENOEXEC/Exec format error) — checkhead -1of the shipped script under the artifact’s version dir; - missing exec bit on the shipped file;
- hook-user setup failure (see the hook-user paragraph above).
Warning: running the script by hand over SSH masks a missing shebang —
bash falls back to interpreting ENOEXEC files as sh scripts, so a script
that “works” when run manually can still spawn_error under atcd, which
exec’s it directly.
Run a hook out-of-band without a push:
atc ota run-hook user@robot commit.exit
-o NAME narrows to one declared artifact, spelled as ota push’s selector
(--artifact stays as a hidden, deprecated alias). Without it, run-hook
picks the single artifact declaring the cell and refuses when several do.
Which scope run-hook reaches. A hook declared under an artifact
([bundle.lifecycle.hook.activate.exit]) is read from that version’s
.atc/lifecycle.pb. A stage hook — top-level
[lifecycle.hook.<state>.<phase>] — is bound to no artifact, so it is read
from the active delivery’s .atc/delivery.pb archive instead, the same file
the gc.* bookends live in. run-hook consults that archive only when NO
artifact declares the cell. So when the same cell is declared at both scopes,
run-hook runs the artifact hook, and the stage hook is not directly
reachable — a push runs both, run-hook runs one.
A stage hook is archived by the delivery that declared it. A delivery
committed by an atcd older than this behaviour wrote no stage table, so
run-hook reports no hook configured for its stage cells until the next
push or poll re-commits the delivery.
A stage hook run this way sees no delivery members. On a push or a poll,
a stage hook’s context lists the artifacts of the delivery being applied, each
with the version it is moving from and the version it is moving to. run-hook
applies no delivery, so it reports an empty list rather than a made-up one.
Write a stage hook that must also be reachable by hand so that it reads the
device state it acts on, not the member list.
run-hook requires a bootstrap-provisioned device (the atc-hooks user
plus its NOPASSWD sudoers drop-in); on a manual no-systemd install it fails
with “device not provisioned for a hook shell” — trigger the hook via a push
instead, or exec the script from the version dir with ATC_HOOK_CONTEXT set by
hand.
On a no-systemd device (where run-hook is unavailable), confirm a hook ran by
triggering a push and checking the hook’s own side effect on the device (e.g.
its marker file) alongside the captured stdout/stderr under ~/.cache/atc/hooks/
on the controller.
Testing hooks without a device
atc ota push --local runs the whole push against a throwaway atcd on your
own machine. There is no target, no SSH, no bootstrap and no root:
atc ota push --local --switch --hook-output=always
It makes a test root, starts a real atcd on it, pushes, and stops the daemon
again. Because it is the real daemon, a hook that passes here ran against the
real executor — same ATC_HOOK_CONTEXT, same working directory, same timeout,
same exit-code rules. A hook that exits non-zero vetoes the delivery, and the
command exits non-zero with it.
--switch or --no-switch is required, and --local will not guess.
--switch activates the pushed version, so the activate and commit hooks
run too. --no-switch stages, which runs the download and install hooks and
stops. Guessing would either report half a hook run as a whole one, or override
a project whose own policy is to stage.
Keep the root to look inside it. Give --local-root an absolute path. A
relative path makes every hook fail to spawn (spawn failed: No such file or directory (os error 2)), and the failure vetoes the whole push:
atc ota push --local --local-root /tmp/hook-test --switch --hook-output=always
ls /tmp/hook-test/store # the store atcd wrote
cat /tmp/hook-test/atcd.log # what the daemon said
ls /tmp/hook-test/hooks # each hook's recorded stdout and stderr
Keep the path short as well. The root holds atcd’s Unix socket, and a socket
path may not exceed 103 bytes. A longer one is refused up front with
ATC-E0463.
A root you name is created and kept, and holds everything about the run. A root
you do not name is a temp directory that goes away with the command. A root that
already holds something else is refused, so --local-root . cannot scatter a
store through your project.
To activate a version that a --no-switch push staged in a kept root, run
atc ota switch --local-root <DIR> with the version or --delivery-id. The
push prints that command. The switch starts atcd on the root for this command
only, so the activate and commit hooks run as they do for --switch. A temp
root holds nothing after the push, so that push prints no command.
Four things this does not do.
- It is not a sandbox. The test root relocates atcd’s store and nothing else. Hooks run under your uid, in your home, against your user services. A hook that writes an absolute path writes it here, on this machine. The command prints this every time it runs.
- A hook that is still running when the command ends is not killed. Every
hook gets its own process group and only
atcdknows them, so killing the daemon leaves a slow hook running. It keeps making changes on this machine after the command has returned. - It is not the device’s hook user. On a robot, hooks drop to
atc-hooks. Locally there is no privileged daemon to drop from, so they run as you. A hook that works only because of something your account has can still fail on a device — and one that needs the service account’s home can fail here and pass there. - Pushing the same content twice runs no hooks at all. The daemon
short-circuits a push whose content is already active. Change something
between runs into a kept root, or the second run does nothing and reads as a
broken feature. A changed second push is also the only way to reach the
deactivatehooks on a push, which need a previous version to deactivate.atc ota switchback to the earlier version reaches them too. - It needs an
atcdfor this machine. Releasedatcdbuilds are Linux only.atclooks foratcdbeside itself, then onPATH;ATC_LOCAL_ATCDnames one directly.
--local takes no target, no --socket, no --ssh-port and no --dry-run:
each of those names a device, and local mode is the absence of one. Two local
pushes cannot share one root — the second is refused rather than becoming a
second writer on the same store.
Common mistakes (all now rejected at atc ota push)
These shapes used to parse with no error and wire no hook — push still
reported success and the hook never ran. They are now rejected with a message
that shows the correct shape:
lifecycle = "x" # lifecycle must be a table, not a scalar
lifecycle = [] # ...not an array
lifecycle.commit.exit = 12345 # missing the required `.hook.` segment
[lifecycle.commit.exit] # missing `.hook.` — `commit` is unknown
[lifecycle.hook.commit.exit]
run = "do-thing.sh" # wrong body key — it's `script`, not `run`
The correct form for the last one:
[lifecycle.hook.commit.exit] # stage scope: @self forbidden, use @<name>/...
script = "@myapp/scripts/x.sh"
…or, more commonly, artifact-scoped under the bundle:
[bundle.lifecycle.hook.commit.exit]
script = "@self/scripts/x.sh"
atc.kdl reference
atc.kdl declares what a repository ships to a robot. It is a
KDL 2.0 document. atc ota push reads it, and
atc config lint checks it before anything depends on it. atc.kdl is an
opt-in alternative format. atc.toml is the format atc ota init writes by
default, and its reference is atc.toml reference. When one
directory holds both files, atc reads atc.kdl, as
Where atc finds the file says.
This page follows the parser in
crates/atc-config/src/kdl_*.rs. Everykdlblock on this page is also a test fixture undercrates/atc-config/tests/fixtures/kdl/valid/, and a test fails when a block and its fixture differ. So each example lints. If this page and the parser disagree, the parser wins. File a docs bug.
Check a file
atc config lint
atc config lint path/to/atc.kdl
atc config lint path/to/atc.toml
atc config lint --json
With no argument, atc config lint reads the manifest that atc ota push
reads. It finds that file as Where atc finds the file
shows. A named file ending in .kdl is linted as KDL. Any other named file is
linted as atc.toml, through the checks that atc ota push runs on it. An
atc.toml lint does not check that a bundle source directory exists, because
the build output may not exist yet.
When it finds neither file, lint stops with ATC-E0561 and names both.
It prints every finding with an ATC-E#### code, and with a line number when
the finding points at a place in the file. It exits
non-zero on an error and exits zero when it finds only warnings.
A file can be valid and still be refused by atc ota push. Some constructs
parse and have no delivery path yet. For each of them, lint prints the warning
ATC-E0597, which starts “no errors, but atc ota push will refuse this file”.
What the push refuses today lists them. Each
example below says whether the push takes it.
Where atc finds the file
Commands that read the manifest, such as atc ota push and atc ota publish,
find it as this table shows. atc config lint with no argument finds it the
same way.
| You run | atc reads |
|---|---|
atc -c path/x.kdl … | path/x.kdl, as KDL. A -c path with any other extension reads as TOML |
a command with no -c | the nearest atc.kdl or atc.toml |
a command with no -c, where atc.kdl and atc.toml are in one directory | atc.kdl. atc prints a note on stderr that names the atc.toml it did not read |
the same, with ATC_KDL_CONFIG=0 set | atc.toml in the nearest directory that has one, at any depth, and atc.kdl only when no atc.toml is found. atc prints a note on stderr that names the atc.kdl it did not read |
These commands look in the current directory, then in each parent directory,
and stop at the first directory that has either file. So an atc.toml in a
subdirectory is read before an atc.kdl above it.
ATC_KDL_CONFIG accepts 0, false, no or off to prefer atc.toml. Any
other value, or no value, keeps the rule in the first rows. atc config lint
prints ATC-E0598 for a file that atc will not read: one beside the file that
wins, or one that discovery from the current directory does not pick.
Convert an atc.toml
atc config migrate writes an existing atc.toml as atc.kdl. It is
optional. atc.toml stays a supported format, and the command never changes
or deletes it.
atc config migrate # print the KDL on stdout
atc config migrate --write # write atc.kdl beside the atc.toml
atc -c path/robot.toml config migrate
With no -c, the command reads the atc.toml that discovery finds. Each field
goes to one of three places:
- A field that
atc.kdlspells is written as KDL. - A device setting is not written. The command lists it on stderr with the
command that sets it on a device:
atcd settings set <key>=<value>, or, for a key that the atcd unit holds,atc bootstrap atcd installwith its flag (--root,--company) or with thisatc.tomlas-c.[daemon]keys other thansocket_pathandremote_path, the[storage]table, and[server]keys other thanurl,organdca_bundle_pathare device settings.server.urlandserver.ca_bundle_pathare both: they are written toatc.kdlfor the CLI and listed for the device. - Any other field stops the command with
ATC-E0792, which names the field. A[[peripheral]]lifecycletable is one: a peripheral’s flash hooks have noatc.kdlspelling. An unknown key is another.
Before it prints or writes, the command loads the KDL back through the KDL
loader. It stops with ATC-E0792 if the result differs from the atc.toml in
any repository field or in a hook that a push would carry.
--write refuses to replace an existing atc.kdl. After --write, both files
are in one directory, so atc reads atc.kdl, as
Where atc finds the file says. The command
prints a note that says which file a command run from the current directory
reads. Set ATC_KDL_CONFIG=0 to read atc.toml instead. atc bootstrap atcd install reads the same file, and atc.kdl does not carry device settings. If
the atc.toml has device settings, the note says to pass it to that command
with -c.
The grammar in five rules
- A file is a list of nodes. Each node is a word, then its arguments, then
an optional
{ … }block of child nodes. - A bare word is a string.
bundle appandbundle "app"are the same node. Quote a value that has a/, a space, or a:, for example a path, an image reference or a platform. - Booleans are
#trueand#false. Numbers are bare:timeout-secs 30. - A
;separates nodes on one line:exit { script "@self/a.sh"; timeout-secs 30 }. //starts a comment.
A name can appear only once where it means one thing. A second source in one
artifact, or a second daemon block, is an error (ATC-E0562).
A minimal file
schema-version 1
bundle app {
source "./dist"
}
The push takes this file.
Top-level nodes
| Node | Count | What it declares |
|---|---|---|
schema-version <n> | at most one | The grammar version. Only 1 exists. A higher number is an error, so an older atc cannot misread a newer file |
bundle <name> { … } | any | A directory tree to ship |
oci <name> { … } | any | A container image |
module <name> { … } | any | A module package: an executable the device runs after a delivery commits |
use <module> | one per module | Makes the node names of a module available: for example systemd, podman, container |
<kind> <name> { … } | any | An artifact or a workload of a kind that a used module defines |
peripheral <name> { … } | any | A hardware peripheral and the bundles it takes |
daemon { … } | at most one | Where atcd listens and where its executable is on the device |
bootstrap { … } | at most one | Whether a push installs atcd on first contact, and whether it asks first |
push { … } | at most one | The channel that atc ota push delivers to, and whether it switches to the new version |
hooks { … } | at most one | Hooks for the whole delivery: stage hooks, bookends and progress hooks |
server { … } | at most one | The atc server that atc commands such as atc ota publish talk to |
ownership { … } | at most one | The server channels whose delivery config this file owns, or defers to another file |
channel <name> { … } | any | A channel composition. The push refuses it today |
Any other word at the top level is an error (ATC-E0542). The word artifact
from an earlier draft is removed. Write the kind as the node name instead
(ATC-E0571).
keep-n and allow-no-rollback are removed. They never changed how many
versions a device keeps. A file that still has one of them parses, and lint
prints the warning ATC-E0795. Delete the line. The device setting
storage.version_keep_n sets how many versions a device keeps.
Artifacts
An artifact node is the kind, then the name: bundle web, oci camera,
module updater. The name of a bundle or a module becomes a directory on
the device, so it must be one path component. It can hold letters, digits, -
and _ (ATC-E0566). Names are unique across all the artifacts and workloads
in a file.
| Child | bundle | oci | module | Meaning |
|---|---|---|---|---|
source "<path or ref>" | required | optional | required | A directory relative to the file, or an image reference for oci |
platform "<os/arch>" | error | required | error | The image platform, for example "linux/arm64" |
exclude "<glob>" … | optional | error | error | Paths to leave out of the walk of source |
ship-python-bytecode #true | optional | error | error | Ship __pycache__, .pyc and .pyo files, which the push leaves out by default |
role "<role>" | optional | optional | error | The host role that installs this artifact. See Roles |
requires <artifact> … | optional | optional | optional | The artifacts that this one depends on. See Dependencies with requires |
payload "<path>" | error | error | optional | A directory that ships in the package’s payload/ subtree |
hooks { … } | optional | optional | optional | Lifecycle hooks for this artifact |
exclude and ship-python-bytecode behave as their atc.toml equals do. See
Leave paths out with exclude and
Python bytecode is left out by default.
Roles
A device can have several hosts, and each host has a role. An artifact with
role "nav" installs only on the host whose role is nav. An artifact with no
role installs on every host. A role is 1 to 63 of a-z, 0-9, - and _,
and starts with a letter or a digit. Only a bundle and an oci take a role.
Any other value or kind fails with ATC-E0536. The rule is the same as for
role in atc.toml. See Devices with several hosts.
bundle nav-stack {
source "./nav"
role "nav"
}
atc ota push --device pushes to every host of a device. A device block maps
each role to the SSH target of its host. It is the [device.hosts] table of
atc.toml. Each child of hosts is one host: the node name is the role, and
the argument is the target. A role follows the rule above (ATC-E0536), and a
blank target fails with ATC-E0537.
bundle planner {
source "./planner"
role "nav"
}
device {
hosts {
nav "ops@nav-14"
compute "ops@compute-14"
}
}
bundle
bundle web {
source "./dist"
exclude "**/.git" "**/*.log" "tmp"
hooks {
install {
exit { script "@self/scripts/check.sh"; timeout-secs 60 }
}
activate {
exit { script "@self/scripts/start.sh" }
}
deactivate {
enter { script "@self/scripts/stop.sh"; timeout-secs 30 }
}
}
}
The push takes this file.
oci
oci camera {
source "ghcr.io/acme/camera:2.1"
platform "linux/arm64"
}
The push takes this file. Leave out source for an image you built locally
with atc oci build.
module
module updater {
source "./modules/updater"
payload "./modules/updater-assets"
}
The push takes this file. A module cannot have the name payload.
Modules today describes the package that source must
contain and what the device does with it.
Dependencies with requires
requires names the other artifacts that an artifact depends on. It is the
requires array of an atc.toml lifecycle table. Each dependency must
already be active on the device, in the channel the push delivers to. The
device refuses a push that carries an artifact and its dependency together,
so push the dependency first.
bundle app {
source "./dist"
requires config
}
The push takes this file, and the device takes it when config is already
active there. Write each name bare, as one path component. An
empty name, a <kind>/<name> entry such as bundle/config, and a name such
as . or ~config are errors (ATC-E0791). Give several names on one
requires line: requires config certs.
Hooks
A hooks block nests a state, then a phase, then a body:
hooks {
<state> {
<phase> { script "<reference>"; timeout-secs <n> }
}
}
The bundle example above shows three hooks.
- State:
check,download,install,activate,deactivate,commit,rollback,failureoruninstall. - Phase:
enterorexit. Theerrorphase parses and never runs, so the file is refused if it declares one. script(required):@self/<path>for a file inside this artifact,@<artifact>/<path>for a file inside another artifact, or an absolute path on the device. A repository-relative path such as"./check.sh"is refused. Atcheck.*,download.*andinstall.enterthe new version is not installed yet, so@selfruns the ACTIVE version’s script. A missing script skips the hook there, which covers a first delivery and a hook that this release adds. Frominstall.exiton,@selfruns the new version’s script.rollback.*andfailure.*run the script of the version that the unwind restores, and skip when there is none. Theatc.tomlreference has the table for every gate.timeout-secs(optional): a whole number of seconds.0removes the time limit. With notimeout-secs, the limit is 30 seconds.allow-missing #true(optional): if the script does not exist on the device, skip the hook instead of failing the delivery. This applies on every delivery.allow-missing-initial #true(optional): the same skip, but only when the device has no earlier version of this artifact. On a later delivery, a missing script fails the delivery as usual.
The two allow-missing nodes are the atc.toml keys allow_missing and
allow_missing_initial, with the same rules. Set one or the other, not both.
They need an absolute script, and lint refuses them on @self or
@<artifact> (ATC-E0775). Only a missing file is skipped. A script that
exists but is not executable still fails the delivery. The
atc.toml reference
has the full rules and an example:
bundle agent {
source "./dist"
hooks {
download {
enter {
script "/usr/lib/agent/pre-download.sh"
allow-missing-initial #true
}
}
}
}
A cell (one state and one phase) can have one hook only (ATC-E0564). The
rules for when each cell runs, the order, the environment and the
ATC_HOOK_CONTEXT document are the same for both formats. Read them in the
Lifecycle hooks section of the atc.toml
reference.
The top-level hooks block
A hooks block at the top level of the file holds the hooks that belong to
the whole delivery, not to one artifact:
bundle app {
source "./dist"
}
hooks {
pre { script "@app/hooks/announce.sh"; timeout-secs 10 }
activate {
enter { script "@app/hooks/stop-sensors.sh" }
}
progress {
download { script "@app/hooks/report.sh"; interval-secs 5 }
}
post { script "@app/hooks/verify.sh" }
gc-enter { script "@app/hooks/may-uninstall.sh" }
}
The push takes this file.
| Child | atc.toml table | When it runs |
|---|---|---|
<state> { <phase> { … } } | [lifecycle.hook.<state>.<phase>] | A stage hook: once at that cell for the whole delivery |
pre { … } | [delivery.hook.pre] | Once, before the delivery starts |
post { … } | [delivery.hook.post] | Once, after the delivery finishes |
gc-enter { … } | [lifecycle.hook.gc.enter] | Before a housekeeping sweep. A failure skips the uninstall pass of that sweep |
gc-exit { … } | [lifecycle.hook.gc.exit] | After the sweep. It reports only |
progress { <state> { … } } | [lifecycle.progress.<state>] | Every interval-secs seconds while the state runs |
A stage hook takes the states that the whole delivery crosses once:
activate, deactivate, commit, rollback and failure. check,
download and install run once for each artifact, so put those hooks in the
artifact’s own hooks block. The body of each hook is script and an
optional timeout-secs, as in an artifact’s hooks block. A stage hook also
takes allow-missing. It does not take allow-missing-initial, because a
stage hook belongs to no one artifact, so it has no first delivery. A bookend
(pre, post, gc-enter, gc-exit) takes neither. A progress body is script and interval-secs,
and both are required. Only download takes a progress hook, and
interval-secs must be more than 0.
None of these hooks belongs to an artifact, so a script cannot use @self.
Name the artifact that carries the script: @<artifact>/<path>. A bookend
also cannot use an absolute path. Lint reports these refusals as ATC-E0775.
A one-label hook other than pre, post, gc-enter or gc-exit is an
error (ATC-E0553). A label that names no state or no phase is ATC-E0775,
and a second hook at one cell is ATC-E0564.
For a <state> { <phase> { … } } hook, the @<artifact> must be in the same
push or active in the channel. If it is in neither, atc ota push refuses the
push at preflight.
Hooks in a published delivery
atc channel deliver --lifecycle-config atc.kdl reads the artifact hooks blocks
and the top-level hooks block of the file. The hooks go into the signed
delivery manifest, as they do from an atc.toml. A path that ends in .kdl
is read as atc.kdl. Any other path is read as atc.toml. A workload hooks
block is refused, as it is for a push.
Modules and use
use <module> makes the node names that a module defines available in this
file. Write it before or after the nodes that use it. atc looks for the
module definition in this order:
.atc/modules/<module>/module.kdlin the repository- the modules built into
atc:systemd,podmanandnerdctl
atc does not fetch a module definition from an atc server yet. To use a
module that is not built in, copy its module.kdl into .atc/modules/.
To see which definition won and why, run atc config lint --explain-modules.
use takes two optional parts:
version="<constraint>"refuses a definition whose version does not match the constraint (ATC-E0751).- A
rename <old>=<new>child gives a kind a different node name in this file. Use it when two modules define the same kind name, because that is an error without a rename (ATC-E0580).
Workloads
A workload is a service on the device that runs from an artifact. from names
that artifact, and it is required on every workload (ATC-E0582). Two
workloads can run from one artifact.
use systemd
bundle stack {
source "./deploy"
}
systemd charger {
from stack
unit "charger.service"
}
systemd telemetry {
from stack
unit "telemetry.service"
}
The push takes this file. The device must have the systemd module.
Modules today, section 5 describes what the
device does with a workload.
The built-in workload kinds take these children:
| Kind | Module | Children |
|---|---|---|
systemd | systemd | from, unit "<name>.service" (required) |
container | podman | from, name "<container name>", restart on-activate or restart never, health { cmd "<command>"; timeout-secs <n> } |
container | nerdctl | from |
Every kind also accepts hooks, which the push refuses today.
A kind from a module in .atc/modules/ takes the fields that its
module.kdl declares. Lint cannot check those fields, so it prints the warning
ATC-E0754.
Artifact kinds from a module
A module can also define an artifact kind. The artifact takes source,
platform and hooks. The module’s production recipe decides how atc
makes its bytes, and which of those children the artifact needs:
walk-directorywalks thesourcedirectory, as abundledoes.platformis an error. The push takes it.oci-pullpulls thesourceimage, as anocidoes.platformis required. The push takes it.oci-pull-erofs-convertis the recipe of the built-inpodmankind.platformis required. The push refuses it today. See What the push refuses today.
This file lints, and the push refuses it because of the podman artifact:
use podman version="^1"
podman web-img {
source "ghcr.io/acme/web:1.4"
platform "linux/arm64"
}
container web {
from web-img
name "acme-web"
restart on-activate
health {
cmd "curl -fsS http://localhost:8080/healthz"
timeout-secs 10
}
}
The podman and nerdctl modules both define container, so a file that
uses both renames one of them. This file lints, and the push refuses it for
the same reason:
use podman
use nerdctl {
rename container=nctr
}
podman web-img {
source "ghcr.io/acme/web:1.4"
platform "linux/arm64"
}
container web {
from web-img
}
nctr sidecar {
from web-img
}
Peripherals
A peripheral node names a hardware peripheral, gives the address that its
flash hooks get as ATC_PERIPHERAL_ADDRESS, and lists the bundles it takes.
Write each bundle as a bare name.
bundle radio-fw {
source "./firmware/radio"
}
peripheral radio-front {
address "usb:001"
radio-fw
}
peripheral radio-rear {
address "usb:002"
radio-fw
}
The push takes this file. Only a bundle can back a peripheral.
A peripheral can declare its flash hooks in a hooks block. It is the
[[peripheral]] lifecycle.hook.* table of an atc.toml, with the same
<state> { <phase> { script …; timeout-secs … } } shape as an artifact’s
hooks block.
bundle radio-fw {
source "./firmware/radio"
}
peripheral radio-front {
address "usb:001"
radio-fw
hooks {
check { enter { script "/usr/local/sbin/probe.sh" } }
install { enter { script "/usr/local/sbin/flash.sh"; timeout-secs 120 } }
rollback { enter { script "/usr/local/sbin/restore.sh" } }
}
}
The push takes this file. Each delivery carries the declarations of the
peripherals that subscribe to one of its bundles: the address, the hooks, and
the declared order. atc ota push sends them on the push wire, and
atc channel deliver --lifecycle-config puts them in the signed manifest
when one of them declares a hook. The device flashes the delivery’s
peripherals with those hooks, in the order this file declares them.
A hook at a cell the peripheral ladder does not run is an error (ATC-E0775).
The table in atc-toml.md (“Which states a [[peripheral]] may declare”)
lists the cells. Two hooks at one cell are ATC-E0564.
An atcd older than this release ignores the declarations and flashes with the
[[peripheral]] blocks of its own --config file. So atc ota push refuses
to send a peripheral hook to such a daemon. Update atcd first with
atc bootstrap atcd upgrade. A pulling device of an older release also ignores
them, and the publish does not refuse.
daemon and bootstrap
Use these blocks when atcd on the device is not where the defaults put it.
daemon {
socket-path "/run/robot/atcd.sock"
remote-path "/opt/robot/bin/atcd"
}
bootstrap {
auto #false
}
bundle app {
source "./dist"
}
The push takes this file.
| Node | Child | Meaning |
|---|---|---|
daemon | socket-path "<path>" | The socket where atcd listens on the device |
daemon | remote-path "<path>" | The path of the atcd executable on the device |
bootstrap | auto #true or auto #false | Whether atc ota push installs atcd on first contact |
bootstrap | confirm #true or confirm #false | Whether atc ota push asks before it installs atcd. Set #false for CI |
Each node needs a block. A bare daemon or bootstrap with no { … } is an
error (ATC-E0553), and an empty block keeps every default. Any other child is an error (ATC-E0544). A child you
leave out takes the same default as in atc.toml.
push
atc ota push reads this block for its defaults.
push {
target "team/staging"
autoswitch #true
}
bootstrap {
confirm #false
}
bundle app {
source "./dist"
}
The push takes this file.
| Child | atc.toml key | Meaning |
|---|---|---|
target "<namespace>/<name>" | [channel] namespace and name | The channel that the push delivers to. With no target, it is local/default. atc ota publish does not read it: it takes the server channel from --channel |
autoswitch #true or autoswitch #false | [config] autoswitch | Whether the push switches the device to the new version. --switch and --no-switch override it |
Each part of target is one path component of letters, digits, - and _.
The namespace agency is reserved for atcd’s own updates. Lint reports a
target that breaks these rules as ATC-E0790. The block needs { … }
(ATC-E0553), and any other child is an error (ATC-E0544).
The push block is not a channel node. A channel node lists the members
of a composition, which the push refuses today.
server
atc ota publish and the atc channel commands read this block to find the atc
server. atc auth login reads only ca-bundle. It takes its server from
--server or from the active login profile, not from this block. It is the same as the [server] table of atc.toml, for these three
keys only.
server {
url "https://atc.example.com"
org "7d0c7f5e-1f7e-4c55-9d43-3f0d6a1b2c3d"
ca-bundle "/etc/atc/private-ca.pem"
}
bundle app {
source "./dist"
}
The push takes this file.
| Child | atc.toml key | Meaning |
|---|---|---|
url "<url>" | url | The base URL of the atc server. --server overrides it |
org "<id>" | org | The organization that atc ota publish publishes to. --org overrides it. The channel is not in this block: atc ota publish requires --channel |
ca-bundle "<path>" | ca_bundle_path | A PEM file of more root certificates to trust on this machine. It must be an absolute path (ATC-E0789) |
The block needs { … } (ATC-E0553), and an empty block keeps every default.
Any other child is an error (ATC-E0544). The other keys of the [server]
table configure atcd on the device, not the repository, so atc.kdl has no
spelling for them. The one exception is credential_store, which nothing
reads, so it has no spelling either. atc bootstrap atcd install --server sets the server for
a device.
ownership
atc ota publish --deliver reads this block. One manifest owns the delivery
config of a server channel: its hooks, workloads, peripherals, state_dirs and member roles.
Another manifest that ships artifacts into that channel defers to the owner.
It is the same as the [ownership] table of atc.toml.
ownership {
id "robot-base"
owns "stable" "beta"
defers {
canary "robot-core"
}
}
bundle app {
source "./dist"
}
The push takes this file.
| Child | atc.toml key | Meaning |
|---|---|---|
id "<name>" | id | The name of this manifest. A delivery to a channel in owns carries it, signed. owns needs an id |
owns "<channel>" … | owns | The server channels whose delivery config this manifest owns |
defers { <channel> "<owner id>" } | defers | One child per channel. This manifest defers that channel to the manifest with that id |
The channels are server channels, the --channel of atc ota publish. They
are not the push { target } channel. An id and an owner id follow the
rules of an artifact name. The load and atc config lint apply the
[ownership] rules of atc.toml, with the same codes.
The block needs { … } (ATC-E0553), and any other child is an error
(ATC-E0544). A file with no ownership block makes no claim.
Release through atc-server
says what the server does with the claim.
What the push refuses today
These constructs lint with no error. The push refuses the whole file and names
the construct, so that nothing in the file is dropped without a message. Lint
gives the same forecast as ATC-E0597.
| Construct | Why | What to do |
|---|---|---|
a channel node | Nothing reads a channel composition yet. It is not the same thing as the [channel] table of atc.toml, which names the push target | Remove the channel node. The push ships every artifact that the file declares. To set the channel that the push delivers to, use push { target … }. Move its pre and post hooks to the top-level hooks block |
a hooks block on a workload | Nothing carries a workload hook to the device yet | Put the hook on the artifact that from names |
| a workload of a module kind with a field that its schema does not declare, or with a value of the wrong type | The push would drop the field | Correct the block against the module’s module.kdl |
an artifact kind with the oci-pull-erofs-convert recipe, such as podman | The device cannot mount the image yet | Declare the image as an oci artifact |
A channel composition with delivery hooks:
bundle app {
source "./app"
}
channel software {
app
os-config
hooks {
pre { script "./scripts/announce.sh" }
post { script "./scripts/verify.sh" }
}
}
A workload hook can attach only to activate and deactivate. Its script
is an absolute path on the device, or @<artifact>/<path> for an artifact of
the same file. @self is an error, because a workload has no tree of its own:
use systemd
bundle stack {
source "./deploy"
}
systemd charger {
from stack
unit "charger.service"
hooks {
deactivate {
enter { script "@stack/drain.sh" }
}
}
}
What has no KDL spelling yet
These settings exist in atc.toml and have no atc.kdl node. They are
device settings: each device keeps them in its atcd store, and
atcd settings set or atc bootstrap atcd install sets them (issue
#6337). A
push from atc.kdl uses the default value of each.
atc config migrate lists the ones an atc.toml sets.
- every
[daemon]key other thansocket_pathandremote_path - the
[storage]table - every
[server]key other thanurl,organdca_bundle_path
Private CA trust
By default the control-plane HTTPS of atcd and atc trusts the public web PKI
roots only. A self-hosted, air-gapped, or lab control plane usually has a
certificate that a private CA signed. Name that CA, or the device cannot enroll.
The reverse tunnel of atcd reads the same field and starts from the same base
trust set. It reads no system certificate store. See
Which roots each half adds to.
The symptom
The device never enrolls, and the atcd unit stays green. Read the journal on
the device:
journalctl -u atcd -n 50
A device that is not enrolled yet writes one line per poll cycle:
enrollment check failed: send enroll request. TLS or connect failure: check [server] ca_bundle_path
An enrolled device writes the check-in form of the same failure:
check-in errored: server: checkin send: error sending request for url (...). TLS or connect failure: check [server] ca_bundle_path
The atc CLI reports the same class of failure, and names both of its sources:
. TLS or connect failure: check [server] ca_bundle_path or SSL_CERT_FILE
The hint fires on every connect failure. A refused port and a wrong port get it too. A request that reached the server never gets it, so a 404 or a 500 is a different problem.
Install a device with the CA
atc bootstrap atcd install uploads the bundle and writes the config field:
atc bootstrap atcd install deploy@device \
--server https://atc.example.com \
--enrollment-token-file ./enroll.token \
--ca-bundle ./ca.pem
--ca-bundle names a PEM file on your controller, not on the device. atc
reads and parses that file before it opens the SSH connection. So a bundle
atc cannot use fails before the device is touched:
error: ATC-E0419: bootstrap preflight on deploy@device failed: --ca-bundle ./ca.pem: No such file or directory (os error 2)
A file that exists but holds no certificate takes the same refusal, and the
message then reads --ca-bundle the trust anchor bundle ./ca.pem holds no certificates.
The device copy lands at /etc/atc/ca.pem, owned by root, mode 0644. A
certificate is public, and every account that verifies the control plane must
read it. Bootstrap then writes these settings to atcd’s store. The device
keeps no config file for them:
server.url = https://atc.example.com
server.control_mode = server_only
server.enrollment_token_path = /etc/atc/enroll.token
server.ca_bundle_path = /etc/atc/ca.pem
Read them on the device with atcd settings ls, as the account that runs atcd.
--ca-bundle needs --server. A trust anchor with no control plane is inert,
so atc refuses the flag alone.
Add the CA to a device that is already installed
Copy the PEM to the device yourself, then set the path in atcd’s store. Run
this on the device as the account that runs atcd (atc by default):
sudo -u atc atcd settings set server.ca_bundle_path=/etc/atc/ca.pem
An enrolled device refuses atc ota settings set from a laptop, because it
accepts changes only from its server. atcd reads its settings at start, so
restart the unit to apply the new value:
systemctl restart atcd
Give an absolute path. atcd settings set refuses a relative one and changes
nothing.
A bundle atcd cannot use stops the pull cycle. Unreadable, unparseable, and holding no certificate all count. There is no fall back to the public roots. A silent fall back would turn a typo into a TLS failure much later:
certificate trust setup failed ([server] ca_bundle_path): the trust anchor bundle /etc/atc/ca.pem holds no certificates. The pull cycle stops until the bundle is fixed
atcd re-reads the file once per poll cycle, so this state recovers with no restart. Replace the bundle with write-then-rename. A partial file aborts one cycle, and the next cycle self-heals.
The atc CLI
atc composes two sources, in this order, on top of the public roots:
[server] ca_bundle_path, from theatc.tomlthe command resolved$SSL_CERT_FILE
Both sources add anchors. Neither removes a public root, and neither replaces
the other. A source you state but atc cannot use is an error, and the message
names which of the two to fix:
error: control plane setting: [server] ca_bundle_path: cannot read the trust anchor bundle /home/you/ca.pem
Which roots each half adds to
[server] ca_bundle_path is additive everywhere. It never removes a root. Both
halves of atcd add it to the same base set.
| Half | Base trust set | Source |
|---|---|---|
Control-plane HTTPS (atc, atcd’s enrollment and check-in) | the BUNDLED webpki roots | reqwest, rustls-tls |
atcd’s reverse tunnel ([daemon.tunnel]) | the BUNDLED webpki roots | rustls, webpki_roots::TLS_SERVER_ROOTS |
atc oci is a third case and is not in that table. Its base is the bundled
webpki roots too, but the field does not reach it. The registry client reads
$SSL_CERT_FILE alone. The component table below records that.
Two consequences follow.
- A CA in the device image does nothing for the tunnel. The tunnel reads no
system store. Name the CA of a private rendezvous in
[server] ca_bundle_path. Before August 2026 the tunnel read the system store instead. A device on an older build still behaves the old way. - Name your own CA, and nothing else. An anchor is an issuer. Any CA in the bundle can sign a certificate for the rendezvous host name, and the device accepts it. A bundle holding a copy of a whole system store hands that power to every CA in it.
What each component reads
| Component | [server] ca_bundle_path | $SSL_CERT_FILE |
|---|---|---|
atcd, against the control plane | yes | no |
atcd, on the reverse tunnel | yes | no |
atc, against the control plane | yes | yes |
atc fleet device shell, on the reverse tunnel | yes | yes |
atc oci, against a registry | no | yes |
atc, with an atc.kdl manifest | yes, as server { ca-bundle } | yes |
atc-server, against the IdP | no | yes |
Four consequences follow, and each one catches operators who expect one rule everywhere:
- atcd reads no environment variable. A stray
SSL_CERT_FILEin a systemd unit must not change the trust set of a whole fleet. That is a failure nobody configured. The device honors the config field alone. - A private registry behind the same CA needs
$SSL_CERT_FILE. The registry client reads that variable alone, so a configured control plane does not coveratc oci. Go’scrypto/x509reads the same variable, so skopeo gets the same trust from the same value. - An
atc.kdlmanifest names the bundle in itsserverblock.server { ca-bundle "/abs/path.pem" }sets the same field as[server] ca_bundle_path. See theserverblock. Anatc.kdlwith noserverblock sets no bundle, so$SSL_CERT_FILEis then the only sourceatchas. atc-serverreads noatc.tomlat all. Its configuration is environment variables. Set$SSL_CERT_FILEfor the process when the identity provider sits behind your CA. Without it the server cannot fetch the signing keys, and then no token validates.
Related
- Onboarding walks the first update end to end.
- Verifying the binary covers a different trust
question: whether the
atcbinary itself is genuine.
Remote access without a VPN
atcd carries its own reverse tunnel. The robot dials out to your control plane
over wss://, and you open a shell on it from wherever you are. There is no mesh
VPN, no second vendor’s agent, and nothing listening for a connection on the
device.
It is off until you configure it, and configuring it is one TOML table. Delete the table and the tunnel is gone. Any other access you installed, such as plain SSH on a routable address, is untouched, and nothing else about the device changes.
That matters if your customers will not accept a VPN mesh on their network. The
usual objection is that nobody can audit who connected when. This path answers
it. Every session through your own atc-server gets a record of the principal
that opened it, the device it reached, and whether the device end answered.
Read the limits on that. The server records the PRINCIPAL, so two API keys for
one principal are indistinguishable. Per-person attribution needs a human
identity, or a separate principal for each person. A failed audit write leaves a gap
and does not close the session. A standalone wstunnel rendezvous writes no
atc-server record at all.
It does NOT make session contents inspectable. The payload is SSH inside TLS, so a packet inspector still cannot read the shell. What it gets instead is one ordinary HTTPS flow to one host you name. It can allow, block, or rate limit that flow by destination, port, and SNI. A mesh VPN gives it neither.
Read what the rendezvous side needs before you plan around this. The device half is ready. The server half runs the rendezvous on one task, and a public deployment without the CloudFront lockdown still 404s the operator route.
How it reaches a robot behind NAT
robot ---- dial out, wss:// ----> atc-server <---- wss:// ---- your laptop
| (pairs the two) |
+--- sshd on 127.0.0.1:22 ssh --------+
The device opens the connection. The control plane holds it, then hands it to an operator who asks for that device. The connection is a WebSocket over TLS. A firewall that does not terminate TLS sees an ordinary HTTPS flow, and it can classify that flow by destination, port, and SNI. It cannot read the WebSocket framing inside.
An egress-only warehouse firewall can therefore allow it, and you need no port forward. It is not automatic. A proxy that terminates TLS, or a policy that refuses a WebSocket upgrade, still blocks the tunnel.
The device needs no inbound rule. A relayed connection goes to the device’s own
sshd, and the operator’s own ssh speaks to it. Today one command opens the
tunnel: atc fleet device shell. The other device-touching subcommands still
need a route you can already reach.
Before you start
The device must already hold enrollment state. The tunnel authenticates with the
device credential that enrollment persisted, and it reads that credential from
the store, not from the config. A device that never enrolled logs
device is not enrolled yet and retries until it does.
A [server] table is what enrolls a device automatically, and it is also where
ca_bundle_path lives. It is not itself the credential. A device enrolled out of
band can park with no [server] table. A device with a [server] table that is
not yet enrolled cannot.
Warning: nothing enforces a distinct listen address
No part of atc, atcd, or atc-server compares one device’s listen value
against another one. There is no fleet-wide register of addresses, and no check
runs at any point. Read which of the two cases below you are in before you write
the field.
Against atc-server, listen is ignored. That rendezvous never reads the
value. It pairs an operator with a device by the UUID that the presented
credential resolves to. Two devices may carry the same listen with no
consequence. atcd still requires the field, and it must parse as an IP address
and port such as 0.0.0.0:2222. A host name is refused.
Against a standalone wstunnel rendezvous, give every device a distinct bind
address. That is a different deployment, and not a drop-in one. atcd dials
the fixed path /v1/devices/tunnel/events, so the rendezvous must accept it.
atc fleet device shell does not work against it either, because that command
only speaks to atc-server’s operator route. An operator points ssh at the
address the rendezvous published instead. Within that deployment, the collision
below is live.
wstunnel keys its reverse listeners by the whole bind address. It REUSES the
existing listener when a second device claims the same one, and it does not
refuse that device. Two devices with one listen value then share one listener.
An operator who connects to that address reaches whichever device takes the connection first, and neither side reports an error. The operator may then get a shell prompt on the wrong robot. A host key already recorded for the intended robot can refuse the connection first. That is a warning, not a guarantee.
Assign the addresses from a table your fleet owns, and do not copy the example value below onto a second device.
Turn it on
Set the three tunnel keys in atcd’s store. Run this on the device as the
account that runs atcd (atc by default):
sudo -u atc atcd settings set \
daemon.tunnel.server=wss://tunnel.internal.example.com:443 \
daemon.tunnel.listen=0.0.0.0:2222 \
daemon.tunnel.forward_to=127.0.0.1:22
daemon.tunnel.serveris the rendezvous the device dials. It must bewss://. Plaintextws://is refused.daemon.tunnel.listenis what the device ASKS the rendezvous to publish. atc-server ignores it, but the key is required. Read the warning above.daemon.tunnel.forward_tois the device-local address a relayed connection is handed to.127.0.0.1:22is the default.
A device that accepts direct control takes the same keys from a laptop with
atc ota settings set <target> …. An enrolled device refuses that, because it
accepts changes only from its server.
Restart the daemon:
sudo systemctl restart atcd
Confirm that the daemon read the table and built a tunnel client:
journalctl -u atcd -b | grep 'atcd tunnel'
A configured device logs one line each time it builds a client:
atcd tunnel: parking on wss://tunnel.internal.example.com:443 to publish 0.0.0.0:2222 -> 127.0.0.1:22
That line comes BEFORE the first dial, so it does not prove that the device
reached the rendezvous. A device that cannot reach it prints the same line and
then logs atcd tunnel: dial to the rendezvous failed.
The only end-to-end proof is a successful atc fleet device shell against that
device. The server-side atc_server_tunnel_park_total metric carries no device
or org label, so it tells you that SOME device parked and nothing about yours.
The daemon checks the values at startup, and it names the field that is wrong.
It checks that server is a URL and that its scheme is wss. It checks that
listen is an IP address and port. forward_to may be a host name and port as
well, so localhost:22 is accepted there and refused in listen.
atcd tunnel: disabled — [daemon.tunnel] server "https://rv.example.com" uses scheme "https"; expected wss://
A bad string VALUE leaves the daemon running. It still ships updates, and only the tunnel is off.
Warning: a table that will not DECODE is different, and it stops the daemon. The
decode runs before any of the checks above. It fails on a missing server or
listen, and on a wrong TOML type such as listen = 2222. A misspelled server
or listen is the same failure, because the required key is then absent.
An unknown key ALONGSIDE the required ones is the third case. It warns, the key is not applied, and the daemon starts.
Open a shell
atc fleet device shell <device-uuid>
Add --user when the account on the device is not your local login name. Put a
one-shot command after --:
atc fleet device shell 4f1c0e2a-... --user deploy -- uptime
Every word after -- reaches the device as one argument. atc quotes each one,
so a space, a brace, a quote, a glob, or a $ inside an argument survives:
atc fleet device shell 4f1c0e2a-... -- jq '{a,b}' /tmp/x.json
--as-ssh-user follows the same rule. It sent the words bare until issue #5414,
and the shell on the device re-split them. The command above then ran as
jq {a,b} /tmp/x.json.
To run a pipeline, a redirect, or a ~, pass the shell program as ONE argument:
atc fleet device shell 4f1c0e2a-... -- sh -c 'journalctl -u atcd | tail -20'
atc opens the tunnel, publishes it on a loopback port, then runs the system
ssh at that port. ssh does the SSH. It records and checks the host key under
the device UUID rather than the loopback port. The key therefore survives from
session to session instead of churning.
Warning: this is trust on first use. atc passes
StrictHostKeyChecking=accept-new, so the FIRST connection to a device UUID
accepts whatever key answers. Only a LATER change of key fails the check. If you
must be sure of the identity on first contact, put the expected host key in your
known_hosts under the device UUID before you connect.
If the device is offline, or if it has no daemon.tunnel.server setting, the command
fails before ssh starts. It reports
ATC-E0461.
Turn it off
A re-run of atc bootstrap atcd install keeps the tunnel settings. An install
writes only the keys the operator states, and --server states only the
server.* keys.
To remove the tunnel, unset the three keys and restart atcd:
sudo -u atc atcd settings unset daemon.tunnel.server daemon.tunnel.listen daemon.tunnel.forward_to
sudo systemctl restart atcd
The daemon then dials nothing, and it behaves as it did before the keys existed. There is no package to remove and no service to disable.
What the device trusts
The DEVICE authenticates the rendezvous certificate against the bundled webpki
roots, plus whatever [server] ca_bundle_path names. It reads no system
certificate store. See Private CA trust.
The OPERATOR half reads the same bundle. atc fleet device shell builds its
TLS configuration from atc_cmd_server::trust::rustls_config_for, which carries
the bundled webpki roots, plus [server] ca_bundle_path, plus $SSL_CERT_FILE.
It reads no system store either.
Warning: check which atc build the operator runs. This half changed on 23
August 2026, in #3548. An atc built before that dialled through
tokio-tungstenite’s built-in connector, which trusts the bundled webpki roots
alone. Against such a build a rendezvous behind your own CA accepts every device
and refuses every operator. Upgrade atc, or give the operator-facing name a
publicly trusted certificate.
The two halves still dial different URLs. The device dials [daemon.tunnel] server. The operator dials the control-plane base URL that the atc CLI is
configured with. They may be different names, and they must reach the same
atc-server process, because the pairing lives in that process’s memory.
So one private CA can now serve both halves. Name it in [server] ca_bundle_path on the device, and in the [server] table the operator’s atc
reads. The two files are different files on different machines, and both need
the anchor.
You cannot turn verification off. wss:// is required, and atcd refuses the
tls_verify_certificate escape that wstunnel offers.
This trust base changed in August 2026. An older build read the device image’s system certificate store instead. Confirm which build a device runs before you plan a trust root around it.
Who may open a shell, and what is recorded
The route always requires an authenticated admin actor. What counts as one
depends on a server-side flag, so check which of these your atc-server runs.
With ATC_ORG_RBAC_ENABLED=true, the caller must belong to the org named in the
path AND hold admin rank there. A platform admin passes for any org.
Warning: the flag is OFF by default. The check then falls back to a plain server-admin check, and a server admin can open a shell on any device in any org. Turn org RBAC on before you tell a customer that the tunnel is scoped to their org.
The device-membership check runs either way. A device that belongs to a different org than the path names answers 404, byte-identical to the answer for a UUID that exists nowhere.
atc-server writes an admin.device.tunnel.open audit record for a granted
session AND for a refused one, once the caller is authenticated. A missing or
invalid credential is refused before the audit request exists, so it leaves no
tunnel record at all. A failed audit write is logged and does not close an
already-open session.
Do not alert on refusals alone. One refused record covers several events, and this list is not exhaustive:
- the UUID exists nowhere
- the device belongs to another org
- the caller does not hold admin rank
- the device is offline
- the device answered, but its
forward_toservice sent nothing - the device dropped between the reachability check and the handoff
Only the first three look anything like an attack. An operator who retries an unplugged robot, or a robot whose sshd is stopped, produces a run of refusals that reads exactly like probing. Connectivity alone does not separate these, so read the refusal message before you treat a run as an attack.
The server also counts every park on the atc_server_tunnel_park_total metric.
It carries one label, displaced, with three values. Read them before you build
an alert on it.
none: the ordinary park, and the denominator. A fleet with no tunnel-enabled device reports zero here, which is not the same as no displacements.closed: this park replaced a registration whose connection had already gone away. A device on a bad network produces these constantly. Do not alert on them.live: this park pushed out a registration that still looked healthy. One per robot recovering from a silent connection drop is normal. A run of them for one device means two holders of that device’s credential, such as a disk image cloned after enrollment. Rotate the device credential.
A granted record proves that the device end answered. It does not prove that an sshd answered. Whoever holds the device credential controls that byte stream. Nothing the server can inspect separates a real sshd from a client that writes one frame. Only a host key you verified elsewhere and pinned first closes that gap. The default trust on first use protects later sessions and not the first one.
Dependencies
atcd and the atc CLI contain no Tailscale code and need no Tailscale daemon.
On the device the tunnel needs outbound TLS to your atc-server and nothing
else. Remote access is a config block you add and delete on its own schedule.
Tailscale does appear elsewhere in this repository. atc-onboard, the tool for
the hardware test farm, uses it to reach lab boards. The atc-server terraform
also defaults ingress_mode to tailscale, which puts the managed load balancer
on a private subnet. That is a hosting choice for our own deployment, not a
requirement of the tunnel.
What the rendezvous side needs
The device half is ready. The server half carries one live limit and one shape
you have to deploy, both recorded in cloud/atc-server/terraform/tunnel.tf.
Read them before you promise this path to anyone.
Both tunnel paths must reach the rendezvous service, and it runs one task. The pairing registry lives in process memory, and a parked device holds a live channel end. So a device parks on whichever task the load balancer picked, and an operator pairs only if their request lands on that same task. With two tasks that is a coin flip, and the operator sees a pairing timeout that looks exactly like an offline robot.
tunnel.tf closes that by ROUTING rather than by pinning: it adds an ECS service
at desired_count = 1, its own target groups, and listener rules that send
/v1/devices/tunnel/events and /v1/admin/orgs/*/devices/*/tunnel to it. serve
keeps both routes in its binary and never receives them, so serve may scale
freely. Do not pin serve for the tunnel’s sake.
Raise the tunnel’s capacity with tunnel_task_cpu, never by adding a task. A
second rendezvous task returns the failure the routing removed.
The costs are real and worth stating. One task is one point of failure. Its rollout is stop-then-start, so a deploy is a tunnel gap of the target group’s 30-second drain plus task start plus two health checks. Devices redial after it. Uninterrupted tunnel availability needs inter-replica forwarding, which issue #3984 carries.
Unprotected public ingress 404s the operator route. The operator half lives
under /v1/admin/*. The load balancer installs a priority-10 rule that answers
404 for that whole prefix. That rule is active for ingress_mode = "public" and
for "protected_public" alike, whenever the CloudFront origin-verify lockdown is
OFF. Our prod is one such deployment. The atc CLI then reports the device as
not found, and atc-server logs nothing, because the request never arrived.
The tunnel rule follows that decision rather than overriding it: where the priority-10 block is active it carries the device path only, deliberately, so the ops block gains no unused hole. A deployment that wants the operator half on unprotected public ingress has to decide to open the prefix.
With the lockdown ON, the block has count = 0, and the tunnel rule carries
both paths plus the origin-verify header the lockdown requires. That is our
staging shape, and the operator half works there. The lockdown, not the ingress
mode alone, is what decides it.
The DEVICE half is unaffected. It parks on /v1/devices/tunnel/events, which no
block rule matches.
Until a shared pairing registry and that forward rule land, treat this as an internal-testing path rather than a fleet-wide one.
What this path does not do yet
- It carries
sshonly.atc ota pushover the tunnel is not wired up. A push still needs a route you can already reach. - One session per tunnel. A second
atc fleet device shellopens its own pairing. - A park lasts 25 seconds, then the device redials. An operator who arrives during the gap waits up to 3 seconds.
Lifecycle hook cutover: the flip moved earlier
The current delivery pointer used to flip in the middle of the Commit state,
between commit.enter and commit.exit. It now flips inside Activate, before
activate.exit. Two new states, Activate and Deactivate, sit between
Install and Commit.
Nothing you already wrote stops parsing and nothing stops running. This is a hard cutover with no compatibility shim and no deprecation window, because none is needed. Every declaration that was valid before is still valid and every hook still fires. What changed is when one of them fires relative to the moment the new version goes live, and how long two others wait for it.
This page answers one question. Does your hook need to move?
What the order is now
Before:
check.* → download.* → install.* → commit.enter → [FLIP] → commit.exit
Now:
check.* → download.* → install.*
→ activate.enter (new version asks: may I go live?)
→ deactivate.enter (old version stands down)
→ deactivate.exit
→ [FLIP]
→ activate.exit (new version confirms: I am live)
→ commit.enter
→ commit.exit
Deactivate runs nested inside Activate, so every step that can fail happens
before the one destructive step. A veto at activate.enter has torn nothing
down and owes no compensation. The transition table that decides this lives in
crates/atc-lifecycle/src/state/fsm.rs; the driver that walks it is
crates/atcd-applier/src/driver/delivery_flow.rs.
commit.enter is the one gate that swapped sides. It used to run before the
flip and now runs after it. activate.exit is a new post-flip gate, and
commit.exit was post-flip before this change and still is.
The observable symptom
Open your hook script. You need only one fact about it.
Does the script reach the live delivery pointer? Either directly, by naming
a path under the channel’s delivery/current, or indirectly, by restarting,
reloading or querying something whose own configuration resolves through it. A
systemctl restart, an atc ota status, a unit file whose ExecStart points
into the active delivery dir: all of those reach it.
If the answer is no, stop reading. Your hook is unaffected. Everything a hook
gets in ATC_HOOK_CONTEXT is stable across the flip: version.new_sha,
version.old_sha, self_root.new, self_root.old and artifacts_dir hold the
same values on both sides of it. A script built only from those fields cannot
observe the flip at all, and so cannot have been broken by moving it.
If the answer is yes, read the section for the gate you declared.
commit.enter: check this one first
commit.enter moved from before the flip to after it. It is the only gate whose
meaning changed, and it changed silently: the hook still runs, still at the same
point in the printed sequence, and still exits 0.
The difference is what it reads.
# in a commit.enter hook, on the same device, same push
atcd get-path
# before this change: the OLD delivery dir
# now: the NEW delivery dir
atcd get-path prints the active delivery directory, and atcd get-path -a <name> prints one artifact’s active payload directory. Use it rather
than building the path yourself: the directory is keyed by delivery id,
and the v5 layout already moved it once, which broke every hand-written
copy. With no flag the verb reads the current pointer. With --delivery-id
it lists the delivery directories of the default channel instead. Neither path
opens the database or needs a running daemon, so both are safe to call from
inside a hook.
If your hook must read one SPECIFIC delivery, name it. atcd get-path --delivery-id <ID> reports that delivery rather than whichever one current
points at, and --delivery-id <ID> -a <name> reports one of its members. A
delivery bookend gets the id from ATC_DELIVERY_CONTEXT. A per-artifact hook
does not receive one.
A delivery gets its directory when it is activated. On a fresh push that
happens at the flip, so delivery.pre, activate.enter, deactivate.enter
and deactivate.exit cannot look the INCOMING delivery up: it has no directory
yet. activate.exit, commit.enter, commit.exit and delivery.post run
after the flip and can. On atc ota switch the target was committed by an
earlier push, so every phase can look it up.
If your commit.enter hook drains connections, flushes a queue, snapshots
state, or stops a service belonging to the version being replaced, it is now
doing that after the replacement went live. Move it. deactivate.enter and
deactivate.exit exist for exactly that work, and they run the outgoing
version’s own declaration rather than the incoming one (see
Whose declaration runs).
If your commit.enter hook only inspects the incoming artifact on disk, or only
reads ATC_HOOK_CONTEXT, leave it alone.
commit.exit: correct, but late
A commit.exit hook was post-flip before and is post-flip now. It still sees
current pointing at the new version. It is not broken and you do not have to
touch it.
What changed is the gap in front of it. The window between the flip and
commit.exit used to hold only the daemon’s own post-flip work. It now also
holds activate.exit, the delivery-wide commit.enter stage barrier, and one
member-scoped commit.enter gate per artifact. Each of those can run an
operator script with its own timeout_secs, so the new version can be live for
a while before your commit.exit hook gets to run.
That matters if the hook is what makes the new version usable. A device whose
current points at v2 while v2’s service is still stopped is running nothing,
and the longer that gap the worse it reads in the field.
Before, and still working:
[bundle.lifecycle.hook.commit.exit]
script = "@self/scripts/restart_service.sh"
timeout_secs = 60
Better now:
# activate.exit is the FIRST gate after the flip. Bring the new version into
# service here, and the live-but-stopped window is as short as it can be.
[bundle.lifecycle.hook.activate.exit]
script = "@self/scripts/restart_service.sh"
timeout_secs = 60
Leave it at commit.exit if it reports, notifies, validates or marks. A hook
that posts to Slack, drops a marker file, or curls a health endpoint does not
care that it runs a few seconds later.
Both cells are veto-capable, so moving the script does not weaken the gate. A
non-zero exit at activate.exit unwinds the delivery and reverts the pointer
exactly as one at commit.exit does.
Where each kind of hook belongs now
| Your hook | Put it at |
|---|---|
| Decides whether the new version may go live at all | activate.enter (pre-flip, and nothing is torn down yet, so a veto is free) |
| Stops, drains, unmounts or flushes the outgoing version | deactivate.enter / deactivate.exit |
| Starts, restarts or registers the new version | activate.exit |
| Reports, notifies, marks, or checks health after the fact | commit.exit (leave it where it is) |
| Cleans up when GC removes an old version | uninstall.enter / uninstall.exit |
| Brackets a whole housekeeping sweep, or opts out of it | gc.enter / gc.exit |
The last two are new slots, not relocations. See
atc-toml.md for their scope rules.
Does your hook claim a name the device holds only once, a systemd unit name or a
TCP port? Read
A name shared across versions
first. The table above puts the start at activate.exit to shorten
the live-but-stopped window. For a shared name that row is also a correctness
rule, and the start must be a restart.
Do you move a @self hook to check.*, download.* or install.enter? There
it runs the ACTIVE version’s script, not the incoming one, and a missing script
skips the hook. See
Which version’s script @self runs.
A @self hook that you move OUT of install.enter to install.exit starts to
run the incoming version’s script.
Does the hook run an absolute script that the artifact itself installs, for
example a file a .deb puts under /usr/lib? Then a move to an earlier cell
can run it before the package exists. On the first delivery to a device that
fails the hook with ENOENT. Set allow_missing_initial = true on that hook.
Read
A hook script that the artifact installs.
Whose declaration runs
deactivate.* is the one state whose hooks come from the version being torn
down rather than the delivery arriving. A push reads them back from that
version’s own .atc/lifecycle.pb sidecar; a pulled delivery reads the signed
declaration the poller retained when it installed that version. That has a
consequence worth stating plainly, on either plane: a deactivate.* hook you
add today does not run on today’s delivery. It runs on the next one, when the
version you are shipping now becomes the outgoing version.
Two corollaries:
- A first deploy traverses
Deactivateand dispatches nothing. There is no outgoing version to tear down. - A stage-only push (one that stages every artifact but flips nothing, so no
--switchand noautoswitch) and a re-push of the already-active version also dispatch nothing there. Neither replaces a running workload, so tearing one down would leave the device running nothing and report success. Neither activates anything either, so neither runsactivate.*.
uninstall.* follows the same rule for the same reason: the sweep reads it from
the sidecar of the version it is about to delete.
atc ota switch and atcd ctl switch
Both now run six gates where they ran two: activate.enter,
deactivate.enter, deactivate.exit, the flip, activate.exit,
commit.enter, commit.exit. If you parse the streamed progress, expect three
times the frames.
deactivate.* was absent from this path until #3698. The reason was real. The
switch plane had none of the machinery Deactivate depends on. A vetoed switch
would then leave the outgoing version torn down, with nothing able to restore
it. It has that machinery now: a durable teardown record, a boot payer, and the
compensation a refusal owes. So the two cells run, from the OUTGOING version’s
declaration.
Read your deactivate.* declarations before you upgrade. A teardown reads
the outgoing version’s declaration. So the scripts that start running are the
ones already on your fleet. A refused teardown vetoes the switch and leaves the
device where it was.
Devices that crashed under the old daemon
Nothing to run. deliveries.phase is free text, and the recovery reader accepts
both the old Commit spelling and the new Activate one, so a device that
crashed mid-flip under a pre-cutover daemon recovers correctly under a
post-cutover one. There is no schema migration and no backfill step. The dual
acceptance is the upgrade path
(crates/atcd-core/src/crash_position.rs::classify_crash_position).
A crash between deactivate.exit and the flip is now a rollback case: the
daemon reactivates the outgoing version on the next boot rather than leaving the
device stranded.
A word that means two different things
atc ota ls prints a push status column whose values include activating
and committing. Those are not the lifecycle states this page is about. They
are a separate vocabulary in crates/atc-store/src/pushes.rs describing where a
push sits in the transfer pipeline, and they predate these states. If you are
matching on one, be sure which one you mean.
Checklist
rg -l 'commit\.(enter|exit)' path/to/your/atc.toml. No hits means nothing to do.- For each hit, open the script. Does it reach
delivery/current, directly or through a service it restarts? No means leave it. commit.enterthat touches the outgoing version, moves todeactivate.*.commit.exitthat brings the new version into service, moves toactivate.exit.- Everything else stays.
- Push to one device and read the hook rows back with
atc ota status --jsonbefore you roll it out. The sequence you should see is in What the order is now.
Modules today: what a repo can actually do
Every claim below is cited to a file and line on dev. Where something does
not work, this says so; the gap list at the end is the point of the document,
not an appendix to it.
The short version: a repo can ship one executable per module. atcd now
drives four of the nine verbs against it. workload start runs after a
successful delivery. workload health runs after a successful start the
applier session itself ran, and after no other start (§4). The
deactivation drain runs workload stop, and workload remove for a module
that leaves the device (§4).
The device also reads the delivered module.kdl. It spawns only the verbs
that file declares. It applies the per-verb timeout the file asks for (§2).
Workloads parse, bridge, cross the wire, and persist per delivery, and the
trigger spawns one workload start per DECLARED workload (§5). A stock
atc can now get one to start. The workload kinds it knows are
systemd (from the systemd module) and container (from podman and
nerdctl). Those are MODULE names, and podman is also an artifact kind, not
a workload kind. A workload of any other kind now pushes too. Its declared
fields are read against its own module’s schema and sent as a
fields_json_v2 document (§5).
A workload of a known kind names a built-in module, and no built-in module is
ever in a push’s changeset — so the trigger cannot see it. The commit derives a
lifecycle for it instead, keyed by a version made from the declaration set, and
the drain runs the start out of the image’s own copy of the module (§5). It
needs the image to really carry that module; nothing here installs one.
A module-defined artifact kind now pushes for walk-directory and oci-pull
(§3). oci-pull-erofs-convert has a producer too, and the bridge still refuses
it, because no device consumer reads what that producer stages — so the
built-in podman artifact kind is still unusable.
atc ota ls, ota status, ota deliveries and atc ota df name
the landed kind by its own spelling (§3). The other five verbs are still
unreachable code.
A worked example that stays inside those limits lives at
examples/heartbeat-module/, with an e2e
test that pushes it to a real container:
crates/atc-cli/tests/module_example_e2e_tc.rs.
1. Getting a module into a config at all
A module can only be declared in atc.kdl. No TOML syntax declares one, by
design — docs/internal-site/src/design/2026-08-02-modules-production-readiness.md §S1b
declined to add a [module] table to the TOML schema
(crates/atc-config/src/module_artifact.rs:1-16).
A repo whose nearest manifest is atc.kdl is read as KDL with no setup at
all. That is the shape a module repo has, so a
module push is the plain command:
atc ota push atc@<device>:22 --version 0.1.0 --switch
Upward discovery stops at the nearest directory that holds atc.kdl or
atc.toml. When that directory holds both, atc.kdl wins (#6337). Both files
parse, so the losing file is ignored with no error. atc prints a note on
stderr that names the ignored file, the file that won, and ATC_KDL_CONFIG
(manifest_format::shadowed_manifest_notice). atc config lint says the same
thing (ATC-E0598).
ATC_KDL_CONFIG=0 (crates/atc-config/src/parse.rs, kdl_config_enabled)
restores the rule from before #6337: an atc.toml at any depth wins, and an
atc.kdl is read only when no atc.toml is on the walk. It is the one-line way
back for a repo that holds both files and depends on its atc.toml.
An explicit -c <file>.kdl needs no variable. The operator names the file, so
nothing is shadowed, and the format follows the name. A .kdl-named file that
holds TOML text no longer loads. It stops at a KDL error that names the file.
The full table lives on KDL_CONFIG_ENV in
crates/atc-config/src/parse.rs; the ambient half of the decision lives in
crates/atc-config/src/manifest_format.rs.
The declaration takes three fields: name on the top line, source, and an
optional payload (ModuleArtifactConfig,
crates/atc-config/src/module_artifact.rs:46-63). The smallest declaration is
three lines:
module heartbeat {
source "./modules/heartbeat"
}
source is required for module (kdl_schema.rs:184-186) and platform
is forbidden (kdl_schema.rs:219-224). The name must be a single path
component, because it becomes a directory on the device
(kdl_schema.rs:160-162). payload names a directory
this push walks into the package’s payload/ subtree (§2).
What the same file may NOT contain
kdl_bridge::bridge translates the rest of atc.kdl into a Config, and it
refuses the whole file rather than silently dropping anything it cannot
carry. Adding any of these to a module repo’s atc.kdl makes the push fail:
| Construct | Refusal | Site |
|---|---|---|
a hooks { … } block on a workload | KdlBridgeUnsupported { feature: "lifecycle hooks" } | kdl_bridge.rs:242-248 |
| a workload whose fields its kind’s schema cannot account for | … "workload fields that do not match their kind's schema" | kdl_bridge.rs:259-284 |
any channel { … } composition | … "channel composition" | kdl_bridge.rs:221-235 |
an artifact whose kind’s recipe reaches no device consumer (podman web-img) | … "production recipes with no device consumer" | kdl_bridge.rs:313-343 |
A hooks { … } block on an artifact is not on that list. kdl_lifecycle
converts it, and PushManifest::artifact_lifecycle carries it to the push
(kdl_bridge.rs:214-220). A hook that cannot convert is refused per hook as
ATC-E0775, by atc config lint and by the push
(push_manifest.rs:155).
A plain workload node is not on that list any more: it bridges, rides the
push, and lands in delivery_workloads (§5). Only the two workload shapes
above still refuse, and each refuses per declaration, so a file with three bad
workloads reports all three.
A bare use <module> line is allowed but buys nothing on its own: it only
makes a module’s node names available. What it buys once you use those names
depends on which one — a systemd workload now pushes, an artifact of a
walk-directory or oci-pull kind now pushes (§3), and a podman artifact is
still caught by the last row above, because its recipe is
oci-pull-erofs-convert.
Device coordinates a KDL repo can say, as of the daemon and bootstrap
top-level nodes:
daemon {
socket-path "/run/robot/atcd.sock"
remote-path "/opt/robot/bin/atcd"
}
bootstrap {
auto #false
}
socket-path and remote-path are the TOML [daemon] fields of the same
name, and auto is [bootstrap] auto; each is derived by the bridge, and
omitting a node means what omitting the TOML table means rather than a second
default (crates/atc-config/src/kdl_bridge.rs, the daemon:/bootstrap:
fields). remote-path is not decoration next to the socket: it also feeds the
push connection target, so a device with atcd installed off
/usr/local/bin/atcd needs it. Both blocks reject an unknown child, so the
rest of each namespace stays reserved for later fields. Working example:
crates/atc-config/tests/fixtures/kdl/valid/pushable/device-coordinates.kdl.
The rest of [daemon] — telemetry endpoints, hook-retry budgets — is still
defaulted: it is daemon-side configuration, not something a producer file
describes.
2. What the module package must contain
Read this section against prepare_module_package
(crates/atc-config/src/module_package.rs:745). That is the production entry
point. resolve_push_set reaches it through
ModulePackageCache::get_or_prepare
(crates/atc-app/src/ota_push_resolve.rs:102-111).
build_module_manifest is not the entry point: it
has no production caller, and its own comment says so
(module_manifest.rs:245). It also refuses any module that declares a
payload, so it cannot describe the package shape below.
prepare_module_package runs ONCE per push, before the retry loop. A producer
re-run inside that loop could hand attempt two different bytes for one push.
The package’s own directory holds exactly two flat files
(stage_core_files, module_package.rs:646):
modules/<name>/module.kdl
modules/<name>/<name> # the executable, same name as the module
- A directory entry is refused (
module_package.rs:669) and a third file is refused (:683). A symlink is refused byO_NOFOLLOW, which reportsELOOP(:381-385), and a socket or an unbacked device node reportsENXIO(:386-391). - The executable must be a regular file (
:675) carrying the owner execute bit (:691). Both checks are sender-side only. A wireManifestcarries no file type, so the receiver has nothing to re-derive them from (module_manifest.rs:29-35). - A module may not be named
module.kdlorpayload(module_package.rs:749-772). The receiver refuses those two andexec-digest.jsonas well (module_protocol.rs:233-268).
The payload node
A package may carry a THIRD thing: a payload subtree. Declare it on the
module node.
module heartbeat {
source "./modules/heartbeat"
payload "./assets"
}
payload names a repository-relative directory. prepare_module_package
walks it and stages every regular file beneath it under the package’s
payload/ prefix (module_package.rs:799-802). The node is valid on a
module artifact only. On any other kind it is ATC-E0770
(kdl_parse_core_nodes.rs:117-133).
payload is sugar for the walk-directory producer, so it refuses what that
walk refuses. The walk ships REGULAR FILES only, and it descends a
directory to find them. It refuses every other entry type by that property,
not by a list (walk_payload_entry, module_package.rs:516-532). The refused
types are a symlink, a FIFO, a socket, a device node, and any type a future
kernel adds. A symlink is ATC-E0759 and the rest are ATC-E0757. The walk
also refuses a non-UTF-8 entry name (:562-566) and a root that escapes the
repository.
The receiver accepts those entries. validate_module_manifest counts only the
NON-payload entries against the two-entry rule, and admits any number of
payload/** entries at any depth (module_protocol.rs:154-165, with
payload_subpath at :130-133). It refuses an entry that names the payload
directory itself (:170-179), and any entry with an empty path component
(:206-214). The entries land under payload/ in the delivered version
directory (§7 gap 10).
The definition
Both sides parse module.kdl. On the push side stage_core_files reads
the definition through the shared bounded loader
(crates/atc-config/src/module_definition.rs, MODULE_KDL_MAX_BYTES). It
refuses the push locally when the definition does not parse, exceeds the cap,
or declares a module other than the one pushed. The error is
ConfigError::InvalidModuleDefinition (module_package.rs:699-722).
So a definition declares its verbs and a per-verb timeout
(verb-timeout "workload start" 120), and it is validated before a byte moves.
That property did not change when production moved off
build_module_manifest. Only the site did.
The device re-reads the delivered file before every verb it spawns.
read_verified_definition checks the file against the digest the delivery’s
validated manifest declared, parses it, and refuses a definition that names a
different module
(crates/atcd-applier/src/agent_push_flow/module_verb_trigger.rs:223-267).
Two declarations then decide what happens. The declared verb set gates every
spawn, and an undeclared verb is skipped rather than run (may_spawn,
:605-627, called at :763). The declared verb-timeout becomes that verb’s
deadline (:798-805). A drained verb has no retained manifest to compare
against, so it parses the delivered file unverified:
DefinitionEvidence::OnDiskOnly (:292-299).
The rest of a definition still reaches no device code. A definition also
declares kinds, their field schemas, their production recipes, its rollback
contracts, a run-as uid, and managed paths. The device parses and validates
all of them, and then reads none of them.
3. Artifact kinds and production recipes
ProductionRecipe has three variants — walk-directory, oci-pull,
oci-pull-erofs-convert (crates/atc-config/src/module_definition.rs:128-133).
All three produce. Only two reach a consumer on the device.
-
A recipe is a field of
ArtifactKind::FromModuleonly (kdl_schema.rs:61-65).ModulePackageis a core kind and has no recipe field. Its payload comes from thepayloadnode instead — sugar for the same walk-directory producer, run byprepare_module_package. -
walk-directoryis performed byprepare_recipe_artifact(crates/atc-config/src/module_package.rs). It is the SAME walk thepayloadnode runs. Sameopenatladder, same staging root. One producer, two callers, not a second directory walker. -
oci-pullis performed byprepare_oci_layout_artifact(crates/atc-config/src/module_package_oci_pull.rs). It is the SAME walk again — over a materialized OCI image layout instead of a repository directory.atc-configcannot resolve an image ref (it depends on neitheratc-ocinoratc-store), so the two halves are split:atc_app::ota_push_resolve_recipesresolves the artifact’ssourceref against the laptop cache and builds the artifact’sindex.json, and this crate walks the cached blobs and writes the two layout files beside them. The resolution is the SAME one a coreociartifact gets —resolve_oci_source_digest, then the cached-platform check — so there is no second registry client. -
oci-pull-erofs-convertisoci-pullplus ONE GATE, in the sameatc_app::ota_push_resolve_recipes: every layer of the resolved manifest must already carry the EROFS media type, the all-or-nothing rule the device’s own desired-set computation applies. It converts nothing; a tar-layered image is refused withatc oci import … --layer-format erofs. The privileged half it was once blocked on is not needed and not rebuilt: mounting is done bycrates/atc-mount-helper, a root service with an identifier-only protocol that shipped separately.The bridge still refuses it, and that is deliberate. The producer stages an image the device cannot yet find: a module-defined artifact materializes into the channel artifact tree, while the mount reconciler and the digest resolver read
oci_tags, the by-digest paths, and mount leases. Until that registration lands, accepting the recipe would report a successful push over an image nothing mounts.
So “which recipes can a module use” still has two answers: walk-directory and
oci-pull. The built-in podman kind declares oci-pull-erofs-convert, so
podman web-img is still refused.
What an oci-pull artifact does end to end
use registry
image web-img {
source "ghcr.io/acme/web:1.4"
platform "linux/amd64"
}
with registry resolved from .atc/modules/registry/module.kdl declaring an
artifact "image" whose recipe is "oci-pull".
The field rules are oci’s, not walk-directory’s, and the asymmetry is the
recipe’s rather than a policy choice per kind. source is required on both,
and it DENOTES different things: a repository-relative directory for one, a
registry image reference for the other. platform is REQUIRED here and
FORBIDDEN there — one ref can hold an image per architecture and a device has
exactly one, so omitting it would push whichever architecture happened to be
imported; a directory walk has no registry to select from at all
(ArtifactKind::platform_rule, enforced in both directions by
kdl_parse_core_nodes).
The image must already be in the LAPTOP CACHE, exactly as for a core oci
artifact: atc oci import <ref> is the pull, and a miss is
CliError::OciNoCachedImage carrying that command as its hint. atc ota push
does not reach a registry.
What lands on the device is the image LAYOUT — oci-layout, index.json,
and blobs/sha256/<digest> — at the artifact root, so an oci: transport
reference resolves against the delivered directory. The index.json names
exactly the ONE ref the artifact declares. It is NOT
atc_oci::layout_view’s, which lists every laptop tag pointing at the digest
so that concurrent atc oci commands do not clobber each other’s view: that
is right for a shared cache view and wrong for an artifact, where it would
make target_sha a function of the laptop’s tag bookkeeping. Nothing is
written into the cache, so --dry-run resolves one of these without touching
store state. Not an unpacked rootfs: a
rootfs is made of symlinks, device nodes and uid/gid-owned entries, the walk
refuses the first two by design (ATC-E0756/ATC-E0757), and the manifest
can carry neither ownership nor whiteouts. Unpacking belongs to whatever
consumer has the privilege for it, and no such consumer exists on the device
yet (§1).
What a walk-directory artifact does end to end
use assets
files web-assets {
source "web"
}
with assets resolved from .atc/modules/assets/module.kdl (the
repo-vendored rung, crates/atc-config/src/module_resolution.rs) declaring
an artifact "files" whose recipe is "walk-directory".
resolve_module_defined_artifacts(module_artifact.rs) resolves it to aModuleDefinedArtifactConfig, parsing the<module>.<kind>spelling (assets.files) against the kind grammar at that one boundary.PushManifest::module_definedcarries it. It does NOT go intoConfig. That is §S1b’s ruling for themodulecore kind, not a second one.Configisatc.toml’s schema,[[bundle]]and[[oci]]are its only sections, and no TOML section name can spell<module>.<kind>.resolve_push_setruns the recipe ONCE per push, through the sameModulePackageCachea module package uses, so a retry does not re-run a producer.- The produced tree is staged at the ARTIFACT ROOT, with no
payload/prefix. That prefix is module-package layout. The wire prefix is now the caller’s parameter, not the producer’s constant. build_from_module_artifact_planbuildsArtifactPushPlan::FromModule.build_artifact_declputs the spelling onArtifactDecl.module_defined_kindand leaves the enumkindUNSPECIFIED. Those are the two halvesresolve_decl_kindreads.
source is REQUIRED for a walk-directory kind
(ArtifactKind::source_required). It is derived from the recipe, not from
provenance, for the reason name_shape is. A ProductionRecipe carries no
path of its own, so the artifact’s own source is the recipe’s only
parameter.
What an operator SEES. ArtifactVersions, ArtifactStatus,
DeliveryMember and PerArtifactResult each carry a module_defined_kind
beside their ArtifactKind, exactly as ArtifactDecl does on the push path,
and artifact_kind_from_wire reads the pair back. So atc ota ls, ota status and ota deliveries name a landed artifact assets.files.
The enum half alone still means bundle when the spelling is empty, which is the v1.0-client case and is deliberate.
atc ota df, push rollback messages and orphan warnings read the pair too
(#5127). pb::DfArtifact and pb::ActiveArtifact each gained a
module_defined_kind field, and the enum-only converter artifact_kind_to_pb
is deleted, so no site can take the first half of the pair by accident.
4. Which verbs actually fire
CommitVerb has six variants and DeferrableVerb three
(crates/atc-module/src/verb.rs:87-93, :24-31) — nine verbs in the
vocabulary. Production invokes four, from four call sites, all of which
go through spawn_commit_verb (module_verb_trigger.rs):
| Verb | Caller | When |
|---|---|---|
workload start | spawn_start_verbs_for_module (module_verb_trigger_start.rs) | a delivery activated the module |
workload start | the drain’s first-activation half (first_activation_starts, module_verb_drain/starts.rs) | a BAKED module’s declaration set activated it. No delivery carries such a module, so it is absent from every changeset and the trigger above cannot see it |
workload health | spawn_health_verbs (module_verb_health.rs:95) | a workload start the applier session ran succeeded. Four other start paths get none: see the scope paragraph below |
workload stop | the deactivation drain (run_retarget_stop and run_departure, module_verb_drain/drain.rs) | the module was retargeted or dropped |
workload remove | the deactivation drain (run_departure, module_verb_drain/drain.rs) | the module was dropped |
The one module package left in the tree does declare another of the
verbs: probe lists workload status
(crates/atc-module/fixtures/probe-module/module.kdl). The podman and systemd
manifests, which also declared verbs production never spawns, were deleted as
unreachable (issue #3665). A declaration is not a trigger, and no production
code path spawns those verbs, so declaring one changes nothing. Every one of
the four that does fire is gated on the module’s own declaration: an
undeclared verb is skipped rather than spawned.
atc_module::verb_declaration::default_action states what the skip means
(may_spawn, module_verb_trigger.rs). A skip is counted, under
atc.agent.delivery.module_verb_skips.
Every committed delivery also books one delivery-level verdict, under
atc.agent.delivery.workload_health_verdict. It is tagged verdict
(healthy|unhealthy|unasserted). It answers a different question from the
per-verb counters above. Did anything report that this delivery’s workload came
up?
A delivery that activates no module books unasserted. So does one whose module
declares no workload health. Per verb an undeclared health check is a benign
skip. Per delivery it is nobody looking, which is issue #3106. The pass emits on
every path, so an absent series means the daemon did not report. It never means
a healthy fleet (spawn_health_verbs, module_verb_health.rs).
Scope, stated rather than implied. spawn_health_verbs has ONE caller, and that
caller is the applier session (crates/atcd-applier/src/session.rs:878). So the
health pass covers the starts that session ran, and FOUR production paths start
a module with no health pass at all (module_verb_health.rs:122-142):
| Start path | Why it gets no health verb |
|---|---|
| a BAKED module’s first activation, and a baked RETARGET | the session’s start phase adds both from outside the changeset, so the health pass finds no activation record to build a context from (module_verb_drain/starts.rs:260-302) |
DrainScope::Aged, the housekeeping sweep | drain_module_deactivations discards the start report (module_verb_drain/drain.rs:199-201) |
DrainScope::Rows, a producer draining its own receipt | the same discarded report |
the pull commit, which drains a Rows receipt | the same discarded report |
A recovered first activation, a replacement start and a pulled delivery’s start
each get an exit status and nothing else. The baked case does reach this pass
and folds unasserted into the delivery verdict, so that skip is a number. The
other three never reach the pass, so they book no verdict at all.
The PUSH plane is therefore this counter’s whole subject. A pulled delivery
books nothing here. Read atcd.pull.delivery_commit_total beside it to see that
half as a number.
Its subject is every COMMITTED delivery, which includes a stage-only push. Such
a push terminalizes Committed and activates nothing, so it books unasserted
and no workload was ever due to come up. That is true, and it does dilute the
ratio on a fleet that stages before it switches. The question the counter
answers survives it: does this device ever produce a healthy verdict at all?
atc_module::commit_coordinator, which did map a CommitStep to
stop/start/health, had no production caller and is gone. It was deleted
in PLAN-D slice 5 along with sequence.rs’s delivery-ordering half
(ArtifactName, DeliveryMember, DeliveryPlan), because it sequenced
verbs around the store flip and production does an atomic flip followed by
best-effort post-commit verbs. Recover it from git history at commit
f8f193746, the last tree that still contained it. (It is unrelated to the
atcd-applier commit coordinator, which batched whole-blob CAS commits, never
touched a module verb, and was deleted in #7439.)
How a verb is invoked, which is the whole ABI a module author has:
- argv is
<exe> "<verb>", for example<exe> "workload start". It is one token, space included (crates/atc-module/src/spawn.rs:443). - the environment is
env_clear()ed down to exactlyPATH(pinned toatc_lifecycle::hook_env::HOOK_PATH) andATC_MODULE_CONTEXT(crates/atc-module/src/spawn.rs:447-448,crates/atc-module/src/invocation.rs:98-107). - stdin is
/dev/null; stdout and stderr are inherited from atcd (crates/atc-module/src/spawn.rs:449). - the child leads its own process group, which is SIGTERMed then SIGKILLed if
the verb outruns its timeout (
crates/atc-module/src/spawn.rs:453,:232-283). Astartthat blocks for the workload’s lifetime gets killed along with everything it spawned. - the timeout is what this module’s
module.kdldeclared for this verb, clamped by a hardcoded 600s device cap (DEVICE_TIMEOUT_CAP_SECSandDeviceTimeoutCap,module_verb_trigger.rs,DEVICE_TIMEOUT_CAP_SECS). A definition that declares noverb-timeoutfor the verb gets 60s (DEFAULT_VERB_TIMEOUT_SECS). The cap is not configurable: nothing reads the root-owned device configuration that would raise it. - exit 0 is success; every nonzero code is a plain failure.
21means “defer” only for the three payload-plane verbs, none of which ever runs (verb.rs:59-65vsverb.rs:124-129).
When start fires, and when it does not: after the delivery commits and
survives every veto window. crate::agent::finish_applier_session_inner
calls session::start_activated_modules_after_commit, which re-reads the
delivery row and starts nothing unless that row is durably Committed
(crates/atcd-applier/src/session.rs:783, :825-845). A delivery that merely
carries a module forward spawns nothing. A delivery that re-lists the same
sha does re-run start (module_verb_trigger.rs’s module doc states it, and
DeliveryActivation’s changed flag is what distinguishes carried-forward from
re-listed-unchanged). A failure is
logged and counted, never returned: the push still reports success and the
module simply never started
(module_verb_trigger_start.rs:81-89). The push does not retry it. A
later housekeeping sweep does, and gap 7 states which shape gets how many
attempts. Do not intervene by hand before reading that gap.
5. Workloads
Declare them in atc.kdl and push. The KDL bridge used to refuse any file
with a workload node; it no longer does, so the chain below is reachable
from a producer file:
WorkloadDeclarationridesPushPreflightbehind theworkload-declarations-v1capability, with identity fields typed and kind-specific fields as one canonicalfields_json_v1JSON document the daemon never interprets (crates/atc-protocol/proto/atc.proto). The client gate fails closed against a daemon that does not advertise the token.- The daemon validates the declarations at preflight and persists them against
the delivery inside the commit transaction (
delivery_workloads), as an idempotent full-set replace. - Workload declarations cross the wire, are persisted per delivery, and
drive the trigger: it spawns one
workload startper DECLARED workload, with the workload’s own name as the context’ssubject.nameand its declared fields asconfig(spawn_start_verbs_for_moduleinmodule_verb_trigger.rs). The module-name subject survives only as the fallback for a module that declares no workloads at all.
The practical consequence for a module author: subject.name is the workload
your invocation acts on — unless the delivery declared none for your module,
in which case it is still your module’s own name. A module that wants to
address several units of behavior individually declares one workload per unit.
Two workload shapes are refused. Each is named in the push error, and each is emitted per declaration.
- A workload with a
hooksblock. A workload hook runs atdeactivate.*andactivate.*, in the passes that stop and start the workload. Nothing carries a workload hook to a device yet, so the push refuses the block. If the behaviour fits the payload, put a hook on the artifact thatfromnames instead. - A workload of a module-defined kind, where a child does not match that kind’s schema. A child fails to match for four reasons. The schema does not declare its name. Its value has a different type than the schema gives it. It repeats a name. It is not a single-argument leaf node.
Those are the only two workload scans in unbridgeable_findings. A kind that
declares no fields at all accepts a block carrying from alone, and refuses
any other child by name.
A module-defined kind whose block matches its schema is not refused. Its
declared values ride the push as a fields_json_v2 document. The field names
are the keys, in alphabetical order. A built-in kind stays on
fields_json_v1 and keeps its declaration order. The two arms split the
kinds by who defines them, so neither replaces the other.
The daemon must advertise the workload-fields-json-v2 token before atc
sends a v2 declaration. The push refuses before any bytes move if it does
not.
A third wall is often met alongside them but is not a workload rule: an
artifact of a module-defined kind, such as podman web-img, is refused as an
artifact. A workload is never refused for what it consumes, so a container
workload whose from names a plain bundle bridges and pushes — it is
use-podman.kdl’s podman ARTIFACT that stops that file, not its container
workload.
Editing only your declarations does not deploy them. A push whose content the device already holds short-circuits, and a stage-only push does not activate, so neither writes new declarations. To deploy a changed workload set, change an artifact in the same push, or switch to a delivery that already carries the declarations you want.
atc ota push --dry-run predicts which case you are in. Below the workload
lines it prints a declarations: line: whether this push would apply them, and
why not when it would not. --json carries the same answer in
push_preview.workload_declarations_disposition.
Removing the last workload from atc.kdl is a destructive push. The file
stays authoritative about its own workload set, so an empty one CLEARS the
device’s declarations rather than leaving them alone — unlike an atc.toml
push, which cannot declare workloads and carries them forward. The dry-run
names that case outright, and --json reports it as
workload_declarations_present: true with an empty workloads array.
A pull now carries workload declarations too (#4610). They ride INSIDE the
signed delivery manifest, on DeliveryManifest.workloads, so the ed25519
signature the publisher already makes covers them. There is no second signature,
no new server column, and no unsigned sibling carrier.
atc channel deliver --workloads <atc.kdl> is what authors them. The producer file
is authoritative about its own workload set, exactly as it is on the push plane,
and fields_json comes from the encoder the push plane sends
(to_workload_wire_v1), so both planes ship the same document. The server
validates the set after it verifies the signature and refuses a publish that
cannot work — a from naming an artifact the delivery does not carry, a name
colliding with one, a fields document that is not a JSON object.
The device extracts the declarations from the VERIFIED bytes and threads them into the same commit transaction the push plane uses. So the rules are now one set of rules on both planes:
Some, the empty set included, REPLACES the delivery’s rows. Emptying the workload set inatc.kdland publishing CLEARS them, as an empty push does.None— a publish that omitted the flag, or any UNSIGNED delivery — carries the prior active delivery’s rows forward. An unsigned delivery attested nothing, so it can never clear a set.
The parity claim is checked rather than asserted:
delivery_pointer::tests::workload_parity_tests drives the same declaration
policy through BOTH commit entry points and compares the resulting rows,
including the authoritative clear.
An atcd that predates #4610 cannot apply a declaration, so the server refuses
to publish one to it. DeliveryManifest does not deny unknown fields, so such
a device verifies the signature, ignores the new field, commits “said nothing”,
inherits its prior rows, and reports a successful convergence. A publisher who
CLEARS a set would get silent false success on it: the workload keeps running,
nothing errors, and nothing logs.
So the device reports declares_workload_declarations at check-in, and
publish_delivery refuses a workload-bearing release to a channel holding a
device that has never reported it. The refusal names the devices to upgrade. It
is the same shape, and the same argument, as the [lifecycle.progress.*] gate.
Two properties of that gate are deliberate:
- An authoritative CLEAR arms it. The empty set is the case that breaks on an old device, so a gate keyed on a non-empty set would wave through the one publish that matters.
- A channel with no devices is REFUSED. An empty candidate set is the state every channel is in before its first device checks in, so “nobody objects, allow” would admit precisely the publishes made during bring-up, and the first device to enrol is the one that pulls them. Over-refusing costs a retry and says so; under-refusing costs a workload that keeps running after it was deleted.
A delivery that declares no workloads is untouched by the gate, which is every delivery published before #4610.
A workload on a built-in module STARTS. The trigger only walks the push’s
changeset, and a built-in module — systemd, podman, nerdctl — is never in
it. So the commit derives a lifecycle for it instead, from the declaration set
itself: the version is baked-<digest of the declared workloads>, and the
ordinary journal diff turns a first declaration into a first activation, a
changed set into a retarget, and a removed set into a departure. The drain then
runs the verbs out of the baked rung.
The version is the DECLARATION SET, not the module’s code. The baked executable
changes only when the image changes, which no transaction observes and no
journal records — a version derived from it would change with nothing to write
the transition, and the previous version’s workload stop would be owed by a
row nobody ever writes.
A lifecycle is not installed code. A module the image does not bake still gets
one, and atc.agent.delivery.baked_module_resolutions is what says so. The
counter atc.agent.delivery.workload_declared_untriggered now reports the
narrower case: a declaration that NO lifecycle covers, which is near-always
zero and is the regression detector for the derivation.
6. What a module can do at the floor
There are no grants. The privileged half of the module system — grants, the
device executor, UserProvisioningHost — was deleted on an explicit ruling
that it will not be built (PR #2303; recovery SHA
d01caf5c6e682e0cd861022e6770cdf316c3fc25). ProcessVerbSpawn authorizes
nothing.
A verb runs as the device’s hook user (hook_run_as_user, default
atc-hooks), the same identity and the same resolution as a lifecycle hook.
Before execve, the verb’s child drops to that user and clears its ambient,
permitted, effective and inheritable capability sets. The hook spawn uses the
same function, atc_process::drop_privileges_in_child. This matters because
under hook isolation the atcd unit grants
AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_KILL, and ambient
capabilities survive execve. If the drop fails, the verb does not run, and
the error names the failed step. atcd installs the identity at start
(atcd_applier::install_module_verb_identity). A process that never installed
one refuses every verb.
Without hook isolation (hook_run_as_user unset, or an explicit --root
install) a verb runs as atcd’s own uid with no switch, as a hook does then.
What a verb is given:
<atc-root>/module-state/<name>/ | the module’s writable home, mode 1770 (sticky) and atcd-owned, created before the verb runs. The hook user writes it through atcd’s group, because atcd cannot chown. The sticky bit stops that user from renaming atcd’s scratch away (crates/atc-module/src/state_dir.rs) |
<state-dir>/scratch/<delivery-id>/<workload>/ | per-delivery, per-WORKLOAD scratch, holding context.json (0640, written by atcd) and records (written by the verb). The leaf is 1770 and the directories between it and the state directory are 0750. atcd creates each one relative to its parent’s descriptor and refuses a symlink or a component it does not own, and it replaces a context.json symlink rather than follow it. The workload component is not cosmetic: two workloads of one module in one delivery would otherwise share one context.json and clobber the file each verb reads its whole input from. A module-subject verb (no declared workload) still gets the bare scratch/<delivery-id>/ (built in spawn_commit_verb, module_verb_trigger.rs) |
<atc-root>/modules/<name>/<sha>/ | the delivered package, whose module.kdl is digest-verified before parse by read_verified_definition (module_verb_trigger.rs) |
<atc-root>/modules/<name>/current | a symlink to the active sha |
No mounts, no chowns, no created users, no managed paths outside that subtree, no capabilities. A module that needs privilege must reach something that already has it on the device (a systemd unit, a rootless podman socket) using nothing but its own uid.
The context document at ATC_MODULE_CONTEXT carries module, subject,
config, payload_root, payload_manifest, version, delivery_id,
state_dir, scratch_dir, records_path. Two of those are not what their
names suggest, per the fields builder’s own doc (module_verb_trigger_fields.rs:27-40):
config is null unless the invocation’s workload declared fields, in which
case it is that workload’s declared fields_json_v1 document verbatim — the
daemon never interprets it. payload_root names <version-dir>/payload/ for
a module that declared no workloads, which materialize creates for every
module package whether or not one shipped, so a module that walks it finds an
empty directory rather than a missing one; for a declared workload it instead
names the version directory of the artifact that workload consumes
(from_artifact), which is a different artifact entirely.
payload_manifest lists the delivered payload files when there are any, and
stays Opaque for a payload-less module — deliberately, so an already-
delivered executable that predates payload support can still deserialize its
context.
7. The gap list
Ordered by how likely a user is to hit it.
-
A repo that holds
atc.kdlbesideatc.tomlreadsatc.kdl(#6337).atcprints a note that names the ignoredatc.tomlandATC_KDL_CONFIG(manifest_format::shadowed_manifest_notice), andatc config lintsays the same (ATC-E0598).ATC_KDL_CONFIG=0reads theatc.tomlinstead (§2). -
A workload declared for a built-in module now STARTS, provided the image really bakes that module. The trigger still walks the push’s changeset and no built-in module is ever in it, so the start does not come from there: the commit derives a baked lifecycle from the declaration set, and the drain runs the first-activation start out of the baked rung (§5). What remains of this gap is the module the image does NOT bake. The lifecycle is derived from the declarations alone, so such a module gets one anyway, its code root is missing, and the start stays owed rather than failing loudly.
atc.agent.delivery.baked_module_resolutionsis the signal: the device resolves every distinct module named by a declaration outside the changeset downatc_module::resolution’s ladder and tags the resultbaked_verified,baked_unverifiable,delivered,absent, orresolve_failed.atc.agent.delivery.workload_declared_untriggeredcounts the narrower residue: a declaration no lifecycle covers at all. Two narrower shapes are still refused outright at the bridge: a workload withhooks, and a workload of a kind with no compiled-in field schema. A module-defined artifact kind is refused too, but as an ARTIFACT (gap 6). No workload rule looks at what a workload consumes. -
Five of the nine verbs never fire. That is one entry and two different problems. The four payload-plane verbs are blocked behind other gaps.
workload statusis not blocked, but it has nowhere to put an answer.The payload plane has its subject and its spawn paths. It has no trigger point. Two walls stood here and both are down.
The first was the subject.
payload installand the three deferrable verbs act on an artifact, never on a workload, andcheck_subject_planerefuses a cross-plane subject (crates/atc-module/src/context.rs). No production code could build an artifact subject: everyVerbSubjectvariant built aSubject::workload.VerbSubject::Artifactnow carries one (module_verb_trigger.rs), built from a module-defined member byModuleDefinedMember::from_committed_member(module_verb_trigger_payload_plane.rs), which reads the DEFINING module off the<module>.<kind>spelling.The second was the spawn API.
VerbRequestadmitted only aCommitVerb, andspawn_commit_verbalways calledModuleContext::for_verb, which REFUSESpayload install— install must carry a writable version root.spawn_payload_install_verbsupplies that root, andspawn_deferrable_verbreads exit21as a defer rather than as a failure.What is left is the four TRIGGER POINTS. Each one changes the delivery state machine, so they land in their own lane:
payload installat the Install gate, before the commit stage barrier;payload stageandpayload removeon their sweeps;payload reconcileon its pass. Until then the seam has no production caller and no payload-plane verb runs.workload statushas a live subject and no answer channel. Workloads reach the device (§5), so the subject exists. The verb reports on stdout, which atcd inherits and no code captures (crates/atc-module/src/spawn.rs, theCommandbuilder at:437-452). The records file is the design’s channel for a structured result, andparse_records_filehas no production caller (crates/atc-module/src/records.rs:11-15). Both module executables in the tree print English to stdout and exit 0 whatever they find (examples/heartbeat-module/modules/heartbeat/heartbeat,crates/atc-module/fixtures/probe-module/probe-module). The exit code therefore reports nothing about the workload.The reader is absent too. The design names
atc statusand check-in reporting as the consumer (docs/internal-site/src/design/2026-07-30-update-modules-design.md:561).StatusReplycarries no module field and no workload field (crates/atc-protocol/proto/atc.proto:1895). Check-in telemetry is per channel and per delivery (ChannelTelemetry,crates/atc-server/src/http/checkin_wire.rs:83). So this verb needs an answer channel, a request path, and a reader before a spawn means anything. Spawn it without them and it writes one journal line, then books an exit code that is 0 by construction.The other four verbs do fire (§4).
-
module.kdldeclares more than the device reads. The device honours the declared verb set and the declared per-verb timeouts. The kinds a definition defines, their field schemas and recipes, its rollback contracts, itsrun-asuid, and its managed paths reach no device code (§2). -
All three recipes produce.
prepare_recipe_artifactperformswalk-directoryandprepare_oci_layout_artifactperformsoci-pull, both reusing thepayloadnode’s own walk;oci-pull-erofs-convertis that second producer behind an all-layers-are-EROFS gate (§3).What
oci-pull-erofs-convertstill lacks is the DEVICE half, and it is not the privileged one. Mounting already ships, incrates/atc-mount-helperand theatc-oci-reconciledesired-state pass, so PR #2303’s ruling on the privileged module framework does not have to be reopened. The gap is unprivileged glue: the delivered version must be published into the cache view the reconciler andatcd ctl podman runread. Until then the bridge refuses the recipe by name (§1). -
A module-defined artifact kind pushes, lands, and reports for both bridgeable recipes. The
podmanARTIFACT kind is still unusable, and now for one reason rather than five: its recipe isoci-pull-erofs-convert, whose device half gap 5 names.All five walls this gap used to list are down. The bridge refusal is narrowed to the recipe (§1).
PushManifest::module_definedresolves such an artifact andArtifactPushPlan::FromModuleplans it. The daemon reads the kind offArtifactDecl.module_defined_kindon both paths (atcd::preflight::resolve_decl_kind,atcd_applier::artifact_kind::pb_artifact_kind_to_store). The device stores the spelling verbatim.artifacts.kindhas no CHECK, and the version directory carries no kind segment (crates/atc-store/src/layout/channel.rs), so the.in<module>.<kind>never reaches a path.Observation was the fifth wall.
pb_kind_to_labelread the wire enum alone and mappedUNSPECIFIEDtoBundle. So everyatclisting called a correctly-storedassets.filesartifact a bundle. That was the fail-open shape of the receive side, on the report side.ArtifactVersions,ArtifactStatus,DeliveryMemberandPerArtifactResultnow each carrymodule_defined_kindbeside the enum. The daemon fills both halves throughatc_protocol::artifact_ident::artifact_kind_to_wire, andartifact_kind_from_wirereads the pair back.KindLabelis deleted, so the views hold the realArtifactKind.UNSPECIFIEDwith an EMPTY spelling still reads asbundle. That is the v1.0 client, not an unknown kind.Three more readers took the enum alone, and all three read the pair now (#5127). §3 names them, the two wire fields they read, and the enum-only converter deleted with them. Nothing is left of this residue.
-
A failed
startis RETRIED. The bound depends on which start failed. A start that runs and fails leaves the module down at that instant. A later housekeeping sweep re-runs it, at most once per sweep.A REPLACEMENT start, which a retarget journals a row for, gets at most five attempts (
MAX_START_ATTEMPTS,module_verb_drain/retry.rs:48, with the gate at:112). Nothing on a device can tell a transient failure from a permanent one, so the drain does not classify. It bounds. What is left of this half is the module that fails all five: its row is retired andatc.agent.delivery.module_drain_unrecoverablefires once.That series is what an operator alerts on. Non-zero means one module will not come back without a new activation.
atc.agent.delivery.module_drain_strandedstill fires per failed attempt, so it reads as an outage RATE rather than as a count of distinct modules.A FIRST activation gets no such bound, because it journals no row to count attempts on. Migration 0044 records the obligation, the sweep leases it, and a start that fails leaves it unsettled. The next sweep takes it again (
module_verb_drain/first_activations.rs:489-499). The module ABI definesworkload startas convergent, which is what makes the unbounded retry safe. The lease re-stamps every obligation it reads. So one module that always fails moves to the back of the queue and starves nothing (crates/atc-store/src/module_journal_first_activation.rs:99-174).The backlog is
atc.agent.delivery.module_drain_first_activation_owed, a gauge. A value that rises and falls is ordinary. A value that does not fall is the one to look at. -
A stranded retarget RECOVERS by itself. A drain that ran a module’s
workload stopand then failed to start the replacement used to leave the journal row claimed and unexecuted, which every later sweep skipped; the only way back was to activate the module again by hand. Migration0055_module_deactivation_stop_stamprecordsstopped_atwhen the stop verb SUCCEEDS, which is the one fact that tells that state apart from a drain that died before its stop. A stamped row has its start re-run under gap 7’s bound. An unstamped one is still skipped forever, and there are three: the crash, a stop that FAILED, and a version directory that is gone. The failed-stop case is the one that looks like this gap and is not — the OLD version is still up and serving, so a retry would add the replacement beside it rather than recover anything. Verified by the drain’s unit tests (module_verb_drain/mod.rs, “A stranded retarget is RETRIED”). A PUSH no longer reaches this path for a module with workloads: its workload transition fails the push and the rollback starts the old workloads again, proved on a device bycrates/atc-cli/tests/baked_module_start_recovery_e2e_tc.rs. -
One producer does not drain its own teardown. The admin switch, the post-flip veto rollback, the per-artifact user activate, and the pull commit each run their stops at once. The pull commit reaches that point through
PendingModuleDrain::spawn, frompull_poller::reconcile. The standalone delivery rollback journals its rows and leaves them to the housekeeping sweep. That sweep’s grace is one hour plus up to one sweep interval (AGED_DEACTIVATION_GRACEinmodule_verb_drain/drain.rs, andmodule_verb_drain/mod.rs’s producer table). The sweep reaches the first-activation starts too: migration0044_module_first_activation_obligationrecords each one, and the sweep leases the marker. So the cost is latency, not loss, for both halves. That is stated once, ondrain_producer_receiptinmodule_verb_drain/drain.rs. -
A module package carries
module.kdl, one executable, and whatever apayload "./dir"node walks in. Still no second binary: the package’s own directory holds exactly the two files, and payload entries land underpayload/, which is read-only on the device.
Shared-library resolution in a delivered tree
A binary starts on the build machine. The same binary, delivered to a device, fails at startup:
/app/bin/myapp: error while loading shared libraries: libhelper.so:
cannot open shared object file: No such file or directory
The library is present in the delivered tree. You can ls it. The process still
cannot load it.
This guide explains why, and gives a lifecycle hook that stops the delivery before it activates.
Read the error correctly
Two properties of that message send operators the wrong way.
It names the library, not the object that asked for it. A dependency of a dependency reports the same way. So the named library is where to look, but the object with the broken search path may be a different file.
It does not list the directories searched. “No such file or directory” means no candidate path produced a usable file. It does not mean the file is absent from the disk.
Get the real answer from the loader:
LD_DEBUG=libs /app/bin/myapp 2>&1 | head -60
That prints every directory tried and every open attempted.
Two things that look like this failure and are not
The artifact is a wrapper script, not a binary
An autotools or libtool build writes a wrapper shell script under the
binary’s own name, and puts the real ELF under .libs/. The wrapper sets the
loader environment, then re-executes the real file. So it runs on the build
machine, and it carries none of that machinery to the robot.
Run file before ldd, on every artifact you stage:
file ./myapp # ELF 64-bit LSB executable ... <- a binary
file ./myapp # POSIX shell script, ASCII text <- a libtool wrapper
ldd on a wrapper prints not a dynamic executable, because the wrapper is not
an ELF file. A fully static binary gives that same answer. So a check that
starts at ldd reads as clean, and stages a script the robot cannot use.
file is what separates the two.
Configure with --disable-shared to build one binary instead of a wrapper, or
stage .libs/myapp and accept the dynamic link.
The library was found, and the symbols still did not resolve
A library that matches the soname, but is not the copy you built against, opens without complaint. The failure moves to symbol-binding time and reads differently:
/app/bin/myapp: symbol lookup error: /app/bin/myapp: undefined symbol: jv_free
There is no “cannot open shared object file” here, because nothing failed to open. Everything in this guide about search order still applies. The loader picked the FIRST match on the search path, and that match was the wrong build.
This is the common ending for a non-static autotools build that a build host
also has an older packaged copy of. The build tree’s .libs/ is not on the
search path, /usr/lib is, and the system copy wins. Name the sonames the
object will accept, then check which file answers each one:
readelf -d ./myapp | grep NEEDED
LD_DEBUG=libs ./myapp 2>&1 | grep -i 'calling init\|trying file'
How the loader searches
The dynamic loader resolves each DT_NEEDED entry in this order:
DT_RPATHof the object holding the entry. This applies only when that object has noDT_RUNPATHLD_LIBRARY_PATHDT_RUNPATHof the object holding the entry/etc/ld.so.cache- The default directories (
/lib,/usr/lib, and the multiarch variants)
Inspect what a file carries:
readelf -d <file> | grep -E 'RPATH|RUNPATH|NEEDED'
$ORIGIN in an RPATH or RUNPATH expands to the directory of the object that
carries it. That is what makes a tree relocatable: the path follows the file.
Three traps
1. RUNPATH is not transitive
DT_RUNPATH applies only to the direct dependencies of the object that
carries it. It is not consulted when the loader resolves a dependency of a
dependency.
DT_RPATH behaves differently. It is inherited down the chain. Modern linkers
emit RUNPATH by default (--enable-new-dtags), so the inherited behaviour is
the one you no longer get.
The consequence: an executable with RUNPATH=$ORIGIN/lib resolves its own
direct dependencies from lib/. It does not resolve a library that one of
those dependencies needs. If that second-level library sits in the same lib/
directory, it still fails, and the neighbouring files load correctly. That
asymmetry is the signature of this trap.
To get inherited behaviour, link with -Wl,--disable-new-dtags. To keep
RUNPATH, give every shared library its own $ORIGIN entry, not only the
executable.
2. A copied file keeps the build machine’s path
CMake rewrites RPATH at install time for install(TARGETS) only.
install(FILES) and install(DIRECTORY) copy bytes without touching the ELF
header.
So a prebuilt vendor library, or a plugin built by a nested project and then copied in, arrives carrying whatever its own build produced. That is often an absolute path on the machine that built it:
RUNPATH: /home/builder/ws/src/app/linux/flutter/ephemeral
That path exists on the build machine, which is why the binary runs there. It does not exist on the device.
Setting CMAKE_INSTALL_RPATH does not reach these files. Nothing in the CMake
install step does. Either fix the RPATH in the project that builds the library,
or rewrite it while assembling the tree:
patchelf --set-rpath '$ORIGIN' <tree>/lib/*.so
3. A hook gets a cleared environment
atcd clears the environment for every lifecycle hook, then runs the hook through
its shebang. A hook receives ATC_HOOK_CONTEXT, a pinned absolute PATH, the
hook user’s HOME/USER/LOGNAME/SHELL, XDG_RUNTIME_DIR, and
DBUS_SESSION_BUS_ADDRESS. It receives nothing else.
You cannot restore LD_LIBRARY_PATH through --hook-env. The boundary
rejects the whole LD_* family, along with GCONV_PATH, LOCPATH, and
GLIBC_TUNABLES (crates/atc-lifecycle/src/hook_env.rs). Those variables make
the loader run code before the hook’s first line, so the denylist is deliberate
and stays.
A hook may still export the variable inside its own body. The denylist governs what an operator passes in, not what the script sets for itself.
A long-running workload is a separate case. A systemd --user service starts
after the hook exits, outside atcd’s spawn, so it inherits neither the hook
environment nor ATC_HOOK_CONTEXT. Such a unit must establish its own
environment, or run a binary that needs none.
If you deliver a ROS install space
catkin and colcon setup.bash export LD_LIBRARY_PATH=<install>/lib. That one
directory is all they add.
A package executable installs to lib/<pkg>/. Anything that ships its own
private library directory, such as a Qt or Flutter application bundle or a
vendored SDK, lands deeper, at lib/<pkg>/<app>/lib. setup.bash never adds that path, so
those libraries resolve only through the RPATH of the object that needs
them. Combine that with trap 2 and the failure is silent until the first
delivery.
Sourcing setup.bash is the normal ROS workflow, so a developer works inside an
environment that hides the problem. Nothing in the ordinary build-and-run loop
tests the unsourced case.
colcon bakes the build path into the install space
A colcon install space does not relocate on its own. install/setup.sh,
install/local_setup.sh, and each package’s share/<pkg>/package.sh default
COLCON_CURRENT_PREFIX to the absolute path the install space was built at.
Deliver that tree to a different path and the scripts still resolve packages
against the build machine’s directory, which does not exist on the robot.
The failure does not name a shared library, so it does not read like anything
else in this guide. Packages simply do not resolve, and LD_LIBRARY_PATH stays
unset or wrong.
Export the new prefix before you source, in the wrapper script the bundle ships:
#!/usr/bin/env bash
set -e
THIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export COLCON_CURRENT_PREFIX="$THIS_DIR/install"
source "$THIS_DIR/install/setup.bash"
set -uo pipefail # only AFTER the source
exec ros2 run <pkg> <executable> "$@"
set -u goes after the source, never before it. ROS 2’s setup.bash reads
AMENT_TRACE_SETUP_FILES without setting it, so under nounset the wrapper
dies before it reaches your program. docs/operating/atc-toml.md carries the
same rule for a wrapper that sources someone else’s environment.
$THIS_DIR resolves at run time, so the wrapper works at whatever path the
delivery lands on. This is the same reasoning as an $ORIGIN rpath, applied to
a shell script rather than to an ELF.
Delivered trees are read-only, so the wrapper cannot rewrite the install space in place at first start. The export is the only route.
Stop it at the gate: an install.exit hook
Verify the tree on the device, before the version pointer flips.
Use install.exit, not check.*. check is an observation-only probe that
runs before any byte is transferred, so the incoming tree does not exist yet.
By install.exit a bundle’s bytes are in versions/<sha>, the flip did not
occur yet, and a non-zero exit vetoes the delivery. The device keeps running the
previous version.
scripts/verify_libs.sh, inside the bundle:
#!/bin/bash
# install.exit — refuse a tree whose binaries cannot resolve their libraries.
set -euo pipefail
# Every tool this gate depends on, checked before anything is judged. A gate
# that cannot run must veto the delivery, never approve it. Absent `file`, the
# find predicate matches nothing and the script would exit 0 over an unscanned
# tree; absent `ldd`, every ELF would read as resolved.
for tool in python3 find file readelf ldd; do
command -v "$tool" > /dev/null 2>&1 ||
{ echo "verify_libs.sh: $tool not found on device" >&2; exit 1; }
done
root="$(printf '%s' "${ATC_HOOK_CONTEXT:-}" |
python3 -c 'import json,sys; print((json.load(sys.stdin).get("self_root") or {}).get("new") or "")')"
[ -n "$root" ] || { echo "no self_root.new in ATC_HOOK_CONTEXT" >&2; exit 1; }
# Sweep EVERY ELF file, not only the executable ones. A shared library is
# conventionally mode 0644, so a `-perm -u+x` filter would skip the exact file
# class this check exists to catch.
#
# The list goes through a file rather than a process substitution. A process
# substitution that fails only sends EOF, so the loop body never runs, `bad`
# stays 0, and a discovery failure reads exactly like a clean tree.
#
# NUL-separated, because a newline in a delivered file name would otherwise
# split one path into two names that resolve to nothing, and the tree would be
# refused for a fault it does not have.
list="$(mktemp)"
trap 'rm -f "$list"' EXIT
find "$root" -type f -print0 > "$list" ||
{ echo "could not scan $root" >&2; exit 1; }
bad=0
while IFS= read -r -d '' f; do
# Classify HERE, not in a find predicate. As `-exec sh -c 'file -b "$1" |
# grep -q ELF'` the pipeline reports grep's status, so a file that `file`
# could not read looks the same as a file that is not an ELF: the predicate
# is false either way, `-print0` is suppressed, and find still succeeds. The
# unreadable file would leave the sweep in silence, which is the one outcome
# a gate must never produce.
#
# Readability is tested directly rather than inferred. `file -b` prints
# "regular file, no read permission" on STDOUT and still exits 0, so a
# status check alone would let an unreadable file leave the sweep wearing
# the same answer as a plain text file.
if [ ! -r "$f" ]; then
echo "cannot read $f" >&2
bad=1
continue
fi
if ! kind="$(file -b "$f" 2> /dev/null)"; then
echo "could not classify $f" >&2
bad=1
continue
fi
case "$kind" in
*ELF*) ;;
*) continue ;;
esac
# Check the interpreter BEFORE calling ldd. `ldd` may execute an object that
# names a non-standard one, which would run that code as the hook user. An
# unexpected interpreter in a delivered tree is itself worth stopping on, so
# refuse rather than skip: this is a gate, and it fails closed.
#
# A readelf FAILURE is judged apart from an empty result. Discarding its
# stderr and reading the empty string as "no interpreter" would send an
# object that could not be parsed straight on to `ldd`, which is the call
# this ordering exists to protect.
if ! interp_header="$(readelf -l "$f" 2> /dev/null)"; then
echo "could not read the program headers of $f" >&2
bad=1
continue
fi
interp="$(printf '%s\n' "$interp_header" |
sed -n 's/.*program interpreter: \([^]]*\)\]/\1/p')"
case "$interp" in
# An empty value here is a genuine absence, not a failure: a shared
# library and a static binary both carry no PT_INTERP.
'' | /lib/ld-linux*| /lib64/ld-linux* | /lib/ld-musl*) ;;
*)
echo "$f names a non-standard interpreter: $interp" >&2
bad=1
continue
;;
esac
# `ldd` exits non-zero on a static object, which is a normal result. Every
# other non-zero exit means the file was never inspected, so it counts
# against the tree instead of passing as resolved.
ldd_out="$(ldd "$f" 2>&1)" && ldd_status=0 || ldd_status=$?
if [ "$ldd_status" -ne 0 ]; then
case "$ldd_out" in
*"not a dynamic executable"*) continue ;;
*)
echo "could not inspect $f: $ldd_out" >&2
bad=1
continue
;;
esac
fi
if printf '%s\n' "$ldd_out" | grep -q 'not found'; then
echo "unresolved libraries in $f" >&2
printf '%s\n' "$ldd_out" | grep 'not found' | sed 's/^/ /' >&2
bad=1
fi
done < "$list"
[ "$bad" -eq 0 ] || { echo "refusing the delivery" >&2; exit 1; }
echo "every ELF file resolves its libraries"
Declare it in atc.toml:
[[bundle]]
name = "app"
source = "install"
[bundle.lifecycle.hook.install.exit]
script = "@self/scripts/verify_libs.sh"
timeout_secs = 120
Three limits to understand before you rely on it.
It measures the device’s environment, not the workload’s. A container changes
the mount point, the base image, and /etc/ld.so.cache. A binary that resolves
on the host can still fail inside the container. Where the workload runs in a
container, run the same ldd sweep in that container instead.
ldd reports what the loader would do for that object alone. It obeys the
same non-transitive RUNPATH rule described above. That is what makes it a
faithful check, not an optimistic one.
ldd can execute what it inspects. Some versions run the program to collect
the dependency list. That runs the code of the ELF interpreter the object names,
whenever the object names one other than ld-linux.so. ldd(1) states this and
tells you never to run it on an untrusted executable. The hook runs as the hook
user on the device, so treat a vendor blob you did not build as untrusted.
The recipe above reads the interpreter first. It refuses any object that names an
unexpected one. That guard is what lets it call ldd on a tree it did not build.
For an object you do not trust, parse instead of resolve:
objdump -p <file> | grep NEEDED
readelf -d <file> | grep -E 'NEEDED|RPATH|RUNPATH'
Both read the file and run nothing. They report direct dependencies only, so
they answer a narrower question than ldd. Walk the tree yourself, or use a
parse-only resolver such as lddtree from pax-utils, when you need the whole
dependency graph from untrusted input.
Diagnostic checklist
| Question | Command |
|---|---|
| What does the binary ask for, and where does it look? | readelf -d <file> | grep -E 'RPATH|RUNPATH|NEEDED' |
| Which directories did the loader actually try? | LD_DEBUG=libs <binary> 2>&1 | head -60 |
| Does anything fail to resolve? | ldd <file> | grep 'not found' (see the ldd limit above) |
| The same, without running anything | objdump -p <file> | grep NEEDED (direct dependencies only) |
| Is the failing library a direct dependency? | readelf -d <binary> | grep NEEDED — absent means it arrives through another library |
| Does the tree on the device match what you inspected? | readlink -f <artifact>/current |
Run every one of these in the environment the workload runs in. A check on the build machine answers a different question.
Device location
atcd can send the position of a device to atc-server, which shows it on the
console map. atcd does not read GPS hardware. A process of yours sends the
position to atcd with the command atcd attr set-location.
Only an enrolled device that pulls from atc-server sends a position.
Send a position with atcd attr set-location
atcd attr set-location sends one position to the running atcd over the
control socket, as atcd attr set does. atcd checks the position and applies
the location.* send rules. A queued position goes out in the next report
and shows on the console map. A throttled position waits until the interval
ends. atcd does not send a too_close or an unchanged position. See
Outcomes and exit codes.
atcd attr set-location --lat 39.7392 --lon -104.9903 --accuracy-m 8
| Option | Required | Meaning |
|---|---|---|
--lat | yes | Latitude in degrees, from -90 to 90. |
--lon | yes | Longitude in degrees, from -180 to 180. |
--accuracy-m | no | The accuracy estimate in metres, 0 or more. |
--timestamp | no | When the position was measured, RFC 3339. If it is absent, atcd uses the time it read the request. |
--socket | no | The control socket. The default is /run/atcd/atcd.sock. |
Add the global option --json for a JSON answer: {"outcome": "queued"}.
One call for each location.min_interval_secs is enough. Inside the interval,
atcd holds the newest position. When the interval ends, atcd sends it if it
moved far enough. A more frequent call replaces the held position. Each call
is one process start and one connection.
Who can run it
The command needs access to the control socket. The socket has mode 0660.
On a non-root atcd, root and the members of the group of the daemon user have
access. Use the least privilege that works:
- Run the sender as root or as the daemon user (
atcby default). - Or give the GPS user one sudo rule. It lets that user run only this subcommand as the daemon user:
<gps-user> ALL=(<daemon user>) NOPASSWD: /usr/local/bin/atcd attr set-location *
The path is daemon.remote_path, /usr/local/bin/atcd by default. On a root
atcd, bootstrap gives the control socket to the SSH login user of the install,
with the group root. So on a root atcd the sudo rule names root, or the
sender runs as that login user.
Adding the GPS user to the group of the daemon user also gives access. It gives much more.
A member of that group can run every operation that the control
mode allows. On a direct_allowed device, this includes pushes, activations
and recovery. On a server_only device, it includes diagnostics, atcd attr
and atcd attr set-location. The group is also the group of the store. Under
hook isolation, the install gives the group %<group> ALL=(<hook user>) NOPASSWD: ALL. So a member can run any command as the hook user, whatever the
control mode.
Do not use the group for a sender that only needs to send a position.
Outcomes and exit codes
On success the command prints position sent to atcd: <meaning>:
| Outcome | Meaning |
|---|---|
queued | queued, goes out with the next report |
throttled | held until location.min_interval_secs has passed since the last send, then checked against location.min_distance_m again |
too_close | not sent, closer than location.min_distance_m to the last send |
unchanged | not sent, the same as the last send |
| Exit code | Meaning |
|---|---|
| 0 | atcd took the position. Any outcome gives 0. |
| 4 | atcd refused it. Causes: a bad position, for example atcd refused: lat must be from -90 to 90, got 95; location.enabled is false; this atcd runs no location reader because it does not pull from atc-server; or an atcd that has atcd attr but not this command, which answers “unknown attributes verb 4; update atcd”. |
| 3 | Nothing accepts a connection on the socket. |
| 5 | atcd accepted the connection and did not answer: a timeout or a hang-up. |
| 1 | atcd could not run it, or the atcd is older than atcd attr. A fault means the location input of atcd is busy, gone or stopped, or did not answer in 10 s. In the last case atcd may still send the position. |
After an upgrade
An earlier dev build read positions from a location socket,
/run/atcd/location.sock, and from the command atcd location set. Both are
gone. A connect to a leftover location.sock fails with “connection refused”.
Move the sending process to atcd attr set-location.
The earlier build also left three things on a device. They grant and hold nothing now. Install and update never create or remove them. Uninstall removes the drop-in with the unit, and keeps the group and the folder:
- the group
atc-location; - the drop-in
atc-location-group.confin/etc/systemd/system/<service>.service.d/; - the folder
/var/lib/atc-location.
In these paths and commands, <service> is the daemon.service_name of the
device, atcd by default. An install by the socket release named the group in
the main unit, /etc/systemd/system/<service>.service, on its
SupplementaryGroups= line. Only
an update of an older unit added the drop-in. A device can have either one.
atc bootstrap atcd update keeps the group and the drop-in. A unit that names
the group fails to start without it, so do not delete the group alone. To
remove the group, use this order:
- If the drop-in
atc-location-group.confis there, delete it. - Take
atc-locationout ofSupplementaryGroups=in the main unit. - Run
sudo systemctl daemon-reload. - Run
systemctl show -p SupplementaryGroups <service>. If the command fails, or its output still namesatc-location, stop here. Find the file that names the group, fix it, and go back to step 3. - Restart atcd:
sudo systemctl restart <service>. - Run
sudo groupdel atc-location.
The folder can hold files of your own. To remove it, stop the process that
wrote position.json. Delete position.json and position.json.tmp. Remove
the folder only if it is empty.
Settings
Four device settings control what atcd sends. They are not in atc.toml.
| Setting | Default | Meaning |
|---|---|---|
location.enabled | true | Set it to false to stop the device from sending a position. atcd then refuses each position with a reason. |
location.min_interval_secs | 60 | atcd sends a position at most once in this many seconds. |
location.min_distance_m | 25 | atcd does not send a move shorter than this many metres. |
location.precision_decimals | 5 | atcd rounds lat and lon to this many decimal places before it sends them. 5 is about 1 m, 3 is about 100 m. The maximum is 7. |
atcd applies the rules in this order: it rounds the position, checks the interval, then checks the distance.
An enrolled device refuses atc ota settings set from a laptop, because it
accepts changes only from the server. So set a value on the device, as the
account that runs atcd (atc by default):
sudo -u atc atcd settings set location.min_interval_secs=300
atcd reads its settings only when it starts. Restart it to apply the value:
sudo systemctl restart atcd
An earlier release read the position from a file, set by location.file. atcd
reads no file now, and skips a stored location.file row.
What the server does
atcd sends the position in the location section of its report. atc-server
keeps only the latest position of each device. A position with an older
timestamp than the stored one does not replace it.
When location.enabled is false, atcd stops sending new positions. The
server keeps the last position it stored.
An organization can turn location off for all its devices. The server then deletes the stored positions, drops new ones, and hides the map. The device still sends its reports.
An atc-server older than this feature accepts the report and drops the
position. It does not acknowledge the section. atcd then holds the position
for one hour. In that hour it sends no new position to that server. After the
hour it sends the newest position in one report, and holds again. The counter
atcd.pull.location_unacked_total counts each such report.
Telemetry
atcd.location.read_total{outcome} counts each position once, by the outcome
atcd answered: queued, throttled, too_close, unchanged, parse_error
and disabled.
It counts disabled once at start, and once for each position refused for
it.
The map
The console map uses OpenStreetMap tiles. It shows the credit “© OpenStreetMap contributors”.
Error codes
Most atc failures print a stable ATC-Exxxx code. Some print
plain error: text with no code: a command-line usage error, such
as an unknown flag or a missing subcommand, which exits with status
2, and a failed atc --release-notes, which exits with status 1.
The chapters
below group the codes the CLI assigns by the part of the workflow
they come from. The same lookup works offline on any machine with
atc installed:
atc error explain <code>
- Configuration (atc.toml) — 11 codes
- Push, versions and deliveries — 18 codes
- Devices and connectivity (bootstrap, ssh) — 19 codes
- OCI images and builds — 24 codes
- Internal (store, protocol, I/O) — 9 codes
Code ranges
A code in none of the chapters comes from a lower layer. The range
names its owner; atc error explain gives the same answer offline:
| Range | Assigned by |
|---|---|
| ATC-E00xx | atc-protocol |
| ATC-E01xx | atc-store |
| ATC-E02xx | atc-protocol |
| ATC-E03xx | atc-transport or atc-store |
| ATC-E04xx | atc-errors |
| ATC-E05xx | atc-config |
| ATC-E06xx | atc-config |
| ATC-E07xx | atc-server or atc-config |
Configuration (atc.toml)
Messages below show representative example values (paths, targets, names); the real message carries yours.
ATC-E0400
no atc.toml or atc.kdl in /tmp or any parent directory
- exit code:
3 - fix:
atc ota init
ATC-E0401
atc.toml is malformed: bad toml
- exit code:
3
ATC-E0402
bundle source directory does not exist: /tmp/missing
- exit code:
3 - fix:
mkdir -p '/tmp/missing' # or fix the path in atc.toml
ATC-E0403
nothing to do: atc.toml declares no artifacts
- exit code:
3 - fix:
add an artifact to atc.toml, e.g. a [[bundle]] with name = "app" and source = "./dist"
ATC-E0404
--bundle "app" not found in atc.toml
- exit code:
3 - fix:
edit atc.toml
ATC-E0406
$EDITOR not set and --non-interactive is required to skip editor
- exit code:
3 - fix:
export EDITOR=vim
ATC-E0424
./atc.toml already exists. Edit it, or pass --force to replace it
- exit code:
3 - fix:
atc ota init --force # or edit ./atc.toml
ATC-E0436
selector references an artifact not declared in atc.toml: kind=oci name=backend
- exit code:
3 - fix:
add backend to atc.toml
ATC-E0468
hook activate.exit on bundle app: script scripts/start_service.sh is not in the artifact. Add the file to the artifact source, or correct the script path in atc.toml
- exit code:
3
ATC-E0481
control plane setting: no server token: pass --token <t> or set ATC_SERVER_TOKEN
- exit code:
3
ATC-E0500
config: atc.toml not found
- exit code:
3 - fix:
create atc.toml, or correct the -c path
Push, versions and deliveries
Messages below show representative example values (paths, targets, names); the real message carries yours.
ATC-E0411
invalid version label "bad label": leading dot
- exit code:
3 - fix:
atc ota push --version <label> # A-Z a-z 0-9 . _ -, 1-64 chars, no leading dot
ATC-E0418
--capture-hooks is not supported with --json (the single-document JSON surface streams no per-hook events); use --progress=json for a streamed JSON surface with hook capture, or drop --json
- exit code:
3 - fix:
atc ota push --progress=json
ATC-E0422
device busy: another push session holds the artifact lock (finalizing or awaiting reap); retry shortly. locked by push-1
- exit code:
2
ATC-E0425
no committed delivery matches delivery ID prefix "01J3ABCDEF45"
- exit code:
3 - fix:
atc ota ls <target>
ATC-E0426
delivery ID prefix "01J3ABCDEF45" is ambiguous; candidates: ["01J3ABCDEF45A", "01J3ABCDEF45B"]
- exit code:
3 - fix:
atc ota rm <target> --sha <longer-delivery-id-prefix>
ATC-E0427
atcd is incompatible with delivery-ID removal: requested "01J3ABCDEF45", echoed ""; upgrade atcd or use channel + version-label removal
- exit code:
4 - fix:
upgrade atcd, or run: atc ota rm <target> <channel> <version-label>
ATC-E0441
multi-artifact attestation: positional <artifact> required
livox-fw
- exit code:
2 - fix:
atc ota attest <target> <peripheral> <artifact>
ATC-E0451
the delivery switch is still in progress on the device; the client stopped waiting. Verify the result with `atc ota ls`.
- exit code:
2 - fix:
atc ota ls <target>
ATC-E0452
delivery removal refused: it is the channel's active delivery; pass --force to roll back first
- exit code:
3
ATC-E0454
switch refused: daemon rejected activate delivery: version "v1" matches 2 deliveries
- exit code:
3 - fix:
atc ota ls <target>
ATC-E0455
atcd is too old for the whole-delivery switch (it did not recognize the request); `atc bootstrap atcd upgrade <target>` upgrades it
- exit code:
4 - fix:
atc bootstrap atcd upgrade <target>
ATC-E0460
atc and atcd are incompatible: controller v0.9.0 cannot talk to daemon v1.2.0: unsupported proto_version 1 (supported 2..=2); upgrade the controller (atc) to match this daemon. Fix with `atc upgrade`
- exit code:
3 - fix:
atc upgrade
ATC-E0463
local test mode: no atcd binary found. `--local` runs a real atcd against a local store root, so it needs one. Run `atc bootstrap atcd fetch` to download the atcd for this machine. Or put atcd next to this atc binary, put it on PATH, or name it with ATC_LOCAL_ATCD
- exit code:
3
ATC-E0465
boot recovery holds this artifact and nothing running on the device will release it, so the push cannot start. If the reject says pending peripheral recovery, run `atc ota recover <target>`: it reports what each push owes and settles what it can. Add `--accept-unknown-state` to clear a hold no payer will settle. If the reject says owed reactivation, restart atcd to run recovery again. dependency rootfs is held by push p-boot pending peripheral recovery; peripherals: arm-a
- exit code:
3 - fix:
atc ota recover <target>
ATC-E0466
downloaded the atcd for 'linux-x86_64-musl' but could not write it anywhere
- exit code:
3 - fix:
export ATC_LOCAL_ATCD=<path/to/atcd> # or re-run the fetch on another channel: atc bootstrap atcd fetch --track <track>
ATC-E0467
--summary-lines conflicts with --progress=json (both write to stdout, and the summary surface carries no event dispatcher); keep --progress=json for the streamed events, or drop it for the one-line-per-artifact summary
- exit code:
3
ATC-E0469
push refused: bundle tools: bin/jq needs glibc 2.39, and the robot runs 2.31.
It cannot start there: the loader has no GLIBC_2.39 to bind.
Build it against the robot's glibc, build it static, or pass --allow-glibc-skew.
- exit code:
3
ATC-E0476
the daemon refused the rm request: control_mode server_only: this device accepts changes only from its server. To allow direct control, run `atcd settings set server.control_mode=direct_allowed` on the device as the atcd user. Then restart atcd.
- exit code:
3 - fix:
follow the daemon's reason in the error message
Devices and connectivity (bootstrap, ssh)
Messages below show representative example values (paths, targets, names); the real message carries yours.
ATC-E0303
transport: ssh exited Some(255) before connection:
- exit code:
2
Shared code: shown here with its atc-transport meaning; as an atc-store error the same code has a different meaning and exit class.
atc error explaindetails both.
ATC-E0408
atcd on robot-7 is not serving after bootstrap: no live listener. Inspect daemon logs on the robot: `ssh -t robot-7 'sudo journalctl -u <atcd-service> --no-pager -n 200'`. Replace `<atcd-service>` with the device's `daemon.service_name` (`atcd` when it is not set).
- exit code:
4 - fix:
ssh -t robot-7 'sudo journalctl -u <atcd-service> --no-pager -n 200'
ATC-E0415
no atcd binary found
- exit code:
3 - fix:
atc bootstrap atcd install <target> --from <path/to/atcd>
ATC-E0416
privileged install on robot-7 failed: useradd failed
- exit code:
4 - fix:
ssh robot-7 sudo journalctl -n 100 --no-pager
ATC-E0417
netcheck found 1 blocking readiness failure(s):
low disk
- exit code:
3 - fix:
atc ota netcheck <target>
ATC-E0419
bootstrap preflight on robot-7 failed: no systemd
- exit code:
4 - fix:
atc shell robot-7
ATC-E0420
atcd on robot-7 is up, but its control socket is not reachable by the SSH login user (permission denied). A root install owns the socket root:root, and the controller opens it as the account you SSH in as. Grant the socket to that account: set [daemon] socket_owner in atc.toml (or re-run the install with an explicit user@host target). detail: EACCES
- exit code:
3 - fix:
set [daemon] socket_owner = "<ssh-login-user>" in atc.toml, then: atc bootstrap atcd install --root robot-7
ATC-E0421
ssh authentication rejected for robot-7: Permission denied (publickey).
- exit code:
2 - fix:
ssh-copy-id robot-7 # or re-run without --non-interactive to allow password auth
ATC-E0423
disk budget exceeded on robot-7: remote reported no space left on device during bootstrap
- exit code:
3 - fix:
free up disk space on robot-7, then re-run the push (auto-bootstrap will retry)
ATC-E0461
cannot reach device through the tunnel: device is not connected to the tunnel
- exit code:
1
ATC-E0470
atcd on robot-7 is installed, but the SSH login user cannot run it: exec of /usr/local/bin/atcd was denied. This is not an ssh authentication failure. The usual cause: that path links into the store, and the store root (/var/atc, mode 0710) is in a group that account is not in, so the account cannot traverse to the binary. Check with `ls -ld /var/atc` and `id`; if the group is the gap, re-run `atc bootstrap atcd install --root <user>@<host>` as the account you SSH in as (the install hands the store's group to that account), or on the device: chgrp $(id -gn <ssh-login-user>) /var/atc. The same text also comes from a binary without its execute bit or a store on a noexec mount; check `ls -l` on the resolved path and `findmnt -T /var/atc`. detail: bash: /usr/local/bin/atcd: Permission denied
- exit code:
3 - fix:
atc bootstrap atcd install --root robot-7 # grants the login user store traverse
ATC-E0472
release server: fetch and verify release index: fetch https://releases.example.com/stable/index.json: network unreachable
- exit code:
1
ATC-E0473
release verification: fetch and verify release index: index signature: signature verification failed
- exit code:
1
ATC-E0474
control plane: list orgs: HTTP request failed: error sending request for url (https://atc.example.com/v1/admin/orgs): tcp connect error: Connection refused
- exit code:
1
ATC-E0477
stale provisioning lock on ops@robot-7: a provisioning install that is no longer running holds /etc/atc/.enroll.lock
- exit code:
3 - fix:
atc bootstrap atcd install ops@robot-7 <the same flags> --clear-stale-lock # repeat the refused command with --clear-stale-lock
ATC-E0478
atc diagnose wrote its bundle directory, then could not send it to Agency. The bundle is not lost: it is still in the directory that the message names. To try again, run atc diagnose --yes, with the same target if you gave one. That writes a new bundle and sends it. Or share the directory with Agency support.
the diagnostic bundle was written but not sent: uploading the bundle failed: no complete answer within 860s. It is still at ./atc-diagnose-01K6Z8Q4M2V7XRB3T9N5HJWCDE
- exit code:
1 - fix:
atc diagnose --yes # or share the bundle directory with Agency support
ATC-E0479
not confirmed: refusing to uninstall atcd from robot-1 without confirmation: stdin is not a TTY. Pass --yes to skip the prompt.
- exit code:
3
ATC-E0480
atcd is not serving on robot-1: no control socket; install atcd with your provisioning system, or set [bootstrap].auto = true
- exit code:
3 - fix:
atc bootstrap atcd install robot-1
ATC-E0482
--from ./atcd is a x86_64 atcd, but the device is aarch64
- exit code:
3 - fix:
atc bootstrap atcd upgrade <target> --from <path/to/atcd>
OCI images and builds
Messages below show representative example values (paths, targets, names); the real message carries yours.
ATC-E0412
no cached OCI image for artifact "backend": run `atc oci import acme/backend:1.4`
- exit code:
3 - fix:
atc oci import acme/backend:1.4
ATC-E0428
OCI daemon unavailable (podman): socket refused
- exit code:
3 - fix:
systemctl status podman # or start the podman daemon
ATC-E0429
multi-arch image at name=app tag=latest resolved to multiple digests; re-run with an explicit digest. candidates: ["sha256:aaa", "sha256:bbb"]
- exit code:
3 - fix:
atc oci inspect app@<digest>
ATC-E0430
multi-arch image at name=app tag=latest resolved to multiple platforms; re-run with --platform <os/arch> (e.g. --platform linux/amd64). available platforms: ["linux/amd64", "linux/arm64"]
- exit code:
3 - fix:
atc oci import app:latest --platform <os/arch>
ATC-E0431
OCI layout invalid at /tmp/layout: missing index.json
- exit code:
3 - fix:
atc oci gc # or re-import the image
ATC-E0432
OCI blob corrupt: digest sha256:aaa, observed sha256:bbb
- exit code:
3 - fix:
atc oci gc # or re-import the image
ATC-E0433
could not resolve laptop cache root: no XDG_DATA_HOME
- exit code:
3 - fix:
export XDG_DATA_HOME=<writable-dir>
ATC-E0434
OCI artifact requires a writable laptop cache (XDG_DATA_HOME); got: read-only fs
- exit code:
3 - fix:
export XDG_DATA_HOME=<writable-dir>
ATC-E0435
OCI artifact not in laptop cache: name=app ref_or_digest=v1
- exit code:
3 - fix:
atc oci import app:v1
ATC-E0437
no build engine on PATH (tried: ["buildah", "docker"]). install buildah (https://github.com/containers/buildah) or enable docker buildx.
- exit code:
3 - fix:
brew install buildah # or: docker buildx install
ATC-E0438
buildah build failed (exit 1); stderr tail:
syntax error
- exit code:
3 - fix:
atc oci build <reference> --verbose-build
ATC-E0439
build output invalid: empty layer list
- exit code:
3 - fix:
atc oci build <reference> --verbose-build
ATC-E0445
skopeo not found; run `atc upgrade` to provision the bundled skopeo, or install one: https://github.com/containers/skopeo/blob/main/install.md
- exit code:
3 - fix:
atc upgrade # provisions the bundled skopeo
ATC-E0446
podman not found on PATH; see https://podman.io/docs/installation
- exit code:
3 - fix:
brew install podman # or: sudo apt install podman
ATC-E0447
push to registry.example.com/app failed (exit 1): auth failed
- exit code:
3 - fix:
atc oci push <reference> registry.example.com/app
ATC-E0448
short digest prefix "abc123" is ambiguous; candidates: ["abc123def", "abc123fed"]
- exit code:
3 - fix:
atc oci inspect <full-digest-or-longer-prefix>
ATC-E0449
bare name "app" has multiple tags; specify one of: ["app:v1", "app:v2"]
- exit code:
3 - fix:
atc oci inspect app:<tag>
ATC-E0450
tag "app:v1" already points at "sha256:aaa"; pass --force to overwrite
- exit code:
3 - fix:
atc oci tag <src> app:v1 --force
ATC-E0458
registry authentication failed (registry.gitlab.com): unauthorized: HTTP Basic: Access denied
- exit code:
3 - fix:
docker login registry.gitlab.com # or: podman login / skopeo login
ATC-E0459
OCI import failed (skopeo): manifest unknown
- exit code:
3
ATC-E0462
cannot read containerfile /home/dev/app/Containerfile: No such file or directory (os error 2)
--file app/Containerfile is relative to the current directory (/home/dev), not to the build context
- exit code:
3 - fix:
atc oci build <reference> --file <containerfile> --context <dir>
ATC-E0464
local docker image registry.example.com/org/app:v1 is incomplete: skopeo copy failed: determining manifest MIME type for docker-daemon:registry.example.com/org/app:v1: loading tar component "blobs/sha256/5f4d0288d52e": file does not exist
the docker export names a blob docker does not have; re-pull or rebuild the image in docker, or import it from its registry if it came from one
- exit code:
3 - fix:
atc oci import registry.example.com/org/app:v1 # pull from the registry, without --docker
ATC-E0471
"db" matches the image ["db:1"] and the digest prefix of ["sha256:db99"]. Pass one listed digest as sha256:<hex>, or one listed image as db:<tag>
- exit code:
3 - fix:
atc oci inspect sha256:<hex> # or: atc oci inspect db:<tag>
ATC-E0475
a running atcd owns the store at /var/atc. atc does not open it, because a change there skips the daemon's hooks and gates. To remove a channel on this device, run `atc ota rm --socket <atcd-socket> <channel> --purge`. For a laptop cache, set ATC_DATA_HOME to another directory.
- exit code:
3
Internal (store, protocol, I/O)
Messages below show representative example values (paths, targets, names); the real message carries yours.
ATC-E0001
protocol: frame too large (999 bytes, max 100)
- exit code:
1
ATC-E0100
store: push failed: Materialize("ATC-E0100: I/O error: Operation not permitted (os error 1)"); artifact=app
- exit code:
1
ATC-E0102
store: schema migration failed at step 3: bad schema
- exit code:
1
ATC-E0442
protocol: broken pipe
- exit code:
1
ATC-E0443
The catch-all for a command failure that has no more specific code. The failure is not always in the local store: the text after store: names the step that failed and its cause, and that text is the lead. A failure to download or verify an atc update has its own code, ATC-E0472 or ATC-E0473. A control plane that atc cannot reach has its own code, ATC-E0474. A failed atc diagnose send has its own code, ATC-E0478.
store: push failed: lifecycle veto at commit.enter
- exit code:
1
ATC-E0444
io: disk full
- exit code:
1
ATC-E0453
io: failed to write output: stdout closed
- exit code:
1
ATC-E0456
internal error: no daemon-ensure behavior was wired in for an SSH-path command (socket is None); this is a caller bug, not a daemon response
- exit code:
1
ATC-E0457
internal error: resolve_connect_target was called with no SSH target and no --socket; this is a caller bug, not a daemon or config problem
- exit code:
1
Migrating to atc from Mender or balena
Who this is for: you run an existing OTA/fleet-update system — Mender or balenaCloud — and you want to move your update pipeline onto atc. This guide maps each platform’s concepts onto atc’s, walks the device and CI migration step by step, and is explicit about what atc does not replace today so you can plan for it.
Read onboarding.md first if you have never pushed an update
with atc — this guide assumes you’ve seen that flow once. Nothing here requires
this repository; you work with the released atc binary.
The atc model, mapped to what you already know
atcis a controller CLI on your machine (or CI runner). It delivers artifacts to a device over SSH — there is no always-on agent connection to a cloud backend, and no server between you and the device.atcdis the on-device daemon. It owns a content-addressed store at/var/atcand applies updates atomically. It does not poll anything; it acts when the controller contacts it.- An artifact is either a bundle (a directory tree — your app, config,
systemd units, scripts) or an OCI image (a container image). You declare
them in an
atc.tomlmanifest. - Every push is versioned and atomic: bytes are staged first, and the
active version flips only when you say so (
--switch). Rollback is a flip back to a version already on the device — nothing is rebuilt or re-downloaded. - Delta transfer is built in and automatic. The daemon announces what content it already has; the controller sends binary diffs (rsync-style or bidiff, chosen per blob) against it. There is no delta artifact to generate in CI, no read-only-rootfs requirement, and no commercial gate.
- Lifecycle hooks (scripts shipped inside your artifact) run at defined states — download, install, commit, rollback, failure — replacing Mender state scripts and the “what happens on update” half of balena’s supervisor.
What atc does not replace (plan for these up front)
Both Mender and balena bundle concerns that atc leaves to you. Be honest with yourself about which of these you actually use:
| Concern | Mender / balena answer | With atc |
|---|---|---|
| Host OS / full rootfs updates | Mender A/B partitions; balena HUP | Not today. atc delivers application-level artifacts, and no atc command pushes an OS. Keep your existing OS provisioning (your Yocto/Debian pipeline, or a stock distro image); atc runs on top of it. This is the one row that is moving, so read Where the OS tier stands before you plan around it. |
| Device connectivity | balena Cloudlink VPN; Mender websocket add-ons | SSH reachability is your job. Devices on LANs or behind NAT typically need an overlay network (WireGuard, Tailscale, …). If you can ssh user@device, atc works. |
| Remote terminal / port forward / public URLs | Add-ons / built-in | Plain SSH (and atc shell for a hook-identity shell on bootstrapped devices). No HTTP ingress to devices. |
| Cloud-held device variables | balena fleet/device variables; Mender Configure | Config ships as content: put env files or config trees in a bundle and version them with everything else. No out-of-band variable store to drift. |
| Hosted dashboard / device inventory | Web UIs | CLI: atc ota status, atc ota ls, --json for machines. |
| Autonomous update agent | Devices poll and self-update | Push model. Nothing changes on a device unless you push to it. Fleet rollout is a loop over your device list in CI — which also means phased rollout, canaries, and maintenance windows are ordinary CI logic, not platform features. |
If your product depends hard on one of the left-column items (e.g. you ship full-OS images to devices you can never re-provision), migrate the application layer to atc and keep your OS-update mechanism for the rare base-image change. The two coexist fine.
Where the OS tier stands
The first row of that table used to be a settled design position. atc ran above the OS and meant to stay there. That position is going away, slowly, so here is exactly what exists.
A device boots two root filesystem slots. It spends one trial boot on the new
slot. When nothing confirms that boot, the bootloader puts the old slot back,
with no help from userspace. One qemu guest ran that sequence on 2026-09-17,
from a Yocto image with a GRUB slot layout. The layer is a laboratory result
and not part of a release, and no hardware target has the layout. No atc
command drives any of it, and there is no date.
Plan your migration on the table above. Nothing else in this guide depends on that work landing.
Device requirements
You need SSH access (key auth), passwordless sudo for the one-time bootstrap,
and systemd as PID 1 (or the
no-systemd recipe).
atcd is a static musl binary for x86_64 and aarch64 Linux.
If your devices don’t run systemd as PID 1 (containers, minimal images),
expect a real operational difference from Mender’s systemd-integrated setup.
atc bootstrap cannot stage a unit to restart atcd for you, and the
no-systemd recipe’s runtime dir lives on tmpfs, so it does not survive a
restart. Bake the recipe’s mkdir + atcd run lines into your container
entrypoint or init script so they re-run on every device start. Skip that and
the first push after a reboot fails with an unreachable daemon.
Part 1 — Migrating from Mender
Concept map
| Mender | atc equivalent |
|---|---|
| Mender Server (hosted or self-hosted) | None needed — the controller talks straight to devices over SSH |
| mender-authd / mender-updated daemons | atcd (one daemon; installed by atc bootstrap) |
| Device authentication (auth sets, JWT, preauthorization) | Your SSH keys. Provisioning a device = installing your deploy key; no accept/reject queue |
Artifact (.mender file) | A push from an atc.toml manifest — bundles and/or OCI images. No artifact file to build, sign, or upload; content is chunked and sent directly |
Artifact name (e.g. release-v2) | Version label at push time: --version v2 (or -t name@v2) |
| Artifact provides/depends | Not needed — the daemon’s store is content-addressed; delta and compatibility bookkeeping is automatic |
| Device type compatibility | Not enforced by atc — target the right devices from your rollout list (and build per-arch OCI images with platform =) |
Update modules: file, directory, deb | A bundle (ship the tree; a hook runs dpkg -i or whatever the module did) |
Update modules: docker, docker-compose “application” | An [[oci]] artifact + a commit hook that loads/restarts the container |
Update module: rootfs-image | No equivalent today. Keep your OS pipeline, and read Where the OS tier stands |
| State scripts | Lifecycle hooks (mapping below) |
| Deployments, static/dynamic groups, phased rollout | A loop over your device inventory in CI; phases/canaries are your loop’s logic |
Standalone mode (mender-update install) | The normal atc model — push without any server |
| Delta updates (commercial, xdelta3, read-only rootfs required) | Built-in automatic delta transfer, no preconditions, no license |
| Remote terminal / file transfer / port forward add-ons | SSH / scp / atc shell |
| RBAC, tenants, audit (Enterprise) | Your CI’s access control — pushes come from wherever you run atc |
Step by step
1. Leave the disk alone. A mender-converted or meta-mender-built device
keeps its A/B partitions, bootloader logic, and /data partition after Mender
is removed. You don’t un-convert a disk in the field, and you don’t need to.
atc installs alongside all of it and ignores it. Your currently-booted rootfs
just becomes “the OS”.
2. Install atc and bootstrap the daemon (onboarding.md
steps 1–2). The bootstrap line below needs systemd as PID 1. Check each device
type once with ssh deploy@device 'ps -p 1 -o comm='. It prints systemd when
bootstrap applies. Any other output means you skip bootstrap and take the
foreground recipe in “No systemd as PID 1” below
instead:
curl -fsSL https://atc.dev/install.sh | sh
export PATH="$HOME/.local/bin:$PATH" # installer drops atc here
atc bootstrap atcd install --yes --non-interactive deploy@device
atc ota status deploy@device # handshake = migrated device is reachable
You are most likely porting this into CI, so note that the export above
covers one shell only. A non-interactive runner sources neither ~/.bashrc nor
~/.profile. Set PATH in each step or call the binary by full path
($HOME/.local/bin/atc). See
onboarding.md for the details.
No systemd as PID 1
On a device that doesn’t run systemd as PID 1, bootstrap stops with
ATC-E0419 before it changes anything — it installs atcd as a systemd unit
and there is no unit to install. The error prints the full foreground recipe;
it’s also in
deploy-your-first-update.md.
Start atcd without sudo, as the user you SSH in as, or the next push
fails with ATC-E0420 on a root-owned socket. A root daemon you cannot avoid
takes --socket-owner <login-user> instead. That flag hands the socket to that
one account. Coming from Mender, see the tmpfs caveat under
Device requirements above. The runtime dir doesn’t
survive a restart, so the recipe belongs in your entrypoint.
3. Repackage what your artifacts carried. For each payload type you ship
today, declare an artifact in atc.toml:
[[bundle]]
name = "app"
source = "dist" # what your directory/file update module carried
[[oci]]
name = "worker"
source = "registry.example.com/team/worker:aarch64" # what your docker module carried
platform = "linux/arm64"
There is no version = field and no artifact-name baked into the build —
the version label is assigned when you push.
4. Translate state scripts to lifecycle hooks. Mender’s
<State>_<Enter|Leave|Error> scripts map almost one-to-one onto atc’s
[<scope>.lifecycle.hook.<state>.<phase>] tables, where <scope> is the
artifact type the hook hangs off — bundle for a [[bundle]], oci for an
[[oci]] (full reference: atc-toml.md):
| Mender state script | atc hook |
|---|---|
Download_Enter_* / Download_Leave_* | download.enter / download.exit |
ArtifactInstall_Enter_* / _Leave_* | install.enter / install.exit |
| stopping the outgoing version (Mender leaves this to you) | deactivate.enter / deactivate.exit |
ArtifactCommit_Enter_* (the gate before the switch) | activate.enter |
ArtifactCommit_Leave_* (bring the new version into service) | activate.exit |
| reporting once the delivery is durable | commit.exit |
ArtifactRollback_* | rollback.enter / rollback.exit |
ArtifactFailure_* | failure.enter / failure.exit |
*_Error_* | No equivalent. No state dispatches an error gate, and <state>.error is rejected at push. Report from <state>.exit, or from the unwind hooks rollback.* / failure.* |
| cleaning up as an old version is reclaimed | uninstall.enter / uninstall.exit |
Idle_* / Sync_* (root-fs scripts) | No equivalent. There is no polling daemon state, so run these outside atc (cron, systemd timers) |
atc splits Mender’s ArtifactCommit into three states, because the version
pointer flips partway through: activate.enter runs before it,
deactivate.* tears the outgoing version down, then the pointer flips, then
activate.exit and commit.* run after it. commit.enter and commit.exit
both still exist and both still run; they just both sit after the flip now. The
mapping above puts each script where its Mender counterpart sat relative to the
switch. Details in the hook cutover runbook.
One asymmetry to plan around: deactivate.* and uninstall.* are read from the
declaration of the version being torn down or deleted, not the one arriving. So
they take effect one delivery later than you write them.
The table names the state and phase, not the whole key. Keep the .hook.
segment: [bundle.lifecycle.hook.commit.exit], never
[bundle.lifecycle.commit.exit]. The body key is script, not run. Both
mistakes are rejected at push time with the correct shape printed — see
atc-toml.md.
Worked example. A Mender ArtifactCommit_Leave script that brought the new
version into service and reported it becomes an activate.exit hook. Declare it
on the artifact, shipped inside the bundle:
[[bundle]]
name = "app"
source = "dist"
[bundle.lifecycle.hook.activate.exit] # first gate after the version flips
script = "@self/scripts/report.sh"
timeout_secs = 120
Mender handed your script discrete MENDER_* variables. atc clears the
environment and passes the whole delivery context as one JSON variable, so the
translation is mostly “read the fields with awk”:
#!/usr/bin/env bash
set -euo pipefail
# Hooks run with a CLEARED environment: no discrete ATC_* scalars, just this one
# JSON document (plus a pinned PATH). Parse it with awk, not jq or python3. A
# device image does not always carry them, and a missing reader gives an EMPTY
# value rather than an error.
#
# `ctx` reads one dotted field. A second argument gives the value to use when
# the field, or an object above it, is null. With no second argument a null
# field exits nonzero, and a field under a null object prints "".
ctx() {
awk -v path="$1" -v nargs="$#" -v dflt="${2-}" '
function fail(why) { print "ATC_HOOK_CONTEXT." path ": " why > "/dev/stderr"; exit 1 }
function find(t, k, j, c, d, q) {
k = "\"" k "\":"
for (j = 1; j <= length(t); j++) {
c = substr(t, j, 1)
if (q) { if (c == "\\") j++; else if (c == "\"") q = 0 }
else if (c == "{") d++
else if (c == "}") { if (--d == 0) return 0 }
else if (c == "\"") { if (d == 1 && substr(t, j, length(k)) == k) return j + length(k); q = 1 }
}
return 0
}
BEGIN {
s = ENVIRON["ATC_HOOK_CONTEXT"]; n = split(path, key, ".")
for (i = 1; i <= n; i++) {
if (substr(s, 1, 4) == "null") { print dflt; exit 0 }
if (substr(s, 1, 1) != "{") fail("not an object")
p = find(s, key[i])
if (p == 0) fail("no such field")
s = substr(s, p)
}
if (substr(s, 1, 4) == "null") { if (nargs < 2) fail("null"); print dflt; exit 0 }
if (substr(s, 1, 1) != "\"") fail("not a string")
for (j = 2; j <= length(s); j++) {
c = substr(s, j, 1)
if (c == "\"") { print v; exit 0 }
if (c == "\\") { j++; c = substr(s, j, 1); if (c != "\"" && c != "\\" && c != "/") fail("holds a control escape") }
v = v c
}
fail("not a string")
}'
}
name=$(ctx artifact.name)
new=$(ctx version.new_sha)
old=$(ctx version.old_sha "") # null on a first install
sudo -n systemctl restart "$name" # needs the sudoers rule below
curl -fsS -X POST https://fleet.example.com/deployed \
--data-urlencode "artifact=$name" --data-urlencode "from=$old" --data-urlencode "to=$new"
Mender scripts ran as root; atc hooks do not. bootstrap creates an
atc-hooks user and runs hooks as it, so a bare systemctl restart fails on
authorization. Because activate.exit is veto-capable, that failed command
rolls the delivery back. Grant the one action the hook needs:
# /etc/sudoers.d/atc-hooks-restart (install with visudo -f, mode 0440)
atc-hooks ALL=(root) NOPASSWD: /usr/bin/systemctl restart app
sudo -n never prompts, so a missing rule fails fast and visibly instead of
hanging the delivery. Which user a hook gets depends on the bootstrap path.
onboarding.md covers both.
Every tool a hook calls must already be on the device. The pinned PATH gives
the hook only what the image installed. That is why the script reads its context
with awk, which every base image carries, busybox included. A minimal image
such as debian:stable-slim has no python3.
jq is absent from many images.
A hook that calls a missing jq gets an EMPTY value instead of an error, so it
reports success over a step that did nothing. A hook that
exits nonzero on activate or commit is a veto: atcd rolls the whole delivery
back and reports the push as failed. That is why the script above fails loudly
instead of reporting nothing. For the field-by-field context reference, see
onboarding.md and
atc-toml.md.
Differences that will bite if unplanned:
- Hooks are shipped inside the artifact (
script = "@self/scripts/x.sh"), like Mender’s artifact-embedded scripts — there is no/etc/mender/scriptsequivalent for root-fs-resident hooks (absolute paths are allowed but sit outside atcd’s rollback guarantee). - Hooks run with a cleared environment; the delivery context arrives as
one JSON variable,
ATC_HOOK_CONTEXT(parse withawk), see atc-toml.md. - There is no exit-21 retry protocol. A non-zero exit fails the state (and a
failed delivery is reverted, firing your
rollback/failurehooks).
5. Rework CI. Replace mender-artifact write … + the Management API
upload/deploy calls with a push per device:
# was: mender-artifact write module-image … && curl …/deployments/artifacts && curl …/deployments/deployments
atc oci import registry.example.com/team/worker:aarch64 # only for [[oci]] artifacts
while read -r device; do
# bare push = every declared artifact (the default); add `--only <name>` to narrow.
atc ota push --version "v${CI_TAG}" --switch --non-interactive "deploy@${device}"
done < devices.txt
--non-interactive (key-only SSH) and --retries N are the automation knobs;
--json gives machine-readable results for your rollout dashboard. Phased
rollout = split devices.txt into waves and gate each wave on health checks —
the logic you previously configured in a deployment, now in code you control.
Reading the push result in a script. The default Push Results table draws
box characters and lays its columns out against the terminal width, so two
pushes of one artifact can print differently shaped rows. Do not parse it. Two
surfaces are stable instead.
--json prints one final-summary document. Use it when the caller has a parser.
--summary-lines prints one plain line per artifact, plus a totals line. Use it
when the caller has no parser, or when a human reads the CI log:
artifact name=robot-app kind=bundle ref=8f3a1c… version=v1.2.0 bytes_sent=1468006 bytes_reused=860112 status=activated
artifact name=vision kind=oci ref=44ab09… version=- bytes_sent=0 bytes_reused=44000000 status=staged
push artifacts=2 bytes_wire=1468006 retries=0 max_retries=5
Four rules hold that shape, so awk '$1 == "artifact"' keeps working.
- Field one is the record type. Every field after it is
key=value. - The keys are the same for both kinds, and every key is always present. An OCI
artifact carries no version label, so it prints
version=-. - No value holds a space.
- A size is a raw byte count, never
1.4 MB.
status is one of three tokens: activated, staged, or already-present.
kind is the artifact’s real kind, so it reads bundle, oci, module, or a
module-defined spelling such as podman.web-img. Those land in different trees
on the device, so a script that picks a path must read it.
--json wins when both flags are given.
--summary-lines also suppresses the switch prompt and the connect prose, the
way --json does, so a step with a TTY cannot block on a question. It renders
the push RESULT. --dry-run previews instead of pushing and keeps its own
output.
One outcome of atc ota push is not a push at all. When the command finds no
atc.toml above the working directory, it writes a starter manifest and stops.
A first-time operator then gets a file to edit instead of an error. That outcome
carries its own record type. No artifact line and no push line come with it:
scaffold path=/srv/ci/work/atc.toml status=written
So read field one before you read a value. A run that prints scaffold pushed
nothing. The answer is a manifest, not a retry.
6. Understand the rollback difference. Mender’s safety net is the
bootloader: a rootfs that can’t boot falls back automatically. Today atc does
not own your bootloader, so its equivalent guarantees are:
- a failed delivery (hook failure, interrupted transfer) is reverted automatically — the previous version stays active;
- a bad-but-installed version is rolled back with one command, using bytes already on the device:
atc ota ls deploy@device # see what's on the device
atc ota switch deploy@device v1.4.2 # host first, then version
What atc cannot save you from today is an update that breaks the device’s SSH
reachability itself. That’s the app’s job not to do. systemd watchdogs and
Restart= policies on your services are the right tool, and they ship in your
bundle.
7. Decommission Mender. Once a device is atc-managed and verified:
ssh deploy@device 'sudo systemctl disable --now mender-updated mender-authd mender-connect 2>/dev/null'
then decommission the device in the Mender UI/API so it stops counting toward your plan. Uninstalling the client package is optional; the partition layout stays either way. Run both systems in parallel per device for as long as you like — they don’t conflict — but a device taking updates from two masters is confusing, so disable Mender’s updater at cutover.
Mender migration checklist
- Inventory which update modules / payload types you actually ship
- Devices SSH-reachable with key auth + passwordless sudo
-
atc bootstrap(or the no-systemd recipe) on a pilot device -
atc.tomlwith a bundle/OCI artifact per payload type - State scripts ported to
@selflifecycle hooks (ATC_HOOK_CONTEXT, read withawk) - CI: artifact build + API deploy replaced with
atc ota pushloop - Rollback drill: push a version,
atc ota switchback, verify - Mender daemons disabled at cutover; device decommissioned server-side
Part 2 — Migrating from balena
Concept map
| balena | atc equivalent |
|---|---|
| balenaCloud / openBalena backend | None — controller → device over SSH |
| balenaOS | Replaced by a stock Linux (see “the host OS decision” below) |
| Supervisor (on-device agent) | atcd — but atcd only delivers and flips versions; running containers is your init system’s job |
| balena-engine | docker or podman, installed by you on the new OS |
| Fleet | Your device inventory list (CI loops over it) |
| Release | A pushed version across the artifacts in atc.toml (delivery-pinned mode flips them together) |
| Service (container in compose) | An [[oci]] artifact + a systemd unit (or compose file) shipped in a bundle |
balena push (cloud builders) | Build images in your own CI (docker buildx for multi-arch), then atc oci import + atc ota push |
| Delta updates | Built-in automatic delta transfer on every push |
Update strategies (io.balena.update.strategy) | Lifecycle hooks — you script the swap, so any strategy is expressible |
| Update locks | Not needed — push model: nothing updates until you push, and --no-switch stages without activating |
| Track latest / release pinning | Explicit per-device pushes; “pinning” is just not pushing |
| Rollback (pin to earlier release, re-download) | atc ota switch — instant, uses bytes already on the device |
| Device/fleet/service variables | Env files shipped in a bundle (versioned with the release) |
Cloudlink VPN, balena device ssh, tunnel | Your own overlay network (WireGuard/Tailscale) + plain SSH |
| Public device URLs | Not provided — expose services with your own ingress |
| Local mode / livepush | Ordinary local docker development; push to a bench device with atc ota push |
| Supervisor API (reboot, restart, purge) | SSH + systemd (atc shell user@device -- systemctl --user restart app) |
The host OS decision
This is the real migration cost, so decide it first: balenaOS is the host OS, and its supervisor/engine are not separable from it. Moving to atc means re-provisioning each device with a general-purpose Linux you control. Pick Debian, Ubuntu, or Yocto, and include systemd, a container runtime (docker or podman), and your SSH deploy key. That’s a reflash, or a fleet-refresh cycle where new hardware ships with the new image.
Keeping that OS current afterwards is your pipeline’s job, exactly as it is for a Mender migrant. Where the OS tier stands covers why, and what is changing.
What you get back for that cost: a device that is a normal Linux box — any tool runs on it, no compose-subset restrictions, no supervisor between you and your containers, and an update system with no cloud dependency and no per-device pricing.
Build the new base image once (golden image with runtime + key + optionally a
pre-installed atcd), and the per-device step is flash → boot →
atc ota push.
Step by step
1. Build the replacement base image (your distro tooling of choice). Include: systemd, docker/podman, your deploy user + SSH key, passwordless sudo for that user, and your overlay-network agent if devices aren’t directly reachable. Then bootstrap:
atc bootstrap atcd install --yes --non-interactive deploy@device
2. De-balenafy the compose file. Two categories of change:
io.balena.features.*labels each stand for a host bind-mount or credential injection — replace with the explicit equivalent (io.balena.features.dbus→ mount/run/dbus;kernel-modules→ mount/lib/modules;balena-socket→ mount your runtime’s socket;supervisor-api→ gone, see step 5). Standard compose has no restrictions here —bindmounts,env_file,container_nameall work again.Dockerfile.template(%%BALENA_MACHINE_NAME%%,%%BALENA_ARCH%%) → a plain Dockerfile on standard multi-arch base images, built per-arch withdocker buildxin your CI (balena itself has deprecated balenalib images in favor of standard ones, so this may be done already).
3. Declare the release in atc.toml. One [[oci]] per service, plus one
bundle carrying the compose file / systemd units and the hooks that swap
containers:
[[oci]]
name = "web"
source = "registry.example.com/acme/web:aarch64"
platform = "linux/arm64"
[[oci]]
name = "worker"
source = "registry.example.com/acme/worker:aarch64"
platform = "linux/arm64"
[[bundle]]
name = "stack" # compose.yaml + scripts/ — the "release config"
source = "deploy/stack"
[bundle.lifecycle.hook.activate.exit] # first gate after the flip: swap containers
script = "@self/scripts/restart-stack.sh"
timeout_secs = 300
In the default delivery-pinned mode a --switch push flips all artifacts
to the new version atomically — that’s your “release”: images and config move
together, like a balena release, and roll back together.
4. Write the swap hook — this is your update strategy. atcd delivers image
bytes into its store; the channel’s artifact store is a standard OCI image
layout at /var/atc/channel/local/default/artifacts/oci, tagged
<name>:<version> with the version label you pushed. Loading images into your
runtime and restarting services is an activate.exit hook on the stack bundle
(have your CI write the release version into an env file inside the bundle, the
same way a balena release pins image tags). activate.exit is the first gate
after the version pointer flips, so the window where the device points at the
new release but is still running the old containers is as short as it can be:
#!/usr/bin/env bash
set -euo pipefail
# This bundle's new version dir, read with awk (jq and python3 are not on
# every image). The helper is the `ctx` from the hook example above.
ctx() {
awk -v path="$1" -v nargs="$#" -v dflt="${2-}" '
function fail(why) { print "ATC_HOOK_CONTEXT." path ": " why > "/dev/stderr"; exit 1 }
function find(t, k, j, c, d, q) {
k = "\"" k "\":"
for (j = 1; j <= length(t); j++) {
c = substr(t, j, 1)
if (q) { if (c == "\\") j++; else if (c == "\"") q = 0 }
else if (c == "{") d++
else if (c == "}") { if (--d == 0) return 0 }
else if (c == "\"") { if (d == 1 && substr(t, j, length(k)) == k) return j + length(k); q = 1 }
}
return 0
}
BEGIN {
s = ENVIRON["ATC_HOOK_CONTEXT"]; n = split(path, key, ".")
for (i = 1; i <= n; i++) {
if (substr(s, 1, 4) == "null") { print dflt; exit 0 }
if (substr(s, 1, 1) != "{") fail("not an object")
p = find(s, key[i])
if (p == 0) fail("no such field")
s = substr(s, p)
}
if (substr(s, 1, 4) == "null") { if (nargs < 2) fail("null"); print dflt; exit 0 }
if (substr(s, 1, 1) != "\"") fail("not a string")
for (j = 2; j <= length(s); j++) {
c = substr(s, j, 1)
if (c == "\"") { print v; exit 0 }
if (c == "\\") { j++; c = substr(s, j, 1); if (c != "\"" && c != "\\" && c != "/") fail("holds a control escape") }
v = v c
}
fail("not a string")
}'
}
here=$(ctx self_root.new)
[ -n "$here" ] || { echo "stack: self_root.new is null" >&2; exit 1; }
source "$here/release.env" # VERSION=v2.0.0, written by CI
oci_root=/var/atc/channel/local/default/artifacts/oci
for svc in web worker; do
skopeo copy "oci:${oci_root}:${svc}:${VERSION}" \
"containers-storage:localhost/${svc}:${VERSION}" # docker-daemon:… for docker
done
podman-compose -f "$here/compose.yaml" up -d # compose pins ${VERSION} tags
(skopeo goes in your base image; standard OCI tooling reads the store
directly — no registry, no tarball.)
Because you write the swap, every balena strategy is expressible:
download-then-kill is the default shape above (bytes are fully staged before the
hook runs). Kill-then-download is a download.enter hook that stops services.
Stop-at-the-last-moment is a deactivate.exit hook, which runs after every
fallible step and immediately before the pointer flips. Hand-over is starting the
new container before stopping the old one in your script. rollback/failure
hooks give you the recovery path.
5. Replace supervisor-API consumers. Containers that called
/v1/update, restart-service, or update locks were coordinating with an
autonomous updater. Under a push model most of that disappears:
- Update locks / maintenance windows: push with
--no-switchany time, thenatc ota switchinside the window — or simply schedule the push. The device never updates itself. - Restart/reboot from inside a container: mount the docker socket or use a host-side systemd path — it’s a normal Linux box now.
6. Export cloud state before you leave. Fleet/device/service variables
live only in balenaCloud — pull them via the API and re-home them as env files
in your stack bundle (secrets go to your secret manager, injected in CI at
push time). Same for anything encoded in BALENA_HOST_CONFIG_* vars: those
become settings baked into your base image (config.txt, kernel args).
7. Cut over per device. Reflash with your base image, bootstrap, push,
verify (atc ota status, service health), then delete the device from the
balena fleet so it stops counting toward your plan. There is no parallel-run
option on one device (balenaOS can’t host atcd’s world) — the pilot phase is a
subset of devices, not a subset of each device.
balena migration checklist
- Base image built: stock Linux + systemd + docker/podman + deploy key (+ overlay network)
- Compose file de-balenafied (labels → explicit mounts; templates → buildx multi-arch)
- Fleet/device/service variables exported from the balena API and re-homed
-
atc.toml: one[[oci]]per service + astackbundle with compose/units - Swap hook written (
activate.exit), rollback drill done - CI:
buildx→ registry →atc oci import→atc ota pushloop over the device list - Pilot devices reflashed and verified; balena devices deleted at cutover
Further reading
- onboarding.md — install
atcand ship a first update - deploy-your-first-update.md — includes the no-systemd / container device recipe and a troubleshooting table
- atc-toml.md — full manifest + lifecycle-hook reference
(
ATC_HOOK_CONTEXTschema) - lifecycle-hook-cutover.md — which gate each hook
belongs at, now that the version pointer flips inside
activate - verifying-the-binary.md — the release trust chain
docs/development/error-codes.md— anyATC-Exxxxyou hit along the way
Verifying the atc / atcd binary you’re running
atc and atcd ship as closed-source binaries. This doc explains the
verification mechanism that already protects every install and self-update,
and how you — a customer — can independently check that the binary on your
machine or device is genuine and unmodified.
The digest check below (sha256 against the published release assets)
needs only curl, sha256sum/shasum, and jq — no access to this repo.
The signature check is stronger and needs nothing extra either:
atc sign verify ships inside the atc binary you already installed. It has
one honest limit: the check runs inside the very binary whose provenance you
may be questioning. For a verifier fully independent of your installed atc,
check the same signature with stock OpenSSL (recipe in that section). Either
way you need no access to this repo. The same ed25519 check also already runs
for you, automatically, on every self-update (see below).
If you are working in this codebase, see
docs/development/README.md instead.
Mental model
Every release is described by a signed release index (index.json):
- It lists, for each package (
agency/atc,agency/atcd) and platform target (e.g.linux-x86_64-musl,darwin-arm64), the artifact’s sha256 digest, byte size, and download URL. - The index is signed with ed25519 by Agency’s release-signing key. The
detached signature ships alongside it as
index.json.sig. - The signing key is pinned inside the
atcbinary itself (crates/atc-update/src/keys.rs,AGENCY_PUBKEYS_HEX) — not fetched at runtime from anywhere an attacker on the network path could substitute.
So there are two independent checks, and both must pass before a binary is trusted:
- Digest check — the downloaded binary’s sha256 must equal the digest the index lists for that platform.
- Signature check — the index itself must carry a valid ed25519 signature
from a key
atcalready has pinned in its binary.
A binary that matches the digest in a tampered (unsigned, or wrongly-signed) index does not pass. A signed index pointing at a digest that doesn’t match the downloaded bytes does not pass either. Both layers are required.
What verifies this automatically
You don’t have to do anything by hand for this to protect you — it already runs on every install and every self-update:
- First install (
curl -fsSL https://atc.dev/install.sh | sh, seetools/scripts/install.sh): fetchesindex.jsonover TLS, parses out the digest and URL for your platform, downloads the artifact, and refuses to install it if the computed sha256 does not match the digest in the index. This first hop relies on TLS + the digest for integrity — the install script itself does not check the ed25519 signature (it has no signing-key material and is deliberately dependency-free: no Python, no jq, justcurl,awk, andsha256sum/shasum). - Device install of
atcd(curl -fsSL https://atc.dev/install-atcd.sh | sudo sh, seetools/scripts/install-atcd.sh): checks the ed25519 signature onindex.jsonwithopensslagainst the pinned key, before it reads the index. Then it checks that the index names the requested track, and the version ifATC_VERSIONis set. Then it checks the sha256 ofatcdagainst the signed digest. If any check fails, it stops and does not runatcd. It needs OpenSSL 3, for ed25519 with-rawin. - Every subsequent update (
atc upgrade, implemented incrates/atc-cmd-bootstrap/src/self_update.rsandcrates/atc-update/src/selfupdate.rs): the binary itself fetchesindex.jsonandindex.json.sig, verifies the ed25519 signature against its pinned key set (crates/atc-update/src/sign.rs::verify_bytes,crates/atc-update/src/fetch.rs::fetch_verified_index), and only then downloads the artifact and checks its sha256 (fetch_artifact). If the source has no trusted key pinned, or the signature doesn’t verify, or the digest doesn’t match, the update is refused outright — nothing is ever applied on a verification failure. This is cryptographic, end-to-end authenticity, enforced by the binary itself, not by the transport.
In short: after your first install, you never have to trust a network path
again — atc will not update itself to anything it can’t verify against its
own pinned key.
Independently verifying an already-installed binary
If you want to check a binary yourself, rather than trusting that the
updater did its job, here is exactly what to do. The digest check (step 2)
needs sha256sum (or shasum), jq, and the published release assets that
step 1 fetches with curl alone. The SHA256SUMS route at the end of step 2
needs no jq. The signature check (step 3) is one command: atc sign verify, built into the atc you already installed. The paranoid-grade
alternative, for a verifier independent of that installed binary, is checking
the same signature with stock OpenSSL.
The GitHub CLI (gh) is optional. You need it only to take the assets from a
GitHub release instead of the origin, or to look up a release tag.
1. Get the published release assets
Every nightly build publishes, alongside the platform binaries:
SHA256SUMS— plain sha256sums of each staged binaryindex.json— the signed release index described aboveindex.json.sig— its detached ed25519 signature
All three are attached to the corresponding GitHub release (e.g.
nightly-2026-07-01). Fetch them either way — from the release origin, which
needs nothing but curl:
track=nightly # the track your atc came from: atc --version prints it
curl -fsSL "https://atc.dev/agency/$track/index.json" -o index.json
curl -fsSL "https://atc.dev/agency/$track/index.json.sig" -o index.json.sig
The dev track is internal and token-gated, so those two curl commands
return 403 for it. The edge serves /agency/dev/* only to requests that carry
X-Atc-Channel-Token. Send the token to verify a dev build:
track=dev
curl -fsSL -H "X-Atc-Channel-Token: $ATC_CHANNEL_TOKEN" \
"https://atc.dev/agency/$track/index.json" -o index.json
curl -fsSL -H "X-Atc-Channel-Token: $ATC_CHANNEL_TOKEN" \
"https://atc.dev/agency/$track/index.json.sig" -o index.json.sig
Get the token from 1Password, and export it as ATC_CHANNEL_TOKEN. The
Opting in section of onboarding.md gives the item
and the whitespace rule. The stable and nightly tracks are public, and they
need no token. A dev build also has no GitHub release, so the gh release download alternative below cannot replace this fetch.
For a stable or nightly build you can also fetch from the GitHub
release, which is where SHA256SUMS lives:
gh release download nightly-2026-07-01 -R Agency-Tool-Company/agency-code \
-p 'index.json' -p 'index.json.sig' -p 'SHA256SUMS'
Finding the release tag for a binary you already have. atc --version
prints the version and the track. Each track spells its tag differently, so do
not guess the tag from the version alone:
atc --version # e.g. "atc v0.1.6 (355c6f1013 2026-10-15T16:23:54Z, 0.1.6, track stable) [wire atc/6]"
# or "atc v0.1.7-nightly.20261016 (2b5eb47bb7 2026-10-16T12:50:16Z,
# 0.1.7-rc.202610161245.2b5eb47bb7fa, track nightly) [wire atc/6]"
The leading token is a short human form. The exact version sits in the
parenthetical, between the build date and the track, and that is the one the
tag lookup below reads. For a script, read atc -V instead. It prints atc
and the exact version, for example atc 0.1.6, so the version is the second
field.
A build appends the wire generation it speaks, as a trailing [wire atc/N]. A
release that predates the banner prints no such segment, and one that predates
the short form leads with the exact version instead (see
onboarding.md). The banner is not
part of the version or the track, so it never changes the tag lookup below.
-
stable: the tag is
vplus the version, so0.1.3becomesv0.1.3. -
nightly: the tag is not derived from the version. A nightly version reads
0.1.4-rc.<YYYYMMDDHHMM>.<sha>, where the trailing<sha>is the commit it was built from. Match on that sha, not on the date. The tag is usuallynightly-<YYYY-MM-DD>for the same day. A manually dispatched nightly can carry an operator-chosenrelease_datein its tag, while the version keeps the real build timestamp. The two dates then disagree. The sha always agrees:sha=2b5eb47bb7fa # the trailing dot-field of the version in the parens gh api "repos/Agency-Tool-Company/agency-code/releases?per_page=100" \ --paginate --jq ".[] | select(.name|contains(\"$sha\")) | .tag_name" # nightly-2026-10-16--paginatewalks every release, so this also finds a nightly far older than any fixedgh release list -L <n>window would show. If you already know the date is trustworthy,gh release view nightly-<YYYY-MM-DD>is the quick path.
Both give you the same signed index. Step 2 below assumes index.json is in the
current directory.
The atc.dev release origin the installer/self-update
use serves index.json and index.json.sig, but not SHA256SUMS. The
origin publishes binaries as content-addressed blobs keyed by the digests
index.json already lists, so https://atc.dev/agency/<track>/SHA256SUMS
returns 404 by design. So verify against index.json, which you always have;
SHA256SUMS is a convenience if you happened to pull it from GitHub.
2. Check the digest
Check against index.json — it’s the one asset both the release origin and the
GitHub release always carry, and it’s the file the signature in step 3 covers:
The index stores the digest as sha256:<hex>, while sha256sum/shasum print
bare hex, so strip the prefix before comparing rather than eyeballing the two:
bin=atc-linux-x86_64-musl # the artifact you downloaded
# ...or the binary already on this machine:
# bin=$HOME/.local/bin/atc
target=linux-x86_64-musl # its key in index.json
got=$(sha256sum "$bin" | cut -d' ' -f1) # Linux
# macOS: got=$(shasum -a 256 "$bin" | cut -d' ' -f1)
want=$(jq -r --arg t "$target" \
'.artifacts."agency/atc"[$t].digest' index.json | sed 's/^sha256://')
[ "$got" = "$want" ] && echo "digest OK" || echo "MISMATCH: $got != $want"
If you pulled SHA256SUMS from the GitHub release, it checks every binary in
one command (but see above — the origin doesn’t serve it):
sha256sum -c SHA256SUMS # Linux
shasum -a 256 -c SHA256SUMS # macOS
If your locally-installed atc/atcd was placed by the installer or by
atc upgrade, this is the same check those
tools already performed for you — a mismatch here would mean something
altered the binary after it was verified and installed (e.g. local tampering
on disk), since both paths refuse to write a binary whose digest doesn’t
match in the first place.
3. Check the index signature
The signature check is what stops a compromised or spoofed download origin
from ever making it to your machine — even if an attacker could rewrite
index.json and its listed digests to point at a malicious binary, they
cannot forge a valid signature over it without the private key (held in
1Password, surfaced to CI only as ATC_RELEASE_SIGNING_KEY; see
crates/atc-update/src/keys.rs).
The key below can rotate. Don’t treat it as permanent.
atcsupports signing-key rotation: a future release can add a new key alongside this one for a transition window, then retire this one. The canonical, always-current source of truth for which key(s) are trusted isAGENCY_PUBKEYS_HEXincrates/atc-update/src/keys.rsat the commit your installedatcwas built from — not this doc. If a signature check against the key below ever fails on an otherwise-current release, check that file (or ask Agency) before assuming tampering; it may mean the key has rotated since this page was last updated.
The production public key, pinned in every atc build as of this
writing, is:
8f0661861de15f450781f8cdb08a33e094aff7c67539371d9ecc4dd12362202d
(from AGENCY_PUBKEYS_HEX, which is a set — it can carry more than one key
during a rotation window; this is the sole key in production today.) The
first 8 bytes of that key, hex-encoded, are the key_id prefix on every
index.json.sig line — you can confirm at a glance that a given signature
claims to be from this key before doing the cryptographic check:
$ cat index.json.sig
8f0661861de15f45 28436bd3a8afc3686edf45e9af39862b41bbe514692f55f41260677e8aec3b3624907251769d651102235db03cb9811905e904d4853eb2289587378829bb4a07
# ^^^^^^^^^^^^^^^^ key_id — matches the first 8 bytes of the pinned pubkey above
To actually verify the ed25519 signature (not just eyeball the key id), run
atc sign verify — the exact verification code path atc’s own self-update
uses (atc_update::sign::verify_bytes), exposed as a subcommand for exactly
this purpose:
atc sign verify index.json index.json.sig \
8f0661861de15f450781f8cdb08a33e094aff7c67539371d9ecc4dd12362202d
# -> OK (signature verifies against the pinned key; exit 0)
# -> VERIFY FAILED: ... (reject the index — do not trust any binary it points at; exit 1)
Pass more than one pubkey during a rotation window; the check passes if the
signature matches any of them. atc sign also carries keygen and create
for producing your own detached signatures.
One honest caveat: this check runs inside the very atc binary whose
provenance you may be questioning, and a hypothetically malicious atc could
lie about it. If that’s your threat model, verify with a tool that isn’t ours.
The format is deliberately standard, so stock OpenSSL 3.x does it. The one
wrinkle is that index.json.sig is a line of two hex fields,
<key_id> <signature> (the key_id selects a key during rotation), so pass
field 2 rather than the whole file:
# Field 2 of the .sig line is the raw 64-byte ed25519 signature.
awk '{print $2}' index.json.sig | xxd -r -p > sig.raw
# Wrap the pinned hex public key in the standard ed25519 DER prefix.
printf '302a300506032b6570032100%s' <pinned-pubkey-hex> | xxd -r -p > atc-pub.der
openssl pkeyutl -verify -pubin -inkey atc-pub.der -keyform DER \
-rawin -in index.json -sigfile sig.raw
# -> "Signature Verified Successfully"
Putting it together
A binary is genuine and untampered iff both hold:
sha256(binary) == index.json's digest for that platform/package, andindex.json’s signature verifies against a key in the trusted set (8f0661861de15f450781f8cdb08a33e094aff7c67539371d9ecc4dd12362202das of this writing — see the rotation caveat above).
If either check fails, do not run the binary — and note that atc’s own
installer and self-update already refuse to complete in that situation, so a
failure here on a binary that came from those paths would itself be a strong
signal of something wrong outside atc’s control (a compromised local disk,
a mirrored/proxied download that served different bytes, etc.).
Where the trust root comes from
- The ed25519 keypair was generated via a secure host + 1Password (
op) ceremony (seecrates/atc-update/src/keys.rsfor the pointer to the private-key location:op://Agency/atc-release-signing/private-key). - The private half never leaves 1Password/CI: it’s surfaced to the nightly
release workflow only as the
ATC_RELEASE_SIGNING_KEYGitHub Actions secret, used solely to signindex.jsonat release time (tools/scripts/stage-nightly-release-binaries.sh). - The public half is compiled into the
atcbinary as a Rust constant (AGENCY_PUBKEYS_HEX) — there is no runtime fetch of “the current trust key” that a network attacker could redirect. Trusting a givenatcbinary build (via the digest/signature chain above, or by building from this source yourself) is what transitively trusts the key it carries. AGENCY_PUBKEYS_HEXis a set, not a single key, specifically so the signing key can be rotated: a future build adds the new key alongside the old one for a rotation window, then drops the retired one once every supportedatchas upgraded past it.
What this does not cover
- The signature check has a CLI verb (
atc sign verify); the digest check does not. There is noatc doctorthat runs both against your installed binary and reports one verdict. Do the digest check by hand, as described above. - There is no standalone public page publishing the current trust key. Both the
digest and signature checks are fully self-service with standard tools
(
sha256sum, OpenSSL), but the trusted key itself reaches you either baked into anatcbuild or copied from this page. Treat the pinned key printed above as a snapshot, not a permanent value, and see the rotation caveat in the signature-check section. - This verifies what Agency’s release pipeline published, not that the source code matches some external audit — it’s a supply-chain integrity check (this exact binary came from this exact signed release), not a reproducible-build proof.