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