Skip to content

Commit 786971b

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 786971b

7 files changed

Lines changed: 77 additions & 117 deletions

File tree

e2e/README.md

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -78,33 +78,34 @@ 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 originally uncovered five generator gaps; all five are now
84+
fixed upstream in `pve-openapi`:
85+
86+
| # | Where | Outcome |
87+
|---|---|---|
88+
| 1 | `api_client.mustache` cookie auth | `Cookie: PVEAuthCookie=<ticket>` joined correctly |
89+
| 2 | new `enrich-request-shapes.ts` spec transform | `upload(node, storage, content, filename=<bytes>)` carries the file body directly |
90+
| 3 | misdiagnosis | `get_content` is the correct content-listing endpoint; `diridx` is the meta-listing of subdirs |
91+
| 4 | `model_generic.mustache` x-pve-indexed-family filter | `to_dict()` no longer references undefined `ide0`/`net0`/… |
92+
| 5 | `api.mustache` Content-Type guard | empty POST body serializes correctly, no more 500 "malformed JSON" |
93+
94+
The helpers in `e2e/helpers/` are now thin shims over the generated SDK.
95+
96+
### Remaining edges
97+
98+
- **Upload `max_length=255` mis-applied to file body.** The
99+
`enrich-request-shapes.ts` transform leaves the original `filename`'s
100+
`MaxLen(255)` constraint on the bytes element of the `Tuple[str, bytes]`
101+
form — meaning the SDK rejects any file bigger than 255 bytes at the
102+
Pydantic layer. `e2e/helpers/upload.py::upload_iso` keeps a raw multipart
103+
fallback until the constraint is moved off the body.
104+
- **`QemuCreateVmRequest.to_dict()` schema-defaults injection.** Independent
105+
of the indexed-family fix, `to_dict()` still emits every field's schema
106+
default (`vmgenid='1 (autogenerated)'`, `vcpus=0`, `cpulimit=0`, …) and
107+
PVE rejects the call in parameter verification. SC-62 stays `xfail` until
108+
`to_dict()` honours `exclude_unset`.
108109

109110
## Downstream cells
110111

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/upload.py

Lines changed: 29 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
"""ISO/snippet upload + listing helpers.
1+
"""ISO/snippet upload + content-listing helpers.
22
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.
3+
The upload helper falls back to a raw multipart POST: the SDK's generated
4+
`NodesStorageApi.upload` signature wrongly applies the original `filename`'s
5+
`max_length=255` validator to the **bytes content** in the `Tuple[str, bytes]`
6+
form, so files larger than 255 bytes are rejected at the Pydantic layer.
87
9-
Tracked as a generator gap; document on the SC-35 test.
8+
The listing helper goes through the SDK's `get_content` but normalizes each
9+
entry: the current generator declares `data: List[Inner]` but actually
10+
deserializes inner items as plain dicts.
1011
"""
1112
from __future__ import annotations
1213

@@ -22,44 +23,36 @@ class UploadError(RuntimeError):
2223
"""Raised when the multipart upload returns a non-2xx response."""
2324

2425

25-
def _auth_url(pve: "Pve", path: str) -> tuple[str, dict[str, str], bool]:
26+
def upload_iso(pve: "Pve", node: str, storage: str, filename: str, data: bytes) -> str:
2627
cfg = pve.api_client.configuration
27-
url = f"{cfg.host.rstrip('/')}{path}"
28+
url = f"{cfg.host.rstrip('/')}/nodes/{node}/storage/{storage}/upload"
2829
auth_header = cfg.api_key.get("PVEApiToken")
2930
if not auth_header:
30-
raise UploadError("raw-upload helpers require PVEApiToken auth")
31-
return url, {"Authorization": auth_header}, cfg.verify_ssl
32-
33-
34-
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"
31+
raise UploadError("upload_iso requires PVEApiToken auth on the client")
32+
response = requests.post(
33+
url,
34+
headers={"Authorization": auth_header},
35+
files={
36+
"content": (None, "iso"),
37+
"filename": (filename, data, "application/octet-stream"),
38+
},
39+
verify=cfg.verify_ssl,
40+
timeout=120,
3741
)
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)
4342
if response.status_code >= 400:
4443
raise UploadError(
4544
f"upload failed: HTTP {response.status_code} {response.text[:300]}"
4645
)
4746
return (response.json() or {}).get("data") or ""
4847

4948

50-
def list_storage_content(pve: "Pve", node: str, storage: str) -> list[dict]:
51-
"""Return the raw content list for a storage.
49+
def _item_volid(item) -> str:
50+
"""Extract `volid` from a listing entry that may be a dict or a model."""
51+
if isinstance(item, dict):
52+
return item.get("volid") or ""
53+
return getattr(item, "volid", "") or ""
5254

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 []
55+
56+
def list_storage_content(pve: "Pve", node: str, storage: str) -> list:
57+
response = pve.nodesStorage.get_content(node=node, storage=storage)
58+
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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
)
1515
from e2e.conftest import requires_network
1616
from e2e.helpers.iso import download_boot_iso
17-
from e2e.helpers.upload import list_storage_content, upload_iso
17+
from e2e.helpers.upload import _item_volid, list_storage_content, upload_iso
1818

1919

2020
@pytest.fixture
@@ -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(item) 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(item) 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: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,24 @@
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
169
from clientapi_pve.models.pve_memory_config import PveMemoryConfig
1710
from clientapi_pve.models.pve_memory_field import PveMemoryField
1811
from clientapi_pve.models.pve_storage_dir_config import PveStorageDirConfig
1912
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
2213
from clientapi_pve.models.storage_create_storage_request import (
2314
StorageCreateStorageRequest,
2415
)
16+
import pytest
17+
2518
from e2e.conftest import requires_kvm, requires_network
2619
from e2e.helpers.iso import download_boot_iso
2720
from e2e.helpers.poll import wait_until
28-
from e2e.helpers.upload import list_storage_content, upload_iso
21+
from e2e.helpers.upload import _item_volid, list_storage_content, upload_iso
2922

3023
CDROM_VMID = 101
3124

@@ -34,13 +27,13 @@
3427
@requires_network
3528
@pytest.mark.xfail(
3629
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."
30+
"Generator gap: QemuCreateVmRequest.to_dict() still injects every "
31+
"schema default into the wire payload (vmgenid='1 (autogenerated)', "
32+
"vcpus=0, cpulimit=0, …); PVE rejects them in parameter verification. "
33+
"Independent of the indexed-family fix in #4 — tracked separately."
4134
),
4235
strict=False,
43-
raises=AttributeError,
36+
raises=Exception,
4437
)
4538
def test_vm_boot_from_iso(pve: Pve, node: str) -> None:
4639
storage_id = "e2e-cdrom-store"
@@ -60,11 +53,10 @@ def test_vm_boot_from_iso(pve: Pve, node: str) -> None:
6053
upload_iso(pve, node, storage_id, iso_name, download_boot_iso())
6154

6255
listing = list_storage_content(pve, node, storage_id)
63-
volids = [item.get("volid", "") for item in listing]
56+
volids = [_item_volid(item) for item in listing]
6457
volid = next((v for v in volids if v.endswith(f"/{iso_name}")), None)
6558
assert volid, f"ISO not found after upload: {volids!r}"
6659

67-
# Create a tiny VM that boots from the CDROM volume.
6860
pve.qemu.create_vm(
6961
node=node,
7062
qemu_create_vm_request=QemuCreateVmRequest(
@@ -75,19 +67,15 @@ def test_vm_boot_from_iso(pve: Pve, node: str) -> None:
7567
),
7668
)
7769
try:
78-
pve.qemu.vm_start(
79-
node=node, vmid=CDROM_VMID, qemu_vm_start_request=QemuVmStartRequest()
80-
)
70+
pve.qemu.vm_start(node=node, vmid=CDROM_VMID)
8171

8272
def _is_running() -> bool:
8373
status = pve.qemu.vm_status(node=node, vmid=CDROM_VMID).data
8474
return status is not None and str(status.status).endswith("RUNNING")
8575

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

88-
pve.qemu.vm_stop(
89-
node=node, vmid=CDROM_VMID, qemu_vm_stop_request=QemuVmStopRequest()
90-
)
78+
pve.qemu.vm_stop(node=node, vmid=CDROM_VMID)
9179
finally:
9280
try:
9381
pve.qemu.destroy_vm(node=node, vmid=CDROM_VMID, purge=1, skiplock=1)

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)