libvirt deletes any volume it is asked to. The storage page and the API
deleted a VM's disk, the base image of another volume or the file a
snapshot was taken on without a check, breaking the VM or its
snapshots.
wvmStorage.del_volume now refuses a volume a domain on the compute uses
as a disk or a backing file of one, a file of a domain's snapshots, or
the backing file of another volume. The check is the one destroy
already used, moved to wvmConnect and extended to snapshot files, so
destroy also keeps the files of another VM's snapshots. The storage
page shows the refusal; the API answers 400 with it, as for other
refusals. Deleting a disk from a VM refuses after the detach when the
VM's own snapshots need the volume, and says the volume was kept.
Network disks count by their source name, pool volume disks by their
volume's path. A volume whose metadata cannot be read stops the check
instead of counting as unused; only one removed meanwhile is skipped.
The check and the delete hold the compute lock, so no VM operation
attaches the volume in between; a busy compute is a message on the page
and 409 in the API.
Deleting a VM with its data deleted only the images its disks pointed
at. With external snapshots that is the newest overlay: the image the
snapshots were taken on, the older overlays and the memory files stayed
on disk, with nothing referring to them once the VM and its snapshot
metadata were gone.
The files the VM's snapshots were taken on, down each disk's backing
chain, the overlays of snapshots kept off the chain after a revert, and
the snapshots' memory files are now the VM's own too. A template the
VM grew from ends the chain and stays, as does an overlay of a disk
detached since. The backing references of the VM's own volumes no
longer count as another domain's use, and a file another VM uses keeps
the files below it.
A backing file that cannot be read (other than one in no pool) stops the
split before anything is deleted, instead of ending the chain there.
When libvirt cannot remove a snapshot file it merged or discarded, it
only logs a warning and the delete or revert still succeeds. On NFS it
removes them as the qemu user, so an image directory the qemu user may
not write to left every merged overlay on disk without a word.
Revert and delete now compare the files in use before and after and
show a warning naming the ones still on disk. Only a snapshot's own
files (overlays, memory) and overlays on a snapshot point count, and a
disk the revert detached is never named, so a kept disk is not offered
for removal.
Create, revert and delete are one libvirt call each. The pflash-to-rom
loader rewrite, the hand-written block commit and the s1./s2. names are
gone, so UEFI VMs can take external snapshots, a revert keeps disks added
later, and deleting an older snapshot keeps the newer one.
Create refuses what libvirt could not revert or delete later: internal
snapshots on the VM, a saved state, a running snapshot job, disks that
are not files, CD-ROM media, a branch after a revert, and memory without
a running VM or room in the first disk's pool. A disk-only revert keeps
the VM running, paused or shut off; one with memory resumes it as saved.
A revert in the same second as the previous one, which libvirt fails
after stopping the VM, is retried once.
A snapshot the old code took of a UEFI VM saved the loader as rom; its
revert is refused (deleting it still works).
The snapshots tab lists internal and external snapshots separately, can
save memory too, shows running snapshot jobs, and the revert confirmation
names the disks it detaches and that NVRAM is not restored.
131 test modules sat next to the code they test; instances alone had
51 of them beside views.py and utils.py. They move into a
tests/ package per app, and the JavaScript checks into
webvirtcloud/tests/js. Each non-empty tests.py becomes tests/test_views.py;
the empty generated stubs are removed.
Imports of the app's own modules become absolute, paths built from
__file__ go one level up, the live helper is instances.tests.livetest,
and coverage omits */tests/*. The suite discovers the same 960 tests as
before.
Static URLs carry no version, but nginx served them with expires max, so
after an update or a theme change browsers kept the old CSS and JS. They
now revalidate each load (304 when unchanged). CI checks the header, the
304 and that a changed file is sent again.
Bootstrap SCSS, the JS bundle and the Bootswatch themes go from 5.3.3 to
5.3.8 (bug fixes only), and wvc-main.min.css is recompiled from zephyr.
Bootswatch now gives table text the body color, and Lux drops card and
modal header borders.
Tests compile every theme with the pinned libsass and check that the
shipped CSS is zephyr from these sources. The update and rollback steps
put back the two files a theme change rewrites, or git refuses to pull
over them.
django-qr-code was used for one thing: the QR code in the OTP e-mail,
as an inline SVG and a PNG. It brought pydantic, pydantic-core,
annotated-types and typing-inspection (about 10 MB) and, from 5.0 on,
Pillow. segno, which it used underneath, now draws both images with the
same options (error correction M, 4-module border, 1.8 mm SVG modules,
12 px PNG modules); the SVG, the PNG and the alt text are byte for byte
what django-qr-code 4.2 produced. account.html loaded the tag library
without using it.
The installer's venv sees the system site packages, and pip keeps a
system package that meets a requirement. On openSUSE Leap 15.6 the LDAP
login thus ran on the distro's python-ldap 3.4.4 (CVE-2025-61911, a
filter-escaping bypass, and CVE-2025-61912) and pyasn1 0.5.0
(CVE-2026-30922 and others), which django-auth-ldap loads through
ldap.controls. python-ldap>=3.4.5 and pyasn1>=0.6.4 make pip install
fixed copies in the venv. python-ldap builds there, so openldap2-devel
joins the openSUSE package lists (installer, manual and development
setup) and the update steps. Checked in a fresh Leap 15.6 container
with the installer's package list: 3.4.8 and 0.6.4 from the venv,
ldap.controls imports.
On an active VM a device was attached in two calls, running VM first.
When the persistent attach was refused, for example because a disk target
or an agent channel was already pending there, the device stayed in the
running VM only and was gone at the next start. A NIC added without a
MAC got a different generated MAC in each definition. One call with
AFFECT_LIVE|AFFECT_CONFIG lets QEMU check the persistent copy before the
hotplug and keeps one MAC. Detach and update still go per definition,
each from its own XML.
The device name of a new disk or CD-ROM was chosen from the running VM
only. A name used only in the persistent definition, such as a disk
renamed by a pending bus change, could be chosen again: the disk was
attached to the running VM, the persistent attach was refused, and the
disk was gone after the next start. Names either definition uses are now
taken.
On an active VM the boot order editor, the video model select and the
console form showed the running VM: a pending boot order, video model or
console setting looked unset, and the boot order editor offered devices
of the running VM only. Choosing one of those cleared the pending order,
since the setter dropped the old order and skipped devices it could not
find. They now read the persistent definition (get_bootmenu,
get_bootorder, get_media_devices, get_video_model and the console
getters take config=), and set_bootorder refuses a device that is not in
it before it changes anything.
On an active VM the options and CPU forms write the persistent definition
but showed the running VM's title, description and vCPUs, so saving one
posted the live values back over a pending change. They now read the
persistent definition (get_vcpu, get_cur_vcpu, get_title and
get_description take config=).
Buttons in the settings and resize tabs that were disabled only by class
get the disabled attribute: Enter in a field no longer submits a form
the page shows as unavailable while the VM runs.
The QoS table showed the running VM's bandwidth while an edit changes the
definition for the next start. Each row posts average, peak and burst,
so an edit wrote the running values back over a pending change, and a
direction added while the VM ran had no row. The table shows the
persistent definition now. An edit also kept only the posted values and
dropped an inbound floor; it now updates them and keeps the rest.
On a running VM a disk edit changes the definition for the next start,
but the disk list and its edit form showed the running VM's disks. The
form posts every field, so an edit wrote the running values back over
earlier pending changes (cache, io, serial, ...), and a disk only in the
next definition could not be edited. The list now shows the persistent
disks. A volume is deleted only when both definitions use it at that
device, since the list may now show another source than the running VM.
libvirt's udev interface backend (RHEL 9 and later, Debian 12) only reads
interfaces, so the create form and the start, stop and delete buttons
only failed there. The pages ask the host with an invalid definition,
which defines nothing: a backend without define refuses it as not
supported. There they show the interface state and a note instead, and
posts change nothing; the host's devices, needed only by the create
form, are not read. Any other answer keeps the pages as they were.
The CPU bar showed the average since the host booted, read for every
compute on every load of the VM list. The dialog now reads each compute's
graph endpoint (load over one second) when it opens, two computes at a
time so the reads hold at most two workers, and ignores answers of an
earlier opening. A host that cannot be read shows a dash. The since-boot
reading had no other caller and is removed.
Enabling hot plug on a stopped VM wrote every vCPU up to the maximum as
enabled, so a VM with 2 of 4 vCPUs booted with 4. The current vCPUs stay
enabled now; libvirt adds the rest as disabled hotpluggable vCPUs.
Only a running VM counted as active, so a paused one (or one blocked,
PM-suspended or shutting down) had its maximum and current memory written
for the next boot and nothing applied live. Every active VM now has its
current memory changed live and in its definition and keeps its maximum;
a paused guest takes the new size once resumed. The quota check follows
the same rule. Checked on a real guest: paused, shrunk, resumed, then
grown back.
The .vv console file is built from the VM found by its UUID, with the
console page's getters, instead of looking the VM up by name five times;
a console without a listen address or password now gives 127.0.0.1 and
an empty password instead of "None". Removed methods nothing calls: the
by-name getters and power methods of wvmInstances, managed save, the vCPU
mode getter (it read the wrong attribute), the old NIC editor and its
bridge lookup. CD-ROM media and NIC hotplug use _definitions() like the
other device edits, the disk editor takes the bus prefix from
util.vol_dev_type, and an unreadable disk is logged, not printed.
Cloning a UEFI VM defined a persistent storage pool on the NVRAM's
directory when no pool held it, and dropped <nvram> from the clone,
which found its copy only because libvirt chose the same file name.
The copy is now made through the NVRAM directory's pool (a transient one
when there is none) or the pool that holds the file, with the original's
format, owner and mode, and the clone's definition names it. A failed
definition removes it with the copied disks. An NVRAM that is not a file
is refused before anything is copied.
A volume of an LVM or iSCSI pool can be attached: by its own path, with
the format its volume reports (raw when the pool knows none). Detaching
a disk takes each definition's own description of it, so a disk only in
the persistent definition goes too. Every NIC type can be a boot device;
bridge and direct NICs were dropped from the boot order. The video model
is a value, never parsed as XML. QoS changes clear the page's read cache.
The NIC model "default" leaves the choice to libvirt instead of writing
model "default", and rt18139 is rtl8139: a VM with either did not start
(checked on a RHEL 10 host). A migration corrects the default NIC type
setting.
A NIC without QoS showed the previous NIC's limits, an empty CD-ROM the
previous drive's pool; a NIC without a source or model broke the page.
The boot order shows the target of block and network disks and of bridge
and direct NICs too. Refreshing the VM's pools before a snapshot or a
delete no longer fails on a disk outside every pool.
Each 32 MiB chunk went out as 1024 SFTP writes, each waiting for its
answer: 19 MiB/s on a gigabit LAN, ~1.9 s per chunk with the SSH session.
The writes now go out without waiting and their answers are read as they
come and at the end: ~0.53 s per chunk, the same file (checked by sha256).
paramiko's own pipelined writes (set_pipelined) drop the errors in their
answers, so a full disk would leave a short ISO behind a successful
upload. Each write is registered to its file instead, the way paramiko
does for prefetched reads: every answer, in any order, reaches the file
and an error is raised as soon as one is read.
Each stats answer carries a signed sample of the counters, bound to the VM,
its compute and UUID. The next poll sends it back and the rates are the
averages since then; a sample from another run of the VM (domain ID),
another host CPU count or device list, or older than 30 s falls back to
one second as before. Windows are measured on the monotonic clock.
Now that a poll is cheap, the Stats tab polls every 5 s like the host
graph, the first time as soon as it opens (pollWhileVisible takes the
delay of the first request).
A counter the host reports as unsupported (-1) or that went back gives no
value instead of 0 or a jump; a NIC without a target keeps its chart, a
pool volume disk is measured, and a disk or NIC attached after the page
loaded no longer stops the other charts. Disk rates are MB/s (they were
MiB/s labelled Mb/s), network Mbit/s; the CPU tooltip names the window.
Each poll of a host's overview graph held a worker for one second between
two CPU samples. The answer now carries its sample, signed with the time
and the compute; the next poll sends it back and the usage is taken since
then, so a point is the average since the previous one (normally 5 s, at
most 15 s). Without a sample, or with one that is altered, older or from
another host, or when the host restarted, it is measured over one second
as before.
A sample is one reading; the busy share was mixed from separate readings,
counted guest time twice (it is part of user) and added half a percent.
A host that cannot be read leaves a gap in both graphs instead of a zero
CPU point and a broken memory axis.
The ARP lookup passed 3 as the address source; libvirt's
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP is 2 and libvirt refused 3 as an
unknown source. The error was suppressed, so a VM without a guest agent
never showed an address.
libvirt undefines the source domain after a migration but keeps its NVRAM
file. A VM created later on that host with the same name reuses the file
and starts with the old VM's UEFI variables (boot entries, enrolled keys):
checked on libvirt 11.10, where only VIR_DOMAIN_START_RESET_NVRAM
recreates it from the template.
After a successful migration the file is now deleted on the source, as a
volume of the pool on libvirt's NVRAM directory or of a transient one.
Only a file in /var/lib/libvirt/qemu/nvram, and only when the destination
cannot create a marker file the source just created there (an exclusive
create also fails on shared storage whose listing an NFS client caches).
A failed removal is logged with the migration, which stands and is
recorded.
The resize tab enabled disk growth only for a shut-off VM, although
resize_disk already grows a running domain's disk through blockResize.
The button is now enabled for running and paused VMs too. A live test
grows qcow2 and raw disks of a running and of a paused VM and checks the
size the guest sees, the volume size, the unchanged persistent definition
and the state.
A VM that disappears from its compute keeps its owners in a tombstone
until INSTANCE_OWNERSHIP_RETENTION_DAYS pass, but nothing showed them.
Superusers now see them on the "Removed VMs" admin page (name, UUID,
owners, removed, expires) and can remove one, so a VM deleted on
purpose no longer hands its owners to a later VM with the same UUID.
The host commands hard-coded www-data, which does not exist on RHEL and
openSUSE (the panel runs as nginx there), so the first command failed
with "invalid user". The user is now set once in U and its home is read
with getent, since ~$U is not expanded. The host block also ends with
the same connection check as the Docker one.
Both blocks are labelled, and the step says to use only the one for the
installation type: a key set up on the host is not seen by the panel in
Docker.
AGENTS.md is a local agent file and is ignored.
The README gave host commands (sudo, ~www-data) and one sentence for
Docker, where there is no sudo and exec runs as root: a key made that way
lands in /root/.ssh, which the panel (www-data) does not use, and the
first connection fails with "Host key verification failed". Docker now
has its own commands, run as www-data, ending with a connection check.
Both variants accept a new host key once (accept-new) instead of
disabling host key checking.
Power cycle force-stopped and started a template, which power on refuses.
A non-staff owner with is_delete could delete a template that the
template rule otherwise leaves to superusers and staff owners;
get_instance(perm_type="delete") now refuses it, and the page offers
Destroy by the same rule.
gstfsd mounted every partition it listed: a root on LVM was never found,
a disk with two OS roots got the change twice and two concatenated
replies, and no root, an error or a request longer than 1024 bytes ended
the connection without a reply. It now lets libguestfs inspect the guest,
mounts its one OS the way the guest does (a broken filesystem off the
changed path is left out), and sends exactly one reply, keeping the first
error. The root entry is found on any line of /etc/shadow and must be
unique; a key needs no shadow file. A request is at most 64 KiB. Each
change runs in a worker process with a directly started appliance; both
are killed when the request is not done 100 s after gstfsd accepted it
(GSTFSD_DEADLINE may only lower that), before the panel's 120 s timeout
releases the VM, or when the panel's connection ends, and the reply waits
until they are gone, so nothing touches the disk once the panel has given
up. A worker that dies says why. The panel
connects over IPv4 or IPv6, sends the whole request and reads the reply
until gstfsd closes.
The documented rule held only for snapshots: an owner with is_change could
still resize a template, set its root password and SSH keys, change its
options and console settings. get_instance(perm_type="change") now
refuses a template to owners who are not staff, which covers every change
view and the API; the per-view snapshot checks it replaces are removed,
and the page offers those controls by the same rule. Viewing, the console,
power (a no-op on a template) and cloning are unchanged.
The README grew as a list of per-change notes. Reorder it along the
operator's path (install, first login, compute node, configuration,
update, usage, development) and correct what no longer matched the code:
supported distros and Python, the openSUSE nginx step that installed a
full nginx.conf as a vhost, the disabled serial console listed as a
feature, missing ALLOWED_HOSTS and file-mode steps, services not
restarted, and an update path that copied settings blocks by hand
instead of rebuilding settings.py from the template with the panel
stopped.
OTP (OTP_ENABLED) was enforced only by the HTML login form, but two other
doors let an enrolled user authenticate with a password and no token, so a
leaked password defeated the second factor. Both were reproduced with a
confirmed TOTP device: /api-auth/login/ (DRF's browsable-API login) created a
site-wide session, and DRF's default BasicAuthentication let every /api/v1/
endpoint accept HTTP Basic credentials.
The api-auth/ include is removed, so the only interactive login is the
OTP-aware form at /accounts/login/; the browsable API still works with the
session that form sets. REST_FRAMEWORK now pins DEFAULT_AUTHENTICATION_CLASSES
to SessionAuthentication only, so the API requires a session and no longer
accepts a password over HTTP Basic. A session therefore implies the OTP factor
was met. Password-based programmatic API access is intentionally dropped.
The authorization matrix drops its rest_framework:login/logout allowlist
entries (the routes are gone). New tests assert the browsable login route is
removed, HTTP Basic is rejected on the API, Basic is not a default auth class,
and a session still reaches the API. Verified live with OTP_ENABLED that
/api-auth/login/ is 404 and an API Basic request is 403.
With "VM Clone Auto Migrate" on, a new clone was migrated to a random
compute, whatever storage, CPU or network that host had, and a failed
migration showed as a failed clone although the clone existed.
The setting and its code are removed; migration appsettings.0015
deletes the setting. A clone can be migrated afterwards like any VM.
The README's update notes list the migration changes: the migration
address, storage pools for the disk check, and this setting.
Nothing limited failed logins: a password could be guessed at the speed
the server answers. django-axes (8.3.1, Django 5.2, Python 3.10-3.13)
now counts them.
- A user is locked for the address the failed logins come from
(username and IP together), so an attacker cannot lock someone out
from elsewhere and users behind one NAT address do not share a lock.
A successful login resets the count. axes sits in front of every
authentication: the login page with or without OTP, LDAP and the API.
- The number of failed logins (default 5) and the lock time in minutes
(default 15) are on the Settings page (LOGIN_FAILURE_LIMIT,
LOGIN_LOCKOUT_MINUTES, added by migration appsettings.0014) and apply
at once; an invalid value falls back to the default. Unlock with
"manage.py axes_reset_username <username>".
- Behind the bundled nginx every request comes from 127.0.0.1; the
client address is read from X-Real-IP, which nginx sets, but only for
requests from a loopback address or from LOGIN_TRUSTED_PROXIES, so a
forged header does not escape a lock.
settings.py.template gets the app, middleware, backend and AXES_*
settings; README tells existing installations what to copy. Tests that
call client.login now pass a request, which the axes backend needs.
The new tests fail with axes disabled.