A shut-off clone copies the VM's NVRAM file just before it defines the
clone; a worker that stopped between the two left the copy, unnamed by
the job, which recovery then reported as leaving nothing. The job
records the copy's path once libvirt made it, and recovery deletes that
file when the clone was not defined, in whichever pool it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
The clone took a new UUID but kept the VM's in its SMBIOS system
information, and libvirt refused the definition (UUID mismatch between
<uuid> and <sysinfo>). The SMBIOS entry takes the clone's UUID.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
A shut-off clone kept the VM's TPM element as it was: an emulated TPM
with a state path of its own, a host's TPM or an external one would be
shared with the VM, not the new, empty TPM the clone form promises. An
emulated TPM loses its state path, and the others are refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
The addresses were checked for their form only: a clone could keep the
VM's address, or give two NICs one, and the NICs on a network then take
each other's traffic. Such an address is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
The name's length was not checked: libvirt defined a clone of a name
longer than the panel's records hold (120), and the job failed to record
it, leaving a VM the panel did not list. The form and the job take 64,
as for a new VM.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
A disk name without an extension that did not start with the VM's name,
as LVM volumes have, became the clone's name alone: two such disks got
the same name and the clone failed on the second. Each is the clone's
name and the disk's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
libvirt refused the copy only after the job had begun; recovery then
found a volume of the planned name and ended the job uncertain, holding
the VM. The names are checked when the job is queued and before it runs,
as a live clone did; that check now refreshes the pool first, so a live
clone's free space is read current too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
The copy was read while the guest wrote: a raw disk was copied with
data of different instants, and a qcow2 one failed on qemu-img's image
lock with a message that did not say why. Such a clone is refused, with
the running VMs named, when it is queued and again before it runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
Only a file in libvirt's NVRAM directory was copied; one elsewhere was
skipped without a word, and the VM started on the destination from the
firmware's template, its saved boot entries lost. Such a VM is now
refused before anything changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
A worker that stopped during the copy left the partial files on the
destination: libvirt ends the migration and the VM stays, but the files
took the space and refused the next try, as a copy never replaces a
file. The job records the copies before libvirt makes them, and
recovery deletes them while the destination has no VM of it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
A libvirt error after a cancel ended the job cancelled at once; a
migration that finished but lost its last reply left the VM on the
destination and its record on the source. The error goes to recovery,
which ends the job cancelled when the VM stayed and succeeded when it
moved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
A copied disk was found by its target as well as its path: after a bus
change waiting for the next start (vda to sda), the next start's
definition kept the source path, which the destination does not have,
and the VM would not start again there. The path alone finds it, so two
disks of one file can no longer be copied to two places.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
The source host alone was asked: a file on storage the computes share,
used by a VM or volume of another compute, was deleted under it. The
check the disk move makes, every compute and a host that cannot tell
counting as using it, moves to instances.utils and serves both. The
destination is not asked when its copy has the source's path: its VM
uses that copy, another file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
Each host's logical volumes are its own: a VM whose disk is /dev/vg/x
migrated, uncopied, onto the destination's volume of the same name and
kept running on other data. Such a volume now counts as missing there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
Python 3.14 starts processes with forkserver, which Django 5.2's parallel
runner does not support: its workers import the tests before
django.setup() and fail (AppRegistryNotReady), and `--parallel` without a
number falls back to one process. The project's test runner sets the
start method to fork, as it was up to 3.13. The production image runs
Python 3.14 (Ubuntu 26.04): CI adds it, and the parallel run moves to that
leg.
Tried on Ubuntu 26.04's Python 3.14: `test --parallel 4` and `--parallel`
(8 processes) pass; before, both hung.
The newest noble tag is noble-1.0.3; the image now starts from the 26.04
LTS one: Python 3.14 and libvirt 12. Tried: the image builds, starts
healthy with every service, connects a compute over TCP, and the main
pages, the VM page with its graphs and the new VM form render without
errors; the test suite passes on Python 3.14 (Ubuntu 26.04).
psycopg 3.3.8, ruff 0.17.4; Chart.js 4.6.1, Ace 1.45.2 (its XML mode and
worker are unchanged) and Bootstrap Icons 1.13.2 (font and stylesheet).
django-bootstrap5 26.4 and coverage 7.17.2 need Python 3.11 and stay at
26.3 and 7.16.2 while Python 3.10 is supported. No known vulnerability was
fixed by them (pip-audit clean before and after).
- The root password's SHA-512 crypt hash comes from libpass (the maintained
passlib fork, pure Python) instead of crypt-r, a copy of the stdlib crypt
module gone in Python 3.13: crypt-r ships no wheels and its build needs
crypt.h, which Ubuntu 26.04 no longer brings with python3-dev, so the
installer and the manual installation failed there. The hash is the same
format ($6$, now with libpass's 656000 rounds), checked with glibc's crypt.
- The README's RHEL compute packages left out python3-libguestfs, which
gstfsd imports with the system python: setting the password or key of a
shut-off VM failed there.
- The tests read YAML themselves and had PyYAML only through
drf-spectacular.
Adding a Compute Node told the panel's side of TLS only and nothing of the
SASL login the bootstrap script makes libvirt ask for over TCP: a host set
up as written refused the panel. It now gives the saslpasswd2 step and the
compute's CA, server certificate and TLS socket (tried on a test compute).
- The VM list with no VMs no longer also says that no VM matches the
search and the state.
- The New VM dialog with no compute yet says to add the first host, with a
link to the Computes page (and links there when none is reachable).
- The new VM page on a host without a running storage pool or a network
says what to add or start and where, instead of "You haven't defined any
storage pools" (also when pools exist but are stopped) and "network
pools".
A disk of the user's VMs whose size could not be read (its volume not in a
pool, the pool stopped) counted as 0 GiB, so a disk-limited user's increase
could pass over the limit. A disk no pool holds now has the size libvirt
reads from the file or device (blockInfo, also for a shut-off VM); one whose
size is still unknown makes the usage unverified and the increase is
refused, as when a host cannot be reached. Users without a disk limit are
not affected.
A revert gives the VM the vCPUs and memory it had when the snapshot was
taken. An owner could take a snapshot, shrink the VM, give the freed share
to another VM and revert, ending over the quota. The revert now checks
what it adds to the VM (against the larger of its running and next-start
sizes) as a resize does: on the page when it is queued, and again in the
worker before it runs, as a resize may have used the quota meanwhile.
A running VM's page read its addresses while it rendered: the guest agent,
when its channel is connected but it does not answer, holds the page (and
a gunicorn worker) for its whole 10 s timeout, on every load. The page now
shows them once it has loaded: vm_addresses.js asks the new
instances/<pk>/addresses/ for each NIC's addresses and fills the header's
IPv4 and each network card's IPv4 and IPv6.
The Computes page, the host overview and the new-VM dialog's capacity line
read each VM's XML for its memory and vCPUs: a round trip a VM, about
200 ms for 150 VMs on a LAN, more with each host. libvirt's domain stats
give them for every VM in one call; a running VM busy with a job is not
waited for (NOWAIT) and its sizes come from its XML as before.
The "Lost OTP?" page writes a row when it mails a user their QR code link,
or fails to, under the anonymous requester and their address. A password
change refused for a wrong old password writes a failed row.
A job keeps the client address of its submission (Operation.client_ip,
migration operations.0003); the rows the worker writes for it, including the
migration's and the VM delete's own rows, carry it. A cancel is written under
the user who asked and from their address, also when an administrator
cancels another user's job; a running job's cancel gets a row when asked,
and its end a row when it stops. A resolution carries the administrator's
address.
- A migration whose libvirt call failed but which recovery finds moved (or
cannot tell) gets a row of that end; only a failure already logged by
migrate_instance is not logged twice.
- A clone made with its resize or start failed, and a recovered migration
whose autostart or NVRAM step failed, write a failed row of that step, as
migrate_instance does for its later steps.
- Setting the link state of an interface or the video model of a VM that
has none is refused: it changed nothing and was logged as done.
- The rows of a removed VM record (deleted or expired) name its host, VM
and UUID, so the VM's history has them.
One serializer created volumes and read them: size was GiB in a request
and bytes in an answer, the same field in the schema. A client that sent
back a size it read asked for a volume 2**30 times as large. A new
volume is CreateVolumeSerializer (size in GiB); a volume read is
VolumeSerializer (allocation and size in bytes), and the schema says so,
as for the pools' size and free space.
- libvirt errors all answered 400. A VM, pool or volume the host does not
have now answers 404, a volume in use 409 (vrtManager.util.InUse) and a
host that cannot be reached 503, which a client may retry.
- A template's power on or cycle and a stopped pool answered
{"status": ...} with 409; they answer {"detail": ...}, as every other
refusal. The schema documents these 409s (ErrorSerializer).
- The volume list and a volume of a stopped pool answered 200 with an
empty list or a made-up volume; they answer 409.
- An API URL that names nothing answered the HTML error page; it answers
a JSON 404 after the API's authentication.
- A session that must change its password was redirected to the HTML
form; an API request of it gets the JSON 403 a token gets.
The create page and the API's create take the architecture from the
URL. One the host's capabilities do not list raised a KeyError in
get_dom_cap_xml: an HTML 500 for both. It is now "This host has no
architecture noarch": the page shows it, the API answers 400.
English-only texts users read, now in their language:
- the serial console's four refusals, which socketiod sends in the
language LocaleMiddleware would choose (cookie, then browser) and logs
in English; the texts live in console/refusals.py, as makemessages
reads only .py files;
- a submission key reused for another request, a malformed MAC address,
the moved-VM refusal of a script's request, the API's "Templates
cannot be started" (now the page's sentence), "Pool is not active"
and the ISO upload's checks;
- "Problem occurred with host" was a fragment with the host's name and
error after it: one sentence with both.
- A failed cancel hook logged "worker task cancel failed": it now names
the job, its kind, resource and user, with the traceback for a fault
of the panel.
- novncd logged "Console connection failed" without the VM or the user.
- django-axes wrote four INFO lines a login ("Reset 0 access
attempts"...); logins are on the Logs page, so it logs warnings only.
- The new VM form's machine types and disk buses answered 200 with an
empty object when libvirt failed, and nothing was logged: the list
stayed empty, the form could not go on and said nothing. They answer
400 with the error, as the pool's volume list does, the warning is
logged with the compute, and the form's lists show an error they get
with the page's messages.
A job that failed on an exception that is not libvirt's or a refusal
showed its raw text to users (a SQL error such as "UNIQUE constraint
failed: instances_instance.uuid", a KeyError's key), and the worker
logged only that text. Users now read "The panel failed unexpectedly;
its log has the details." and the worker logs the exception with its
traceback.
A live clone whose recovery found the clone made but could not thaw the
source's file systems kept that in the error of a succeeded job, which
the job panel does not show. It is a warning of the clone's result, as
when the job itself made the clone.
The console page of a VM whose host does not answer said "Fail to get
console. Please check the console configuration of your VM." and logged
nothing. It now shows the connection error (to users who do not see the
hosts, that the host cannot be reached) and logs it with the VM and host.
A host's Storage, Networks, Interfaces, Filters and Secrets pages showed
the connection error and also "This host has no ...". The list is None
when it was not read, and the page shows only the error.
Restoring only the database on newer code copied the backup's old
settings.py back and ran no migrations: a backup from v4.3.0 brought back
RemoteUserMiddleware and left the password validators and WAL out, with
seven migrations pending. The restore runs migrate, which changes nothing
on a rollback, and the text names every line that takes a file from the
backup.
Since the VM create became a job (6f0c7300) every new VM was defined with
no VNC password, and a clone copied its source's password (or its lack
of one) exactly: every clone of a template shared it. Computes listen
for VNC on all their addresses (dev/libvirt-bootstrap.sh), so anyone who
could reach a compute's VNC ports opened any of these consoles directly,
without the panel; the README's firewall rule was the only guard.
The create job asks for create.py's random 8-character password again,
and a clone's VNC graphics get a new random one. The console page sends
the password to a user with passwordless_console, which the user create
form now ticks by default next to change_password; a user without it is
asked for the password, as the permission intends. Existing VMs keep
their settings.
Live create, clone and VM page tests pass on a TCP compute.
An administrator resets a password to close an account someone else
took over, but the API tokens stayed valid: a stolen token kept working
with the new password, for as long as it was made to last. Setting a
password on the user's Password page now deletes that user's tokens,
and the page says so. A user who changes their own password keeps their
tokens (scripts keep working); they can delete them on their profile.
Engine.IO accepts a connection, and holds one of socketiod's 40 threads
for it, from its first request; the console token is checked only in
Socket.IO's connect(), which a client need never send. Anyone who could
reach the panel, without an account, could open 40 websockets, answer
their pings, and no serial console would open for anybody (checked: the
41st handshake got no answer until they were closed).
The daemon's WSGI app now refuses a request whose session cookie names
no signed-in user, with 403, before Engine.IO sees it; the page always
sends that cookie. A token without its session no longer connects either,
as for novncd. Checked again: 60 such websockets are each answered 403 at
once and hold nothing.
The process tests sign their client in with a session cookie, as a
browser; python-socketio sends a custom Cookie header only with its first
request, so the client gets a cookie jar instead.
Every route accepted a 1 GiB body, and nginx buffers a body to disk
before the panel can refuse it: anyone, without an account, could fill
that disk with large requests sent slowly and never finished. The one
large body is an ISO upload's chunk, 32 MiB (storage_page.js); the limit
is now 64 MiB.
Checked with nginx and this config: 33 and 64 MiB pass to the panel,
65 MiB is answered 413.
Step 2 rebuilds settings.py from the template, whose DATABASES is sqlite
unless WEBVIRTCLOUD_DB_HOST is set; its list of values to put back did
not name DATABASES. An installation that set PostgreSQL in settings.py
(the only way before the WEBVIRTCLOUD_DB_* variables) would have migrate
make a new, empty sqlite database and the panel start on it.
Checked by upgrading an installation of the upstream release of
September 2026 on PostgreSQL 16 with DATABASES put back: every user,
permission, owner, compute, VM and log row came through.
Installers up to September 2026 wrote SECRET_KEY into settings.py and
made no data/ directory. Step 2 said that leaving the key out signs every
user out; in fact the template then has no key at all: migrate in step 3
stops with "The SECRET_KEY setting must not be empty" and every page
answers 500. The step now says to create data/ first and what happens
without the key.
Checked on an installation of the upstream release of September 2026
upgraded to this version, with data/secret_key moved away and back.
Every upstream release and this fork's up to v2.0.0 created the first
user as admin/admin. The first-login password change only covered the
password the panel generates itself (data/admin_password), so an upgraded
installation whose admin never changed the default kept a working
admin/admin login with no prompt, on the pages and through API tokens.
Migration accounts.0013 marks a user named admin whose password is
"admin" with must_change_password: the next login goes to the password
change form, and the API refuses the user's tokens until then. Only that
one name is checked, so the migration hashes one password at most.
Found by upgrading an installation of the upstream release of
September 2026 (installer and Docker) to this version: admin/admin still
signed in.
Installers up to September 2026 ran collectstatic into the tracked static/
(which changed static/drf-yasg and static/rest_framework files) and deleted
the tracked install.sh and webvirtcloud.sh. Step 1 put back only
static/css and dev/scss, so `git pull` refused with "Your local changes
would be overwritten" on every such installation, and the later steps ran
on the old code. Step 1 now puts back every tracked file under static/ and
the two installer scripts.
Checked by installing the upstream release of September 2026 with its own
installer on Debian 12, Ubuntu 24.04 and AlmaLinux 10, and following the
update steps to this version.
Since deleting a VM became a job (#431), the delete page queues it and
returns to the VM page; the live VM page tests still expected the VM
gone at once and a redirect to the list, so they failed, and the VMs
they left behind failed later tests. They now run the queued job and
check the VM is gone.
- gunicorn.conf.py was gunicorn's sample file: pages of generic
comments, a broken link and examples of the eventlet worker the panel
no longer has. Only the panel's own comments stay; every setting keeps
its value.
- docker-compose.yml: the backups hold the database, keys and
certificates, not "both".
- accounts' system checks point to settings.py.template, which has the
middleware they ask for (the README does not list it).
- The authorization matrix test names doc/permissions.md, where the
roles table is; the keymap list no longer cites a dead page.
- Deleting VMs and moving disks are jobs too (Features, Behavior
changes, architecture), and the API's delete answers 202.
- Docker keeps the TLS client certificates in webvirtcloud-pki.
- The manual installation covers Ubuntu 26.04 and Debian 13, says lxml
is reused like libvirt-python, and adds the service user to the
libvirt and kvm groups, as the installer does, for a socket compute.
- Moving to a new machine starts in the clone's directory (Docker) and
copies secret_key only when the backup has it (installer); the
off-site steps run in the panel's checkout; an update's backup also
holds /etc/default/webvirtcloud and local-migrations.tar.
- A kept settings.py is rebuilt from the template instead of patched by
a list that had fallen behind (knox, axes, four middlewares).
- gstfsd on 127.0.0.1 serves a panel that adds its compute as localhost;
LOGIN_TRUSTED_PROXIES matters only for a proxy on another machine;
socket computes reach a localhost console too.
- New passwords pass Django's validators (Behavior changes).
- permissions: a VM an administrator paused stays paused for owners.
- architecture: nginx listens on 80, on 443 only with your TLS.
- Running tests: the libvirt layer's tests are in vrtManager/tests.
- console settings were split over four files; who sees the form, the
listen address, and the settings themselves are test_console_settings,
and who may open a console is test_console_access;
- the three template files are test_templates;
- the quota lock test joins test_quota;
- accounts' test_security_fixes held two topics: the SSH key tests are
test_ssh_keys, the owner-row delete redirect is in test_views;
- instances' test_security_mutations, who may change a VM, is
test_permissions.
The suite runs the same 2097 tests.
Tests that build vrtManager objects and touch no view, form or model of
an app lived in that app; vrtManager/tests already holds such tests, so
the convention was split. They move to vrtManager/tests: the firmware,
CPU mode and PCIe port definitions of a new VM (test_create), USB and PCI
host devices, the console listener, host CPU reads, interface reads,
deletes and XML, network filters, PCI passthrough rules, node devices,
network changes, addresses and leases, pool XML, secrets, and the live
tests that use vrtManager only (disk resize, ISO, snapshots, XML, live
clone). livetest.py, the live-test helper six apps import, and the
network test driver move with them.
Others go to their app: the quota with a host down to instances, the
401/403 Logs rows (webvirtcloud's middleware) to webvirtcloud, the VM
page's tab script test to instances, novncd's own tests out of the
console views tests, the compute lock and input validation tests out of
the computes views tests, the interface views out of the forms tests.
instances/tests/test_views.py, entirely live, is test_live_views.py.
Test modules imported fixtures from each other; shared ones are now in
vrtManager/tests/fakes.py (migration fakes, node devices, IOMMU groups,
leases, a NAT network, a Ceph secret), interfaces/tests/fakes.py,
console/tests/daemons.py (novncd loaded once), livetest.wait_for and
instances/tests/create_page.BODY. Every live test checks
livetest.enabled(). The suite runs the same 2097 tests.