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
ARG APP_VERSION=0.0.1
ARG APP_VERSION=0.1.0
LABEL org.opencontainers.image.title="proxmox-api-simulator" \
org.opencontainers.image.version="$APP_VERSION" \
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
infrastructure tooling without a real hypervisor cluster.
The runnable foundation and authoritative contract toolchain are implemented.
Imported methods can be registered dynamically, but no stateful Proxmox method
is claimed as compatible yet; the vertical slice is tracked in
[the implementation plan](docs/implementation-plan.md).
Release 0.1.0 provides a deliberately narrow, stateful vertical slice backed by
the authoritative imported PVE 9.2.3 contract. Compatibility claims and known
limits are recorded in [the 0.1.0 compatibility report](docs/compatibility-0.1.0.md).
The bundled PVE 9.2.3 declared contract contains 444 paths and 675 methods.
Implemented semantics currently include version, ticket login, node listing and
@@ -24,7 +23,9 @@ make install
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
cp .env.example .env
@@ -38,6 +39,27 @@ curl -X POST -d 'username=root@pam&password=secret' \
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
with SHA-256 checksums. Re-running `make db-migrate` is safe; changing an already
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,)
app = FastAPI(
title=resolved.app_name,
version="0.0.1",
version="0.1.0",
lifespan=create_lifespan(resolved, database_factory, resolved_workers or ()),
)
app.state.settings = resolved
+10 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
@@ -10,6 +11,7 @@ from typing import Any
from app.tasks.repository import Task, TaskRepository
TaskHandler = Callable[[Task], Awaitable[dict[str, Any] | None]]
logger = logging.getLogger(__name__)
@dataclass(slots=True)
@@ -31,7 +33,14 @@ class TaskWorker:
if len(self._running) >= self.concurrency:
await asyncio.sleep(self.poll_seconds)
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:
await asyncio.sleep(self.poll_seconds)
continue
+19
View File
@@ -41,5 +41,24 @@ services:
security_opt:
- 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:
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]
name = "proxmox-api-simulator"
version = "0.0.1"
version = "0.1.0"
description = "Stateful asynchronous Proxmox VE API simulator"
readme = "README.md"
requires-python = ">=3.13,<3.14"
@@ -26,6 +26,8 @@ dev = [
"pytest>=8.4,<9",
"pytest-asyncio>=1.1,<2",
"pytest-cov>=6.2,<7",
"proxmoxer>=2.3,<2.4",
"requests>=2.32,<3",
"ruff>=0.12,<0.13",
]
@@ -57,6 +59,7 @@ testpaths = ["tests"]
markers = [
"integration: requires PostgreSQL or another external service",
"contract: validates imported API contracts",
"compatibility: exercises an external client against a running simulator",
]
[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."""
import asyncio
import uuid
from typing import cast
@@ -72,3 +73,28 @@ async def test_worker_honors_persisted_cancellation() -> None:
assert not called
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