Skip to content

Commit b57a42e

Browse files
committed
test(e2e): add SC-01..SC-62 live-server suite + CI
Wire the Phase 5 E2E rollout for the PVE cell of pve-python — mirrors the TypeScript reference at pve-ts/e2e/ in idiomatic Python. 22 tests pass green against the proxmox-docker pve-test container, 1 skips when cgroup v2 is unavailable, 1 xfails on a documented QemuCreateVmRequest.to_dict generator gap that auto-promotes when fixed upstream. - e2e/ skeleton with conftest fixtures (creds, pve token client, node, session cleanup) and capability gates (kvm/cgroupv2/network) as importable markers - e2e/helpers/ for credentials, fixtures, polling, ISO download (SHA-256 verified), and raw multipart upload + content listing (SDK upload helper can't carry bytes; diridx response mistypes inner items) - e2e/test_*.py covers version (SC-01), auth (SC-10..14), authz (SC-20..22), CRUD (SC-30..34), ISO upload (SC-35), errors (SC-40..42), types (SC-50..52, including int64 verification), and VM/CT lifecycle (SC-60..62) - pyproject.toml gains [project.optional-dependencies] test group (pytest, pytest-timeout, requests for the multipart fallback) - docker-compose.yml at repo root for local runs against ghcr.io/client-api/proxmox-docker/pve-test:9.2 - .github/workflows/e2e.yml runs client-api/proxmox-docker-action@v1 and pytest -v with the action's PROXMOX_* env exports - .openapi-generator-ignore preserves e2e/, the workflow, and docker-compose.yml across regeneration - tests/e2e/README.md redirects to the new layout
1 parent 7f93b61 commit b57a42e

26 files changed

Lines changed: 1390 additions & 70 deletions

.github/workflows/e2e.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: e2e
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
jobs:
10+
pve:
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 20
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: actions/setup-python@v5
17+
with:
18+
python-version: '3.13'
19+
cache: pip
20+
21+
- name: Install package + test deps
22+
run: pip install -e .[test]
23+
24+
- name: Authenticate to GHCR
25+
uses: docker/login-action@v3
26+
with:
27+
registry: ghcr.io
28+
username: ${{ github.actor }}
29+
password: ${{ secrets.GITHUB_TOKEN }}
30+
31+
- name: Start PVE test container
32+
id: proxmox
33+
uses: client-api/proxmox-docker-action@v1
34+
with:
35+
product: pve
36+
tag: '9.2'
37+
enable-kvm: auto
38+
39+
- name: Run E2E tests
40+
run: pytest e2e/ -v --tb=short
41+
env:
42+
PROXMOX_INSECURE: '1'
43+
PROXMOX_KVM_AVAILABLE: ${{ steps.proxmox.outputs.kvm-available }}
44+
PROXMOX_CGROUPV2_AVAILABLE: ${{ steps.proxmox.outputs.cgroupv2-available }}

.openapi-generator-ignore

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
# OpenAPI Generator Ignore
22
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
3-
3+
#
44
# Use this file to prevent files from being overwritten by the generator.
5-
# The patterns follow closely to .gitignore or .dockerignore.
5+
# The patterns follow .gitignore syntax. Pipeline-tooling in pve-openapi
6+
# (scripts/sdk-sync.ts) also reads this file before deciding what to delete.
67

7-
# As an example, the C# client generator defines ApiClient.cs.
8-
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
9-
#ApiClient.cs
8+
# Hand-written E2E suite — never regenerate.
9+
e2e/
10+
e2e/**
1011

11-
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
12-
#foo/*/qux
13-
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
12+
# CI workflow we own; generator only manages ci.yml + publish.yml.
13+
.github/workflows/e2e.yml
1414

15-
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
16-
#foo/**/qux
17-
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
15+
# Local docker harness for the E2E suite.
16+
docker-compose.yml
1817

19-
# You can also negate patterns with an exclamation (!).
20-
# For example, you can ignore all files in a docs folder with the file extension .md:
21-
#docs/*.md
22-
# Then explicitly reverse the ignore rule for a single file:
23-
#!docs/README.md
18+
# Note: pyproject.toml is overwritten by every sync. The
19+
# [project.optional-dependencies] block must be re-added via the upstream
20+
# Mustache template (templates/_shared/pyproject.toml.mustache in pve-openapi)
21+
# — tracked as a follow-up; not ignored here.

docker-compose.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
services:
2+
pve-test:
3+
image: ghcr.io/client-api/proxmox-docker/pve-test:9.2
4+
container_name: pve-test
5+
privileged: true
6+
devices:
7+
- /dev/fuse
8+
- /dev/kvm
9+
tmpfs:
10+
- /tmp
11+
- /run
12+
- /run/lock
13+
ports:
14+
- "8006:8006"
15+
healthcheck:
16+
# /version requires auth on PVE 9.x — accept any HTTP response (200/401) as "API is up".
17+
test: ["CMD-SHELL", "curl -sk -o /dev/null -w '%{http_code}' https://localhost:8006/api2/json/version | grep -qE '^(200|401)$'"]
18+
interval: 5s
19+
timeout: 5s
20+
retries: 24
21+
start_period: 10s

e2e/README.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# E2E tests for `clientapi_pve`
2+
3+
Live-server pytest suite. Runs against a real Proxmox VE instance — by default the
4+
`ghcr.io/client-api/proxmox-docker/pve-test` container, either spun up locally
5+
via `docker compose up -d` or in CI via
6+
[`client-api/proxmox-docker-action@v1`](https://github.com/client-api/proxmox-docker-action).
7+
8+
## Quick start (local)
9+
10+
```bash
11+
docker compose up -d
12+
sleep 20 # wait for healthcheck
13+
14+
export PROXMOX_URL=https://localhost:8006
15+
export PROXMOX_USER=root@pam
16+
export PROXMOX_PASSWORD=proxmox123
17+
# Read the joined token header from the container:
18+
export PROXMOX_TOKEN_HEADER_VALUE="$(docker exec pve-test cat /run/credentials.json | jq -r .token_header_value)"
19+
export PROXMOX_INSECURE=1
20+
export PROXMOX_KVM_AVAILABLE=$(test -e /dev/kvm && echo true || echo false)
21+
export PROXMOX_CGROUPV2_AVAILABLE=$(test -d /sys/fs/cgroup/cgroup.controllers && echo true || echo false)
22+
23+
pip install -e .[test]
24+
pytest e2e/ -v
25+
```
26+
27+
## Environment contract
28+
29+
| Var | Source | Notes |
30+
|---|---|---|
31+
| `PROXMOX_URL` | `proxmox-docker-action` | e.g. `https://localhost:8006` (no `/api2/json`) |
32+
| `PROXMOX_USER` | action | typically `root@pam` |
33+
| `PROXMOX_PASSWORD` | action | password ticket auth (SC-10..11, SC-14) |
34+
| `PROXMOX_TOKEN_HEADER_VALUE` | action | whole `PVEAPIToken=root@pam!test=<uuid>` string |
35+
| `PROXMOX_TOKEN_VALUE` | action | UUID half only (rarely needed) |
36+
| `PROXMOX_INSECURE` | local | set to `1` to skip TLS verification (self-signed dev cert) |
37+
| `PROXMOX_KVM_AVAILABLE` | action output | `true`/`false`; gates SC-60, SC-62 |
38+
| `PROXMOX_CGROUPV2_AVAILABLE` | action output | `true`/`false`; gates SC-61 |
39+
| `PROXMOX_NO_NETWORK` | manual | `1` to skip network-egress tests (SC-35, SC-62) |
40+
41+
> Never reconstruct `PROXMOX_TOKEN_HEADER_VALUE` by hand — the Perl family
42+
> (PVE, PMG) joins with `=`, the Rust family (PBS, PDM) with `:`. The
43+
> container pre-joins it correctly.
44+
45+
## Capability gates
46+
47+
Tests that need kernel features import the marker symbol, never an inline env check:
48+
49+
```python
50+
from e2e.conftest import requires_kvm
51+
52+
@requires_kvm
53+
def test_vm_lifecycle(pve, node):
54+
...
55+
```
56+
57+
When the gate is closed the test is **skipped**, not failed.
58+
59+
## Fixture convention
60+
61+
All entities created during the suite are named `e2e-…` (users, storages,
62+
ACLs) and live in the VM-ID range `101..199`. `cleanup_e2e()` runs at session
63+
start and end. VM 100 (`tiny-test`) and CT 200 (`tiny-ct`) are pre-seeded by
64+
the container and are never touched.
65+
66+
## Scenario index (SC-01 … SC-62)
67+
68+
| File | Scenarios |
69+
|---|---|
70+
| `test_version.py` | SC-01 |
71+
| `test_auth.py` | SC-10 … SC-14 |
72+
| `test_authz.py` | SC-20 … SC-22 |
73+
| `test_crud.py` | SC-30 … SC-34 |
74+
| `test_iso_upload.py` | SC-35 |
75+
| `test_errors.py` | SC-40 … SC-42 |
76+
| `test_types.py` | SC-50 … SC-52 |
77+
| `test_vm_lifecycle.py` | SC-60 (kvm-gated) |
78+
| `test_ct_lifecycle.py` | SC-61 (cgroupv2-gated) |
79+
| `test_vm_cdrom.py` | SC-62 (kvm+network-gated) |
80+
81+
## Known SDK gaps surfaced by this suite
82+
83+
These are real generator gaps the live tests uncovered; each has a focused
84+
workaround in `e2e/helpers/` so the rest of the suite stays clean, and the
85+
SC-NN that exercises it stays in the suite so it will auto-promote when the
86+
upstream template is fixed.
87+
88+
1. **Ticket cookie format.** `Configuration.auth_settings` emits
89+
`Cookie: <ticket>` for `PVEAuthCookie`. PVE expects
90+
`Cookie: PVEAuthCookie=<ticket>`. Workaround in
91+
`e2e/helpers/clients.py::ticket_client` pre-joins the prefix.
92+
2. **`NodesStorageApi.upload` carries no file body.** The OpenAPI spec models
93+
the upload field as `tmpfilename` (a server-side path reference), so the
94+
generated SDK can only send metadata. Workaround: raw multipart POST in
95+
`e2e/helpers/upload.py::upload_iso`.
96+
3. **`NodesStorageDiridxResponse.data` types inner items as
97+
`AccessGetAccessResponseDataInner`.** The generator picked the wrong inner
98+
model, so `volid` deserializes as empty. Workaround:
99+
`e2e/helpers/upload.py::list_storage_content` parses the raw JSON.
100+
4. **`QemuCreateVmRequest.to_dict()` references undefined indexed-family
101+
fields** (`ide0`, `net0`, …). The model exposes collapsed `ides`/`nets`
102+
maps but the serializer reaches for individual numbered properties.
103+
SC-62 is marked `xfail` until the template is fixed.
104+
5. **POST endpoints with optional request models drop the body entirely.**
105+
`vm_start` / `vm_stop` (etc.) send no body when no request model is
106+
supplied; PVE returns 500 "malformed JSON". Tests always pass an empty
107+
request model (`QemuVmStartRequest()`) instead of relying on the default.
108+
109+
## Downstream cells
110+
111+
`pbs-py`, `pmg-py`, `pdm-py` follow the same shape — copy `e2e/`, the
112+
pyproject test group, `docker-compose.yml`, and `.github/workflows/e2e.yml`,
113+
then swap product-specific bits (image tag, port, token separator). PMG skips
114+
SC-12/13 (no API tokens). PDM uses `/api2/extjs` in raw HTTP paths.

e2e/__init__.py

Whitespace-only changes.

e2e/conftest.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Shared pytest fixtures and capability gates for the E2E suite."""
2+
from __future__ import annotations
3+
4+
from typing import Iterator
5+
6+
import pytest
7+
8+
from clientapi_pve import Configuration, Pve
9+
from e2e.helpers.capability_gate import (
10+
cgroupv2_available,
11+
kvm_available,
12+
network_available,
13+
)
14+
from e2e.helpers.credentials import Credentials, MissingCredentialError
15+
from e2e.helpers.fixtures import cleanup_e2e, first_node
16+
17+
# Declared once here so test files import a symbol rather than re-checking env vars.
18+
requires_kvm = pytest.mark.skipif(not kvm_available(), reason="KVM not available")
19+
requires_cgroupv2 = pytest.mark.skipif(
20+
not cgroupv2_available(), reason="cgroup v2 not available"
21+
)
22+
requires_network = pytest.mark.skipif(
23+
not network_available(), reason="network not available (PROXMOX_NO_NETWORK=1)"
24+
)
25+
26+
27+
@pytest.fixture(scope="session")
28+
def creds() -> Credentials:
29+
try:
30+
return Credentials.from_env()
31+
except MissingCredentialError as exc:
32+
pytest.skip(str(exc))
33+
34+
35+
def _token_client(creds: Credentials) -> Pve:
36+
cfg = Configuration(host=f"{creds.url}/api2/json")
37+
cfg.verify_ssl = not creds.insecure
38+
cfg.api_key["PVEApiToken"] = creds.token_header_value
39+
return Pve(cfg)
40+
41+
42+
@pytest.fixture(scope="session")
43+
def pve(creds: Credentials) -> Pve:
44+
"""Default client: API-token auth (no CSRF dance)."""
45+
return _token_client(creds)
46+
47+
48+
@pytest.fixture(scope="session")
49+
def node(pve: Pve) -> str:
50+
return first_node(pve)
51+
52+
53+
@pytest.fixture(scope="session", autouse=True)
54+
def _session_cleanup(creds: Credentials, pve: Pve) -> Iterator[None]:
55+
"""Wipe any e2e-* leftovers before and after the suite."""
56+
try:
57+
node = first_node(pve)
58+
except Exception:
59+
node = ""
60+
if node:
61+
cleanup_e2e(pve, node)
62+
yield
63+
if node:
64+
cleanup_e2e(pve, node)

e2e/helpers/__init__.py

Whitespace-only changes.

e2e/helpers/capability_gate.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Capability gates for kernel features the test container may or may not have.
2+
3+
The proxmox-docker-action exposes `kvm-available` and `cgroupv2-available` as
4+
step outputs; the workflow mirrors them into env vars. Locally, set them by hand.
5+
"""
6+
from __future__ import annotations
7+
8+
import os
9+
10+
11+
def _truthy(name: str) -> bool:
12+
return os.environ.get(name, "").lower() in ("1", "true", "yes")
13+
14+
15+
def kvm_available() -> bool:
16+
"""True when /dev/kvm is usable inside the container (PVE can boot VMs)."""
17+
return _truthy("PROXMOX_KVM_AVAILABLE")
18+
19+
20+
def cgroupv2_available() -> bool:
21+
"""True when the host exposes cgroup v2 unified hierarchy (LXC requires it)."""
22+
return _truthy("PROXMOX_CGROUPV2_AVAILABLE")
23+
24+
25+
def network_available() -> bool:
26+
"""True unless PROXMOX_NO_NETWORK is set (air-gapped runners)."""
27+
return os.environ.get("PROXMOX_NO_NETWORK", "") != "1"

e2e/helpers/clients.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Client factories for the two auth modes PVE supports.
2+
3+
Token clients use the Authorization header and don't need a CSRF dance.
4+
Ticket clients require a paired ticket cookie + CSRFPreventionToken header
5+
for non-GET requests; SDK already wires both when both api_key entries are set.
6+
"""
7+
from __future__ import annotations
8+
9+
from typing import TYPE_CHECKING
10+
11+
from clientapi_pve import Configuration, Pve
12+
13+
if TYPE_CHECKING:
14+
from e2e.helpers.credentials import Credentials
15+
16+
17+
def token_client(creds: "Credentials") -> Pve:
18+
cfg = Configuration(host=f"{creds.url}/api2/json")
19+
cfg.verify_ssl = not creds.insecure
20+
cfg.api_key["PVEApiToken"] = creds.token_header_value
21+
return Pve(cfg)
22+
23+
24+
def ticket_client(
25+
creds: "Credentials",
26+
*,
27+
ticket: str,
28+
csrf: str | None = None,
29+
) -> Pve:
30+
"""Build a Pve client authenticated with a ticket cookie and (optionally) a CSRF header.
31+
32+
Omit `csrf` to deliberately simulate the SC-14 "missing CSRF header on write" case.
33+
"""
34+
cfg = Configuration(host=f"{creds.url}/api2/json")
35+
cfg.verify_ssl = not creds.insecure
36+
# SDK bug workaround: the auth_settings cookie path emits `Cookie: <value>`
37+
# but PVE expects `Cookie: PVEAuthCookie=<value>`. Pre-join here.
38+
cfg.api_key["PVEAuthCookie"] = f"PVEAuthCookie={ticket}"
39+
if csrf is not None:
40+
cfg.api_key["CSRFPreventionToken"] = csrf
41+
return Pve(cfg)
42+
43+
44+
def issue_ticket(creds: "Credentials", *, password: str | None = None) -> Pve:
45+
"""Log in with username+password and return a Pve client wired for ticket auth.
46+
47+
Raises ApiException(401) when the password is wrong (SC-11).
48+
"""
49+
from clientapi_pve.models.access_ticket_create_ticket_request import (
50+
AccessTicketCreateTicketRequest,
51+
)
52+
53+
anon = Configuration(host=f"{creds.url}/api2/json")
54+
anon.verify_ssl = not creds.insecure
55+
bootstrap = Pve(anon)
56+
57+
response = bootstrap.accessTicket.create_ticket(
58+
AccessTicketCreateTicketRequest(
59+
username=creds.user,
60+
password=password if password is not None else creds.password,
61+
)
62+
)
63+
data = response.data
64+
if data is None or not data.ticket:
65+
raise RuntimeError(f"ticket login returned no ticket: {response!r}")
66+
return ticket_client(
67+
creds,
68+
ticket=data.ticket,
69+
csrf=data.csrf_prevention_token,
70+
)

0 commit comments

Comments
 (0)