Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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. Every kdl block on this page is also a test fixture under crates/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 runatc 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 -cthe nearest atc.kdl or atc.toml
a command with no -c, where atc.kdl and atc.toml are in one directoryatc.kdl. atc prints a note on stderr that names the atc.toml it did not read
the same, with ATC_KDL_CONFIG=0 setatc.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.kdl spells 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 install with its flag (--root, --company) or with this atc.toml as -c. [daemon] keys other than socket_path and remote_path, the [storage] table, and [server] keys other than url, org and ca_bundle_path are device settings. server.url and server.ca_bundle_path are both: they are written to atc.kdl for the CLI and listed for the device.
  • Any other field stops the command with ATC-E0792, which names the field. A [[peripheral]] lifecycle table is one: a peripheral’s flash hooks have no atc.kdl spelling. 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

  1. A file is a list of nodes. Each node is a word, then its arguments, then an optional { … } block of child nodes.
  2. A bare word is a string. bundle app and bundle "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.
  3. Booleans are #true and #false. Numbers are bare: timeout-secs 30.
  4. A ; separates nodes on one line: exit { script "@self/a.sh"; timeout-secs 30 }.
  5. // 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

NodeCountWhat it declares
schema-version <n>at most oneThe grammar version. Only 1 exists. A higher number is an error, so an older atc cannot misread a newer file
bundle <name> { … }anyA directory tree to ship
oci <name> { … }anyA container image
module <name> { … }anyA module package: an executable the device runs after a delivery commits
use <module>one per moduleMakes the node names of a module available: for example systemd, podman, container
<kind> <name> { … }anyAn artifact or a workload of a kind that a used module defines
peripheral <name> { … }anyA hardware peripheral and the bundles it takes
daemon { … }at most oneWhere atcd listens and where its executable is on the device
bootstrap { … }at most oneWhether a push installs atcd on first contact, and whether it asks first
push { … }at most oneThe channel that atc ota push delivers to, and whether it switches to the new version
hooks { … }at most oneHooks for the whole delivery: stage hooks, bookends and progress hooks
server { … }at most oneThe atc server that atc commands such as atc ota publish talk to
ownership { … }at most oneThe server channels whose delivery config this file owns, or defers to another file
channel <name> { … }anyA 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.

ChildbundleocimoduleMeaning
source "<path or ref>"requiredoptionalrequiredA directory relative to the file, or an image reference for oci
platform "<os/arch>"errorrequirederrorThe image platform, for example "linux/arm64"
exclude "<glob>" …optionalerrorerrorPaths to leave out of the walk of source
ship-python-bytecode #trueoptionalerrorerrorShip __pycache__, .pyc and .pyo files, which the push leaves out by default
role "<role>"optionaloptionalerrorThe host role that installs this artifact. See Roles
requires <artifact> …optionaloptionaloptionalThe artifacts that this one depends on. See Dependencies with requires
payload "<path>"errorerroroptionalA directory that ships in the package’s payload/ subtree
hooks { … }optionaloptionaloptionalLifecycle 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, failure or uninstall.
  • Phase: enter or exit. The error phase 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. At check.*, download.* and install.enter the new version is not installed yet, so @self runs the ACTIVE version’s script. A missing script skips the hook there, which covers a first delivery and a hook that this release adds. From install.exit on, @self runs the new version’s script. rollback.* and failure.* run the script of the version that the unwind restores, and skip when there is none. The atc.toml reference has the table for every gate.
  • timeout-secs (optional): a whole number of seconds. 0 removes the time limit. With no timeout-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.

Childatc.toml tableWhen 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:

  1. .atc/modules/<module>/module.kdl in the repository
  2. the modules built into atc: systemd, podman and nerdctl

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:

KindModuleChildren
systemdsystemdfrom, unit "<name>.service" (required)
containerpodmanfrom, name "<container name>", restart on-activate or restart never, health { cmd "<command>"; timeout-secs <n> }
containernerdctlfrom

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-directory walks the source directory, as a bundle does. platform is an error. The push takes it.
  • oci-pull pulls the source image, as an oci does. platform is required. The push takes it.
  • oci-pull-erofs-convert is the recipe of the built-in podman kind. platform is 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.

NodeChildMeaning
daemonsocket-path "<path>"The socket where atcd listens on the device
daemonremote-path "<path>"The path of the atcd executable on the device
bootstrapauto #true or auto #falseWhether atc ota push installs atcd on first contact
bootstrapconfirm #true or confirm #falseWhether 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.

Childatc.toml keyMeaning
target "<namespace>/<name>"[channel] namespace and nameThe 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] autoswitchWhether 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.

Childatc.toml keyMeaning
url "<url>"urlThe base URL of the atc server. --server overrides it
org "<id>"orgThe 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_pathA 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.

Childatc.toml keyMeaning
id "<name>"idThe name of this manifest. A delivery to a channel in owns carries it, signed. owns needs an id
owns "<channel>" …ownsThe server channels whose delivery config this manifest owns
defers { <channel> "<owner id>" }defersOne 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.

ConstructWhyWhat to do
a channel nodeNothing reads a channel composition yet. It is not the same thing as the [channel] table of atc.toml, which names the push targetRemove 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 workloadNothing carries a workload hook to the device yetPut 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 typeThe push would drop the fieldCorrect the block against the module’s module.kdl
an artifact kind with the oci-pull-erofs-convert recipe, such as podmanThe device cannot mount the image yetDeclare 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 than socket_path and remote_path
  • the [storage] table
  • every [server] key other than url, org and ca_bundle_path