ARD is a local development and test harness for VM-backed DevStack deployments. The primary local workflow uses Ansible provider playbooks to provision libvirt VMs, generate an ARD/Zuul-like inventory, and then run the existing DevStack deployment roles inside those VMs.
The primary local provider target is libvirt. KubeVirt and static are also provider paths; static targets pre-provisioned SSH hosts.
Docs navigation was reorganized; see:
docs/repo-navigation.mdfor repo layout and entry pointsdocs/README.mdfor the documentation indexdocs/concepts/ard-render-model.mdfor user-facing render model concepts and examplesdocs/providers/kubevirt-prerequisites.mdfor OpenShift/KubeVirt setup requirementsdocs/architecture/render-contracts.mdfor contributor render/provider contractsansible/README.mdfor Ansible structure and canonical playbook paths
Bootstrap the repository and host dependencies:
./bootstrap-repo.shRender a named deployment workspace:
make render ARD_DEPLOYMENT=devstack-aCreate the VMs and generated inventory:
make apply ARD_DEPLOYMENT=devstack-a
make ping ARD_DEPLOYMENT=devstack-aSSH to a deployment node, or print the SSH command without running it:
make ssh ARD_DEPLOYMENT=devstack-a ARD_NODE=controller
make ssh-print ARD_DEPLOYMENT=devstack-a ARD_NODE=compute-1
# equivalent explicit dry-run/print mode
make ssh ARD_DEPLOYMENT=devstack-a ARD_NODE=compute-1 ARD_SSH_PRINT=1For KubeVirt clusters where the Kubernetes API is reachable over VPN but VMs
are not exposed with LoadBalancer services, OpenSSH can use virtctl port-forward as a proxy. Place this before any broad Host * block in
~/.ssh/config so the VM-specific settings take precedence:
Host vm/* vmi/*
ProxyCommand virtctl port-forward --stdio=true %h %p
StrictHostKeyChecking accept-new
LogLevel QUIET
ConnectTimeout 10
User stack
IdentityFile ~/.ssh/id_ed25519_stack
ForwardX11 no
ForwardAgent yes
ControlMaster no
ControlPath none
ServerAliveInterval 30
ServerAliveCountMax 3
Compression yesDeploy DevStack:
make deploy ARD_DEPLOYMENT=devstack-a
make verify ARD_DEPLOYMENT=devstack-aDestroy provider resources when finished:
make destroy ARD_DEPLOYMENT=devstack-aRemove the local deployment workspace only when you no longer need the rendered inputs or generated state:
make cleanup ARD_DEPLOYMENT=devstack-aThe normal local workflow is:
render: create a concrete deployment workspace from presets and optional overlays.apply: create libvirt network/domain/disk/seed resources, generate inventory, and wait for SSH/cloud-init readiness.ping: verify SSH/Ansible connectivity again.deploy: run the multinode DevStack playbook.verify: run basic post-deploy checks.destroy: remove libvirt resources but keep the workspace and generated artifacts for inspection.clean-generated: remove generated inventory/state/rendered artifacts without touching provider resources.cleanup: delete the workspace.
A deployment workspace lives under:
deployments/<deployment-name>/
render.yaml # optional persistent render intent
local-vars.yaml # optional site/workload vars for lifecycle playbooks
deployment.yaml # generated by render
nodes.yaml # generated by render
devstack/
common.yaml
group_vars/
controller.yaml
compute.yaml
host_vars/
inventory.yaml # generated by apply
provider-state.yaml # generated by apply
rendered/ # generated provider artifacts
logs/
render treats deployment.yaml, nodes.yaml, and devstack/*.yaml as generated output and may overwrite them. Keep render intent in render.yaml, an external render file, or a deployment-local overlay such as overrides/render.yaml. Keep site-local or workload deployment variables in local-vars.yaml; Make automatically passes that file to lifecycle playbooks when it exists.
After a workspace has been rendered, later lifecycle commands only need the logical deployment name. The workspace directory remains deployments/<deployment> even when provider resources, such as KubeVirt VMs in a shared namespace, use a user-prefixed resource name. If the workspace contains render.yaml, make render ARD_DEPLOYMENT=<name> also reloads it automatically.
make render ARD_DEPLOYMENT=oko-cyborg
make apply ARD_DEPLOYMENT=oko-cyborg
make ping ARD_DEPLOYMENT=oko-cyborg
make deploy ARD_DEPLOYMENT=oko-cyborg
make destroy ARD_DEPLOYMENT=oko-cyborgThe static provider runs workloads on pre-provisioned SSH hosts. A static
render intent supplies logical inventory nodes and their SSH transport details;
make apply discovers the host addresses used by workload services.
A complete CentOS Stream 10 AIO example with Cyborg and the pci-sim kernel
module is available under
examples/devstack/static-cyborg-pci-sim/:
make render \
ARD_DEPLOYMENT=static-cyborg-pci-sim \
ARD_RENDER_FILE=examples/devstack/static-cyborg-pci-sim/render.yaml
make apply ARD_DEPLOYMENT=static-cyborg-pci-sim
make ping ARD_DEPLOYMENT=static-cyborg-pci-sim
make deploy ARD_DEPLOYMENT=static-cyborg-pci-simThe host must already be reachable by SSH with passwordless sudo. See
docs/providers/static.md for node declarations,
address discovery, lifecycle behavior, and host reuse.
The equivalent managed-VM example uses the same Cyborg configuration with a CentOS Stream 10 guest on a remote libvirt execution host:
examples/devstack/remote-libvirt-cyborg-pci-sim/
make render \
ARD_DEPLOYMENT=remote-libvirt-cyborg-pci-sim \
ARD_RENDER_FILE=examples/devstack/remote-libvirt-cyborg-pci-sim/render.yaml
# Add the execution host to deployment-local local-vars.yaml, then:
make apply ARD_DEPLOYMENT=remote-libvirt-cyborg-pci-sim
make deploy ARD_DEPLOYMENT=remote-libvirt-cyborg-pci-simThe remote-libvirt OKO example demonstrates that the same provider transport also composes with a MicroShift control plane and two pre-provisioned EDPM compute nodes. It reuses the existing OKO workload topology unchanged:
examples/oko/remote-libvirt-microshift-two-edpm-compute/
make render \
ARD_DEPLOYMENT=remote-libvirt-oko \
ARD_RENDER_FILE=examples/oko/remote-libvirt-microshift-two-edpm-compute/render.yaml
# Add the execution host to deployment-local local-vars.yaml, then:
make apply ARD_DEPLOYMENT=remote-libvirt-oko
make deploy ARD_DEPLOYMENT=remote-libvirt-oko
make verify ARD_DEPLOYMENT=remote-libvirt-okoThe root Makefile wraps the provider playbooks. The default target is a full
local rebuild workflow:
make
# equivalent to
make defaultmake default runs destroy-clean-generated, cleanup, render, apply,
ping, deploy, and verify in order. The initial destroy and cleanup steps
are best-effort so the target also works from a fresh checkout.
Individual workflow targets are also available:
make render
make apply
make ping
make ssh
make ssh-print
make deploy
make verify
make destroy
make destroy-clean-generated
make clean-generated
make cleanup
make siteUseful variables:
ARD_DEPLOYMENT deployment name, default devstack-1
ARD_DEPLOYMENTS_DIR deployment parent dir, default ./deployments
ARD_DEPLOYMENT_DIR full workspace path; defaults to deployments/<deployment>
ARD_PROVIDER provider, default libvirt; required for first render when selecting another provider
ARD_TOPOLOGY topology preset
ARD_TARGET_BRANCH DevStack target branch, default master
ARD_WORKLOAD workload to deploy, default devstack; use microshift for MicroShift
ARD_SERVICES comma-separated service profiles, default devstack,ovn,tempest; empty for MicroShift
ARD_PROVIDER_PROFILE provider profile, default local-libvirt
ARD_IMAGE optional image key override; defaults to centos-stream-10 for MicroShift
ARD_NETWORK_CIDR libvirt management CIDR, default 192.168.96.0/24
ARD_RENDER_FILE optional render intent file; defaults to existing $(ARD_DEPLOYMENT_DIR)/render.yaml
ARD_NODE inventory node for make ssh, default controller
ARD_SSH_PRINT print SSH command without running it when set to 1
ARD_SSH_ARGS extra arguments passed to ssh
ARD_EXTRA_VARS extra Ansible vars appended to provider commands
When $(ARD_DEPLOYMENT_DIR)/local-vars.yaml exists, Make automatically passes it to apply, deploy, verify, destroy, destroy-clean-generated, and cleanup before ARD_EXTRA_VARS. Use it for local proxy settings, workload deploy-time settings, or development overrides that should persist with a rendered workspace but are not part of provider/topology render intent.
make apply is the provider-create phase: it creates provider resources, waits for SSH/cloud-init, and writes inventory.yaml plus provider-state.yaml. make deploy is the workload-converge phase: it uses reusable workload converge playbooks under ansible/playbooks/workloads/*/converge*.yaml. Top-level Molecule scenarios delegate to the same converge playbooks, so Make and Molecule exercise the same workload flow.
For KubeVirt OKO deployments, make deploy maps to the same high-level converge flow used by molecule/kubevirt-oko: deploy MicroShift when needed, configure the ARD multinode bridge overlay, then apply OKO network, control-plane, and dataplane resources. This keeps make apply equivalent to Molecule create and make deploy equivalent to Molecule converge.
Example:
make render \
ARD_DEPLOYMENT=devstack-a \
ARD_TARGET_BRANCH=master \
ARD_TOPOLOGY=one-controller-two-compute \
ARD_SERVICES=devstack,ovn,tempest \
ARD_NETWORK_CIDR=192.168.99.0/24Use a unique deployment name and management CIDR for each local deployment:
make render ARD_DEPLOYMENT=devstack-a ARD_NETWORK_CIDR=192.168.99.0/24
make render ARD_DEPLOYMENT=devstack-b ARD_NETWORK_CIDR=192.168.100.0/24Provider resources are named with the deployment name, for example:
ard-devstack-a-controller
ard-devstack-a-compute-1
Inventory hostnames remain logical names such as controller, compute-1, and
compute-2.
Supported local render presets:
all-in-one
one-controller-one-compute
one-controller-two-compute
all-in-one renders:
controller
one-controller-one-compute renders:
controller
compute-1
one-controller-two-compute renders:
controller
compute-1
compute-2
Topology presets are built from generic node pools. Singleton pools can set an explicit name such as controller; counted pools default to readable hyphenated names such as {type}-{index}. Multinode topologies disable nova-compute on the controller through the rendered controller group vars when the topology says the controller does not run compute services.
The examples/ tree is the primary interface for reusable render intent. The
same example can be used through Make, Molecule, or direct Ansible. Examples
carry ard_render_schema_version: 1 and describe workload/topology intent;
provider, identity, namespace, CIDR, and scenario-specific values can be
overridden by the caller.
make render \
ARD_DEPLOYMENT=stable-test \
ARD_RENDER_FILE=examples/devstack/aio-plus-compute/render.yaml \
ARD_TARGET_BRANCH=stable/2026.1 \
ARD_NETWORK_CIDR=192.168.98.0/24
uv run ansible-playbook -i localhost, ansible/playbooks/provider/render.yaml \
-e ard_deployment_name=stable-test \
-e @examples/devstack/aio-plus-compute/render.yamlMolecule scenarios set provisioner.ard_render_file to one of these examples
and keep scenario-local overrides under provisioner.ard.
See docs/concepts/ard-render-model.md for the render model, provider identity,
node classes, networks, and state artifacts. See
docs/architecture/render-contracts.md for contributor-level schema and merge
contracts.
Use ard_render_overrides for simple kustomize-like customizations. Overrides use ordinary recursive dictionary merge semantics: later dictionaries replace scalar and list values for the relevant section.
ard_management_network: ard-mgmt
ard_render_overrides:
provider_defaults:
image: ubuntu-24.04
node_pools:
compute:
count: 2
flavor: devstack-compute
profiles:
- ssh
- nested_virt
- performance
networks:
- name: ard-mgmt
ip_start: 3
- name: storage
ip_start: 20
networks:
storage:
cidr: 192.168.120.0/24
provider_network: ard-storage
devstack:
common:
enable_ceph: true
controller:
controller_localrc_extra:
DEBUG_LIBVIRT_COREDUMPS: true
ard_render_node_overrides:
compute-2:
image: ubuntu-24.04
flavor: devstack-compute
profiles:
- ssh
- nested_virt
- gpu
networks:
ard-mgmt:
ip: 192.168.98.50The libvirt provider supports multiple rendered networks. ard_management_network selects which attached network is used for SSH inventory and nodepool.private_ipv4; additional networks are rendered as extra libvirt networks and attached as additional VM interfaces.
The built-in tenant network preset is isolated and opt-in. It is not attached by default, but can be added to a node pool when guests need a bridge-only network for their own VLANs, DHCP, or overlay experiments:
ard_render_overrides:
node_pools:
compute:
networks:
- name: ard-mgmt
ip_start: 3
- name: tenant
mac_start: 20Isolated networks render as libvirt networks without host-side IP, NAT, or DHCP. Guest interfaces are still given deterministic MAC addresses and stable interface names.
Current service profiles are:
devstack
ovn
tempest
ceph
Reusable provider defaults live in the ARD provider common role. Deployment workspaces normally only select the image/flavor by name instead of embedding the full registry.
Current image keys:
debian-13
ubuntu-24.04
centos-stream-10
fedora-eln
almalinux-10
rocky-linux-10
Current flavor keys:
devstack-control 8 vCPU, 16 GiB RAM, 80 GiB disk
devstack-compute 8 vCPU, 8 GiB RAM, 80 GiB disk
microshift-node 8 vCPU, 8 GiB RAM, 80 GiB disk
The default local image is Debian 13 genericcloud.
Base cloud images are cached under:
$XDG_CACHE_HOME/ard/images
or, if XDG_CACHE_HOME is unset:
~/.cache/ard/images
Per-deployment libvirt disks, seed ISOs, NVRAM, and console logs live under:
$XDG_STATE_HOME/ard/libvirt/images/<deployment-name>
or, if XDG_STATE_HOME is unset:
~/.local/state/ard/libvirt/images/<deployment-name>
make destroy removes per-deployment provider resources but keeps cached base
images and generated workspace artifacts for inspection. Use
make destroy-clean-generated to destroy provider resources and then remove
inventory.yaml, provider-state.yaml, and rendered/. Use
make clean-generated to remove those generated files without touching
provider resources.
Top-level Molecule scenarios are full ARD-backed validation flows. They call
the same provider playbooks used by Make and do not use Vagrant. Migrated
scenarios load reusable intent from provisioner.ard_render_file and keep only
scenario-local overrides in molecule.yml under provisioner.ard; Molecule
platforms are intentionally omitted so topology and node names are defined
only once by ARD render presets.
Available scenarios:
default Debian 13, controller + compute-1, master
one-controller-two-compute Debian 13, controller + compute-1 + compute-2
stable-2026.1 Ubuntu 24.04, controller + compute-1, stable/2026.1
Run a full scenario test:
uv run molecule test -s defaultFor a cheaper loop:
uv run molecule create -s default
uv run ansible -i molecule/default/deployment/inventory.yaml all -m ping
uv run molecule converge -s default
uv run molecule verify -s default
uv run molecule destroy -s defaultRole-level Molecule scenarios live under ansible/roles/*/molecule and use
Podman where containers are sufficient:
make molecule-test
make molecule-role-ensure_kustomizeThe libvirt provider can execute through Ansible on a remote virtualization
host while keeping deployment.yaml, generated inventory, rendered artifacts,
and provider-state.yaml in the local deployment directory. Configure the host
in deployment-local local-vars.yaml:
ard_libvirt_execution_host:
name: virt-host
ansible_host: hypervisor.example.com
ansible_user: virt-admin
ansible_port: 22
ansible_private_key_file: ~/.ssh/id_ed25519
ansible_ssh_common_args: ""
ansible_become: falseThe execution host defaults to localhost. In both modes ARD runs host-local
virsh with qemu:///system; remote libvirt transport URIs are not used.
Libvirt cloud-init only makes guests reachable by Ansible. The shared provider
apply and workload roles perform subsequent configuration, preserving the same
flow used by static and KubeVirt providers.
Images, VM disks, seed media, and console logs live on the execution host. Their
resolved paths and the non-secret SSH connection metadata are recorded in
provider-state.yaml before resources are created, so later apply, destroy,
deploy, verify, and make ssh commands use the same target. Private key
contents and passwords are never copied into deployment state.
When VM management addresses are not routed to the controller, the generated
workload inventory automatically proxies guest SSH through the execution host.
The same inventory drives apply readiness, workload deployment, verification,
and make ssh, including distinct hypervisor and guest keys or a nonstandard
hypervisor SSH port.
Override execution-host storage locations when needed:
ard_libvirt_execution_image_dir: /var/lib/ard/libvirt/images
ard_libvirt_execution_image_cache_dir: /var/cache/ard/imagesThe remote account still needs the permissions required for qemu:///system,
plus virsh, qemu-img, setfacl, compatible QEMU/KVM firmware, and access to
the configured storage paths and either cloud-localds or genisoimage for
NoCloud seed media. CentOS Stream 10 uses the EPEL genisoimage package. The
controller needs ssh-keygen.
Host package installation is deliberately separate from normal provider
apply. To opt in to bootstrapping a virtualization host, run the dedicated
playbook against an explicit inventory target:
uv run ansible-playbook \
-i virt-host, \
ansible/playbooks/provider/bootstrap-libvirt-host.yaml \
-e ard_libvirt_bootstrap_user=stackThe playbook contains Debian-family and Red Hat-family package mappings,
installs the libvirt/QEMU, firmware, ACL, and NoCloud seed tooling, enables the
appropriate libvirt service sockets, and verifies qemu:///system. The CentOS
Stream 10 path has been exercised against a live execution host. Validate or
override package choices for other distributions. On CentOS it enables EPEL to install
genisoimage. Omit ard_libvirt_bootstrap_user if group
membership should not be changed. Reconnect after adding a user to libvirt.
The provider uses host-local qemu:///system on the selected execution host.
That account normally needs libvirt/qemu group access. If bootstrap reports
missing group membership, log out and back in or use newgrp libvirt before
running provider commands.
On SELinux hosts, apply labels each generated console log for virtlogd.
This per-file chcon label is not a persistent file-context policy; if an
administrator runs restorecon over the image tree, rerun apply before
restarting affected guests.
Libvirt firmware auto-selection is used for UEFI boot with secure boot disabled. Install the OVMF/edk2 firmware package for your distribution if libvirt cannot define or start the domain.
apply creates VMs and inventory, then waits for SSH and cloud-init completion
before returning. If apply times out, inspect the serial console logs under the
libvirt deployment state directory or retry:
make apply ARD_DEPLOYMENT=devstack-aMolecule create uses the same apply playbook, so it also waits for node readiness before converging.
Use a management CIDR that does not conflict with existing host networks or other ARD deployments:
make render ARD_DEPLOYMENT=devstack-b ARD_NETWORK_CIDR=192.168.100.0/24Destroy provider resources while preserving generated artifacts for inspection:
make destroy ARD_DEPLOYMENT=devstack-aDestroy provider resources and remove generated inventory/state/rendered files:
make destroy-clean-generated ARD_DEPLOYMENT=devstack-aRemove generated files without touching provider resources:
make clean-generated ARD_DEPLOYMENT=devstack-aIf the workspace is no longer needed:
make cleanup ARD_DEPLOYMENT=devstack-aGenerated runtime files are ignored by git:
inventory.yaml
provider-state.yaml
rendered/
logs/