Skip to content

About

Terraform to install AlmaLinux 9 to KVM based systems using libvirt provider

Resources

Stars

3 stars

Watchers

1 watching

Forks

Repository files navigation

terraform-almalinux-libvirt

Build AlmaLinux 9 VMs on KVM/libvirt with Terraform. One base image is fetched once and shared; every VM is a copy-on-write overlay on it, configured through cloud-init.

vms = {
  "alma9-01" = { ip = "192.168.122.240/24" }
  "alma9-02" = { memory = 8192, vcpu = 4, disk_size = 60, extra_disks = [100] }
  "alma9-03" = {}   # no ip = DHCP
}
terraform init && terraform apply

Layout

Path What
provider.tf provider and version constraints
variables.tf every input, typed and described, no defaults
terraform.tfvars every value — the only file you normally edit
main.tf the shared base image, then one module.vm per entry in vms
outputs.tf names, UUIDs, MACs, addresses, ready-made ssh and console commands
modules/vm/ one VM: disks, cloud-init, the domain
modules/vm/templates/ cloud-init user-data and network-config

Variables carry no defaults on purpose: a value you forgot fails at plan time instead of quietly taking one that was buried in the code.

What you get

  • Any number of VMs from one map, each able to override memory, CPUs, disk size, address, network, extra disks and autostart.
  • Static addressing or DHCP per VM, through cloud-init network-config v2 rather than hand-written ifcfg files, and converted to NetworkManager keyfiles on first boot.
  • Copy-on-write disks. The base image is stored once. A 30 GiB VM disk occupies about 200 KiB until the guest writes to it.
  • The image is fetched for you from a URL — nothing has to be downloaded onto the hypervisor first.
  • SSH hardening following the ssh-audit RHEL8 hardening guide: host keys regenerated (the image ships the same ones to everybody), weak moduli pruned, the ECDSA host key dropped, and ciphers, MACs, key exchange and signature algorithms restricted.
  • Serial console wired up, so virsh console <name> shows the whole boot.
  • UEFI, Secure Boot, TPM 2.0, SPICE/VNC/headless, extra disks, CPU mode and machine type are all switches.
  • Graceful shutdown on destroy — an ACPI shutdown with a timeout, instead of pulling the plug.

Supported releases

Tested end to end on this hypervisor, same configuration, only image_url, image_name and image_capacity changed:

Release Result
9.8 static IP, NetworkManager keyfile after nmcli connection migrate, ECDSA host key removed, ipv6 off, SELinux enforcing
10.2 same, and cloud-init picks the network-manager renderer itself, so the keyfile is written directly

/etc/sysconfig/network-scripts does not exist on 10.x, which is why the configuration describes the network once in cloud-init network-config v2 rather than writing ifcfg files. 10.x also needs an x86-64-v3 host CPU:

/usr/lib64/ld-linux-x86-64.so.2 --help | grep x86-64-v3

What you need

Terraform >= 1.16 (tested on 1.16.5)
Provider dmacvicar/libvirt ~> 0.9.9
Hypervisor libvirt with a storage pool and a network; swtpm only if tpm = true, OVMF only if firmware = "efi"

Upgrading from the 0.8 provider

Version 0.9.0 of the libvirt provider is a redesign, not a new release: HCL now maps almost one-to-one onto libvirt XML, and the author states there is no automated way to migrate the HCL. This repo was rewritten against the new schema. If you are pinned to the old one, the legacy provider lives on in the project's v0.8 branch.

State does not carry over. Destroy with the old code, then apply with this.

Notes

  • image_capacity has to be set because the image URL redirects to mirrors that do not always send Content-Length, and the upload then fails. Read it with qemu-img info <file> | grep 'virtual size'.
  • image_url takes a local path or a file:// URL as happily as https://. Worth doing if your nearest mirror is slow: measured from one host, the AlmaLinux image pulled at ~14 MB/s and the Rocky one at ~2-3 MB/s. The image is fetched once and shared, so this is a per-image cost, not per VM.
  • selinux_mode = "disabled" and any grub_* option need one reboot to take effect; reboot_after_first_boot does that at the end of first boot.
  • crashkernel=auto, which earlier versions of this repo set, is not carried over: EL9 ignores it.
  • Changing cloud-init settings does not reconfigure a running VM. cloud-init runs once per instance id, so the VM has to be rebuilt.

Licence

Apache 2.0. See LICENSE.

Full input and output reference, generated by terraform-docs:

Requirements

Name Version
terraform >= 1.16.0
libvirt ~> 0.9.9

Providers

Name Version
libvirt ~> 0.9.9

Inputs

Name Description Type Required
console_show_ip Print the guest's IPv4 address at the login prompt, by writing /etc/issue
and /etc/issue.net with agetty's \4{iface} escape. The Rocky repo did
this; the AlmaLinux one did not.
bool yes
cpu_mode libvirt CPU mode. host-passthrough blocks live migration between unlike hosts. string yes
default_disk_size Root disk in GiB for any VM that does not set its own. number yes
default_gateway Default route for a VM that sets ip without its own gateway. Null writes none. string yes
default_memory Guest memory in MiB for any VM that does not set its own. number yes
default_network libvirt network for any VM that does not name one. string yes
default_vcpu Virtual CPUs for any VM that does not set its own. number yes
firmware bios or efi. efi needs OVMF on the hypervisor. string yes
graphics spice, vnc or none. none leaves only the serial console. string yes
grub_cmdline_extra Extra kernel command-line arguments, appended to whatever the serial and
ipv6 options add. Note crashkernel=auto from the old repo is obsolete:
EL9 ignores it.
list(string) yes
grub_serial_console Put the boot loader and the kernel on the serial port as well as the
console, so virsh console shows the whole boot. This is what the old
repo's GRUB_TERMINAL / GRUB_SERIAL_COMMAND lines did.
bool yes
harden_ssh Apply the ssh-audit hardening (hardening_guides.html#rhel8) and regenerate the host keys the image shipped with. bool yes
image_capacity Virtual size of the base image, in bytes. Normally the provider reads this
from the upload's Content-Length, but repo.almalinux.org redirects to
mirrors that do not send one, and the upload then fails with "Volume
capacity is required when the upload source does not provide
Content-Length". Read it off an image with:
qemu-img info | grep 'virtual size'
number yes
image_name Volume name for the shared base image inside the pool. string yes
image_url Where to fetch the AlmaLinux cloud image from. The provider uploads it into
the pool, so an https:// URL or a local path both work. Fetched once; every
VM gets a thin overlay on top of it.
string yes
include_default_user Keep the cloud image's own user (almalinux, rocky) alongside the ones in
users. The original did this with a bare - default; dropping it removes
the documented fallback login.
bool yes
ipv6_disable Add ipv6.disable=1 to the kernel command line, as the old repo did. bool yes
libvirt_uri libvirt connection URI. qemu+ssh://user@host/system for a remote hypervisor. string yes
machine_type QEMU machine type. q35 is current; pc is legacy i440fx. string yes
nameservers DNS resolvers written into guests that use a static address. list(string) yes
nm_keyfiles Convert the network config cloud-init renders into NetworkManager keyfiles
on first boot, with nmcli connection migrate.

On EL9, cloud-init selects the sysconfig renderer and writes
/etc/sysconfig/network-scripts/ifcfg-eth0 - the same legacy file this repo
used to hand-write. The renderer itself cannot be changed from user-data,
because it is chosen in the init-local stage before user-data merges, so
the file is converted after the fact instead. Verified on AlmaLinux 9.8
with NetworkManager 1.54: the ifcfg file is removed and the address is
kept. A no-op on EL10, which has no network-scripts to begin with.
bool yes
package_upgrade Run a full package upgrade on first boot. Adds minutes. bool yes
packages Packages cloud-init installs on first boot. list(string) yes
permit_root_ssh Whether sshd accepts root logins. Keys only; password auth stays off either way. bool yes
pool Storage pool for the base image, the VM disks and the cloud-init ISOs. string yes
primary_interface Interface name agetty reads the address from, for the issue files. string yes
qemu_agent Attach the guest agent channel. Required for the ip_addresses output. bool yes
rc_local_every_boot Install the original's /etc/rc.d/rc.local, which runs on EVERY boot and
re-runs grub2-mkconfig, a full package upgrade, and the ssh host key
regeneration. Note the consequence: the host keys change on every reboot,
so known_hosts breaks each time. Off by default because that reads as a
mistake in the original rather than an intent.
bool yes
reboot_after_first_boot Reboot once at the end of first boot. The old repo always did this, from
its run-once rc.local script, because grub and SELinux changes need it.
Not needed when selinux_mode is enforcing and no grub option is set.
bool yes
root_password_hash SHA-512 crypt hash for root. Null leaves root locked, as the cloud image ships it. string yes
root_ssh_authorized_keys Keys for root. Only reachable when permit_root_ssh is true. list(string) yes
runcmd Extra shell commands for the end of first boot. list(string) yes
search_domains DNS search domains written into guests that use a static address. list(string) yes
selinux_mode enforcing, permissive or disabled. Anything but enforcing costs one extra reboot. string yes
shutdown_timeout Seconds to wait for an ACPI shutdown on destroy before forcing it off. number yes
ssh_authorized_keys Keys added to every user, on top of that user's own list. list(string) yes
ssh_password_auth Allow password logins over ssh. The original set this true; it defaults to
false here, which is the one security default deliberately changed from the
original. Key-only login is unaffected either way.
bool yes
timezone Guest timezone, IANA name. string yes
tpm Attach an emulated TPM 2.0. Needs swtpm on the hypervisor. bool yes
users Logins to create.
sudo passwordless sudo (default true)
shell (default /bin/bash)
groups (default ["adm","wheel"])
password_hash SHA-512 crypt, from mkpasswd -m sha-512.
Null locks the password, leaving key-only login.
list(object({
name = string
sudo = optional(bool, true)
shell = optional(string, "/bin/bash")
groups = optional(list(string), ["adm", "wheel"])
password_hash = optional(string)
ssh_authorized_keys = optional(list(string), [])
}))
yes
vms The VMs to create, keyed by name. The key becomes the domain name and the
guest hostname. Anything left out of an entry falls back to the matching
default_* value.
map(object({
memory = optional(number)
vcpu = optional(number)
disk_size = optional(number)
ip = optional(string)
gateway = optional(string)
network = optional(string)
extra_disks = optional(list(number))
autostart = optional(bool)
}))
yes

Outputs

Name Description
console Attach to a guest's serial console.
ip_addresses IPv4 addresses libvirt reports per VM, from the DHCP leases or the guest
agent. Empty until the guest is up, so a fresh apply may need one
terraform refresh before these fill in.
ssh Ready-to-paste ssh commands. DHCP guests resolve through ip_addresses instead.
vms Name, UUID, MAC, configured address and root disk path for each VM.

About

Terraform to install AlmaLinux 9 to KVM based systems using libvirt provider

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages