KVM/libvirt VM provisioning with cloud-init via
dmacvicar/libvirt.
Supports cloud-init user-data injection, optional additional data
disks, and configurable CPU, memory, and disk resources.
Provider pin: ~> 0.9.0
(ADR-0002 pin rule;
bumped 0.8→0.9 in
ADR-0016). Cloud-init
defaults:
ADR-0004.
- OpenTofu ≥ 1.10 (
versions.tf). The floor is aligned with the productionuse_lockfile = truetarget (ADR-0003) and is the oldest version CI exercises in practice; CI runs1.12(.opentofu-version). - A running libvirt/KVM host accessible via the provider's
uri - A cloud-init compatible base image. The
base_imageinput is version-neutral — both Ubuntu 24.04 LTS (noble,cloud-images.ubuntu.com/noble/) and Ubuntu 26.04 LTS (resolute, kernel 7.0,cloud-images.ubuntu.com/resolute/) work. The shippedcloud_init.cfgis distro-neutral: netplan and cloud-init behaviour is unchanged across the two LTS releases, so no template edit is needed to switch. The only caveat is the image path/URL itself (set viavar.base_image); the secure cloud-init defaults are identical. - An existing libvirt network named by
var.network_name(default:default) — the module does not create the network - An existing libvirt storage pool named by
var.storage_pool(default:default) — the module does not create the pool
module "k3s_server" {
source = "../../modules/libvirt-vm"
vm_name = "k3s-server-01"
vcpus = 2
memory_mib = 4096
disk_size_gib = 30
base_image = var.base_image_path
network_name = "default"
ssh_public_key = var.ssh_public_key
}| Name | Type | Default | Description |
|---|---|---|---|
vm_name |
string |
— | VM hostname (validated as an RFC 1123 label) |
vcpus |
number |
2 |
Virtual CPU count (≥ 1) |
memory_mib |
number |
2048 |
Memory in MiB (≥ 512) |
disk_size_gib |
number |
20 |
Root disk size in GiB (≥ 1, and ≥ base image virtual size) |
base_image |
string |
— | Path or URL to a cloud-init compatible base image (version-neutral: Ubuntu 24.04 noble or 26.04 resolute) |
network_name |
string |
"default" |
Libvirt network name |
storage_pool |
string |
"default" |
Libvirt storage pool for volumes and the cloud-init disk |
ssh_public_key |
string |
— | SSH public key for cloud-init injection (sensitive, validated) |
additional_disks |
list(object({name, size_gib})) |
[] |
Optional additional data disks (unique names, size ≥ 1 GiB) |
autostart |
bool |
true |
Start VM on host boot |
graphics |
object({type, listen_address?, autoport?}) |
null |
Optional graphics device. null omits graphics entirely (secure default, ADR-0008); set it only for VMs that need a SPICE/VNC console |
| Name | Description |
|---|---|
vm_id |
Libvirt domain ID |
vm_name |
Libvirt domain name |
ip_address |
VM IP address from DHCP lease, or null if no lease is available |
mac_address |
VM MAC address, or null if unavailable |
data_disk_ids |
Map of additional_disks name to libvirt volume ID (empty when none configured) |
cloudinit_disk_id |
Libvirt volume ID of the cloud-init NoCloud disk |
The domain ships with a serial console (devices.serials + a matching
devices.consoles aliased to it, so virsh console <vm> works) and no
graphics device by default. SPICE and VNC listeners are intentionally omitted.
Rationale, threat model, and operator note in
ADR-0008.
Operators who need graphical access for a specific VM set the optional
graphics input rather than forking the module — the secure
no-listener default holds whenever graphics is left null:
module "workstation" {
source = "../../modules/libvirt-vm"
# ... required inputs ...
graphics = {
type = "spice"
listen_address = "127.0.0.1" # keep the listener host-local
}
}The shipped cloud_init.cfg:
- Sets the VM hostname
- Adds the provided SSH public key to the
ubuntuuser - Locks the
ubuntuuser password (lock_passwd: true), disables password authentication (ssh_pwauth: false) and root login (disable_root: true) - Runs
package_updateon first boot (no upgrade — operator action) - Installs and enables
qemu-guest-agent(the domain attaches anorg.qemu.guest_agent.0virtio channel — the 0.9.x equivalent of 0.8.x'sqemu_agent = true— so libvirt can report guest state)
Native OpenTofu tests live in tests/ and mock the libvirt
provider (mock_provider "libvirt"), so they need no libvirtd:
tofu init -backend=false
tofu testtests/validation.tftest.hcl— every input validation rejects bad input at plan time (bad hostnames, malformed/emptyssh_public_key, sub-floormemory_mib, duplicateadditional_disksnames).tests/module.tftest.hcl— positive assertions: the deterministic NoCloud meta-data (ADR-0007), GiB-to-byte disk math, one volume per additional disk, the ADR-0004 cloud-init security invariants (ssh_pwauth: false,disable_root: true,lock_passwd: true), and thegraphicsdefault /override behaviour. CI runs the suite as theModule Testsjob.
- The base image is cloned into a per-VM backing volume; the root disk
is a thin-provisioned overlay on top of it. Because the backing
volume is created per module instance (named
<vm_name>-base.qcow2), provisioning N VMs from the same image creates N copies. For large fleets, manage a single shared base volume outside this module and reference it. disk_size_gibmust be ≥ the virtual size ofbase_image. A smaller value fails at apply with a libvirt volume error; the root overlay cannot be smaller than its backing store.- Additional disks are created as separate volumes and attached after
the root disk (vda) as vdb, vdc, … in declared
additional_diskslist order, with the cloud-init CD-ROM last. The module provisions and attaches the raw block devices only — partitioning, formatting (mkfs), and mounting are the configuration-management (Ansible) layer's responsibility, consistent with the infra/config-management split in ADR-0004. Nofs_setup/mountsdirectives are injected into cloud-init. Thedata_disk_idsoutput exposes each volume's libvirt ID so the downstream layer can map names to devices. ip_address/mac_addressare read via thelibvirt_domain_interface_addressesdata source (source = "lease"), since libvirt 0.9.x dropped 0.8.x'swait_for_lease/network_interface[].addressessurface (ADR-0016). The address only becomes known once the guest boots and acquires a DHCP lease, soip_addressmay benullon the first apply and populate on a subsequent refresh/apply — a behaviour change from 0.8.x's blockingwait_for_leasethat is flagged for host verification in ADR-0016.var.storage_poolandvar.network_namemust point at libvirt resources that already exist on the host. Creating them is outside the module's scope; on a fresh libvirtd install,virsh pool-list --allandvirsh net-list --allshould show the defaults.- The module sets
meta_dataonlibvirt_cloudinit_disktoinstance-id: ${vm_name}\nlocal-hostname: ${vm_name}\n, satisfying the cloud-init NoCloud contract forinstance-iddeterministically fromvar.vm_name. See ADR-0007 for the rationale and migration note. Operators on existing infra see a one-timelibvirt_cloudinit_diskre-create + domain restart on the first apply after upgrading past this change.