Using fakepve in CI¶
fakepve is one Python process on aiohttp: no PVE, QEMU, Ceph or corosync, a few hundred milliseconds to start. That makes it cheap to run in every pipeline. This page covers the ways to run it, the choice of time_scale, certificates, and ready-made recipes for GitHub Actions, Forgejo and Gitea Actions, GitLab CI and plain pytest.
Contents: three ways to run it | pytest (also the plugin) | a background process | time_scale | several fakes | certificates | a container | pipelines | when a pipeline fails
Three ways to run it¶
| In the test process | A background process | A container | |
|---|---|---|---|
| Best for | Python tests, any client library you can call from Python | Anything that is not Python: Terraform, Ansible, Go, shell, a CLI | A pipeline that already uses service containers |
| How | FakePve in a fixture |
python -m fakepve and wait for it |
docker run or a service |
| Scenarios (locks, quorum, failures, ...) | All of them, set from the test | Only what the command line sets, or a script like custom_server.py |
The same as a process |
| Start-up wait | None | Wait for the "listening" line or poll | Health check or poll |
If your client is not written in Python but you want the scenarios, write a small server script (as examples/custom_server.py does), run it as a background process, and give it your tests' setup.
pytest¶
The quickest way is the plugin that comes with fakepve: pip install 'fakepve[pytest]' and ask for the fakepve fixture. It works for sync and async tests and for pytest-xdist; see pytest. The rest of this section builds the fixtures by hand, which gives more control.
Async client (aiohttp, httpx)¶
The fake runs in the test's own event loop, so there is nothing to wait for. examples/aiohttp_pytest.py is a complete file; the core is two fixtures:
import pytest
import pytest_asyncio
from fakepve import FakePve, settle
from fakepve.certs import make_cert
@pytest.fixture(scope='session')
def cert(tmp_path_factory):
certfile, keyfile, _ = make_cert(str(tmp_path_factory.mktemp('pki')))
return certfile, keyfile
@pytest_asyncio.fixture
async def pve(cert):
fake = FakePve(user='root@pam', password='secret')
await fake.start(*cert)
yield fake # the test adds VMs and breaks things here
await settle()
await fake.stop()
VMs and scenarios can be set up after the fake has started, so the test body does the configuring. Add asyncio_mode = "auto" under [tool.pytest.ini_options] in pyproject.toml to drop the pytest.mark.asyncio marks. A function-scoped fixture gives every test a clean cluster; the session-scoped certificate keeps key generation out of the loop.
Within one process every fake gets its own loopback address, so any number of them can use port 8006. Separate processes do not know about each other: each pytest-xdist worker starts counting from 127.0.0.2 again, and so do two CI jobs on one machine. Give them different addresses or ports, for example FakePve(host='127.0.0.{0}'.format(10 + worker_index)), or let the system choose with FakePve(host='127.0.0.1', port=0) and read pve.port after start(), as the plugin does.
Sync client (requests, proxmoxer, the proxmoxer-based Ansible modules)¶
A synchronous client blocks the thread it runs in, so the fake needs a thread of its own. examples/requests_pytest.py and examples/proxmoxer_pytest.py show it. The idea is a loop in a background thread, and run_coroutine_threadsafe for the few calls that are coroutines:
class ThreadedFake(object):
def __init__(self, certfiles, **attrs):
self.pve = FakePve(user='root@pam', password='secret')
for name, value in attrs.items():
setattr(self.pve, name, value)
self.certfiles = certfiles
self.loop = asyncio.new_event_loop()
self.thread = threading.Thread(target=self.loop.run_forever, daemon=True)
def run(self, coro):
return asyncio.run_coroutine_threadsafe(coro, self.loop).result(30)
def __enter__(self):
self.thread.start()
self.run(self.pve.start(*self.certfiles))
return self
def __exit__(self, *exc):
self.run(settle())
self.run(self.pve.stop())
self.loop.call_soon_threadsafe(self.loop.stop)
self.thread.join(10)
self.loop.close()
Setting attributes (vm.shutdown_secs = 90, pve.tfa = True, pve.fail(...)) from the test thread is safe. Coroutine methods (backup, migrate, hold_config_lock) go through fake.run(...).
Checking what the client sent¶
pve.requests lists every request as (method, path, body). It is the simplest way to assert that a module sent the right call, or did not send one:
assert ('POST', '/api2/json/nodes/pve1/qemu/100/status/start', b'') in pve.requests
A background process¶
For clients that are not Python, start the fake as a process and wait until it is up. The command prints one line, fakepve listening on https://HOST:PORT, after it is listening, so reading that line is a reliable "ready":
import json
import ssl
import subprocess
import sys
import urllib.request
proc = subprocess.Popen([sys.executable, '-m', 'fakepve', 'examples/guests.json', '--host', '127.0.0.77',
'--time-scale', '0'], stdout=subprocess.PIPE, text=True)
try:
print(proc.stdout.readline().strip()) # blocks until it is listening
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with urllib.request.urlopen('https://127.0.0.77:8006/api2/json/access/domains', context=ctx) as rsp:
print(rsp.status, json.load(rsp))
finally:
proc.terminate()
proc.wait(10)
fakepve listening on https://127.0.0.77:8006
200 {'data': [{'realm': 'fake', 'type': 'fake'}]}
From a shell, poll instead. /access/domains is one of the few endpoints that needs no login, which makes it a good probe:
python -m fakepve guests.json --host 127.0.0.2 --time-scale 0 > fakepve.log 2>&1 &
for i in $(seq 1 30); do
curl -ksf https://127.0.0.2:8006/api2/json/access/domains >/dev/null && break
sleep 1
done
curl -ksf https://127.0.0.2:8006/api2/json/access/domains >/dev/null || { cat fakepve.log; exit 1; }
Stop it with kill %1, or let the job end: CI runners clean up processes with the job. Keep its output in a file and print the file when the job fails.
Choosing time_scale¶
Durations (a shutdown that takes 90 seconds, a lock held for a minute) are in PVE seconds, and time_scale is the number of real seconds per PVE second.
time_scale |
Use it for | Notes |
|---|---|---|
0 |
Unit tests that check outcomes: what the client sends, what it does with an error | Every task finishes before the request returns. Durations (shutdown_secs, latency, ha_delay) do not wait, but a shutdown still fails when it exceeds its timeout. A held config lock cannot be observed. This is the class default. |
0.01 |
Scenario tests where timing is the point: timeouts, polling a running task, locks, HA, migrations | 60 PVE seconds take 0.6 real seconds. Tasks show running, so the client must poll. Pick the smallest scale at which your client's own polling still sees the states. |
1 |
The default of the command line; real-time behaviour, demos | Too slow for most pipelines. |
With a non-zero scale, a test that races a task needs to wait for it (poll tasks/{upid}/status) rather than sleep. The recipes in Scenarios do this with a small wait helper.
Several fakes¶
Each FakePve takes the next loopback address (127.0.0.2, 127.0.0.3, ...), so any number of fakes can listen on port 8006 in one process or one machine at once. This is the way to test a client against several clusters or several sites:
import tempfile
from fakepve import FakePve, settle
from fakepve.certs import make_cert
certfile, keyfile, _ = make_cert(tempfile.mkdtemp())
site_a = FakePve()
site_b = FakePve()
site_b.set_node_offline('pve1')
await site_a.start(certfile, keyfile)
await site_b.start(certfile, keyfile)
print(site_a.host != site_b.host, site_a.port == site_b.port == 8006)
await settle()
await site_a.stop()
await site_b.stop()
True True
From the command line pass --host for each. This works on Linux, where all of 127.0.0.0/8 is local. Where only 127.0.0.1 is (macOS by default), give each fake that address and a different --port, or set the address explicitly with FakePve(host='127.0.0.1', port=18006).
Certificates¶
Without --cert and --key (and with cryptography installed) the fake makes a self-signed ECDSA certificate: P-256, valid for 30 days, common name fakepve, and subject alternative names for localhost, 127.0.0.1 and the address the fake listens on (the command line's --host, or pve.host). Its key usage is server authentication only and it is not a CA. A client that verifies host names accepts it once the certificate is its trust anchor, so there are three choices.
Skip verification. The usual flag in a test. It is the right choice when the code under test already has an option for self-signed PVE (the people who run Proxmox at home have it set). Typical forms, to be checked against your client's documentation:
| Client | Setting |
|---|---|
| curl | -k |
| Python requests | verify=False (and silence InsecureRequestWarning) |
| aiohttp | TCPConnector(ssl=False), or an SSLContext with check_hostname = False and verify_mode = ssl.CERT_NONE |
| proxmoxer | verify_ssl=False |
| Go | tls.Config{InsecureSkipVerify: true} |
| Terraform providers | the provider's insecure option (pm_tls_insecure in telmate/proxmox) |
Ansible community.general.proxmox* |
validate_certs: false |
Trust the generated certificate. fakepve.certs.make_cert(directory, hosts=(pve.host,)) writes the certificate and key, and the pytest fixtures give you the path as fakepve_cafile. Pass it to the client as its CA bundle (requests.get(url, verify=cafile), ssl.create_default_context(cafile=cafile), curl --cacert). The command line makes its own certificate at each start and removes the files, so with the command line use --cert and --key to keep one.
Trust a certificate you made. To use your own, for example to name a host the generated one does not, make one with the address as a subject alternative name, serve it with --cert and --key, and give the client the certificate as its CA:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout fake.key -out fake.crt -days 30 -subj "/CN=fakepve" \
-addext "subjectAltName=IP:127.0.0.2,DNS:localhost"
fakepve examples/guests.json --cert fake.crt --key fake.key
curl --cacert fake.crt https://127.0.0.2:8006/api2/json/access/domains
If the key is in the same PEM file as the certificate, --key can be left out. The same file works as the trust anchor for Python (requests.get(url, verify='fake.crt'), ssl.create_default_context(cafile='fake.crt')) and through the environment variables REQUESTS_CA_BUNDLE and SSL_CERT_FILE. From Python, await pve.start('fake.crt', 'fake.key') takes the same files.
A container¶
Each release is published as ghcr.io/qisback/fakepve:X.Y.Z (and :latest); docker run --rm -p 127.0.0.1:8006:8006 ghcr.io/qisback/fakepve:0.1.0 needs no build. Pin the version in a pipeline. The repository's Dockerfile builds an image from python:3.12-slim, installs the package with the crypto extra, and runs python -m fakepve:
docker build -t fakepve .
docker run --rm -p 127.0.0.1:8006:8006 fakepve
Publish the port on loopback (127.0.0.1: in front, as above). The image listens on 0.0.0.0 inside the container so that the published port works, and its login is the public default root@pam / secret; -p 8006:8006 would offer that login to every network the host is on. The container prints a warning on stderr for exactly this reason. To silence it, and to make the login private where the container is reachable from other hosts, set a password: docker run -e PVE_PASS=... fakepve.
By default the container serves examples/guests.json (copied into the image as guests.json) on 0.0.0.0:8006 with --time-scale 0, runs as an unprivileged user, and has a health check that connects to the port. Arguments after the image name replace the default command:
docker run --rm -p 127.0.0.1:8006:8006 fakepve guests.json --host 0.0.0.0 --time-scale 0.01
docker run --rm -p 127.0.0.1:8006:8006 -v "$PWD/mine.json:/opt/fakepve/mine.json:ro" fakepve mine.json --host 0.0.0.0
Remember --host 0.0.0.0 whenever you replace the arguments: inside a container the default 127.0.0.2 is unreachable from outside. The certificate is self-signed and made at each start, and names localhost and 127.0.0.1 (not the container's own address), so a client that verifies has to trust a certificate you mount with --cert and --key, or skip verification.
As a CI service container. The image is reachable by the service name (GitLab: the alias) or localhost on a published port (GitHub), at port 8006. Services cannot be given arguments on GitHub and Forgejo, so build an image that bakes in your guests file and FROM the fakepve one:
FROM ghcr.io/qisback/fakepve:0.1.0
COPY ci/guests.json /opt/fakepve/guests.json
The workflows below use it. A service container is meant to be reachable from its own job only (publish its port on 127.0.0.1, as the GitHub example does), so the default login is acceptable there, and the warning in its log is expected; give the service PVE_PASS (GitHub and Forgejo: env: under the service; GitLab: variables:) if you want it gone, and have the tests read the same value. On GitLab the service's command can set the arguments instead.
Pipelines¶
Complete files are in examples/ci/. They are examples: replace the test and smoke-test steps with the client you are testing. ci/guests.json and ci/smoke.sh stand for your own files (examples/curl.sh makes a start for the latter).
GitHub Actions¶
examples/ci/github-actions.yml has four jobs: in-process pytest, a background process, a container built in the job, and a service container. The background-process job is:
background-process:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- run: pip install 'fakepve[crypto]'
- name: start fakepve
run: |
python -m fakepve ci/guests.json --host 127.0.0.2 --time-scale 0 > fakepve.log 2>&1 &
for i in $(seq 1 30); do
curl -ksf https://127.0.0.2:8006/api2/json/access/domains >/dev/null && exit 0
sleep 1
done
cat fakepve.log
exit 1
- name: run the client under test
env:
PVE_URL: https://127.0.0.2:8006
run: sh ci/smoke.sh
The step that starts the process must end: the & puts it in the background and the loop returns, leaving the process running for the following steps.
Forgejo and Gitea Actions¶
examples/ci/forgejo-actions.yml is the same shape for .forgejo/workflows/ or .gitea/workflows/. Self-hosted runners often have no setup-python or node available, so it uses the runner's python3 and installs by name, as this repository's own workflow does (python3 -m pip install ... aiohttp cryptography pytest pytest-asyncio). Use $RUNNER_TEMP for the log file.
GitLab CI¶
examples/ci/gitlab-ci.yml has pytest, a background process and a service container. GitLab starts a service next to the job and reaches it by its alias:
service-container:
image: python:3.12-slim
services:
- name: registry.example.org/yourteam/fakepve:latest
alias: pve
command: ["guests.json", "--host", "0.0.0.0", "--time-scale", "0"]
variables:
PVE_URL: https://pve:8006
script:
- sh ci/smoke.sh
Plain pytest¶
No CI-specific files at all: pip install 'fakepve[crypto]' pytest pytest-asyncio aiohttp and pytest. The fake is part of the test process, so any CI that can run your tests can run it.
When a pipeline fails¶
- Address already in use. Another fake or a real service holds the address and port, often a parallel job on the same runner. In one process fakes pick their own address; across processes use
--hostor--port(orhost=andport=). ModuleNotFoundError: No module named 'cryptography'. Install thecryptoextra, or pass--certand--key.certificate verify failed. The client is verifying the self-signed certificate; see certificates.- Connection refused right after start. The process was not ready. Wait for the "listening" line or poll as above.
- A client sees
ServerDisconnectedErrororConnection reset. A flaky-network scenario is on (flaky_network), or the process died. The server's stderr, kept in a file, says which. - Print what the client did.
pve.requestsfor in-process runs; for a background process there is no request log, so give the client a verbose flag.