Errors¶
Clients are tested properly only when they meet the errors a real Proxmox VE gives. fakepve carries a catalogue of those errors, taken from the PVE sources, and can raise any of them by name.
Contents: the catalogue | listing | triggering | placeholders | errors in tasks | cross-cutting errors | the shape of an error | limits | regenerating
The catalogue¶
src/fakepve/data/pve-errors.json.gz lists, per API method, the errors PVE can answer with. It was read from the PVE sources (the repositories and commits are recorded in fakepve.errors.meta()) and holds only message strings, status codes and source references. Each entry has:
| Field | Meaning |
|---|---|
name |
What you pass to pve.fail(error=...). Made from the message text, unique within a method (a repeat gets _2). |
status |
The HTTP status: 400, 403, 500, ... |
message |
The message as a template, with {placeholders} for values only known at run time. |
errors |
For a 400, the per-field errors ({'vmid': 'invalid format - value does not look like a valid VM ID\n'}). Absent otherwise. |
in_task |
true if PVE raises it inside a worker process, so it ends a task instead of failing the request. |
source |
repository/path:line in the PVE sources, or cross-cutting for the ones added in code. |
At the time of writing it holds 2314 errors on 465 of the schema's 680 methods; 658 methods are mapped to their source, and the rest (such as the QEMU guest agent commands, which PVE builds in a loop) have none. fakepve.errors.meta() says exactly how many, from which commits, and when.
Listing errors¶
pve.errors_for(method, path) returns the errors a method can produce at a concrete path, with the placeholders filled from the path. This is the quickest way to see what is available:
from fakepve import FakePve
pve = FakePve()
entries = pve.errors_for('DELETE', '/nodes/pve1/qemu/100')
for entry in entries[:5]:
print(entry['name'], entry['status'], entry['in_task'], '|', entry['message'])
print(len(entries) > 15)
print(sorted(entries[0]))
param_skiplock_only_root_may 400 False | Parameter verification failed.
unable_remove_used_ha_resources 500 False | unable to remove VM 100 - used in HA resources and purge parameter not set.
running_destroy_failed 500 False | VM 100 is running - destroy failed
configuration_file_does_not_exist 500 False | Configuration file 'nodes/pve1/qemu-server/100.conf' does not exist
protection_mode_enabled 500 False | fake - protection mode enabled
True
['errors', 'in_task', 'message', 'name', 'source', 'status']
The same list for every method is in the generated endpoint reference. fakepve.errors.catalogue() is the raw data, and fakepve.errors.listing(method, path) the templates before filling.
Triggering an error¶
pve.fail(method, path, error='name') makes the next matching requests fail as PVE would:
from fakepve import FakePve
pve = FakePve()
pve.fail('DELETE', '/nodes/pve1/qemu/100', error='running_destroy_failed') # every time
pve.fail('POST', '/nodes/pve1/qemu/100/status/stop', error='no_quorum', times=1) # the next one only
pve.fail('POST', '/nodes/{node}/qemu/{vmid}/status/start', error='locked') # any node and VM
print(sorted(key[1] for key in pve.failures))
print(pve.failures[('POST', '/nodes/pve1/qemu/100/status/stop')]['times'])
['/nodes/pve1/qemu/100', '/nodes/pve1/qemu/100/status/stop', '/nodes/{node}/qemu/{vmid}/status/start']
1
The recipe with the response the client sees is in Scenarios.
error=takes a name fromerrors_for. A name the method does not have raisesValueErrorlisting the ones it does, when the path is complete (starts with/); a partial suffix is checked when a request arrives, and a request for a name that does not fit gets 500fakepve: no error 'name' for ....error=cannot be combined withstatusandmessage.- The path is matched against the end of the request path (
/qemu/100/status/startmatches any node), or, if it contains{, against the schema path, where{node}and{vmid}stand for anything. - A failure persists until you delete it from
pve.failures, unless you givetimes=n. This is the same for your own status and message. - It works for the hand-written routes and the generic ones alike.
- Failures are checked before login, so a failing request fails even for a client with no ticket. The exception is an
in_taskerror, which is raised after the login is checked, as it is in PVE. - If several failures match, the first one added wins.
Placeholders¶
Messages are templates. {vmid}, {node} and the other placeholders are filled from the request path and body. Any that the request does not supply get a plausible value: {vmid} 100, {node} pve1, {storage} local, {lock} backup, {guest_type} VM (or CT under lxc), {userid} root@pam, and fake for anything unknown. In a 400's errors, a key that is itself a placeholder (a parameter named at run time in PVE) becomes the first parameter the method has that the path does not fix.
from fakepve import FakePve, errors
pve = FakePve()
template = errors.find('DELETE', '/nodes/pve1/qemu/100', 'running_destroy_failed')['message']
print(template)
filled = pve.errors_for('DELETE', '/nodes/pve1/qemu/222')
print(next(e['message'] for e in filled if e['name'] == 'running_destroy_failed'))
print(errors.fill('unable to find {thing} on {node}', {'node': 'pve9'}))
VM {vmid} is running - destroy failed
VM 222 is running - destroy failed
unable to find fake on pve9
Errors in tasks¶
Errors raised in a PVE worker process, marked in_task, do not fail the request. The request succeeds with a UPID, and the task ends with exitstatus set to the message and a log that ends TASK ERROR: <message>. This is what a client that only checks the HTTP status misses, and why it is important to test: starting a VM that is already running, in PVE's worker, is such an error. fakepve does the same for in_task entries, so the client has to read the task to find out.
Cross-cutting errors¶
Some errors can happen to many methods and come from PVE's daemons rather than its API modules. They are added in code and listed with source cross-cutting:
| Name | Status | When it applies |
|---|---|---|
invalid_ticket |
401 | permission denied - invalid PVE ticket: any method that needs a login. |
authentication_failure |
401 | authentication failure: any method that needs a login. |
invalid_csrf |
401 | Permission denied - invalid csrf token: writes. Kept at 401, as the fake's own login handling answers. |
no_quorum |
500 | cluster not ready - no quorum?: writes. |
config_lock_timeout |
500 | can't lock file '/var/lock/qemu-server/lock-N.conf' - got timeout: writes to a QEMU or LXC guest (the LXC path differs). |
not_implemented |
501 | Method 'METHOD path' not implemented: every method. |
no_route_to_host |
595 | No route to host: paths with a {node}. |
hostname_lookup_failed |
500 | hostname lookup 'node' failed - ...: paths with a {node}. |
permission_denied |
403 | Permission check failed (path, privilege): methods that are not open to every user. |
The shape of an error¶
PVE answers an error with the status code set, the first line of the message as the HTTP reason phrase, and a JSON body:
{"data": null, "message": "VM 100 is running - destroy failed\n"}
A 400 from parameter validation adds the per-field errors:
{"data": null, "message": "Parameter verification failed.\n", "errors": {"vmid": "invalid format - value does not look like a valid VM ID\n"}}
Messages carry PVE's trailing newline in the JSON, and none in the catalogue. PVE appends at /usr/share/perl5/... line N. to a die without a newline; that is left out.
Limits and approximations¶
The catalogue is an approximation, because it reads the code and does not run it:
- Errors in functions a method calls are followed two levels deep, and
eval {}bodies are treated as caught. A few errors therefore belong to a branch the request would not take, and a few real ones deeper down are missed. - Messages built at run time are templates with the variable's name as the placeholder, filled with a plausible value.
- 22 of the 680 methods could not be mapped to a source method at all (most are the guest agent commands, which PVE registers in a loop), and so have only the cross-cutting errors. Another 193 are mapped but raise nothing the extraction found, typically plain reads.
permission_deniedstates the rule's first privilege, oruser != root@pamfor rules it cannot summarise, whatever the real rule is. The generic endpoints themselves evaluate the real rule when a user without the privilege calls them.- Names come from the message text and can change when the catalogue is regenerated for another PVE release. Pin the catalogue (it is a file in the package), or check
pve.errors_forbefore relying on a name after updating.
Known gaps in what the fake checks, from the schema: some formats are accepted without checking (formats), and regular expressions that only Perl understands are not applied.
Regenerating the catalogue¶
On a machine with shallow clones of pve-manager, qemu-server, pve-container, pve-storage, pve-access-control, pve-cluster, pve-firewall, pve-network, pve-guest-common, pve-common, pve-ha-manager and proxmox-acme (from https://git.proxmox.com/git/<name>.git) in one directory:
python3 tools/extract_errors.py /path/to/clones
It reads the vendored schema, writes src/fakepve/data/pve-errors.json.gz (the repositories' commits and the date are in its meta), and prints the counts and the source methods it could not map. It uses only the Python standard library. The PVE sources are AGPL and nothing but messages and status codes is copied. How it works has the whole refresh routine.