A virt-install command can become a reviewable XML artifact before it becomes a virtual machine. In the reproduced run for this article, domain-plan.xml was written while both guest.qcow2 and the named libvirt domain voxfor-preflight-191 remained absent. The storage appeared only after an explicit qemu-img create, and the later definition remained shut off.
That result requires two options, not one. --print-xml tells virt-install to print the generated domain definition instead of registering it. The upstream virt-install manual warns that printing XML can still create storage unless --dry-run is also present. Here, “creates anything” is deliberately limited to the two side effects we measure: the requested disk path and the named domain.
This guide turns those facts into a build gate. You will freeze one VM contract, render its current defaults, make schema validation reject a well-formed control, create only approved storage, define without starting, compare libvirt’s normalized XML, preserve the disk hash, and remove the owned domain and files. It does not install Debian, boot a guest, test an application or approve a production hypervisor.
A domain definition is a plan for a VM, not the running VM itself. Libvirt uses XML to describe compute, boot, storage, network and device choices. That boundary matters because containers and virtual machines choose different isolation and recovery units; this workflow is reviewing the VM boundary before libvirt owns it.
Four gates appear in order:
virt-install calculates XML from your arguments, its version, OS information and the selected libvirt connection.Only the first three occur here, and the third ends with shut off. A clean render does not establish schema success. Schema success does not establish that a host can use every path or device. Definition success still does not prove that firmware, an operating system or a workload will boot.
Connection scope is also part of the artifact. qemu:///session keeps this reproduction in the current user’s libvirt scope and uses user-mode networking. A production build may require qemu:///system, bridges, storage pools, SELinux labels and different permissions. Do not review XML against one URI while silently intending to define it against another.
Write the intended result before asking virt-install to supply defaults. This lab contract is deliberately small: one unique name, 1 GiB of memory, two vCPUs, a q35 machine selected through Debian 13 OS information, one 64 MiB QCOW2 path on a virtio bus, and a user-mode virtio interface. The tiny disk is a structural fixture, not enough for a Debian installation.
Ownership begins in the first tested input. It refuses an existing lab path or domain, creates a marker, and gives the failure trap permission to undefine only the unique lab identity. Run all eight tested-input blocks in order in the same Bash process.
set -Eeuo pipefail
LAB=/var/tmp/voxfor-virt-install-lab-191
MARKER="$LAB/.voxfor-virt-install-lab-191"
NAME=voxfor-preflight-191
DISK="$LAB/guest.qcow2"
PLAN="$LAB/domain-plan.xml"
INVALID="$LAB/domain-invalid.xml"
NORMALIZED="$LAB/domain-normalized.xml"
RECEIPT_OUT=/tmp/voxfor-virt-definition-191.txt
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
domain_exists() {
virsh -c qemu:///session dominfo "$NAME" >/dev/null 2>&1
}
owned_cleanup() {
if domain_exists; then
[[ -f "$MARKER" ]] || fail "refusing undefine without ownership marker"
virsh -c qemu:///session undefine "$NAME" >/dev/null
fi
if [[ -d "$LAB" ]]; then
[[ "$LAB" == /var/tmp/voxfor-virt-install-lab-191 ]] ||
fail "unexpected lab path"
[[ -f "$MARKER" ]] ||
fail "refusing cleanup without ownership marker"
find "$LAB" -depth -mindepth 1 -delete
rmdir "$LAB"
fi
}
trap 'rc=$?; owned_cleanup || true; exit $rc' EXIT
for tool in virt-install virsh virt-xml-validate qemu-img xmllint sha256sum install find perl awk grep jq cmp tr xargs sed; do
command -v "$tool" >/dev/null || fail "missing tool: $tool"
done
[[ ! -e "$LAB" ]] || fail "lab path already exists"
! domain_exists || fail "domain already exists: $NAME"
[[ ! -e "$RECEIPT_OUT" ]] || fail "receipt path already exists"
install -d -m 0750 "$LAB"
install -m 0600 /dev/null "$MARKER"
[[ ! -e "$DISK" ]]
Before using this on a VPS, establish that the environment can really execute a nested guest; CPU flags or an existing /dev/kvm node alone are not enough. The separate nested KVM execution check provides that qualification. No inspected Voxfor service page explicitly promises the needed nested or host-level libvirt capability, so this article does not add a commercial link.
--import avoids needing an installer or network boot to generate the definition. --osinfo debian13 lets virt-install choose current defaults for that OS profile. --graphics none and the serial console keep the candidate narrow; none of these options start a guest because the command stops at XML.
virt-install \
--connect qemu:///session \
--name "$NAME" \
--memory 1024 \
--vcpus 2 \
--disk "path=$DISK,size=0.0625,format=qcow2,bus=virtio" \
--network user,model=virtio \
--graphics none \
--console pty,target.type=serial \
--osinfo debian13 \
--import \
--print-xml \
--dry-run > "$PLAN"
Treat the output as generated configuration, not as decoration. A UUID is generated, and defaults can change with virt-install, libosinfo, libvirt, QEMU or the connection. Retain the XML from the exact destination environment if you need a durable review artifact.
Next, prove that the candidate is nonempty and that the two measured side effects are still absent. Then read the fields that decide whether this definition matches the contract. The memory element in this version omits a unit, so libvirt’s default is KiB; 1048576 KiB equals the requested 1 GiB.
[[ -s "$PLAN" ]] || fail "virt-install did not render XML"
[[ ! -e "$DISK" ]] || fail "dry-run created storage"
! domain_exists || fail "dry-run defined a domain"
PLAN_SHA="$(sha256sum "$PLAN" | awk '{print $1}')"
PLAN_NAME="$(xmllint --xpath 'string(/domain/name)' "$PLAN")"
PLAN_MEMORY="$(xmllint --xpath 'string(/domain/memory)' "$PLAN")"
PLAN_MEMORY_UNIT="$(xmllint --xpath 'string(/domain/memory/@unit)' "$PLAN")"
PLAN_VCPUS="$(xmllint --xpath 'string(/domain/vcpu)' "$PLAN")"
PLAN_MACHINE="$(xmllint --xpath 'string(/domain/os/type/@machine)' "$PLAN")"
PLAN_DISK="$(
xmllint --xpath 'string(/domain/devices/disk[@device="disk"]/source/@file)' "$PLAN"
)"
PLAN_DISK_BUS="$(
xmllint --xpath 'string(/domain/devices/disk[@device="disk"]/target/@bus)' "$PLAN"
)"
PLAN_NETWORK="$(
xmllint --xpath 'string(/domain/devices/interface/@type)' "$PLAN"
)"
PLAN_NETWORK_MODEL="$(
xmllint --xpath 'string(/domain/devices/interface/model/@type)' "$PLAN"
)"
[[ "$PLAN_NAME" == "$NAME" ]] || fail "wrong rendered name"
[[ "$PLAN_MEMORY" == 1048576 ]] || fail "wrong rendered memory"
[[ -z "$PLAN_MEMORY_UNIT" || "$PLAN_MEMORY_UNIT" == KiB ]] ||
fail "unexpected memory unit"
PLAN_MEMORY_UNIT_EFFECTIVE="${PLAN_MEMORY_UNIT:-default_KiB}"
[[ "$PLAN_VCPUS" == 2 ]] || fail "wrong rendered vCPU count"
[[ "$PLAN_MACHINE" == q35 ]] || fail "unexpected machine"
[[ "$PLAN_DISK" == "$DISK" && "$PLAN_DISK_BUS" == virtio ]] ||
fail "wrong rendered disk"
[[ "$PLAN_NETWORK" == user && "$PLAN_NETWORK_MODEL" == virtio ]] ||
fail "wrong rendered network"
Libvirt’s domain XML format defines the fields, but your change record must define the desired values. A syntactically valid <memory> or <source file> element can still name the wrong capacity or path. Validation cannot replace contract inspection.
Well-formed XML only proves that tags nest correctly. The domain schema also constrains which elements are allowed and where they belong. Libvirt’s virt-xml-validate manual specifies a zero exit for valid XML and a nonzero exit for rejection.
A useful positive check needs a negative control. This input validates the candidate, changes only vcpu to an unknown vcpu_typo element, proves the control remains well formed with xmllint, and requires the domain schema to reject it. Replacing both opening and closing tags avoids confusing an XML parser error with a schema result.
virt-xml-validate "$PLAN" domain >/dev/null
perl -0pe 's#<vcpu>2</vcpu>#<vcpu_typo>2</vcpu_typo>#' "$PLAN" > "$INVALID"
cmp -s "$PLAN" "$INVALID" &&
fail "negative control was not changed"
xmllint --noout "$INVALID"
set +e
virt-xml-validate "$INVALID" domain > "$LAB/invalid-schema.log" 2>&1
INVALID_SCHEMA_EXIT=$?
set -e
[[ "$INVALID_SCHEMA_EXIT" -ne 0 ]] ||
fail "schema-invalid control unexpectedly passed"
grep -Eq 'failed to validate|Expecting an element|Invalid|error' "$LAB/invalid-schema.log" ||
fail "schema-invalid control lacked a diagnostic"
In the reproduced run, the control exited 3 and reported that domain had extra content vcpu_typo. That is evidence for this schema gate only. The validator does not open the disk, allocate memory, confirm CPU virtualization, find a production bridge or boot the operating system.
Rendering and schema checks deliberately leave the requested disk absent. Now create that resource explicitly, inspect what was created, and record its hash before libvirt receives the definition. The 64 MiB virtual size is 67108864 bytes.
qemu-img create -f qcow2 "$DISK" 64M > "$LAB/qemu-create.log"
DISK_INFO="$(qemu-img info --output=json "$DISK")"
DISK_FORMAT="$(printf '%s' "$DISK_INFO" | jq -r '.format')"
DISK_VIRTUAL_SIZE="$(
printf '%s' "$DISK_INFO" | jq -r '."virtual-size"'
)"
[[ "$DISK_FORMAT" == qcow2 ]] || fail "wrong disk format"
[[ "$DISK_VIRTUAL_SIZE" == 67108864 ]] ||
fail "wrong virtual size"
DISK_SHA_BEFORE="$(sha256sum "$DISK" | awk '{print $1}')"
A QCOW2 format result does not establish guest-filesystem health. The reproduced qemu-img versus guest-filesystem integrity lab shows why those layers need separate checks. This fixture has no guest filesystem at all.
virsh define registers persistent configuration but does not start it. --validate asks virsh to validate before defining. Afterward, require the state to remain shut off, dump the daemon’s normalized XML, and compare the central fields instead of demanding byte-for-byte equality.
virsh -c qemu:///session define --validate "$PLAN" > "$LAB/define.log"
domain_exists || fail "domain was not defined"
DOMAIN_STATE="$(
virsh -c qemu:///session domstate "$NAME" |
tr -d '\r' | xargs
)"
[[ "$DOMAIN_STATE" == "shut off" ]] ||
fail "defined domain is not shut off"
virsh -c qemu:///session dumpxml "$NAME" > "$NORMALIZED"
NORMALIZED_NAME="$(
xmllint --xpath 'string(/domain/name)' "$NORMALIZED"
)"
NORMALIZED_MACHINE="$(
xmllint --xpath 'string(/domain/os/type/@machine)' "$NORMALIZED"
)"
NORMALIZED_DISK="$(
xmllint --xpath 'string(/domain/devices/disk[@device="disk"]/source/@file)' "$NORMALIZED"
)"
NORMALIZED_NETWORK="$(
xmllint --xpath 'string(/domain/devices/interface/@type)' "$NORMALIZED"
)"
NORMALIZED_VCPUS="$(
xmllint --xpath 'string(/domain/vcpu)' "$NORMALIZED"
)"
[[ "$NORMALIZED_NAME" == "$NAME" ]]
[[ "$NORMALIZED_MACHINE" == pc-q35-* ]]
[[ "$NORMALIZED_DISK" == "$DISK" ]]
[[ "$NORMALIZED_NETWORK" == user ]]
[[ "$NORMALIZED_VCPUS" == 2 ]]
In this QEMU 10.0.11 environment, the short machine alias q35 resolved to pc-q35-10.0. Libvirt also adds or normalizes fields such as a CPU model and device addresses. That is why virsh’s define and dumpxml operations belong to the review: the generated candidate and daemon-owned definition are related artifacts, not necessarily identical files.
Do not start the guest merely to make this section feel complete. The empty QCOW2 has no bootable operating system. A boot attempt would answer a different question and would require firmware, media, console, shutdown and cleanup evidence of its own.
--print-xml and --dry-run?--print-xml prints the generated domain XML instead of defining the guest. By itself, it can still allow device creation, including storage. --dry-run disables that device creation. This lab then proves the requested disk path and named domain are both absent after rendering.
virt-xml-validate prove the host can run the VM?No. It proves that the XML conforms to the selected published schema. It does not prove that the disk exists, permissions allow access, the machine type is supported, networking works, acceleration is available, or the guest can boot. This guide tests a later definition against one local daemon but still does not start the VM.
virsh dumpxml differ from the rendered XML?Libvirt normalizes aliases and may add defaults or addresses when it stores a domain. In this run, q35 resolved to pc-q35-10.0. Compare contract fields and record the normalized artifact instead of treating byte inequality alone as failure.
virsh define --validate start the guest or write to its disk?No. define registers persistent configuration, and the reproduced state remained shut off. The QCOW2 hash was identical before and after definition. Operations such as QCOW2 rebase and commit deliberately change storage relationships and need a separate mutation plan.
Only with a separate destination-host check. Defaults and accepted capabilities depend on virt-install, libosinfo, libvirt, QEMU, connection URI, storage paths, security policy and networks. Render and validate against the intended destination class, then repeat definition and boot gates there.
Before cleanup, prove that registering the definition did not change the owned QCOW2 bytes. Then undefine only the unique domain and require its absence. The disk remains long enough to produce the final receipt; it is deleted only by the marker-checked cleanup function.
DISK_SHA_AFTER="$(sha256sum "$DISK" | awk '{print $1}')"
[[ "$DISK_SHA_AFTER" == "$DISK_SHA_BEFORE" ]] ||
fail "define changed disk bytes"
virsh -c qemu:///session undefine "$NAME" > "$LAB/undefine.log"
! domain_exists || fail "domain remained after undefine"
Finally, print every decision fact, copy none of the mutable lab state elsewhere, and delete only the exact owned directory. The receipt stays at the separately named /tmp path. In a real change, move the XML, hashes and logs to controlled evidence storage before cleanup.
{
printf 'virt_install_version=%s\n' "$(virt-install --version)"
printf 'libvirt_version=%s\n' "$(virsh --version)"
printf 'qemu_img_version=%s\n' "$(qemu-img --version | sed -n '1s/^qemu-img version //p')"
printf 'connection=qemu:///session\n'
printf 'domain_before=absent\n'
printf 'disk_before=absent\n'
printf 'render_result=xml_written\n'
printf 'disk_after_dry_run=absent\n'
printf 'domain_after_dry_run=absent\n'
printf 'render_sha256=%s\n' "$PLAN_SHA"
printf 'rendered_name=%s\n' "$PLAN_NAME"
printf 'rendered_memory=%s_%s\n' "$PLAN_MEMORY" "$PLAN_MEMORY_UNIT_EFFECTIVE"
printf 'rendered_vcpus=%s\n' "$PLAN_VCPUS"
printf 'rendered_machine=%s\n' "$PLAN_MACHINE"
printf 'rendered_disk_bus=%s\n' "$PLAN_DISK_BUS"
printf 'rendered_network=%s_%s\n' "$PLAN_NETWORK" "$PLAN_NETWORK_MODEL"
printf 'schema_valid=pass\n'
printf 'invalid_xml_well_formed=yes\n'
printf 'invalid_schema_exit=%s\n' "$INVALID_SCHEMA_EXIT"
printf 'invalid_schema_result=rejected\n'
printf 'disk_format=%s\n' "$DISK_FORMAT"
printf 'disk_virtual_size=%s\n' "$DISK_VIRTUAL_SIZE"
printf 'domain_state_after_define=%s\n' "${DOMAIN_STATE// /_}"
printf 'normalized_machine=%s\n' "$NORMALIZED_MACHINE"
printf 'disk_sha256_before_define=%s\n' "$DISK_SHA_BEFORE"
printf 'disk_sha256_after_define=%s\n' "$DISK_SHA_AFTER"
printf 'storage_unchanged_by_define=yes\n'
printf 'domain_after_undefine=absent\n'
} | tee "$RECEIPT_OUT"
owned_cleanup
trap - EXIT
[[ ! -e "$LAB" ]] || fail "cleanup did not remove the lab"
printf 'cleanup=absent\n' | tee -a "$RECEIPT_OUT"
A representative Debian run produced:
virt_install_version=5.0.0
libvirt_version=11.3.0
qemu_img_version=10.0.11 (Debian 1:10.0.11+ds-0+deb13u1)
connection=qemu:///session
domain_before=absent
disk_before=absent
render_result=xml_written
disk_after_dry_run=absent
domain_after_dry_run=absent
render_sha256=10bb566a0c6d66c66d544bf3347d3a7fc1d0904b5ab8a8507df90ea10c6284ac
rendered_name=voxfor-preflight-191
rendered_memory=1048576_default_KiB
rendered_vcpus=2
rendered_machine=q35
rendered_disk_bus=virtio
rendered_network=user_virtio
schema_valid=pass
invalid_xml_well_formed=yes
invalid_schema_exit=3
invalid_schema_result=rejected
disk_format=qcow2
disk_virtual_size=67108864
domain_state_after_define=shut_off
normalized_machine=pc-q35-10.0
disk_sha256_before_define=1b7af784841125887e688254229e282829e17245d0e47fe7280da9b68a30d9e1
disk_sha256_after_define=1b7af784841125887e688254229e282829e17245d0e47fe7280da9b68a30d9e1
storage_unchanged_by_define=yes
domain_after_undefine=absent
cleanup=absent
Generated UUIDs make the render hash vary across runs. The resolved machine string can also change with the QEMU/libvirt package set. Judge one run by its internal contract, positive and negative controls, before/after storage hash, and final absence—not by equality to this example’s UUID-derived XML hash.
Approve the definition for its next gate only when the exact URI and tool versions are recorded; the named domain and disk are absent before and after dry-run rendering; every contract field matches; the valid XML passes and the well-formed negative control fails schema validation; the explicitly created disk has the expected format and size; define --validate leaves the domain shut off; normalized key fields still match; the disk hash is unchanged; and both domain and owned lab path are absent after cleanup. Boot, firmware, installation, networking, performance and workload readiness remain unproved.
If any clause fails, do not start or promote the guest. Keep the domain shut off, preserve the candidate XML and failure logs, and revise the contract or destination environment. Undefine only voxfor-preflight-191 after confirming the marker, then remove only /var/tmp/voxfor-virt-install-lab-191 after confirming .voxfor-virt-install-lab-191; never broaden cleanup to a parent directory or reuse an unrelated domain name.
Once a real guest exists, storage allocation and memory policy become different operating decisions. Measure QCOW2 allocation before reclaiming space and read guest pressure before changing a KVM balloon target in their own change windows. This preflight is complete when the reviewed XML and receipt remain, while the lab VM and its disk do not.