Node Types
Every test and step runs on a node: the machine, container, or VM under test. This is the full reference for node types and their options.
DART supports several types of nodes that can be used as test targets:
-
Local Node (
local)
Execute tests on the local machine where DART is running. Invariant: at most onelocalnode per suite. A second one fails configuration withonly one local node allowed; "<name>" duplicates "<first>", reported against that node’s line in the YAML and caught by--check. The limit applies only tolocal; other types may appear any number of times. Several roles on one machine are modelled with a single local node, distinguished by test and step naming rather than by separate node entries. -
Docker Node (
docker)
Run tests inside Docker containers, with volume, environment, port, capability, privileged-mode, and command/entrypoint options. Supports both local and remote Docker hosts. -
Docker Compose Node (
docker-compose)
Manage and test services defined in Docker Compose files. Multiple nodes can target different services in the same compose stack. -
LXD Node (
lxd)
Execute tests in LXD containers or virtual machines, with automatic provisioning and cleanup. Supports both local Unix socket connections and remote HTTPS connections with certificate-based authentication. -
LXD VM Node (
lxd-vm)
Shorthand for anlxdnode withinstance_type: virtual-machine, identical tolxdin every other respect. Note: the alias setsinstance_typeon a copy of the node’s options, so an explicitinstance_typein the YAML is overridden rather than honoured. -
SSH Node (
ssh)
Run tests on remote machines via SSH, supporting both key-based (key) and password (pass) authentication.
Each node type takes type-specific keys under options:. For example:
nodes: - name: localhost type: local options: shell: /bin/bash
- name: remote-server type: ssh options: host: example.com port: 22 user: testuser key: ~/.ssh/id_rsa # must be an unencrypted private key; see SSH Node Options # Host keys are verified against ~/.ssh/known_hosts by default; # set known_hosts: <path> or insecure_skip_host_key: true to change it. # bastion: { host: jump.example.com, user: jumpuser, key: ~/.ssh/id_rsa }
- name: test-container type: docker # facts: is a sibling of options:, not one of its keys facts: kernel: uname -r options: # The image must already exist in the daemon and must run a # foreground process; see Docker Node Options. image: nginx:alpine env: ["LOG_LEVEL=debug"] volumes: ["./fixtures:/fixtures:ro"] ports: ["8080:80"] # privileged: true # opt-in; capabilities are usually enough # capabilities: [NET_ADMIN]
# Docker Compose nodes - target specific services - name: web-service type: docker-compose options: compose_file: docker-compose.yml project_name: my-stack service: web
- name: db-service type: docker-compose options: compose_file: docker-compose.yml project_name: my-stack service: db
# Remote LXD node with certificate authentication - name: remote-lxd type: lxd options: remote_addr: https://10.0.0.1:8443 client_cert: ~/.config/lxc/client.crt client_key: ~/.config/lxc/client.key image: ubuntu:24.04 instance_type: containerThe lxd-vm alias exists so a virtual machine does not need the extra option. The
two nodes below are equivalent:
nodes: - name: my-vm type: lxd-vm # equivalent to the block below options: image: ubuntu:24.04
- name: my-vm-alt type: lxd options: image: ubuntu:24.04 instance_type: virtual-machineNode names
Section titled “Node names”The name of a node is more than a label — it is an identifier DART uses verbatim
on the target platform.
- Names must be unique and non-empty. A missing name or a repeated one is a
configuration error reported with its file location, caught by
--checkbefore anything is created. - At most one
localnode per suite. A secondlocalnode is rejected as a configuration error. This check lives in the node factory rather than the configuration loader, so unlike the duplicate-name check it does not surface under--check. - Docker nodes: the node name becomes both the container name and the
container’s hostname. It must therefore be a legal Docker container name
(
[a-zA-Z0-9][a-zA-Z0-9_.-]*), and creation fails if a container of that name already exists — DART does not adopt or replace pre-existing containers. - LXD/Incus nodes: the node name becomes the instance name, so it must satisfy LXD’s instance-name rules (letters, digits, and hyphens; at most 63 characters) and must not clash with an existing instance in the target project.
- Docker Compose nodes: the node name is used as the Compose project name when
project_nameis omitted.
container_name (docker) and instance_name (lxd, lxd-vm) decouple the platform
identifier from the node identity. Both default to the node name, which is what
makes a suite’s containers and instances findable by the name the YAML uses;
setting one is for suites that must match an externally fixed name. The node name
remains what node: references, what reports and console output show, and — for
docker — what the container’s hostname is set to, so node-side commands still see
the name the suite uses.
Note: name syntax is not validated by DART. A name the platform rejects surfaces as the daemon’s or LXD server’s own error during node setup.
Node Security Defaults
Section titled “Node Security Defaults”Two defaults changed in favour of least privilege — suites relying on the old behaviour need one line each:
- Docker containers are no longer privileged. DART used to set
--privilegedon every container. Addprivileged: trueif a test genuinely needs it, or prefercapabilities: [NET_ADMIN]for the common network-testing case. - SSH host keys are verified. DART used to accept any key. Verification
uses
~/.ssh/known_hostsby default; pointknown_hosts:elsewhere, or setinsecure_skip_host_key: truefor throwaway lab targets. A missing or unmatched key is an error naming both options.
SSH nodes also accept a bastion: block to reach targets through a jump
host. The bastion inherits the target’s host-key policy but may set its
own (known_hosts / insecure_skip_host_key), so relaxing verification
for an ephemeral target need not relax it for the long-lived jump host;
reconnects after reboot route through the bastion too, and chained
bastions are rejected rather than silently dropped.
--check validates everything about a node that needs no connection:
- Required fields —
hostonssh,imageondocker,compose_fileondocker-compose, and a bastion’shostwhen abastion:block is present. - Option names — any key the node type does not accept is an error naming the accepted set (see Unrecognised Options).
- Credentials and host keys — SSH authentication (a key file that exists and
parses, or a password),
known_hostsreadability under the configured host-key policy, and that a bastion carries usable credentials and is not chained. - Specification syntax — docker
volumesandports, resolving relative volume host paths (./fixtures:/fixtures) to absolute paths, since the Engine API would otherwise treat them as named volumes and mount an empty one. - Cross-node constraints — duplicate node names, and more than one
localnode.
What remains outside its reach is anything that needs the platform to answer: whether an image exists, whether a host is reachable, whether a bind source exists on the daemon.
Unrecognised Options
Section titled “Unrecognised Options”Option names must match exactly. A key the node type does not accept is a configuration error naming the offending key and the full accepted set:
Error: node "web": unknown option "privilaged" for a docker node (accepted:capabilities, command, container_name, entrypoint, env, exec_opts, image,networks, ports, privileged, volumes)Rationale: options: is decoded by a JSON round-trip into a typed struct, which
discards anything it does not recognise. Without this check a misspelling such as
priviliged for privileged left the option at its default while the suite read
as though it were set — the assertion looked configured and tested nothing.
--check reports these, so a typo surfaces before any infrastructure is created.
Local nodes additionally warn about keys misplaced inside exec_opts.
Historically a dropped SSH security key failed safe: a mistyped
insecure_skip_host_key left it false, and a mistyped known_hosts fell back
to ~/.ssh/known_hosts, so host-key verification stayed on and the symptom was a
confusing connection error
rather than a silent downgrade. The real cost is a silently ineffective option — a
privileged or capabilities typo, for example, surfaces later as an unexplained
permission failure inside the container.
Node Facts
Section titled “Node Facts”facts: is a top-level key on a node — a sibling of options:, not a key inside
it. Each entry maps a fact name to a command that runs on that node; the command’s
stdout becomes the fact’s value.
nodes: - name: web type: docker options: image: nginx:alpine facts: ipaddr: "hostname -i | awk '{print $1}'" kernel: uname -rFact values are referenced from step options, test options, and a test’s setup:
and teardown: commands as {{ fact "web" "ipaddr" }}, or {{ fact "self" "ipaddr" }}
for the node the step or test runs on. Node options: are not templated — facts do
not exist yet when nodes are created, so a fact reference there is left unresolved.
Semantics:
- Timing. Facts are gathered after node setup but before any setup step runs. A fact command therefore sees only what the base image or host already provides; a command depending on a tool installed by a setup step fails the run.
- Order. Within a node, fact commands run in sorted fact-name order. Nodes are processed in the order they appear in the configuration.
- Failure. A fact command that fails to execute, or exits non-zero, aborts the entire run before any test starts, with an error naming the fact and the node. Built-in address facts behave the opposite way: their discovery failures are ignored.
- Output. Trailing spaces, tabs, carriage returns, and newlines are stripped from stdout; leading whitespace is preserved. Only stdout is captured — stderr appears only in the failure message.
- Precedence. A fact whose name collides with a built-in network fact overrides the built-in.
- Substitution is literal. Fact values are inserted verbatim into commands with no shell quoting. Warning: a fact whose command can return attacker-influenced output can alter the command it is substituted into.
- Reporting. Declaring any
facts:block turns on the “Gathering node facts” phase in the console output; suites that rely only on built-ins get no extra output.
Docker and LXD nodes also publish built-in address facts (ipv4, ipv6, and
per-network or per-interface variants such as ipv4.test-net or ipv4.eth0).
Those are documented under
Built-in Network Facts. Local, SSH, and
Docker Compose nodes publish no built-in facts.
Local Node Options
Section titled “Local Node Options”A local node accepts four options, all optional:
| Option | Type | Behaviour |
|---|---|---|
shell |
string | Shell used to run commands. Defaults to /bin/sh (cmd on Windows), so bashisms — [[ ]], source, arrays, pipefail — need an explicit shell: /bin/bash. |
env |
list of KEY=VALUE strings |
Environment for executed commands. Note: this replaces the inherited environment rather than adding to it, so anything the command needs — including PATH — must be listed. |
sudo |
map | Password supplied to sudo prompts. Either env_var: <NAME> (read from that environment variable when the node is constructed) or password: <literal>. |
exec_opts |
map | The same three keys nested one level, matching the shape the Docker, Compose, and LXD node types use. |
nodes: - name: localhost type: local options: shell: /bin/bash env: - PATH=/usr/local/bin:/usr/bin:/bin - LOG_LEVEL=debug sudo: env_var: SUDO_PASSWORD
# Equivalent, using the container-node nesting - name: localhost-nested type: local options: exec_opts: shell: /bin/bash sudo: env_var: SUDO_PASSWORDPrecedence and warnings:
env_varwins overpasswordwhen asudoblock sets both; the literal is then dead configuration.- An
env_varnaming an unset or empty variable does not fail the run: DART printsWarning: sudo env_var "NAME" is empty or unsetto stderr and proceeds with an empty password, sosudotypically fails later at the prompt. - When
exec_optsis present it becomes the sole source of exec options — top-levelshell,env, andsudoare ignored. Each ignored top-level key produces a stderr warning; nothing fails. - Unrecognised keys are warned about on stderr and ignored rather than rejected, so
a typo such as
shel:falls back to/bin/sh. - A
shell,env, orsudovalue of the wrong YAML type is also warned about and skipped. Warning: a wrong-typedshellsuppresses the/bin/shdefault as well, leaving commands to run without a shell.
SSH Node Options
Section titled “SSH Node Options”Options for type: ssh, decoded by a JSON round-trip into a typed struct, so these
key names are exact:
| Key | Type | Default | Notes |
|---|---|---|---|
host |
string | — | Target hostname or IP address. Not separately validated; an empty value fails at dial time. |
port |
int | 22 |
TCP port of the SSH service. |
user |
string | — | Remote username; no default. |
key |
string | — | Path to an unencrypted private key file. A leading ~ is expanded. |
pass |
string | — | Password authentication. The key is pass — not password. |
known_hosts |
string | ~/.ssh/known_hosts |
OpenSSH known_hosts file used for verification; ~ is expanded. |
insecure_skip_host_key |
bool | false |
Opts out of host-key verification entirely. |
bastion |
map | — | Jump host; see below. |
At least one of key or pass must be set. Both may be set, in which case the
public key is offered first and the password second. With neither, node
construction fails with:
no ssh credentials configured: set key or passand --check reports the same error without connecting.
nodes: - name: remote-server type: ssh options: host: example.com port: 22 user: testuser pass: hunter2 # or key: ~/.ssh/id_rsa; at least one is requiredNote: key: must point at an unencrypted private key. DART parses it with
ssh.ParsePrivateKey, which has no passphrase variant, so a passphrase-protected
key fails with
unable to parse private key: ssh: this private key is passphrase protected.
There is no passphrase option, and ssh-agent (SSH_AUTH_SOCK) is not consulted.
Adding pass: does not help: a key that fails to parse aborts node construction
before password authentication is attempted — use pass: on its own for password
authentication, or generate a dedicated unencrypted key for test runs. The same
applies to key: inside a bastion: block. --check catches an unusable key
before any connection is made.
A leading ~ is expanded to the current user’s home directory in key and
known_hosts, on both the node and its bastion:. ~otheruser/... is not
resolved; another user’s home needs an absolute path.
Bastion options
Section titled “Bastion options”A bastion: block accepts the same keys as the target: host, port (default
22), user, key, pass, known_hosts, and insecure_skip_host_key.
# bastion: # host: jump.example.com # port: 22 # user: jumpuser # key: ~/.ssh/id_rsa # or pass: <password> # known_hosts: ~/.ssh/known_hosts_jump # optional; inherits the target's otherwise # insecure_skip_host_key: false # optional; inherits the target's otherwiseDifferences from the target node:
hostis required; omitting it fails withbastion host is required.known_hostsandinsecure_skip_host_keyare inherited from the target node when unset and override it when set.insecure_skip_host_keyis tri-state on the bastion for that reason.- A nested
bastioninsidebastionis rejected withchained bastions are not supported: remove the nested bastion block. - The bastion needs its own credentials; missing ones fail with
bastion: no ssh credentials configured: set key or pass.
SSH connection lifecycle
Section titled “SSH connection lifecycle”An ssh node connects when it is constructed — during dependency wiring, after the
configuration has been loaded and validated, but before platform setup, before node
setup, and before any setup step runs. Consequences worth knowing:
- Every ssh node must be reachable at startup, on every run. If any one of them
cannot be reached, or its host key cannot be verified, DART reports the error and
exits before executing anything. This includes
--teardown-onlyruns: a suite cannot be torn down through DART once one of its ssh targets is down.--checkvalidates credentials,known_hostsreadability, and bastion shape without connecting. SetupandTeardowndo nothing on an ssh node. Thesetting up nodeandtearing down nodelines are no-ops; there is nothing to provision or destroy. Preparation and cleanup on an ssh target belong insetup:andteardown:steps.- One connection is reused for the whole run, and is closed when the process
exits, along with the bastion tunnel. If the target reboots or drops the
connection outside DART’s
rebootstep, the client goes stale and subsequent commands fail; only therebootstep redials, through the bastion when one is configured. An expected restart is best modelled with arebootstep rather than triggered from anexecutestep.
Docker Node Options
Section titled “Docker Node Options”| Option | Type | Notes |
|---|---|---|
image |
string | Image reference used to create the container. Must already exist in the daemon. |
env |
list of KEY=VALUE strings |
Environment set at container creation. |
volumes |
list of host:container[:options] |
Bind mounts; relative host paths are resolved to absolute paths and a leading ~ is expanded. |
ports |
list of host:container[/proto] |
Published ports. |
privileged |
bool | Opt-in full host capabilities; defaults to false. |
capabilities |
list of strings | Individual Linux capabilities, for example [NET_ADMIN]. |
command |
list of strings | Overrides the image’s CMD. Use it to give an image that would otherwise exit a process that stays in the foreground. |
entrypoint |
list of strings | Overrides the image’s ENTRYPOINT. |
container_name |
string | The container’s name on the daemon; defaults to the node name. |
Note: DART does not pull Docker images. The image: a docker node references must
already exist in the local daemon — pulled beforehand (docker pull nginx:alpine)
or built from the suite’s docker.images block, which runs docker build against
a Dockerfile and is therefore not a substitute for a pull. A missing image fails
node setup with could not create container: ... followed by the daemon’s
No such image. This applies to type: docker nodes only: docker-compose nodes
pull through Compose, and LXD/Incus nodes fetch images through the LXD client.
The container is created from the image’s own CMD/ENTRYPOINT unless
command: or entrypoint: overrides them. DART allocates no TTY and attaches no
stdin, so the process it runs must stay in the foreground. After starting the
container, node setup polls every second for up to two minutes until the container
reports Running and a trivial exec of true succeeds. An image whose CMD
exits immediately — bare ubuntu:latest, whose CMD is /bin/bash — never
becomes ready, and setup fails after two minutes with
container <name> not ready: timeout waiting for container ... context deadline exceeded.
Three ways to satisfy the readiness check:
-
a service image whose
CMDalready stays up (nginx:alpine,postgres:16); -
a bare distribution image plus a
command:that stays up:nodes:- name: shellboxtype: dockeroptions:image: ubuntu:24.04command: ["sleep", "infinity"] -
a purpose-built image whose
CMDis a supervisor, asexamples/docker/docker.yamlbuilds through thedocker.imagesblock.
Note: there is no tty option. A command that requires a terminal still fails.
networks attaches the container to networks the suite declares under
docker.networks. Joining a user-defined network takes the container off the
default bridge, which is what makes isolation between nodes real, and gives it
Docker’s embedded DNS so containers resolve each other by name.
docker: networks: - name: frontend subnet: 172.30.0.0/24 gateway: 172.30.0.1
nodes: - name: web type: docker options: image: nginx:alpine networks: - name: frontend # must name a declared docker.networks entry ip: 172.30.0.10 # optional; requires the network to define a subnetNote: Docker accepts one network at container creation, so the first entry is applied there and any others are connected immediately afterwards. The order of the list is preserved.
Note: a node joins a network; it does not define one. Setting subnet on a
node-level entry is a configuration error naming the right place for it —
docker.networks[].subnet, which is what creates the network.
Note: Docker and Docker Compose nodes always run commands as sh -c "<command>"
inside the target container; the shell is not configurable. The exec_opts block
honoured by lxd and local nodes is parsed but ignored on docker and
docker-compose nodes, and no warning is emitted, so exec_opts: {shell: /bin/bash}
silently has no effect. Commands must therefore be POSIX-sh compatible — no
bashisms such as [[ ]], <<<, or arrays — or invoke the interpreter explicitly,
for example bash -c '...'. Container environment variables are set at creation
time with the docker node’s env: option rather than at exec time;
docker-compose nodes take their environment from the compose file. sudo is
likewise unavailable as an exec option: commands run as the image’s exec user, and
a docker node needing extra privileges uses capabilities: or privileged:.
Docker Platform Configuration
Section titled “Docker Platform Configuration”The optional top-level docker: block declares networks and images that are
created before node setup and removed during platform teardown. It is consulted
only when present.
docker: networks: - name: test_net # network name passed to the Docker API subnet: 192.168.200.0/24 # IPAM subnet gateway: 192.168.200.1 # IPAM gateway images: - name: test_server # image repository name tag: latest # image tag dockerfile: dockerfiles/server.dockerfile- Path resolution. A relative
dockerfileis joined to the directory of the suite YAML file, not to the process working directory. Absolute paths are used as-is. - Build command and context. DART splits the resolved Dockerfile path into a
directory and a filename and runs
docker build -t <name>:<tag> -f <filename> .through a shell with the working directory set to the Dockerfile’s own directory. The build context is therefore that directory, soCOPYandADDsources must be relative to it rather than to the suite file. - Lifecycle. Networks are created and images built during platform setup, before node setup. During platform teardown every listed network is removed and then every listed image is removed. Resources that are already gone are tolerated.
Warning: teardown is destructive and name-based rather than ownership-based. DART
removes any network or image matching the configured name, whether or not this run
created it, so a pre-existing image or network sharing a name in docker: is
deleted. Image removal also passes only name with no tag, which Docker resolves
as <name>:latest: with a non-latest tag, the image DART just built is left
behind while an unrelated <name>:latest is deleted instead. Suite-unique names
are recommended, and tag: latest until the tag handling is fixed.
Note: the networks declared here are created before node setup and removed
during platform teardown. A node joins one by naming it under the node’s own
networks: option.
See examples/docker/docker.yaml for a complete worked example.
Remote Docker Support
Section titled “Remote Docker Support”Docker nodes can connect to remote Docker hosts using standard Docker environment variables:
- DOCKER_HOST: URL to the Docker server (e.g.,
tcp://remote-host:2376orssh://user@host) - DOCKER_TLS_VERIFY: Enable TLS verification (set to
1) - DOCKER_CERT_PATH: Path to directory containing TLS certificates (
ca.pem,cert.pem,key.pem)
Example:
export DOCKER_HOST=tcp://10.0.0.1:2376export DOCKER_TLS_VERIFY=1export DOCKER_CERT_PATH=/path/to/certsdart -c config.yamlFor SSH-based connections (no Docker daemon configuration needed):
export DOCKER_HOST=ssh://user@remote-hostdart -c config.yamlWarning: volumes host paths are resolved on the machine running DART but
interpreted by the daemon. DART expands a leading ~ from the local $HOME and
makes any relative path absolute against the suite file’s directory; the result
is handed to the daemon as-is. With a remote DOCKER_HOST, ./fixtures:/fixtures
becomes a local absolute path the daemon host probably does not have — and a bind
source that does not exist is created as an empty directory rather than failing, so
a test can read nothing and still pass. --check validates the
host:container[:options] shape only; it cannot know what exists on the daemon
host. Remote daemons need paths that exist on the daemon host, a named volume (a
bare name with no / or ., which DART leaves untouched for the daemon to
resolve), or fixtures copied in with a file_copy step instead of a bind mount.
See examples/docker/docker-remote.yaml for a complete example.
Docker Compose Teardown
Section titled “Docker Compose Teardown”Compose stacks are torn down only by the process that started them. A
docker-compose node records its stack during setup, and Teardown is a no-op
when that record is absent. A --teardown-only run is a fresh process that never
runs node setup, so it does not issue docker compose down for the stack — and,
because it reports no error, the run still shows node teardown as completed. For
the same reason, teardown steps targeting a docker-compose node fail in that
mode with compose stack not initialized, since there is no running stack handle
to exec into.
Note: Docker and LXD nodes are unaffected. They address the container or instance by name and treat “not found” as already cleaned up.
A stack left behind by an aborted run is cleaned with the same command DART would have issued, from the directory containing the compose file:
docker compose -f <compose_file> -p <project_name> downproject_name defaults to the node’s name when the option is omitted.
LXD Node Options
Section titled “LXD Node Options”| Option | Type | Default | Notes |
|---|---|---|---|
image |
string | — | Image reference; see LXD/Incus Auto-Detection for the recognised remotes. |
empty |
bool | false |
Create the instance with no source, so it boots from its devices. |
instance_type |
string | container |
container or virtual-machine. |
profiles |
list of strings | LXD’s default profile |
LXD profiles applied to the instance. |
config |
map | — | Instance configuration keys, applied at creation. |
devices |
map | — | Arbitrary LXD device configuration, merged over the NICs generated from networks. |
networks |
list of {name, ip} |
— | NIC devices attaching the instance to LXD networks. |
boot_wait |
map | — | Replaces the default readiness check; see Empty VMs and ISO Boot. |
exec_opts |
map | — | Currently one key, shell, defaulting to /bin/bash. |
project |
string | default |
LXD project the instance is created in. Not inherited from lxd.project. |
instance_name |
string | the node name | The instance’s name on the LXD/Incus server. |
socket |
string | auto-detected | Unix socket path; used only when the suite has no top-level lxd: block. |
server, protocol |
string | local, lxd |
Image server URL and protocol; used only with a bare image alias. |
remote_addr, trust_token, client_cert, client_key, server_cert, skip_verify |
— | — | Remote connection settings; used only when the suite has no top-level lxd: block. See Remote LXD Support. |
networks
Section titled “networks”networks: is a list of {name, ip} entries. Each entry becomes a NIC device on
the instance, named eth0, eth1, … in list order, with type: nic and
network: set to the entry’s name. Because the names follow the conventional
ethN sequence, an entry replaces the profile’s NIC of the same name — this is how
an lxd node attaches to a network declared under the suite’s lxd.networks: block.
ip is optional. When given it is parsed and set as ipv4.address or
ipv6.address according to the address family; a value that does not parse as an
IP address fails node setup with
invalid IP address for network <name>: <value>.
nodes: - name: test-container type: lxd options: image: ubuntu:24.04 instance_type: container networks: - name: test-network ip: 10.100.0.10Note: a node attaches to an existing network; it does not define one. Setting
subnet on a node-level entry is a configuration error naming the right place
for it — lxd.networks[].subnet, which is what actually creates the bridge.
The generated NICs are applied first, and any same-named key under devices
overwrites them, which is what makes devices usable for overriding a generated
NIC.
profiles
Section titled “profiles”profiles: is a list of LXD profile names applied to the instance. Omitting it
leaves the instance with LXD’s own default profile.
Warning: the list replaces the instance’s profiles rather than adding to them.
Naming any profile drops default, and with it the root disk and eth0 that
default normally supplies — the instance then fails to create, or comes up with
no storage and no network. Include default explicitly unless the named profiles
provide those devices themselves.
This is the only way to apply a profile declared under the suite’s lxd.profiles:
block: DART creates those profiles during platform setup but attaches them to
nothing, so a profile that is not named here is created, left unused, and deleted
at teardown.
exec_opts
Section titled “exec_opts”exec_opts.shell sets the shell DART runs commands through inside the instance and
defaults to /bin/bash. Every command is executed as <shell> -c "<command>",
which covers test and step commands, boot_wait.ready_command, the ready_command
of a reboot step or test, and the readiness poll after a snapshot restore.
nodes: - name: alpine-container type: lxd options: image: images:alpine/3.20 exec_opts: shell: /bin/sh # default: /bin/bashImages without bash — alpine, busybox, most minimal images, and an empty VM before
the install has landed — must set shell: to an interpreter that exists in the
image, otherwise every command and every readiness poll fails.
Note: with boot_wait configured but no ready_command, DART still polls
<shell> -c true, so a missing shell blocks readiness even when no command was
supplied. A missing shell surfaces as a boot_wait timeout naming the full argument
vector, since the per-poll exec error is not reported; a plain command failure
surfaces as an error executing command from LXD.
Note: exec_opts.shell is honoured by lxd nodes only. Docker nodes always exec
through sh -c and ignore exec_opts entirely.
Remote LXD Support
Section titled “Remote LXD Support”LXD nodes support remote connections using modern trust token authentication or traditional certificate-based authentication.
Note: node-level connection options and a top-level lxd: block are mutually
exclusive, and setting both is a configuration error caught by --check.
When a suite defines an lxd: block, DART builds a shared LXD platform wrapper.
That wrapper connects over a local Unix socket only — either the path in
lxd.socket or the auto-detected LXD/Incus socket. The lxd: block has no
remote-connection fields; it accepts only socket, project, networks,
profiles, and images.
Once that wrapper exists, every lxd and lxd-vm node is constructed through it
and reuses its connection. A node that also sets remote_addr, trust_token,
client_cert, client_key, server_cert, skip_verify, or socket is
rejected, naming every conflicting option:
Error: node "remote-box" sets remote_addr, trust_token, which selects an LXDserver, but the suite's lxd: block already connects to one — move theconnection settings to the lxd: block, or remove the lxd: block so the nodemanages its own connectionRationale: the wrapper creates the suite’s projects, networks, and profiles on one server. An instance created somewhere else would reference resources that do not exist there — and previously it was created locally in silence, so the tests passed against a machine the suite never named.
Targeting a remote LXD server therefore means omitting the top-level lxd: block
entirely and configuring the connection per node. The project, network, and profile
management features described under LXD Project Support are
local-socket only and cannot be combined with remote nodes.
examples/lxd/lxd-remote.yaml omits the lxd: block for this reason.
Trust Token Authentication (Recommended):
Configure the remote LXD server and generate a trust token:
# On the remote LXD serverlxc config set core.https_address "[::]:8443"lxc config trust add dart-client# Copy the generated tokenThe token goes in the node’s options:
nodes: - name: remote-container type: lxd options: remote_addr: https://10.0.0.1:8443 trust_token: eyJjbGllbnRfbmFtZSI6ImRhcnQtY2xpZW50... image: ubuntu:24.04 instance_type: containerCertificate-Based Authentication (Traditional):
Generate client certificates:
# Add remote server and generate certificateslxc remote add myremote https://remote-server-ip:8443The generated certificates go in the node’s options:
nodes: - name: remote-container type: lxd options: remote_addr: https://10.0.0.1:8443 client_cert: ~/.config/lxc/client.crt client_key: ~/.config/lxc/client.key # Optional: server_cert for custom CA # Optional: skip_verify: true (not recommended for production) image: ubuntu:24.04 instance_type: containerSee examples/lxd/lxd-remote.yaml for a complete example.
Empty VMs and ISO Boot
Section titled “Empty VMs and ISO Boot”LXD nodes are normally created from an image. To test an installer instead, create an empty virtual machine and attach the ISO as a boot device. DART creates the instance with no source, attaches the devices, starts it, and then waits for the install to finish rather than expecting the instance to answer right away.
nodes: - name: iso-vm type: lxd options: instance_type: virtual-machine
# Create the VM with no image; it boots from its devices instead empty: true
# Instance configuration keys, applied at creation config: security.secureboot: "false"
# Devices attached to the instance devices: iso: type: disk source: work/output/example-0.1.0-amd64.iso # Rank the ISO above the root disk so the installer boots first boot.priority: 10
# An installing VM is unreachable until it reboots from disk, so poll for it boot_wait: timeout: 1800 # Maximum seconds to wait (default 300) interval: 15 # Seconds between checks (default 2) initial_delay: 60 # Seconds before the first check (default 0) ready_command: cat /etc/hostname # Detach the installer media once the install powers the VM off, # then boot from disk eject_on_poweroff: [iso]Notes:
empty: truecreates the instance with no source. Omittingimagehas the same effect; setting bothempty: trueandimageis rejected.devicesaccepts any LXD device configuration and is merged over the NICs generated fromnetworks, so a node can override a generated device if it needs to.- Relative
sourcepaths on pool-less disk devices are made absolute against the suite file’s directory, the same ruledocker.images[].dockerfileand every other local path follows. A disk device that names apoolrefers to a storage volume and is passed through untouched, as are all sources on remote nodes, which are paths on the remote server. boot_waitreplaces the default readiness check: DART pollsready_commandthrough the node’s shell (exec_opts.shell, default/bin/bash) until it exits zero or the timeout expires. Withoutready_command, being able to run any command at all counts as ready. An installer image without bash must setexec_opts.shellaccordingly.boot_wait.eject_on_poweroffnames devices to detach when the instance powers itself off during the boot wait. An unattended install that ends by powering off — for example autoinstall’sshutdown: poweroff— leaves the ISO attached at a higherboot.prioritythan the root disk, so every subsequent boot would run the installer again and the readiness poll would never succeed. With this set, DART waits afterinitial_delayfor the instance to reach theStoppedstate, polling atintervaland bounded bytimeout, detaches each named device, starts the instance again, and only then begins pollingready_command. The wait for poweroff and the readiness poll each get the fulltimeout, so the worst-case setup time for such a node is roughly twicetimeout. A device name that is not present on the instance fails node setup withdevice "<name>" in eject_on_poweroff not found on instance <node>; the names must match the keys of the node’s owndevices:map. A device inherited from a profile is not in the instance’s local device map and is rejected as not found, so install media that must be ejected has to be declared on the node rather than on a profile.timeout,interval, andready_commandare not creation-only: the same readiness poll runs again after arebootstep or test on this node — where the step’s ownready_commandandtimeouttake precedence, butintervalstill comes fromboot_wait— and after a snapshot restore, which has no per-step override. A longtimeoutchosen for an ISO install is therefore also the ceiling for every later reboot and restore, while a node with noboot_waitgets the five-minute and two-second defaults and a bare readiness check.initial_delayandeject_on_poweroffapply only at creation.- An empty instance with no
boot_waitis started and left alone, since it has no guest agent to wait for.
See examples/lxd/lxd-iso-vm.yaml for a complete example.
Node Readiness
Section titled “Node Readiness”Node setup blocks until the target accepts commands. The bounds differ by node type
and are not the same as the boot_wait values above.
- Docker nodes. After the container is started, DART blocks until it reports
Runningandsh -c trueexits zero inside it, polling every second for up to two minutes; failing to reach that state fails node setup withcontainer <name> not ready. The bound is fixed — no YAML option changes the docker timeout or poll interval. A container whose image has noshnever satisfies the check. - LXD nodes, default path. Unless
boot_waitis set, DART polls every two seconds for up to five minutes until all three conditions hold: instance status isRunning; at least one interface reports an address with global scope, so loopback and link-local addresses do not count; andtrueexecutes successfully through the guest agent. Because the address check must pass, an instance attached only to a network with no address assignment — or with no NIC at all — never becomes ready and fails after the full five minutes withtimeout waiting for instance <name> to become ready. boot_waitreplaces that check entirely, including the running-status and global-address requirements: DART only polls<shell> -c <ready_command>(defaulting totrue) until it exits zero. That is what makes it usable for an instance that is unreachable mid-install, and it is also the way to opt out of the address requirement or to change the five-minute bound (timeout) and two-second poll (interval) — those twoboot_waitdefaults are the same values the default path uses.- An
empty: trueinstance with noboot_waitskips readiness entirely and is started and left alone.
Local and SSH nodes have no readiness wait; their Setup is a no-op.
LXD/Incus Auto-Detection
Section titled “LXD/Incus Auto-Detection”DART automatically detects whether the host system has LXD or Incus installed and configures the appropriate socket path. This allows test configurations to be portable across systems without modification.
Detection Priority:
/var/lib/incus/unix.socket(Incus)/var/snap/lxd/common/lxd/unix.socket(LXD snap)/var/lib/lxd/unix.socket(LXD native)
Explicit socket paths:
socket: can be set at the suite level (lxd.socket) or per node
(options.socket). A node-level socket is used only when the suite has no lxd:
block; with an lxd: block present, every LXD node connects through the platform
wrapper and its own socket value is ignored.
When a socket path is supplied explicitly, the runtime is inferred by exact string
match: /var/lib/incus/unix.socket is treated as Incus, and every other path is
treated as LXD. A custom, rootless, or bind-mounted Incus socket is therefore
classified as LXD and image names are not translated — ubuntu:24.04 is looked up
unchanged against the LXD ubuntu remote. Incus-native image references such as
images:ubuntu/24.04/cloud avoid the problem with non-standard socket paths.
Auto-detection, used when neither socket nor remote_addr is set, probes the
three listed paths and does classify the runtime correctly.
Image Name Translation:
When Incus is detected, DART rewrites ubuntu: references only. Canonical’s
simplestreams endpoint behind ubuntu: does not serve Incus clients, so the
reference is redirected to the equivalent image on the linuxcontainers server,
where Ubuntu images live under ubuntu/<release> and the cloud-init build
carries a /cloud suffix:
ubuntu:24.04becomesimages:ubuntu/24.04/cloudubuntu:24.04/cloudbecomesimages:ubuntu/24.04/cloud— an explicit variant is not doubled
Every other reference is passed through as written, including images:,
lxc:, a private or self-hosted remote, and any reference with no remote at
all. All references are passed through when the runtime is LXD.
Image sources:
The image remotes DART understands are a closed set of three aliases:
| Prefix | Server | Protocol |
|---|---|---|
ubuntu: |
https://cloud-images.ubuntu.com/releases |
simplestreams |
images: |
https://images.linuxcontainers.org |
simplestreams |
lxc: |
https://images.linuxcontainers.org |
simplestreams |
These are not lxc remote names: a remote added with lxc remote add is unknown to
DART.
- On an LXD host, and on any remote LXD connection via
remote_addr, animage:whose prefix is not one of those three fails node construction withunknown image server alias: <prefix>before any instance is created. On an Incus host the reference is rewritten first, so an unrecognised prefix is not rejected up front — it turns into animages:lookup that fails later as image-not-found. - An
imagewith no colon is not expanded to any remote. It is used as-is with the node’sserverandprotocoloptions, which default tolocalandlxd, so the alias resolves against the connected LXD/Incus server rather than a public image server. server:(an image server URL, such as a private simplestreams mirror) andprotocol:(lxdorsimplestreams) are the escape hatch for other image servers. Both are overwritten wheneverimagecontains aremote:aliasprefix, so they take effect only with a bare alias.
Limitations:
This auto-detection provides basic compatibility but has limitations. Production suites and complex scenarios are better served by configuring test definitions explicitly for the target virtualization platform:
# Explicit socket configuration (recommended for production)lxd: socket: /var/lib/incus/unix.socket
nodes: - name: test-container type: lxd options: image: images:ubuntu/24.04 # Use Incus-native format instance_type: containerLXD Project Support
Section titled “LXD Project Support”LXD projects provide resource isolation and organization within LXD. DART supports creating and managing LXD projects, automatically copying the default profile, and organizing instances, networks, and profiles within projects.
Benefits of Using Projects:
- Resource Isolation: Separate test environments without conflicts
- Organization: Group related resources together
- Scoped Cleanup: Teardown removes the profiles and networks DART created for the project, then the project itself
- Multi-tenancy: Run multiple test suites in parallel
Note: the lxd: platform block connects over a local Unix socket only. Projects,
networks, and profiles cannot be created on a remote LXD server, and adding this
block causes any node-level remote_addr or trust_token settings to be ignored.
See Remote LXD Support.
Configuring a Project:
lxd: project: name: dart-test-project # required description: Test project # optional config: # optional; these four default to "true" features.images: "true" features.profiles: "true" features.networks: "true" features.storage.volumes: "true" # e.g. features.networks: "false" to share the default project's networks
# Networks are created within the project, always as bridges networks: - name: test-network subnet: 10.100.0.0/24 # required; must be valid CIDR notation gateway: 10.100.0.1 # required; must be a valid IP address nat: true # optional; defaults to true when omitted
# Profiles are created within the project # The default profile is automatically copied profiles: - name: custom-profile description: Custom profile for tests config: limits.cpu: "2" limits.memory: "2GB" # Devices attached to every instance using the profile devices: root: type: disk # required; the only key always sent to LXD path: / pool: default
nodes: - name: test-container type: lxd options: # Required: instances are NOT placed in lxd.project automatically project: dart-test-project image: ubuntu:24.04 instance_type: container profiles: [default, custom-profile] networks: - name: test-networkProject configuration:
- The whole
lxd.projectblock is optional. When present,nameis the only required field — an empty name fails platform setup withproject name cannot be empty.descriptionandconfigare optional and are not validated. - All four
features.*keys in the example are DART’s own defaults rather than requirements. They are injected as"true"only when absent fromconfig, so omittingconfig:entirely produces an identical project. Values are plain strings and must be quoted in YAML. - Any value supplied for one of those keys is passed through unchanged — setting
features.networks: "false", for example, makes the project inherit that resource class from LXD’sdefaultproject instead of owning its own copy. Other LXD project configuration keys may also be set; DART forwards the map verbatim and only fills in the four defaults.
Network fields:
- Every
lxd.networks[]entry is created as an LXD bridge network. Atype:key is accepted by the configuration parser but never read, so writingtype: ovnsilently produces a bridge with no warning. subnetmust be valid CIDR notation andgatewaymust be a valid IP address. Both are validated locally before any request reaches the LXD server, so a malformed value fails platform setup withnetwork <name>: subnet "<value>" is not valid CIDR notationornetwork <name>: gateway "<value>" is not a valid IP address. The bridge address is composed fromgatewayplus the prefix length taken fromsubnet.natdefaults totruewhen omitted and sets bothipv4.natandipv6.naton the bridge.nat: falseyields an air-gapped bridge: instances still receive addresses from the bridge and can reach each other and the gateway, but have no NATed route off the host. Both families must be closed together, because LXD auto-assigns an IPv6 subnet with NAT enabled — disabling only IPv4 NAT would leave outbound IPv6 working. DART does not setipv6.address, so LXD still auto-assigns an IPv6 subnet; only NAT is disabled.- Only the IPv4 subnet and gateway are configurable. There is no field for an IPv6 subnet, DNS, or other bridge options.
Profile devices:
-
Profile devices are keyed by device name. Only
type,path,pool, andnameare first-class keys.typeis always sent to LXD;path,pool, andnameare sent only when non-empty. -
Every other LXD device key —
source,nictype,parent,boot.priority, and so on — must be nested underopts::devices:eth0:type: nicname: eth0opts:nictype: bridgedparent: test-networkValues under
opts:and under a profile’sconfig:are decoded as strings, so numeric and boolean values must be quoted (boot.priority: "10",limits.cpu: "2"); an unquoted number fails configuration loading with a YAML type error.Keys placed at the top level of a profile device are discarded by the YAML parser, so a device copied from the node-level flat form loses everything except those four keys.
-
optsentries are merged into the device map after the first-class keys, so anoptsentry namedtype,path,pool, ornameoverrides the first-class value. -
Warning: profile devices behave differently from node-level
options.devices, where the device map is flat and accepts arbitrary LXD keys directly, a missingtypeis reported as an error, and a relativesourceon a pool-less disk device is resolved to an absolute path. Profile devices get none of those three behaviours — an emptytypeis passed to LXD unchecked, andopts.sourceis sent verbatim, so profile disk sources must be absolute paths on the LXD host.
Important Notes:
- The default profile is automatically copied to new projects.
- Projects are created during setup and deleted during teardown.
- Each
lxdnode must setproject:explicitly to be created insidelxd.project. Omitting it makes the node use LXD’sdefaultproject: an unsetprojectdefaults to the literal"default", and the node’s LXD client is captured when the node is constructed — before the LXD platform setup switches the wrapper’s server to the configured project. The instance is therefore created indefaulteven thoughlxd.networksandlxd.profileswere created inside the named project. A node that then references one of those project-scoped networks or profiles fails, and project teardown, which only enumerates instances inside the named project, does not see the stray instance. - A profile declared under
lxd.profilesis attached to nothing unless a node names it inprofiles:; likewise a network underlxd.networksis joined only by nodes that name it innetworks:. - Project deletion does not cascade. Teardown removes the profiles listed under
lxd.profiles(thedefaultprofile is never deleted), then the networks listed underlxd.networks, then the project. Resources that no longer exist are treated as already removed. - Instances are removed by node teardown, not by project deletion. If any instance
still exists in the project, teardown fails with
project <name> still contains <N> instance(s), cannot delete, and the project — along with anything DART did not explicitly create — is left in place. - In a normal run a failing node teardown aborts the ordered teardown sequence. The
error-cleanup path then retries node teardown best-effort and still attempts
platform teardown, so an instance that could not be removed surfaces as
Error cleaning up lxd environment: project <name> still contains ...and leaves the project behind. With--teardown-onlythe project is not deleted at all, because the project name is recorded only during setup; only the configured profiles and networks are removed. - Resources DART did not create inside the project — extra instances, networks, profiles, storage volumes — are never removed.
- Note: the
lxd:block also accepts animages:key (alias,server,protocol), but it is unimplemented. The LXD platform manager reads onlysocket,project,networks, andprofiles, so anything underlxd.imagesis parsed and then ignored without a warning. Image selection is per node, via the node’simage:option.
See examples/lxd/lxd-project.yaml for a complete example.