feat: release proxmoxer-compatible 0.1.0 slice

This commit is contained in:
Sergey Antropoff
2026-07-13 00:55:11 +03:00
parent 4b923e63f2
commit 6175c724d9
13 changed files with 234 additions and 9 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ RUN pip install --upgrade "pip>=25.1,<26" && pip install .
FROM python:3.13-slim-bookworm AS runtime FROM python:3.13-slim-bookworm AS runtime
ARG APP_VERSION=0.0.1 ARG APP_VERSION=0.1.0
LABEL org.opencontainers.image.title="proxmox-api-simulator" \ LABEL org.opencontainers.image.title="proxmox-api-simulator" \
org.opencontainers.image.version="$APP_VERSION" \ org.opencontainers.image.version="$APP_VERSION" \
org.opencontainers.image.source="https://github.com/example/proxmox-api-simulator" org.opencontainers.image.source="https://github.com/example/proxmox-api-simulator"
+27 -5
View File
@@ -3,10 +3,9 @@
Stateful asynchronous Proxmox VE API simulator for testing API clients and Stateful asynchronous Proxmox VE API simulator for testing API clients and
infrastructure tooling without a real hypervisor cluster. infrastructure tooling without a real hypervisor cluster.
The runnable foundation and authoritative contract toolchain are implemented. Release 0.1.0 provides a deliberately narrow, stateful vertical slice backed by
Imported methods can be registered dynamically, but no stateful Proxmox method the authoritative imported PVE 9.2.3 contract. Compatibility claims and known
is claimed as compatible yet; the vertical slice is tracked in limits are recorded in [the 0.1.0 compatibility report](docs/compatibility-0.1.0.md).
[the implementation plan](docs/implementation-plan.md).
The bundled PVE 9.2.3 declared contract contains 444 paths and 675 methods. The bundled PVE 9.2.3 declared contract contains 444 paths and 675 methods.
Implemented semantics currently include version, ticket login, node listing and Implemented semantics currently include version, ticket login, node listing and
@@ -24,7 +23,9 @@ make install
make ci make ci
``` ```
Local services use plain HTTP at this stage: Local services expose internal HTTP on port 8006 and a development-only HTTPS
gateway on port 8007. The checked-in certificate and key are disposable local
test credentials and must never be used in production:
```bash ```bash
cp .env.example .env cp .env.example .env
@@ -38,6 +39,27 @@ curl -X POST -d 'username=root@pam&password=secret' \
http://localhost:8006/api2/json/access/ticket http://localhost:8006/api2/json/access/ticket
``` ```
Unmodified proxmoxer 2.3 clients use the HTTPS gateway:
```python
from proxmoxer import ProxmoxAPI
proxmox = ProxmoxAPI(
"localhost",
port=8007,
user="root@pam",
password="secret",
verify_ssl=False, # local self-signed development certificate
)
print(proxmox.version.get())
print(proxmox.nodes("pve1").qemu.get())
```
Run the external-client smoke flow against the Compose network with
`PROXMOXER_HOST=tls-gateway`, `PROXMOXER_PORT=8443`, and pytest marker
`compatibility`. It covers login, reads, CSRF-protected mutation, and UPID task
completion.
Database migrations are ordered SQL files applied transactionally and recorded Database migrations are ordered SQL files applied transactionally and recorded
with SHA-256 checksums. Re-running `make db-migrate` is safe; changing an already with SHA-256 checksums. Re-running `make db-migrate` is safe; changing an already
applied migration is rejected instead of silently drifting the schema. applied migration is rejected instead of silently drifting the schema.
+1 -1
View File
@@ -51,7 +51,7 @@ def create_app(
resolved_workers = (task_worker,) resolved_workers = (task_worker,)
app = FastAPI( app = FastAPI(
title=resolved.app_name, title=resolved.app_name,
version="0.0.1", version="0.1.0",
lifespan=create_lifespan(resolved, database_factory, resolved_workers or ()), lifespan=create_lifespan(resolved, database_factory, resolved_workers or ()),
) )
app.state.settings = resolved app.state.settings = resolved
+10 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -10,6 +11,7 @@ from typing import Any
from app.tasks.repository import Task, TaskRepository from app.tasks.repository import Task, TaskRepository
TaskHandler = Callable[[Task], Awaitable[dict[str, Any] | None]] TaskHandler = Callable[[Task], Awaitable[dict[str, Any] | None]]
logger = logging.getLogger(__name__)
@dataclass(slots=True) @dataclass(slots=True)
@@ -31,7 +33,14 @@ class TaskWorker:
if len(self._running) >= self.concurrency: if len(self._running) >= self.concurrency:
await asyncio.sleep(self.poll_seconds) await asyncio.sleep(self.poll_seconds)
continue continue
task = await self.repository.claim(self.worker_id, self.lease_seconds) try:
task = await self.repository.claim(self.worker_id, self.lease_seconds)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("task claim failed; polling will retry")
await asyncio.sleep(self.poll_seconds)
continue
if task is None: if task is None:
await asyncio.sleep(self.poll_seconds) await asyncio.sleep(self.poll_seconds)
continue continue
+19
View File
@@ -41,5 +41,24 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
tls-gateway:
image: nginx:1.28.0-alpine
depends_on:
simulator:
condition: service_healthy
ports:
- "8007:8443"
volumes:
- ./docker/tls/nginx.conf:/etc/nginx/nginx.conf:ro
- ./docker/tls/server.crt:/etc/nginx/tls/server.crt:ro
- ./docker/tls/server.key:/etc/nginx/tls/server.key:ro
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
- /tmp
security_opt:
- no-new-privileges:true
volumes: volumes:
postgres-data: postgres-data:
+16
View File
@@ -0,0 +1,16 @@
events {}
http {
server {
listen 8443 ssl;
server_name localhost;
ssl_certificate /etc/nginx/tls/server.crt;
ssl_certificate_key /etc/nginx/tls/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://simulator:8006;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Request-ID $request_id;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE-----
MIICyTCCAbGgAwIBAgIJAIbJhnhVx8uWMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
BAMMCWxvY2FsaG9zdDAeFw0yNjA3MTIyMTQyNTFaFw0zNjA3MDkyMTQyNTFaMBQx
EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBALscj7/1WDybjz8x01EvUFVemov6zkezOwfsOXKVyEOnOTxPjWruzDYnB8y6
NH/5PojUns7GB1kuRhZWUXGY0FG/sSgF0X9nwEHoby8ekju2F55NUzzpu9BfM2AU
S17S8h5Oxc4Qi6d9RoeRG25YmMywPCyp2SMnuu14w55KTAt7Ir7mbTAv8ZIMbVhq
34tH45ONQvGftN4JNvwZr7Uf+EuupWsnILfkz1Cw1cj88adDZHwxE7Hkx7TiQP6o
DPDeg+XYH0vB2HR25JSP9z0uyeeF6n6cExgfwVZy2una7jQp887N5xLgTUGlnmFM
y1z2AO2+Mw1Lh2UC/OQrp9T1ztMCAwEAAaMeMBwwGgYDVR0RBBMwEYIJbG9jYWxo
b3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQCICPRCT+m+EKHkaWG2eY2AqQ7a
24Bd60ZsZxJNAloXAd1X8cedz5yq0rm9pqF5Fq883dysgCVSDylwqy4YzllhTWsy
+M3TE85ZyKKi6S7kR7Z0Exf0I4S7G9zTtrzEXn9kco1q5g/jE7aQi2E2z5poaIg+
TlUCq5IePsS6gZCvzXPgU1mJ5dQFlqsOW6Lk1mOCjmKT2SaF4eL2hleatqHv667c
fJWYLotjAJoVQKrjItGeHXPosZEW5g17gFD88XZRMlUx5xN5/ioaKmLiyI28aNF3
nNaukGC/N5fPsgghb0wkYmPHh+dFE/1uMIq0cOzNcyG6ZQkQzikiYhytWsOu
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7HI+/9Vg8m48/
MdNRL1BVXpqL+s5HszsH7DlylchDpzk8T41q7sw2JwfMujR/+T6I1J7OxgdZLkYW
VlFxmNBRv7EoBdF/Z8BB6G8vHpI7theeTVM86bvQXzNgFEte0vIeTsXOEIunfUaH
kRtuWJjMsDwsqdkjJ7rteMOeSkwLeyK+5m0wL/GSDG1Yat+LR+OTjULxn7TeCTb8
Ga+1H/hLrqVrJyC35M9QsNXI/PGnQ2R8MROx5Me04kD+qAzw3oPl2B9Lwdh0duSU
j/c9Lsnnhep+nBMYH8FWctrp2u40KfPOzecS4E1BpZ5hTMtc9gDtvjMNS4dlAvzk
K6fU9c7TAgMBAAECggEBAINmo2zjF3w4onh2vTgeSgQp087J62Ne8u21bwKRPXqF
TSSVmXKnELJW5ptXiNb2anwdFQmQ+EggvwegxsFH18QRIpBAxcb7TYD7gllM1tUo
I54AH5x/aG4E7Udj+So2aeHu3+q+o9STnZxGw0TS4zub6CZVgS+3DwcF8BqRgqXs
NuDIIJWosuchbb3DdlPygRajiN2teJtNfw9rcLfC4BY5i4y/H7RMpklM5VkTXiGc
NxyG4qkdHP0jlL9Z9wRa859uYeb7kVm+vhfgUXMbiRn9FcxrROOpPIwTAv76Y5nu
4EF/s0TPC+ei7hjCpN1WK2/n6dgiVYBVBUpWHalZBIECgYEA7+E2T5PlKuT7Kugs
qx+CHvZXm2hZ9NVDYS6gNAZt6kr5enbb9rzCF/U+jx14COPyGcoJeDOUW1yZGTgH
98JkEEHB6fgSPAU3pp2aMslMRNTZqfM0vL+BRpJT+fPbI9y8WpMzm/NpmnZZK8rh
xLbg+xAa7iMltscCcY2uD8NmhKkCgYEAx6+Ma5WQ0Enmju+XUANrOuKDN9aTXXc6
iqlqtXfadc/Lc6E+lzSxRm5t95t+6AX2mYsNOWsuWRCBqHMFDxg31moYEeBQvQW9
kwJQ5JsmOSCzMfDPUrQHihaq9xwxhoBxJXIRs3JlYm/nty8LO959R1V0IrHsPznH
BVs7pbAo6RsCgYEAyvK4t3UCI1tdoPyTpiffOADlN+d+jCTOf+8pvTpfTiUmk1Ty
XvtuH0TvK7gb8TGhh+4mOtswvmdGZE7CdvyxGgv4WtH142/qmH2okyU58NZAXYgV
a0d+wU1V3RhSpDHB7cOym1PCWdudL+7TOlIbYG5MyoNUCiKvT5E13cJM/xkCgYAC
WWNahKjuemAXAGSUUWX6jF2k04ZqTBPJO9MAjYdpaWdoVdZJqxoGzRfIGPE2Q5Oy
HLusGEG0VIhh9fByTAOkJx1fYHcyshWX3CgdeGHLvEG/bajSvUF1c2zReWhvv6UV
HrFsngTpUo20Tv5f1u88Xpn+Kn+wArr/qiIagecJTwKBgDPEa71fqt7WyjHCNuIm
hJeBCIjTZ8N1Jk0GUHyucbFPARWxYcn0zRTwHOXnXt+Z6GvAfuS7YElSXYVjw2Uy
wUVD+7zh0ydkWC1HJPnjalmHHVpv1RFNEJGAYQ8Vxd6G2EoY4ZFByZomWTxfXrvq
Dr9hsGtmZu1knNwfrOu2kyB5
-----END PRIVATE KEY-----
+45
View File
@@ -0,0 +1,45 @@
# Compatibility report — 0.1.0
This report records evidence for simulator release 0.1.0 against the bundled
Proxmox VE 9.2.3 API contract. It is a limitation matrix, not a claim of general
Proxmox compatibility.
## Summary
| Level | Methods | Contract share | Evidence |
|---|---:|---:|---|
| Declared and dynamically routed | 675 | 100% | Imported immutable API Viewer artifact |
| Stateful semantics implemented | 13 | 1.93% | Handler registry and unit/integration tests |
| Schema-only or explicitly unsupported | 662 | 98.07% | Default 501 fallback |
| proxmoxer smoke exercised | 9 | 1.33% | Unmodified proxmoxer 2.3 compatibility test |
The smoke set is `POST /access/ticket`, `GET /version`, `GET /nodes`,
`GET /nodes/{node}/qemu`, `GET /nodes/{node}/qemu/{vmid}/status/current`, one of
the two state mutations (`start` or `stop`), and repeated
`GET /nodes/{node}/tasks/{upid}/status`. Both mutations have independent API and
worker tests; a single smoke run chooses the transition valid for current state.
## Implemented surface
- Core: version, ticket login, node list/status, and cluster resources.
- QEMU: list, configuration, current status, start, and stop.
- Tasks: node task list, status, and append-only log.
- Authentication: ticket cookie and ticket-bound CSRF validation for mutations.
- Persistence: PostgreSQL resources, durable leased tasks, and deterministic
`small` seed data.
## Known limitations
| Area | 0.1.0 behavior |
|---|---|
| Other imported endpoints | Registered, but return explicit unsupported errors |
| API tokens and broad ACL administration | Primitives exist; public management surface is incomplete |
| QEMU lifecycle | No create, update, delete, snapshots, clone, or migration |
| LXC, storage, pools, backup, HA | Contract-only; no stateful semantics yet |
| Observation parity | Responses are contract-tested, but no sanitized real-PVE observation corpus exists |
| TLS | Local nginx gateway with a checked-in self-signed development key only |
| Client certification | proxmoxer 2.3 smoke only; Terraform and other clients are not certified |
The live `/admin/compatibility` endpoint is the machine-readable source for
declared and implemented counts. Unsupported methods remain failures by default
so the simulator cannot silently overstate compatibility.
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "proxmox-api-simulator" name = "proxmox-api-simulator"
version = "0.0.1" version = "0.1.0"
description = "Stateful asynchronous Proxmox VE API simulator" description = "Stateful asynchronous Proxmox VE API simulator"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13,<3.14" requires-python = ">=3.13,<3.14"
@@ -26,6 +26,8 @@ dev = [
"pytest>=8.4,<9", "pytest>=8.4,<9",
"pytest-asyncio>=1.1,<2", "pytest-asyncio>=1.1,<2",
"pytest-cov>=6.2,<7", "pytest-cov>=6.2,<7",
"proxmoxer>=2.3,<2.4",
"requests>=2.32,<3",
"ruff>=0.12,<0.13", "ruff>=0.12,<0.13",
] ]
@@ -57,6 +59,7 @@ testpaths = ["tests"]
markers = [ markers = [
"integration: requires PostgreSQL or another external service", "integration: requires PostgreSQL or another external service",
"contract: validates imported API contracts", "contract: validates imported API contracts",
"compatibility: exercises an external client against a running simulator",
] ]
[tool.coverage.run] [tool.coverage.run]
+1
View File
@@ -0,0 +1 @@
"""External client compatibility tests."""
+39
View File
@@ -0,0 +1,39 @@
"""Unmodified proxmoxer HTTPS smoke flow."""
import os
import time
import pytest
from proxmoxer import ProxmoxAPI # type: ignore[import-untyped]
pytestmark = [
pytest.mark.compatibility,
pytest.mark.skipif(not os.getenv("PROXMOXER_HOST"), reason="running TLS simulator required"),
]
def test_proxmoxer_read_and_qemu_task_flow() -> None:
proxmox = ProxmoxAPI(
os.environ["PROXMOXER_HOST"],
port=int(os.getenv("PROXMOXER_PORT", "8007")),
user="root@pam",
password=os.getenv("PROXMOXER_PASSWORD", "secret"),
verify_ssl=False,
)
assert proxmox.version.get()["version"] == "9.2.3"
assert any(node["node"] == "pve1" for node in proxmox.nodes.get())
assert any(vm["vmid"] == 101 for vm in proxmox.nodes("pve1").qemu.get())
if os.getenv("PROXMOXER_MUTATION_TEST") == "1":
status = proxmox.nodes("pve1").qemu("101").status.current.get()
operation = "start" if status["status"] == "stopped" else "stop"
endpoint = proxmox.nodes("pve1").qemu("101").status(operation)
upid = endpoint.post()
for _attempt in range(100):
task = proxmox.nodes("pve1").tasks(upid).status.get()
if task["status"] == "stopped":
break
time.sleep(0.05)
assert task["status"] == "stopped"
assert task["exitstatus"] == "OK"
+26
View File
@@ -1,5 +1,6 @@
"""Bounded task worker outcome tests.""" """Bounded task worker outcome tests."""
import asyncio
import uuid import uuid
from typing import cast from typing import cast
@@ -72,3 +73,28 @@ async def test_worker_honors_persisted_cancellation() -> None:
assert not called assert not called
assert repository.finishes == [("cancelled", None)] assert repository.finishes == [("cancelled", None)]
async def test_worker_retries_after_claim_failure() -> None:
class RecoveringRepository:
attempts = 0
async def claim(self, _worker_id: str, _lease_seconds: float) -> None:
self.attempts += 1
if self.attempts == 1:
raise RuntimeError("database schema is not ready")
return None
repository = RecoveringRepository()
worker = TaskWorker(
cast(TaskRepository, repository),
"worker",
{},
poll_seconds=0.001,
)
running = asyncio.create_task(worker.run())
await asyncio.sleep(0.01)
worker.stop()
await running
assert repository.attempts > 1