diff --git a/Dockerfile b/Dockerfile index fb1c8d0..8202000 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" diff --git a/README.md b/README.md index 858c60b..37ddde3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/main.py b/app/main.py index 3f43ecb..69c805f 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/tasks/worker.py b/app/tasks/worker.py index d1d50f8..2e8c7f9 100644 --- a/app/tasks/worker.py +++ b/app/tasks/worker.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 56564de..0ebc807 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docker/tls/nginx.conf b/docker/tls/nginx.conf new file mode 100644 index 0000000..c415402 --- /dev/null +++ b/docker/tls/nginx.conf @@ -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; + } + } +} diff --git a/docker/tls/server.crt b/docker/tls/server.crt new file mode 100644 index 0000000..bce557b --- /dev/null +++ b/docker/tls/server.crt @@ -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----- diff --git a/docker/tls/server.key b/docker/tls/server.key new file mode 100644 index 0000000..5cc6910 --- /dev/null +++ b/docker/tls/server.key @@ -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----- diff --git a/docs/compatibility-0.1.0.md b/docs/compatibility-0.1.0.md new file mode 100644 index 0000000..878e6b6 --- /dev/null +++ b/docs/compatibility-0.1.0.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 6c1c624..07e8546 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/compatibility/__init__.py b/tests/compatibility/__init__.py new file mode 100644 index 0000000..c1212c3 --- /dev/null +++ b/tests/compatibility/__init__.py @@ -0,0 +1 @@ +"""External client compatibility tests.""" diff --git a/tests/compatibility/test_proxmoxer.py b/tests/compatibility/test_proxmoxer.py new file mode 100644 index 0000000..6dbda37 --- /dev/null +++ b/tests/compatibility/test_proxmoxer.py @@ -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" diff --git a/tests/unit/test_task_worker.py b/tests/unit/test_task_worker.py index 3c41cc6..8529ad5 100644 --- a/tests/unit/test_task_worker.py +++ b/tests/unit/test_task_worker.py @@ -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