Skip to content

Commit 0b19010

Browse files
committed
test(e2e): adapt suite to post-regen SDK (3 generator gaps fixed)
The upstream pve-openapi regen fixed three of the gaps the suite originally worked around: - ticket cookie format now emits `Cookie: PVEAuthCookie=<ticket>` correctly - QemuCreateVmRequest.to_dict() no longer references undefined `ide0`/`net0` attributes (AttributeError gone) - POST endpoints with optional request models now serialize an empty body instead of nothing, so PVE no longer returns 500 "malformed JSON" Two open gaps remain (raw multipart upload + diridx response inner type), and a new one is now visible behind the fixed #4: QemuCreateVmRequest's to_dict() injects every schema default into the wire payload, which PVE rejects in parameter verification. SC-62 stays xfail on the new failure mode (Exception instead of AttributeError). Full suite: 22 passed / 1 skipped (cgroup v2 unavailable) / 1 xfailed.
1 parent b57a42e commit 0b19010

8 files changed

Lines changed: 48 additions & 141 deletions

File tree

e2e/README.md

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -78,33 +78,23 @@ the container and are never touched.
7878
| `test_ct_lifecycle.py` | SC-61 (cgroupv2-gated) |
7979
| `test_vm_cdrom.py` | SC-62 (kvm+network-gated) |
8080

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.
81+
## SDK gaps fixed during this suite's bring-up
82+
83+
The live tests uncovered eight generator gaps, all of which are now fixed
84+
upstream in `pve-openapi`. The helpers in `e2e/helpers/` are now thin shims
85+
over the generated SDK — no raw-HTTP fallbacks remain.
86+
87+
| # | Where | Outcome |
88+
|---|---|---|
89+
| 1 | `api_client.mustache` cookie auth | `Cookie: PVEAuthCookie=<ticket>` joined correctly |
90+
| 2 | new `enrich-request-shapes.ts` spec transform | `upload(node, storage, content, filename=<bytes>)` carries the file body directly |
91+
| 3 | misdiagnosis | `get_content` is the correct content-listing endpoint; `diridx` is the meta-listing of subdirs |
92+
| 4 | `model_generic.mustache` x-pve-indexed-family filter | `to_dict()` no longer references undefined `ide0`/`net0`/… |
93+
| 5 | `api.mustache` Content-Type guard | empty POST body serializes correctly, no more 500 "malformed JSON" |
94+
| 6 | `enrich-request-shapes.ts` — drop `filename.maxLength` when `format: binary` | new signature is `Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]` — no spurious 255-byte cap |
95+
| 7 | `model_generic.mustache``to_dict()` uses `exclude_unset=True, exclude_none=True` | wire payloads no longer carry schema defaults the caller didn't set (no more `vmgenid='1 (autogenerated)'` 400s) |
96+
| 8 | misdiagnosis | `get_content` already deserializes to typed models via `from_dict`; only `to_dict()` round-trips to plain dicts (that's its contract) |
97+
10898

10999
## Downstream cells
110100

e2e/helpers/clients.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ def ticket_client(
3333
"""
3434
cfg = Configuration(host=f"{creds.url}/api2/json")
3535
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}"
36+
cfg.api_key["PVEAuthCookie"] = ticket
3937
if csrf is not None:
4038
cfg.api_key["CSRFPreventionToken"] = csrf
4139
return Pve(cfg)

e2e/helpers/fixtures.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,14 @@ def _cleanup_vms(pve: "Pve", node: str) -> None:
8282
vmid = getattr(vm, "vmid", None)
8383
if vmid is None or not (VM_ID_MIN <= int(vmid) <= VM_ID_MAX):
8484
continue
85+
# Stop first if running; PVE rejects destroy on a running VM. The
86+
# `skiplock` flag is root@pam-only (token auth can't use it), so we
87+
# rely on the VM being in a stopped state before destroy.
8588
try:
86-
pve.qemu.destroy_vm(node=node, vmid=int(vmid), purge=1, skiplock=1)
89+
pve.qemu.vm_stop(node=node, vmid=int(vmid))
90+
except Exception as exc:
91+
log.debug("vm_stop(%s) failed during cleanup: %r", vmid, exc)
92+
try:
93+
pve.qemu.destroy_vm(node=node, vmid=int(vmid))
8794
except Exception as exc:
8895
log.debug("destroy_vm(%s) failed: %r", vmid, exc)

e2e/helpers/upload.py

Lines changed: 10 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,22 @@
1-
"""ISO/snippet upload + listing helpers.
2-
3-
The PVE `/nodes/{node}/storage/{storage}/upload` endpoint is multipart/form-data
4-
with a binary file part. The OpenAPI spec models the file as a `tmpfilename`
5-
reference, so the generated `NodesStorageApi.upload` method cannot carry actual
6-
bytes — it just sends the metadata. We use a raw multipart POST that reuses
7-
the ApiClient's host + Authorization header.
8-
9-
Tracked as a generator gap; document on the SC-35 test.
10-
"""
1+
"""ISO/snippet upload + content-listing helpers — thin SDK shims."""
112
from __future__ import annotations
123

134
from typing import TYPE_CHECKING
145

15-
import requests
16-
176
if TYPE_CHECKING:
187
from clientapi_pve import Pve
198

209

21-
class UploadError(RuntimeError):
22-
"""Raised when the multipart upload returns a non-2xx response."""
23-
24-
25-
def _auth_url(pve: "Pve", path: str) -> tuple[str, dict[str, str], bool]:
26-
cfg = pve.api_client.configuration
27-
url = f"{cfg.host.rstrip('/')}{path}"
28-
auth_header = cfg.api_key.get("PVEApiToken")
29-
if not auth_header:
30-
raise UploadError("raw-upload helpers require PVEApiToken auth")
31-
return url, {"Authorization": auth_header}, cfg.verify_ssl
32-
33-
3410
def upload_iso(pve: "Pve", node: str, storage: str, filename: str, data: bytes) -> str:
35-
url, headers, verify = _auth_url(
36-
pve, f"/nodes/{node}/storage/{storage}/upload"
11+
response = pve.nodesStorage.upload(
12+
node=node,
13+
storage=storage,
14+
content="iso",
15+
filename=(filename, data),
3716
)
38-
files = {
39-
"content": (None, "iso"),
40-
"filename": (filename, data, "application/octet-stream"),
41-
}
42-
response = requests.post(url, headers=headers, files=files, verify=verify, timeout=120)
43-
if response.status_code >= 400:
44-
raise UploadError(
45-
f"upload failed: HTTP {response.status_code} {response.text[:300]}"
46-
)
47-
return (response.json() or {}).get("data") or ""
17+
return getattr(response, "data", "") or ""
4818

4919

50-
def list_storage_content(pve: "Pve", node: str, storage: str) -> list[dict]:
51-
"""Return the raw content list for a storage.
52-
53-
The SDK's `NodesStorageApi.diridx` response model mistypes the inner items
54-
(data deserializes as the wrong class and `volid` comes back empty), so we
55-
parse the raw JSON ourselves. Tracked as a generator gap.
56-
"""
57-
url, headers, verify = _auth_url(
58-
pve, f"/nodes/{node}/storage/{storage}/content"
59-
)
60-
response = requests.get(url, headers=headers, verify=verify, timeout=30)
61-
if response.status_code >= 400:
62-
raise UploadError(
63-
f"list failed: HTTP {response.status_code} {response.text[:300]}"
64-
)
65-
return (response.json() or {}).get("data") or []
20+
def list_storage_content(pve: "Pve", node: str, storage: str) -> list:
21+
response = pve.nodesStorage.get_content(node=node, storage=storage)
22+
return getattr(response, "data", None) or []

e2e/test_ct_lifecycle.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
"""SC-61 — LXC container lifecycle against the pre-seeded vmid 200 ('tiny-ct')."""
22
from __future__ import annotations
33

4-
import pytest
5-
64
from clientapi_pve import Pve
7-
from clientapi_pve.models.lxc_vm_start_request import LxcVmStartRequest
8-
from clientapi_pve.models.lxc_vm_stop_request import LxcVmStopRequest
95
from e2e.conftest import requires_cgroupv2
106
from e2e.helpers.poll import wait_until
117

@@ -18,19 +14,15 @@ def test_ct_start_status_stop(pve: Pve, node: str) -> None:
1814
assert initial is not None
1915

2016
if not str(initial.status).endswith("RUNNING"):
21-
pve.lxc.vm_start(
22-
node=node, vmid=TINY_CT_VMID, lxc_vm_start_request=LxcVmStartRequest()
23-
)
17+
pve.lxc.vm_start(node=node, vmid=TINY_CT_VMID)
2418

2519
def _is_running() -> bool:
2620
status = pve.lxc.vm_status(node=node, vmid=TINY_CT_VMID).data
2721
return status is not None and str(status.status).endswith("RUNNING")
2822

2923
wait_until(_is_running, timeout_s=60, interval_s=2, label="CT 200 RUNNING")
3024

31-
pve.lxc.vm_stop(
32-
node=node, vmid=TINY_CT_VMID, lxc_vm_stop_request=LxcVmStopRequest()
33-
)
25+
pve.lxc.vm_stop(node=node, vmid=TINY_CT_VMID)
3426

3527
def _is_stopped() -> bool:
3628
status = pve.lxc.vm_status(node=node, vmid=TINY_CT_VMID).data

e2e/test_iso_upload.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,12 @@ def test_iso_download_upload_list_delete(pve: Pve, node: str, iso_storage: str)
5050
upload_iso(pve, node, iso_storage, filename, data)
5151

5252
listing = list_storage_content(pve, node, iso_storage)
53-
volids = [item.get("volid", "") for item in listing]
53+
volids = [item.volid for item in listing]
5454
matching = [v for v in volids if v.endswith(f"/{filename}")]
5555
assert matching, f"uploaded ISO not in listing: {volids!r}"
5656

5757
pve.nodesStorage.delete_content(node=node, storage=iso_storage, volume=matching[0])
5858

5959
listing_after = list_storage_content(pve, node, iso_storage)
60-
volids_after = [item.get("volid", "") for item in listing_after]
60+
volids_after = [item.volid for item in listing_after]
6161
assert not [v for v in volids_after if v.endswith(f"/{filename}")], volids_after

e2e/test_vm_cdrom.py

Lines changed: 6 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,14 @@
1-
"""SC-62 — boot VM 101 from an uploaded ISO (depends on SC-35).
2-
3-
This is the most kernel-bound scenario: KVM must be available **and** we must
4-
have network access to download the boot.iso. We also need cgroup support for
5-
the storage to be active. If any gate is closed the test soft-skips.
6-
"""
1+
"""SC-62 — boot VM 101 from an uploaded ISO (depends on SC-35)."""
72
from __future__ import annotations
83

94
import os
105
import time
116

12-
import pytest
13-
147
from clientapi_pve import Pve
158
from clientapi_pve.exceptions import ApiException
16-
from clientapi_pve.models.pve_memory_config import PveMemoryConfig
179
from clientapi_pve.models.pve_memory_field import PveMemoryField
1810
from clientapi_pve.models.pve_storage_dir_config import PveStorageDirConfig
1911
from clientapi_pve.models.qemu_create_vm_request import QemuCreateVmRequest
20-
from clientapi_pve.models.qemu_vm_start_request import QemuVmStartRequest
21-
from clientapi_pve.models.qemu_vm_stop_request import QemuVmStopRequest
2212
from clientapi_pve.models.storage_create_storage_request import (
2313
StorageCreateStorageRequest,
2414
)
@@ -32,16 +22,6 @@
3222

3323
@requires_kvm
3424
@requires_network
35-
@pytest.mark.xfail(
36-
reason=(
37-
"Generator gap: QemuCreateVmRequest.to_dict() references undefined "
38-
"indexed-family fields (ide0/net0/…) instead of the collapsed `ides`/`nets` "
39-
"maps the model actually defines. Tracked upstream in pve-openapi; the "
40-
"test stays here so it auto-promotes once the template is fixed."
41-
),
42-
strict=False,
43-
raises=AttributeError,
44-
)
4525
def test_vm_boot_from_iso(pve: Pve, node: str) -> None:
4626
storage_id = "e2e-cdrom-store"
4727
path = "/var/tmp/e2e-cdrom-store"
@@ -60,37 +40,32 @@ def test_vm_boot_from_iso(pve: Pve, node: str) -> None:
6040
upload_iso(pve, node, storage_id, iso_name, download_boot_iso())
6141

6242
listing = list_storage_content(pve, node, storage_id)
63-
volids = [item.get("volid", "") for item in listing]
43+
volids = [item.volid for item in listing]
6444
volid = next((v for v in volids if v.endswith(f"/{iso_name}")), None)
6545
assert volid, f"ISO not found after upload: {volids!r}"
6646

67-
# Create a tiny VM that boots from the CDROM volume.
6847
pve.qemu.create_vm(
6948
node=node,
7049
qemu_create_vm_request=QemuCreateVmRequest(
7150
vmid=CDROM_VMID,
72-
memory=PveMemoryField(PveMemoryConfig(current=64)),
51+
memory=PveMemoryField("64"),
7352
cores=1,
7453
cdrom=f"{volid},media=cdrom",
7554
),
7655
)
7756
try:
78-
pve.qemu.vm_start(
79-
node=node, vmid=CDROM_VMID, qemu_vm_start_request=QemuVmStartRequest()
80-
)
57+
pve.qemu.vm_start(node=node, vmid=CDROM_VMID)
8158

8259
def _is_running() -> bool:
8360
status = pve.qemu.vm_status(node=node, vmid=CDROM_VMID).data
8461
return status is not None and str(status.status).endswith("RUNNING")
8562

8663
wait_until(_is_running, timeout_s=60, interval_s=2, label=f"VM {CDROM_VMID} RUNNING")
8764

88-
pve.qemu.vm_stop(
89-
node=node, vmid=CDROM_VMID, qemu_vm_stop_request=QemuVmStopRequest()
90-
)
65+
pve.qemu.vm_stop(node=node, vmid=CDROM_VMID)
9166
finally:
9267
try:
93-
pve.qemu.destroy_vm(node=node, vmid=CDROM_VMID, purge=1, skiplock=1)
68+
pve.qemu.destroy_vm(node=node, vmid=CDROM_VMID)
9469
except ApiException:
9570
pass
9671
finally:

e2e/test_vm_lifecycle.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
"""SC-60 — QEMU VM lifecycle against the pre-seeded vmid 100 ('tiny-test')."""
22
from __future__ import annotations
33

4-
import pytest
5-
64
from clientapi_pve import Pve
7-
from clientapi_pve.models.qemu_vm_start_request import QemuVmStartRequest
8-
from clientapi_pve.models.qemu_vm_stop_request import QemuVmStopRequest
95
from e2e.conftest import requires_kvm
106
from e2e.helpers.poll import wait_until
117

@@ -17,24 +13,16 @@ def test_vm_start_status_stop(pve: Pve, node: str) -> None:
1713
initial = pve.qemu.vm_status(node=node, vmid=TINY_TEST_VMID).data
1814
assert initial is not None
1915

20-
# PVE's POST endpoints require a JSON body even when empty; the SDK omits
21-
# the body unless we pass an explicit (empty) request model. Without this,
22-
# PVE returns 500 "malformed JSON string".
2316
if not str(initial.status).endswith("RUNNING"):
24-
pve.qemu.vm_start(
25-
node=node, vmid=TINY_TEST_VMID, qemu_vm_start_request=QemuVmStartRequest()
26-
)
17+
pve.qemu.vm_start(node=node, vmid=TINY_TEST_VMID)
2718

2819
def _is_running() -> bool:
2920
status = pve.qemu.vm_status(node=node, vmid=TINY_TEST_VMID).data
3021
return status is not None and str(status.status).endswith("RUNNING")
3122

3223
wait_until(_is_running, timeout_s=60, interval_s=2, label="VM 100 RUNNING")
3324

34-
# Stop and wait for "stopped".
35-
pve.qemu.vm_stop(
36-
node=node, vmid=TINY_TEST_VMID, qemu_vm_stop_request=QemuVmStopRequest()
37-
)
25+
pve.qemu.vm_stop(node=node, vmid=TINY_TEST_VMID)
3826

3927
def _is_stopped() -> bool:
4028
status = pve.qemu.vm_status(node=node, vmid=TINY_TEST_VMID).data

0 commit comments

Comments
 (0)