Releases: dmacvicar/terraform-provider-libvirt
v0.9.1
Bugfixes
- Domains are now undefined with flags
VIR_DOMAIN_UNDEFINE_NVRAMandVIR_DOMAIN_UNDEFINE_TPM. These defaults may change in the future and be part of a domain block likecreateanddelete. (#1203 ) - Fix SIGSEGV when connecting to a hypervisor over qemu+ssh. Fixes #1210 (#1211)
- Misc documentation fixes #1209, #1210
Features
Support for full libvirt API (XML) (#1208 )
The provider now supports the whole libvirt API 🥳 (* that is supported by libvirtxml), thanks to a code generation engine which generates the whole terraform glue for the schemas and conversions.
For now, the usual resources (domain, network, volume, pool) are included, but this opens the door to handle other resources (secrets, etc) with little effort.
Migration Guide: 0.9.0 → v0.9.1
Due to the introduction of the generator and some bugs in the 0.9.0 schema, we had to do some changes in the schema.
This document explains how to move Terraform configurations from provider v0.9.0 (the last manual schema) to the current HEAD that uses the libvirt-schema code generator. It only covers resources and attributes that existed in 0.9.0: domains, networks, storage pools, and storage volumes. Anything new that HEAD exposes can simply be added following the generated schema documentation.
What Changed Globally
- Attr names now mirror libvirt XML – the generator emits snake_case names derived from the XML schema (e.g.,
accessmode→access_mode,portgroup→port_group). Set exactly the fields you care about; anything left null stays absent in the XML. - Value/unit pairs are explicit – whenever libvirt exposes a value with a unit attribute the provider now has two attributes (
memory+memory_unit,capacity+capacity_unit, etc.). Leaving the unit unset lets libvirt use its default. - Presence/"yes"/"no" semantics follow libvirt – booleans that previously toggled simple structs may now expect
yes/nostrings when libvirt models them as attributes (e.g.os.loader_readonly). True presence booleans (likefeatures.acpi) still use Terraform bools. - Nested objects match the XML tree exactly – device sources, interfaces, backing stores, etc. now use the full nested structure. Plan to touch every place where v0.9 flattened things like
source.poolorfilesystem.source. - Metadata is structured – string blobs became
{ metadata = { xml = <<EOF ... } }so we can extend later without breaking state.
Domain Resource
Top-level attribute mapping
| v0.9 attribute | HEAD attribute(s) | Notes |
|---|---|---|
unit |
memory_unit |
Same semantics, renamed so every value/unit pair is consistent. |
max_memory |
maximum_memory |
Value only; use maximum_memory_unit if you previously used a non-default unit. |
max_memory_slots |
maximum_memory_slots |
Same semantics. |
current_memory |
current_memory + optional current_memory_unit |
Value stays the same; set the unit explicitly if you relied on non-default units. |
metadata (string) |
metadata = { xml = <<EOF ... EOF } |
Wrap your XML in the nested object. |
os.arch |
os.type_arch |
The type_* prefix mirrors <os><type arch="..."/>. |
os.machine |
os.type_machine |
Same rationale as above. |
os.kernel_args |
os.cmdline |
Field name matches the XML <cmdline> element. |
os.loader_path |
os.loader |
0.9 kept the loader path in a separate attribute; now it is the element’s value (see “value + attributes” below). |
os.loader_readonly (bool) |
os.loader_readonly (string) |
Accepts "yes"/"no" because the XML attribute is a string. |
os.nvram.* |
os.nv_ram = { file, template, format = { type = ... } } |
Rename plus richer structure. |
devices.filesystems[*].accessmode |
access_mode |
All camelCase names were converted to snake_case. |
devices.filesystems[*].readonly |
read_only |
Same semantics. |
devices.interfaces[*].source.portgroup |
source = { network = { port_group = ... } } |
See below for the full source mapping. |
devices.rngs[*].device |
backend = { random = "/dev/urandom" } or backend = { egd = { ... } } |
Backends are now modeled exactly like the XML. |
OS block specifics
os.boot_devicesis still a list, but if you previously stored strings you now provide objects:boot_devices = [{ dev = "hd" }, { dev = "network" }].- Loader/read-only/secure/stateless flags now accept the literal XML strings (
"yes"/"no"). Wrap them intostring()if you had boolean locals. - NVRAM becomes
os = { nv_ram = { file = "/var/lib/libvirt/nvram.bin", template = "/usr/share/OVMF/OVMF_VARS.fd", format = { type = "raw" } } }.
Loader value + attributes
<loader> is a “value + attributes” element. The path is the value (os.loader), and every XML attribute becomes a sibling attribute:
os = {
loader = "/usr/share/OVMF/OVMF_CODE.fd"
loader_type = "pflash"
loader_readonly = "yes"
loader_secure = "no"
loader_format = "raw"
}Leave the attribute unset to let libvirt pick its default (the provider preserves user intent for optional attributes).
Disks and filesystems
0.9 flattened every disk source. HEAD requires you to pick the XML variant explicitly:
# v0.9
source = {
pool = libvirt_pool.test.name
volume = libvirt_volume.test.name
}
# HEAD
source = {
volume = {
pool = libvirt_pool.test.name
volume = libvirt_volume.test.name
}
}
# File-based disk (previously source.file)
source = { file = "/var/lib/libvirt/images/disk.qcow2" }
# Block device
# Block device
source = { block = "/dev/sdb" }Filesystems follow the same pattern. Replace the old flat fields with nested objects:
# v0.9
filesystems = [{
source = "/exports/share"
target = "shared"
accessmode = "mapped"
readonly = true
}]
# HEAD
filesystems = [{
source = { mount = { dir = "/exports/share" } }
target = { dir = "shared" }
access_mode = "mapped"
read_only = true
}]Variant notation
Every <source> element with mutually exclusive children (files, volumes, blocks, etc.) becomes an object whose attributes map 1:1 to the libvirt XML children. Only set the branch you need:
# Filesystem backed by a block device
source = { block = { dev = "/dev/vdb" } }
# RAM-backed filesystem with extra attributes
source = { ram = { usage = 1024, unit = "MiB" } }Even if a variant has additional attributes in XML, the generated struct exposes them in that nested object (e.g., ram = { usage = 1024, unit = "MiB" }). This pattern is consistent across disks, filesystems, host devices, etc.
Interfaces
source.network, source.bridge, and source.dev are now mutually exclusive nested objects. Example conversions:
# Network-backed NIC (v0.9)
source = { network = "default" }
# HEAD
source = { network = { network = "default" } }
# Direct/Macvtap NIC (v0.9)
source = { dev = "eth0", mode = "bridge" }
# HEAD
source = { direct = { dev = "eth0", mode = "bridge" } }portgroup became port_group, wait_for_ip stays the same helper object.
RNG / TPM / other devices
- RNG devices now mirror
<backend>. Usebackend = { random = "/dev/urandom" }for /dev/random orbackend = { egd = { source = { mode = "connect", host = "unix", service = "..." } } }for EGD sockets. - TPM backends are nested (
backend = { emulator = { path = "/var/lib/swtpm/sock" } }). Map your previousbackend_typeto one of the backend objects:emulator,passthrough, orexternal. - Graphics, consoles, serials, and video devices already used nested objects in 0.9; the only change is snake_case attribute names (
auto_port,websocket, etc.).
Metadata
0.9 stored raw XML as a string. Now wrap it:
metadata = {
xml = <<EOFXML
<libosinfo:libosinfo xmlns:libosinfo="http://libosinfo.org/xmlns/libvirt/domain/1.0">
<libosinfo:os id="http://libosinfo.org/linux/2024" />
</libosinfo:libosinfo>
EOFXML
}Storage Volume Resource
Key differences:
| v0.9 attribute | HEAD attribute(s) | Notes |
|---|---|---|
format (string) |
target = { format = { type = "qcow2" } } |
The format lives under the target block now. |
permissions.* |
target.permissions.* |
Same keys, just explicitly under target. |
backing_store.format |
backing_store = { format = { type = "qcow2" } } |
Mirrors libvirt <format> element. |
capacity |
capacity + optional capacity_unit |
Leave capacity_unit unset to keep KiB. |
allocation |
allocation + allocation_unit (read-only) |
Useful when libvirt reports GiB/MiB units. |
path (computed) |
still path, but it mirrors target.path |
You no longer set this manually. Use pool target paths to control locations. |
Everything else (name, pool, create/content) behaves exactly like 0.9. Plan/apply will touch terraform state automatically once you update the config.
Storage Pool Resource
The generated schema simply fills in additional optional sub-objects (source.host, source.auth, features, etc.). All attributes that existed in 0.9 keep their names and shapes:
target = { path = "/var/lib/libvirt/pools" }works unchanged.target.permissions.*still take strings, not integers.source.device = [{ path = "/dev/sdb" }]keeps the same structure.
Unless you opt into the new nested fields you do not need to change existing pool configurations.
N...
v0.9.0
Background
When this provider was developed, the idea was to mimic a cloud experience on top of libvirt. Because of this, the schema was done as flat as possible, features were abstracted and some features like disks from remote sources were added as convenience.
The initial users of the provider were usually makers of infrastructure software who needed complex network setups. Lot of code was contributed which added complexity outside of its initial design.
So for long time I wanted to restart the provider under a new design principles where:
- HCL maps almost 1:1 to libvirt XML and therefore almost any libvirt feature can be supported
- Most of the validation work is left to libvirt which is already doing it
- No abstractions or extra features, and when they do, they should be designed in a way that are quite independent
- More consistency, for example, most libvirt APIs can be mostly separated in lifecycle (create, destroy) which map quite well to terraform resources, and then some query APIs, which map well to data sources. This was not the case how we implemented for example querying the IP addresses
- No unnecessary defensive code, for example, checking that a volume exist when referenced, is a problem that terraform solves if the ID is interpolated and libvirt solves with its own checks, if the volume is referenced by a hardcoded strings.
I knew 1.0 would never come in the current form.
The new provider
The new provider is based on the new plugin framework. This gives us some room for better diagnostics and better plans.
It makes definitions more verbose, but it also means we can implement any libvirt feature. Defaults work as long as they are defaults in libvirt.
Migration plan
You can find the legacy provider in the v0.8 branch. New releases can be done of 0.8.x versions to add bugfixes, so people who rely on it have a path forward. I'd likely not maintain much of 0.8.x, but I guess many people will help here, as they do today with different PRs.
There is no automated way of migrating the HCL of previous providers, but given that it is documented how the new schema is defined, which was not the case with the previous schema, it should be much easier to drive LLMs to perform a conversion.
You should check the documentation and README, which will give you an idea of the main differences and equivalences, but here is an example of the new schema to get an idea:
terraform {
required_providers {
libvirt = {
source = "dmacvicar/libvirt"
}
}
}
provider "libvirt" {
uri = "qemu:///system"
}
# Base Alpine Linux cloud image stored in the default pool.
resource "libvirt_volume" "alpine_base" {
name = "alpine-3.22-base.qcow2"
pool = "default"
format = "qcow2"
create = {
content = {
url = "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/generic_alpine-3.22.2-x86_64-bios-cloudinit-r0.qcow2"
}
}
}
# Writable copy-on-write layer for the VM.
resource "libvirt_volume" "alpine_disk" {
name = "alpine-vm.qcow2"
pool = "default"
format = "qcow2"
capacity = 2147483648
backing_store = {
path = libvirt_volume.alpine_base.path
format = "qcow2"
}
}
# Cloud-init seed ISO.
resource "libvirt_cloudinit_disk" "alpine_seed" {
name = "alpine-cloudinit"
user_data = <<-EOF
#cloud-config
chpasswd:
list: |
root:password
expire: false
ssh_pwauth: true
packages:
- openssh-server
timezone: UTC
EOF
meta_data = <<-EOF
instance-id: alpine-001
local-hostname: alpine-vm
EOF
network_config = <<-EOF
version: 2
ethernets:
eth0:
dhcp4: true
EOF
}
# Upload the cloud-init ISO into the pool.
resource "libvirt_volume" "alpine_seed_volume" {
name = "alpine-cloudinit.iso"
pool = "default"
create = {
content = {
url = libvirt_cloudinit_disk.alpine_seed.path
}
}
}
# Virtual machine definition.
resource "libvirt_domain" "alpine" {
name = "alpine-vm"
memory = 1048576
vcpu = 1
os = {
type = "hvm"
arch = "x86_64"
machine = "q35"
}
features = {
acpi = true
}
devices = {
disks = [
{
source = {
pool = libvirt_volume.alpine_disk.pool
volume = libvirt_volume.alpine_disk.name
}
target = {
dev = "vda"
bus = "virtio"
}
},
{
device = "cdrom"
source = {
pool = libvirt_volume.alpine_seed_volume.pool
volume = libvirt_volume.alpine_seed_volume.name
}
target = {
dev = "sdb"
bus = "sata"
}
}
]
interfaces = [
{
type = "network"
model = "virtio" # e1000 is more compatible than virtio for Alpine
source = {
network = "default"
}
# TODO: wait_for_ip not implemented yet (Phase 2)
# This will wait during creation until the interface gets an IP
wait_for_ip = {
timeout = 300 # seconds, default 300
source = "any" # "lease" (DHCP), "agent" (qemu-guest-agent), or "any" (try both)
}
}
]
graphics = {
vnc = {
autoport = "yes"
listen = "127.0.0.1"
}
}
}
running = true
}
# Query the domain's interface addresses
# This data source can be used at any time to retrieve current IP addresses
# without blocking operations like Delete
data "libvirt_domain_interface_addresses" "alpine" {
domain = libvirt_domain.alpine.name
source = "lease" # optional: "lease" (DHCP), "agent" (qemu-guest-agent), or "any"
}
# Output all interface information
output "vm_interfaces" {
description = "All network interfaces with their IP addresses"
value = data.libvirt_domain_interface_addresses.alpine.interfaces
}
# Output the first IP address found
output "vm_ip" {
description = "First IP address of the VM"
value = length(data.libvirt_domain_interface_addresses.alpine.interfaces) > 0 && length(data.libvirt_domain_interface_addresses.alpine.interfaces[0].addrs) > 0 ? data.libvirt_domain_interface_addresses.alpine.interfaces[0].addrs[0].addr : "No IP address found"
}
# Output all IP addresses across all interfaces
output "vm_all_ips" {
description = "All IP addresses across all interfaces"
value = flatten([
for iface in data.libvirt_domain_interface_addresses.alpine.interfaces : [
for addr in iface.addrs : addr.addr
]
])
}Feedback is appreciated. There will be a long journey for people to port and iron all the issues, but it is clear this is the path to go.
Docs: https://registry.terraform.io/providers/dmacvicar/libvirt/latest/docs
v0.8.3
v0.8.2
What's Changed
Content sniffing
- The provider no longer detects the image format qcow2 using content sniffing for remote HTTP images. If you leave it blank, it will just set it based on the extension. This allows to use HTTP servers without HTTP Range support.
Upgrade dependencies
- Bump golang.org/x/crypto from 0.27.0 to 0.31.0 by @dependabot in #1138
- Bump golang.org/x/net from 0.29.0 to 0.33.0 by @dependabot in #1157
Bug fixes
- Bugfix: ssh port override for #1116 by @memetb in #1117
- fix(ci): failing terraform fmt (#1158) by @dmacvicar in #1159
- fix(domain): restore error handling for network operations by @SJFCS in #1144
- fix: fix the wrong error return value by @cangqiaoyuzhuo in #1161
New Contributors
- @SJFCS made their first contribution in #1144
- @farsonic made their first contribution in #1154
- @cangqiaoyuzhuo made their first contribution in #1161
Full Changelog: v0.8.1...v0.8.2
v0.8.1
What's Changed
This release is mostly about fixes for the SSH transport, which was released with many bugs in v0.8.0
- Do not panic on invalid SSH key by @scabala in #1103
- Sshconfig missing bugfix - addresses issue #1105 by @memetb in #1109
- allow for multiple default identity key files by @memetb in #1112
Experimental LVM storage pool support
There is a new experimental feature, support for LVM storage pools. I don't use myself this type of pools, so I put together all the contributions and made the code ready for release mostly based on integration tests. Try it and give feedback.
New Contributors
Full Changelog: v0.8.0...v0.8.1
v0.8.0
What's Changed
Two big features include improved ssh config support (for example for supporting jump hosts) and a new data source for host information.
- expanded ssh_config parameters for qemu+ssh uri option by @memetb in #1059
- feat: add data sources to extract node and device information by @muresan in #1042
Breaking changes
- DNS is enabled by default, like in libvirt. #1100
- Wait intervals for polling libvirt are reduced, making everything faster (including testsuite)
Other highlights:
- Acceptance testsuite is finally fully passing again
- Many code cleanups
- Updated golangci-lint
- Many updated dependencies
- Mark disk wwn and nvram arguments as computed by @wfdewith in #1064
- Default machine by @e4t in #1014
- Add Combustion resource to use instead of the ignition one by @cbosdo in #1068
Community
We activated discussions, so that the community can share useful files, help each other and also get announcements.
Contributors
Thanks to all the community for their contributions and for supporting other users:
- @muresan made their first contribution in #1042
- @wfdewith made their first contribution in #1064
- @kubealex made their first contribution in #1056
- @shafer made their first contribution in #927
- @testwill made their first contribution in #1086
- @memetb made their first contribution in #1059
- @michaelbeaumont
- @cbosdo
- and others... (let me know if I missed you)
Full Changelog: v0.7.6...v0.8.0
v0.7.6
v0.7.5
v0.7.4
v0.7.2
Fixes
- upgrade ingition dependency
- port to the new libvirt-go dialer constructor
- make 'option_value' for dnsmasq optional (#960)
- Fix malformed connection remote name when using ssh remote uri (#1030)
- Fix
testmake target to run all tests (#1034) - Update URL to show how to setup cert (#1007)
Thanks to contributors @michaelbeaumont @flat35hd99 @tiaden @e4t