feat: release proxmoxer-compatible 0.1.0 slice
This commit is contained in:
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""External client compatibility tests."""
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user