Add a stateful Proxmox API console and broad handler coverage beyond the

initial QEMU slice, backed by imported contracts for majors 6–9.
- Implement durable handlers for access/auth, cluster, LXC, storage, HA,
  firewall, Ceph, SDN, ACME, notifications, pools, mapping, and node ops
- Serve an interactive Web UI with catalog browsing, demo seed controls,
  and OpenAPI/help surfaces
- Bundle PVE 6.4-15, 7.4-16, and 8.4.5 contract revisions alongside 9.2.3
- Support in-memory runtime contract Apply (POST /ui/api/contract/apply)
  so /version and /api2 routes follow the selected major until restart
- Expand seed profiles (including demo-cluster), migrations 007–008, TLS
  gateway config, Compose/Makefile tooling, and compatibility evidence
- Tighten .gitignore for macOS, hidden directories (.*/), and local secrets
This commit is contained in:
Sergey Antropoff
2026-07-16 01:08:01 +03:00
parent 003ee5d634
commit 777926487b
189 changed files with 241501 additions and 944 deletions
-1
View File
@@ -6,6 +6,5 @@ __pycache__
.ruff_cache
.env
htmlcov
tests
docs
+8
View File
@@ -1,6 +1,7 @@
APP_HOST=0.0.0.0
APP_PORT=8006
DATABASE_URL=postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
TEST_DATABASE_URL=postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
DB_POOL_MIN_SIZE=1
DB_POOL_MAX_SIZE=10
DB_CONNECT_TIMEOUT_SECONDS=10
@@ -9,7 +10,12 @@ LOG_LEVEL=INFO
REQUEST_ID_HEADER=X-Request-ID
PVE_API_VERSION=9.2.3
CONTRACT_SNAPSHOT=/app/contracts/pve-9.2.3.json
COMPATIBILITY_EVIDENCE=/app/evidence/pve-9.2.3.json
CONTRACT_FALLBACK=error
CATALOG_ARTIFACT_URL_6=https://pve.proxmox.com/pve-docs-6/api-viewer/apidoc.js
CATALOG_ARTIFACT_URL_7=https://pve.proxmox.com/pve-docs-7/api-viewer/apidoc.js
CATALOG_ARTIFACT_URL_8=https://pve.proxmox.com/pve-docs-8/api-viewer/apidoc.js
CATALOG_ARTIFACT_URL_9=https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js
TICKET_SIGNING_KEY=development-only-signing-key-change-me
TASK_WORKER_CONCURRENCY=2
TASK_LEASE_SECONDS=30
@@ -17,3 +23,5 @@ SIMULATION_SEED=42
SIMULATION_TIME_SCALE=10
SIMULATOR_ADMIN_ENABLED=false
SIMULATOR_ADMIN_TOKEN=replace-with-a-long-random-secret
PROXMOXER_HOST=tls-gateway
PROXMOXER_PORT=8443
+69 -8
View File
@@ -1,14 +1,75 @@
# Hidden directories (.cursor/, .pytest_cache/, .mypy_cache/, .ruff_cache/, …)
.*/
# Keep GitHub Actions / repo metadata trackable despite the rule above.
!.github/
!.github/**
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
.apdisk
Network Trash Folder
Temporary Items
# Environment / secrets (keep .env.example tracked)
.env
.venv/
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
.coverage
coverage.xml
htmlcov/
.mypy_cache/
.pytest_cache/
.ruff_cache/
*$py.class
*.so
.Python
*.egg
*.egg-info/
.eggs/
dist/
build/
*.egg-info/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.manifest
*.spec
pip-log.txt
pip-delete-this-directory.txt
.venv/
venv/
ENV/
env/
# Test / coverage leftovers (directories also covered by .*/)
.coverage
.coverage.*
coverage.xml
htmlcov/
.cache
nosetests.xml
pytestdebug.log
hypothesis/
# Editors / IDE leftovers outside .*
*.swp
*.swo
*~
*.sublime-project
*.sublime-workspace
# Docker / local runtime
*.log
docker-compose.override.yml
# Lab probe output (regenerated by scripts/probe_api_surface.py)
evidence/_api_surface_probe.json
+22 -3
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.7
FROM python:3.13-slim-bookworm AS builder
FROM python:3.13-slim AS builder
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
@@ -11,7 +11,7 @@ COPY pyproject.toml README.md ./
COPY app ./app
RUN pip install --upgrade "pip>=25.1,<26" && pip install .
FROM python:3.13-slim-bookworm AS runtime
FROM python:3.13-slim AS runtime
ARG APP_VERSION=0.1.0
LABEL org.opencontainers.image.title="proxmox-api-simulator" \
@@ -26,7 +26,7 @@ RUN groupadd --system --gid 10001 simulator \
&& useradd --system --uid 10001 --gid simulator --home-dir /app --no-create-home simulator
COPY --from=builder /opt/venv /opt/venv
COPY contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json /app/contracts/pve-9.2.3.json
COPY evidence/pve-9.2.3-0.1.0.json /app/evidence/pve-9.2.3-0.1.0.json
COPY evidence/ /app/evidence/
WORKDIR /app
USER 10001:10001
EXPOSE 8006
@@ -34,3 +34,22 @@ HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8006/health/live', timeout=2)"]
ENTRYPOINT ["uvicorn", "app.main:app"]
CMD ["--host", "0.0.0.0", "--port", "8006"]
FROM python:3.13-slim AS dev
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
VIRTUAL_ENV=/opt/venv \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN python -m venv "$VIRTUAL_ENV"
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /workspace
COPY pyproject.toml README.md ./
COPY app ./app
COPY tests ./tests
COPY contracts ./contracts
COPY evidence ./evidence
RUN pip install --upgrade "pip>=25.1,<26" && pip install -e '.[dev]'
ENTRYPOINT []
CMD ["bash"]
+138 -39
View File
@@ -1,84 +1,183 @@
PYTHON ?= python3.13
VENV ?= .venv
BIN := $(VENV)/bin
COMPOSE ?= docker compose
SERVICE_DEV := dev
SERVICE_SIM := simulator
PYTEST_OFFLINE := -m "not integration and not compatibility"
.PHONY: help install format lint typecheck test test-unit test-integration test-contract coverage run dev docker-build docker-up docker-down docker-logs db-up db-down db-migrate db-reset api-import api-diff seed clean ci
# Docker Hub release image (runtime target only — not the local bind-mount "dev" image).
DOCKERHUB_USER ?= inecs
IMAGE_NAME ?= proxmox-api-simulator
VERSION ?= $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)
DOCKER_IMAGE ?= $(DOCKERHUB_USER)/$(IMAGE_NAME)
PUSH_LATEST ?= 1
COMPOSE_RELEASE ?= $(COMPOSE) -f docker-compose.release.yml
HELM_CHART ?= ./helm/proxmox-api-simulator
.PHONY: help install format lint typecheck test test-unit test-integration test-contract test-compatibility test-surface evidence coverage run dev up down restart logs docker-build docker-up docker-down docker-logs docker-restart db-up db-down db-migrate db-reset api-import api-diff seed clean ci ci-all shell release release-build release-up release-down release-seed helm-deps helm-template
help: ## Show available commands
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install: ## Create the Python 3.13 environment and install development dependencies
$(PYTHON) -m venv $(VENV)
$(BIN)/python -m pip install --upgrade "pip>=25.1,<26"
$(BIN)/python -m pip install -e '.[dev]'
install: ## Build runtime and development images
@test -f .env || cp .env.example .env
$(COMPOSE) build simulator $(SERVICE_DEV)
format: ## Format Python sources
$(BIN)/ruff format .
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) ruff format .
lint: ## Run Ruff lint checks
$(BIN)/ruff check .
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) ruff check .
typecheck: ## Run strict mypy checks
$(BIN)/mypy
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) mypy
test: ## Run all offline tests
$(BIN)/pytest
test: ## Run offline unit and contract tests
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest $(PYTEST_OFFLINE)
test-unit: ## Run unit tests
$(BIN)/pytest tests/unit
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest tests/unit
test-integration: ## Run tests that require PostgreSQL
$(BIN)/pytest -m integration
@test -f .env || cp .env.example .env
$(COMPOSE) up -d postgres
$(COMPOSE) run --rm $(SERVICE_DEV) pytest -m integration
test-contract: ## Run offline API contract tests
$(BIN)/pytest -m contract
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest -m contract
coverage: ## Run tests with coverage enforcement
$(BIN)/pytest --cov=app --cov-report=term-missing --cov-report=xml
run: ## Run the application
$(BIN)/uvicorn app.main:app --host "$${APP_HOST:-0.0.0.0}" --port "$${APP_PORT:-8006}"
dev: ## Run with auto-reload
$(BIN)/uvicorn app.main:app --reload --host "$${APP_HOST:-0.0.0.0}" --port "$${APP_PORT:-8006}"
docker-build: ## Build the runtime image
$(COMPOSE) build simulator
docker-up: ## Start PostgreSQL and simulator
test-compatibility: ## Run proxmoxer smoke flow against the Compose stack
@test -f .env || cp .env.example .env
$(COMPOSE) up -d --build
$(COMPOSE) up -d --build --wait
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.simulation.seed_cli
$(COMPOSE) run --rm $(SERVICE_DEV) pytest -m compatibility
docker-down: ## Stop local services
test-surface: ## Probe every declared method on majors 6-9 (0x501 / 0xexception)
@test -f .env || cp .env.example .env
$(COMPOSE) up -d postgres
$(COMPOSE) run --rm $(SERVICE_DEV) pytest tests/compatibility/test_api_surface_probe.py -q
evidence: ## Regenerate per-major verified surface evidence ledgers
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) python -m app.evidence_gen
coverage: ## Run offline tests with coverage enforcement
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest $(PYTEST_OFFLINE) --cov=app --cov-report=term-missing --cov-report=xml
run: ## Run the application in the foreground
@test -f .env || cp .env.example .env
$(COMPOSE) up --build
up: ## Start PostgreSQL, simulator, and TLS gateway
@test -f .env || cp .env.example .env
$(COMPOSE) up -d --build --wait
down: ## Stop local services
$(COMPOSE) down
docker-logs: ## Follow simulator logs
restart: ## Rebuild and restart the stack
@test -f .env || cp .env.example .env
$(COMPOSE) up -d --build --force-recreate --wait
logs: ## Follow logs from all services
$(COMPOSE) logs -f
dev: ## Run the application with auto-reload in Docker
@test -f .env || cp .env.example .env
$(COMPOSE) up -d postgres migrate
$(COMPOSE) up simulator
docker-build: ## Build runtime and development images
$(MAKE) install
docker-up: up ## Alias for up
docker-restart: ## Rebuild and recreate simulator and TLS gateway only
$(COMPOSE) up -d --build --force-recreate simulator tls-gateway
docker-down: down ## Alias for down
docker-logs: ## Follow simulator logs only
$(COMPOSE) logs -f simulator
db-up: ## Start PostgreSQL only
@test -f .env || cp .env.example .env
$(COMPOSE) up -d postgres
db-down: ## Stop PostgreSQL
$(COMPOSE) stop postgres
db-migrate: ## Apply database migrations
$(BIN)/python -m app.db.migrate_cli
@test -f .env || cp .env.example .env
$(COMPOSE) run --rm migrate
db-reset: ## Recreate the local database volume
$(COMPOSE) down -v
$(COMPOSE) up -d postgres
$(COMPOSE) run --rm migrate
api-import: ## Import an API snapshot
$(BIN)/proxmox-api-contract import $(ARGS)
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) proxmox-api-contract import $(ARGS)
api-diff: ## Compare API snapshots
$(BIN)/proxmox-api-contract diff $(ARGS)
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) proxmox-api-contract diff $(ARGS)
seed: ## Seed simulation data
SEED_PROFILE="$${PROFILE:-small}" $(BIN)/python -m app.simulation.seed_cli
@test -f .env || cp .env.example .env
SEED_PROFILE="$${PROFILE:-small}" $(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.simulation.seed_cli
shell: ## Open an interactive shell in the development container
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) bash
clean: ## Remove generated local artifacts
rm -rf $(VENV) .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache
rm -rf .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache
ci: format lint typecheck coverage ## Run the complete local quality gate
ci: ## Offline quality gate + full API surface probe (Postgres)
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) sh -c '\
ruff format --check . && \
ruff check . && \
mypy && \
pytest $(PYTEST_OFFLINE) --cov=app --cov-report=term-missing --cov-report=xml'
$(MAKE) test-surface
ci-all: ## Full CI: offline + surface + remaining integration + proxmoxer
$(MAKE) ci
$(MAKE) test-integration
$(MAKE) test-compatibility
release-build: ## Build the runtime image tagged for Docker Hub (no push)
@test -n "$(VERSION)" || (echo "VERSION is empty; set VERSION=... or version in pyproject.toml" >&2; exit 1)
@echo "Building $(DOCKER_IMAGE):$(VERSION) (target=runtime)"
docker build \
--target runtime \
--build-arg APP_VERSION=$(VERSION) \
-t $(DOCKER_IMAGE):$(VERSION) \
$(if $(filter 1 true yes,$(PUSH_LATEST)),-t $(DOCKER_IMAGE):latest,) \
.
release: release-build ## Build and push the runtime image to Docker Hub
@echo "Pushing $(DOCKER_IMAGE):$(VERSION)"
@docker push $(DOCKER_IMAGE):$(VERSION)
@if [ "$(PUSH_LATEST)" = "1" ] || [ "$(PUSH_LATEST)" = "true" ] || [ "$(PUSH_LATEST)" = "yes" ]; then \
echo "Pushing $(DOCKER_IMAGE):latest"; \
docker push $(DOCKER_IMAGE):latest; \
fi
@echo "Released $(DOCKER_IMAGE):$(VERSION)$(if $(filter 1 true yes,$(PUSH_LATEST)), and $(DOCKER_IMAGE):latest,)"
release-up: ## Pull and start the published Hub stack (docker-compose.release.yml)
IMAGE_TAG="$${IMAGE_TAG:-$(VERSION)}" DOCKER_IMAGE="$(DOCKER_IMAGE)" $(COMPOSE_RELEASE) pull
IMAGE_TAG="$${IMAGE_TAG:-$(VERSION)}" DOCKER_IMAGE="$(DOCKER_IMAGE)" $(COMPOSE_RELEASE) up -d --wait
release-down: ## Stop the published Hub stack
$(COMPOSE_RELEASE) down
release-seed: ## Seed the published Hub stack (PROFILE=small by default)
SEED_PROFILE="$${PROFILE:-small}" IMAGE_TAG="$${IMAGE_TAG:-$(VERSION)}" DOCKER_IMAGE="$(DOCKER_IMAGE)" \
$(COMPOSE_RELEASE) run --rm --entrypoint python simulator -m app.simulation.seed_cli
helm-deps: ## No-op placeholder (chart has no OCI dependencies)
@echo "Chart $(HELM_CHART) vendors PostgreSQL templates; no helm dependency update required."
helm-template: ## Render Helm manifests locally (requires helm)
helm template pve-sim $(HELM_CHART) \
-f $(HELM_CHART)/values-ingress-example.yaml \
--set certManager.email=docs@example.com \
--set secret.ticketSigningKey=docs-only-signing-key
+135 -127
View File
@@ -1,57 +1,127 @@
# proxmox-api-simulator
Stateful asynchronous Proxmox VE API simulator for testing API clients and
infrastructure tooling without a real hypervisor cluster.
Stateful asynchronous [Proxmox VE](https://www.proxmox.com/) API simulator for
testing API clients and infrastructure tooling without a real hypervisor
cluster.
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 simulator is backed by PostgreSQL, driven by imported official API
contracts, and exposes the same `/api2/json` and `/api2/extjs` surfaces as
Proxmox VE. Semantic handlers persist mutations; long-running work returns
durable UPIDs executed by leased task workers.
The bundled PVE 9.2.3 declared contract contains 444 paths and 675 methods.
Implemented semantics currently include version, ticket login, node listing and
status, cluster resources, basic QEMU list/config/status/start/stop, and task
list/status/log, QEMU create/sync-update/async-update/delete, plus API-token
list/create/read/update/delete. Mutations require the ticket-bound CSRF header
and execute through PostgreSQL-leased workers; all other declared methods return
an explicit unsupported error.
## Verified API coverage
QEMU create, asynchronous config update, and delete return durable UPIDs and use
the same PostgreSQL resource lock as lifecycle operations. Synchronous config
PUT uses optimistic versioning. Common fields and unknown version-dependent
parameters are retained in JSONB; duplicate VMIDs and overlapping operations
fail with 409.
Handler registry and verified surface ledgers are **100%** for every bundled major:
Power lifecycle now includes start, stop, graceful shutdown, reboot, reset,
suspend, and resume. Every operation is validated by the explicit VM state
machine, exposes intermediate/final state through current status, and runs as a
leased task under the same VM lock.
| Contract | Declared | Implemented | Verified |
|---|---:|---:|---:|
| PVE 6.4-15 | 504 | 504 | 504 |
| PVE 7.4-16 | 540 | 540 | 540 |
| PVE 8.4.5 | 605 | 605 | 605 |
| PVE 9.2.3 | 675 | 675 | 675 |
## Development
Switch the active contract at runtime from the Web UI (**Apply as runtime**) or
`POST /ui/api/contract/apply?major=N` — each Apply loads
`evidence/pve-{version}.json` so observed/verified scores follow the selected
major. Regenerate ledgers after importing a new contract with `make evidence`.
Live reports: `/admin/compatibility` (also `.md` / `.html`). See
[Compatibility](docs/compatibility.md) and [API versions](docs/api-versions.md).
Python 3.13 is required.
> This is measurable contract and handler coverage for a laboratory simulator —
> not a claim that every Proxmox edge case or remote integration behaves
> identically to production hardware.
## Quick start (published image)
Image: [`inecs/proxmox-api-simulator`](https://hub.docker.com/r/inecs/proxmox-api-simulator)
### Docker Compose
```bash
docker compose -f docker-compose.release.yml up -d
docker compose -f docker-compose.release.yml run --rm --entrypoint python \
simulator -m app.simulation.seed_cli
curl http://localhost:8006/health/ready
curl http://localhost:8006/api2/json/version
```
Or: `make release-up && make release-seed PROFILE=small`
### Helm (Kubernetes + Ingress + Let's Encrypt)
```bash
helm upgrade --install pve-sim ./helm/proxmox-api-simulator \
-n proxmox-sim --create-namespace \
-f ./helm/proxmox-api-simulator/values-ingress-example.yaml \
--set certManager.email=you@example.com \
--set ingress.hosts[0].host=pve-sim.example.com \
--set ingress.tls[0].hosts[0]=pve-sim.example.com \
--set secret.ticketSigningKey="$(openssl rand -hex 32)"
```
Requires an Ingress controller and cert-manager. Details:
[Kubernetes / Helm](docs/kubernetes.md).
- HTTP API and Web UI (Compose): [http://localhost:8006/](http://localhost:8006/)
- FastAPI schema docs: [http://localhost:8006/docs](http://localhost:8006/docs)
- Default seeded admin: `root@pam` / `secret`
## Quick start (development checkout)
Build and run the bind-mounted development stack from this repository:
```bash
make install
make ci
```
make up
make seed PROFILE=small
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
make docker-up
curl http://localhost:8006/health/live
curl http://localhost:8006/health/ready
make db-migrate
make seed
curl http://localhost:8006/api2/json/version
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:
- HTTP API and Web UI: [http://localhost:8006/](http://localhost:8006/)
- HTTPS gateway (self-signed, development only): `https://localhost:8007`
— checked-in `docker/tls/server.key` is a **lab-only** localhost cert; do not
reuse it outside local Compose.
- FastAPI schema docs: [http://localhost:8006/docs](http://localhost:8006/docs)
### Web UI
Interactive console with light/dark themes, endpoint catalog for PVE 69, and
runtime contract hot-swap. More detail: [Web UI](docs/web-ui.md).
![Web UI light theme](docs/images/web-ui-light.png)
![Web UI dark theme](docs/images/web-ui-dark.png)
## Documentation
| Guide | Description |
|---|---|
| [Getting started](docs/getting-started.md) | First successful lab session |
| [Configuration](docs/configuration.md) | Environment variables and Compose |
| [Authentication](docs/authentication.md) | Tickets, CSRF, API tokens, ACLs |
| [API versions](docs/api-versions.md) | Contracts 69 and hot-swap |
| [Clients & examples](docs/clients.md) | Python, Go, Java, Perl, Ansible, Terraform, Pulumi |
| [Seed profiles](docs/seed-profiles.md) | Deterministic cluster fixtures |
| [API surface](docs/api-surface.md) | Routing, handlers, fallbacks |
| [Domains](docs/domains/README.md) | QEMU, LXC, storage, HA, SDN, … |
| [Web UI](docs/web-ui.md) | Interactive console and catalogs |
| [Operations](docs/operations.md) | Migrate, reseed, upgrade |
| [Kubernetes / Helm](docs/kubernetes.md) | Hub image + Ingress + Let's Encrypt |
| [Security](docs/security.md) | Lab threat model and credentials |
| [Observability](docs/observability.md) | Health endpoints and logging |
| [Troubleshooting](docs/troubleshooting.md) | Common failure modes |
| [FAQ](docs/faq.md) | Short answers |
| [Architecture](docs/architecture.md) | Component boundaries |
| [Compatibility](docs/compatibility.md) | Evidence model and release matrix |
Runnable cookbooks live under [`examples/`](examples/README.md).
## proxmoxer (HTTPS gateway)
```python
from proxmoxer import ProxmoxAPI
@@ -61,109 +131,47 @@ proxmox = ProxmoxAPI(
port=8007,
user="root@pam",
password="secret",
verify_ssl=False, # local self-signed development certificate
verify_ssl=False, # local self-signed development certificate only
)
print(proxmox.version.get())
print(proxmox.nodes("pve1").qemu.get())
print(proxmox.nodes("pve01").qemu.get())
```
The deterministic seed also provides hashed development API tokens. For token
authentication use proxmoxer `token_name="automation"` and
`token_value="automation-secret"` with user `root@pam`. The readonly
`auditor@pve!readonly` token proves privilege separation: authenticated reads
work, while QEMU power operations return 403. API-token requests do not require
CSRF; ticket-authenticated mutations still do. These are disposable local test
credentials only.
API token example: user `root@pam`, `token_name="automation"`,
`token_value="automation-secret"`. Token requests do not need CSRF; ticket
mutations do.
The permission acceptance matrix additionally seeds an audit-only user through
an inherited group ACL, a VM operator, and a storage-scoped user. Compatibility
tests verify root access, inherited `Sys.Audit`/`VM.Audit`, operator power
management, token privilege intersection, denial, and identical denial for an
existing and a nonexistent VM when the principal lacks `VM.Audit`.
QEMU snapshots are durable PostgreSQL records. Create, delete, and rollback use
UPID tasks and the same per-VM lock as other mutations; rollback restores both
the captured VM configuration and runtime state. Snapshot listing, inspection,
and description updates are available through the native Proxmox API paths.
Full QEMU clones copy configuration into a new stopped VM, and local migration
moves a VM between seeded nodes through an explicit migrating state. Both are
durable UPID operations with VMID collision and target-node validation.
Indexed contract fields such as `scsi[n]` accept their concrete Proxmox names
(`scsi0`, `scsi1`, and so on). QEMU disk growth is synchronous and rejects
shrinking; disk moves are durable tasks that update normalized disk metadata and
the preserved VM configuration.
Token lifecycle is available at
`/access/users/{userid}/token[/{tokenid}]`. A generated secret is returned only
by create or explicit regenerate; only its scrypt hash is stored. List/read never
return token values, and deletion immediately invalidates authentication.
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.
Machine-readable evidence is served at `/admin/compatibility`; deterministic
Markdown and HTML variants use `/admin/compatibility.md` and
`/admin/compatibility.html`. Scores are separated across all 13 contract,
response, state, task, error, and permission dimensions.
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. Readiness
stays unavailable until the latest packaged migration is present; task workers
retry claims and recover automatically when migrations are applied after process
startup.
Seed profiles are deterministic and replace the previously seeded simulation
state atomically. `small` creates one node, two QEMU guests, one LXC, two
storages, an administrator, and completed task history. `medium` creates three
nodes, 50 QEMU guests, 20 LXC guests, shared/local storage and a pool;
`ha-demo` adds HA state, while `broken-storage` makes one storage unavailable.
`large` uses bounded asyncpg batch operations and is configurable through
`SEED_LARGE_NODES` and `SEED_LARGE_RESOURCES` (10,000 resources by default):
## Common Make targets
```bash
make up / make down / make logs / make dev
make test # unit + contract (includes verified surface)
make test-integration # PostgreSQL-backed
make test-surface # all verbs × majors 6-9 (0x501 / 0xexception)
make test-compatibility # proxmoxer against Compose
make evidence # regenerate evidence/pve-*.json ledgers
make seed PROFILE=small
make seed PROFILE=medium
make seed PROFILE=large
make seed PROFILE=ha-demo
make seed PROFILE=broken-storage
make db-migrate
make shell
make ci # ruff + mypy + offline pytest + surface probe
make release # build + push runtime image to Docker Hub
make release-up # pull/start docker-compose.release.yml
make release-seed PROFILE=small
```
All stable simulation identifiers use UUIDv5. Migration 004 adds normalized
cluster, QEMU, LXC, storage/content, snapshot, backup, pool, identity,
observation and fault-rule tables while generic resources remain the current
0.1 compatibility boundary.
Contract artifacts can be validated and imported into immutable local revisions:
Docker Hub release (requires `docker login` as the Hub owner; see
[Operations](docs/operations.md)):
```bash
.venv/bin/proxmox-api-contract validate tests/fixtures/api-viewer/pve-9.2.3-version.json
.venv/bin/proxmox-api-contract --store contracts import \
--file tests/fixtures/api-viewer/pve-9.2.3-version.json --version 9.2.3
.venv/bin/proxmox-api-contract --store contracts list
make release # inecs/proxmox-api-simulator:<pyproject version> + :latest
make release VERSION=0.2.0 # override tag
make release-build # build/tag only, no push
make release-up && make release-seed # run the published stack locally
```
Remote imports accept HTTPS URLs on the explicit official-domain allowlist and
reject private address resolution, unsafe redirects, oversized responses, and
unbounded retries. Imported revisions are addressed by their normalized
snapshot checksum and are never overwritten.
## What this is not
Normalized snapshots can be compared in text, JSON, Markdown, or HTML. The diff
command exits with status 1 when it finds a breaking change, making it suitable
for CI policy checks:
```bash
.venv/bin/proxmox-api-contract diff old-snapshot.json new-snapshot.json \
--format markdown
```
Set `CONTRACT_SNAPSHOT` to a normalized snapshot file to register its methods
under both `/api2/json` and `/api2/extjs`. Routes without a semantic handler
return an explicit 501 by default. `CONTRACT_FALLBACK=schema-default` enables
schema-only exploration; `fixture` serves only values explicitly embedded in a
method contract.
See [the architecture](docs/architecture.md) for component boundaries and
durability decisions. Commands for not-yet-implemented milestones intentionally
return a non-zero status instead of pretending to succeed.
- Not a hypervisor: no KVM/LXC execution on bare metal or nested hosts.
- Not a drop-in multi-tenant production Proxmox replacement.
- Remote IdP / LDAP / live Ceph / live ACME directories are simulated locally;
they do not call real external systems.
+109
View File
@@ -0,0 +1,109 @@
"""OpenAPI tag resolution for contract-driven routes."""
from __future__ import annotations
_NODE_SECTION_LABELS: dict[str, str] = {
"qemu": "QEMU",
"lxc": "LXC",
"ceph": "Ceph",
"storage": "Storage",
"sdn": "SDN",
"firewall": "Firewall",
"apt": "APT",
"certificates": "Certificates",
"scan": "Scan",
"network": "Network",
"services": "Services",
"capabilities": "Capabilities",
"hardware": "Hardware",
"replication": "Replication",
"tasks": "Tasks",
"subscription": "Subscription",
"vzdump": "Backup",
"disks": "Disks",
"config": "Config",
"dns": "DNS",
"hosts": "Hosts",
"status": "Status",
"time": "Time",
"aplinfo": "Appliance",
}
_CLUSTER_SECTION_LABELS: dict[str, str] = {
"sdn": "SDN",
"firewall": "Firewall",
"notifications": "Notifications",
"ha": "HA",
"mapping": "Mapping",
"acme": "ACME",
"config": "Config",
"ceph": "Ceph",
"jobs": "Jobs",
"metrics": "Metrics",
"qemu": "QEMU",
"backup": "Backup",
"bulk-action": "Bulk Action",
"replication": "Replication",
"backup-info": "Backup Info",
"options": "Options",
"log": "Log",
"nextid": "Next ID",
"resources": "Resources",
"status": "Status",
"tasks": "Tasks",
}
def contract_openapi_tag(path: str) -> str:
"""Map a semantic contract path to a Swagger UI category."""
parts = [part for part in path.strip("/").split("/") if part]
if not parts or parts == ["version"]:
return "Core"
root = parts[0]
if root == "access":
return "Access"
if root == "nodes":
if len(parts) >= 3 and parts[1] == "{node}":
section = parts[2]
label = _NODE_SECTION_LABELS.get(section, section.replace("-", " ").title())
return f"Nodes · {label}"
return "Nodes"
if root == "cluster":
if len(parts) >= 2:
section = parts[1]
label = _CLUSTER_SECTION_LABELS.get(section, section.replace("-", " ").title())
return f"Cluster · {label}"
return "Cluster"
if root == "storage":
return "Storage"
if root == "pools":
return "Pools"
return root.replace("-", " ").title()
def contract_openapi_tags(path: str, renderer: str) -> list[str]:
"""Return OpenAPI tags for a contract route, including the API renderer."""
renderer_label = "API2 JSON" if renderer == "json" else "API2 ExtJS"
return [contract_openapi_tag(path), renderer_label]
def openapi_tag_metadata() -> list[dict[str, str]]:
"""Descriptions shown in Swagger UI for each tag group."""
descriptions: dict[str, str] = {
"Core": "Version and global simulator metadata.",
"Access": "Authentication, users, groups, roles, ACLs, and API tokens.",
"Nodes": "Node inventory and node-level endpoints without a resource section.",
"Storage": "Cluster-wide and node storage definitions and content.",
"Pools": "Resource pools and membership.",
"API2 JSON": "Proxmox `/api2/json` renderer routes.",
"API2 ExtJS": "Proxmox `/api2/extjs` renderer routes.",
"Simulator": "Health checks, compatibility reports, and the web console.",
}
for label in _NODE_SECTION_LABELS.values():
descriptions.setdefault(f"Nodes · {label}", f"Node-level {label} API.")
for label in _CLUSTER_SECTION_LABELS.values():
descriptions.setdefault(f"Cluster · {label}", f"Cluster-level {label} API.")
return [{"name": name, "description": text} for name, text in sorted(descriptions.items())]
+73 -20
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Literal, cast
from urllib.parse import parse_qsl
@@ -12,7 +14,9 @@ from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api.errors import ApiError, ContractValidationError
from app.api.openapi import contract_openapi_tags
from app.config import Settings
from app.contracts.examples import schema_example
from app.contracts.model import Method, Schema, Snapshot
from app.db.pool import AsyncpgDatabase
from app.security.acl import AclEntry, CapabilityRequirement, authorize, requirement_from_contract
@@ -48,18 +52,35 @@ def register_contract_routes(
snapshot: Snapshot,
handlers: HandlerRegistry,
fallback: FallbackMode = "error",
) -> None:
seen: set[tuple[str, str, str]] = set()
*,
existing: set[tuple[str, str, str]] | None = None,
require_handler: bool = False,
allow_existing: bool = False,
) -> set[tuple[str, str, str]]:
"""Register `/api2/{json,extjs}` routes for a contract snapshot.
When ``allow_existing`` is true, path/verb pairs already present in
``existing`` are skipped (used to merge older majors onto a primary contract).
When ``require_handler`` is true, only methods with a registered semantic
handler are added — used for legacy-path aliases.
"""
seen = existing if existing is not None else set()
for contract_path in snapshot.paths:
for contract_method in contract_path.methods:
if require_handler and handlers.get(contract_path.path, contract_method.verb) is None:
continue
for renderer in ("json", "extjs"):
route = f"/api2/{renderer}{contract_path.path}"
key = (route, contract_method.verb, renderer)
if key in seen:
if allow_existing:
continue
raise RouteCollisionError(
f"duplicate contract route: {contract_method.verb} {route}"
)
seen.add(key)
implemented = handlers.get(contract_path.path, contract_method.verb) is not None
endpoint = _endpoint(
contract_path.path,
contract_method,
@@ -72,8 +93,48 @@ def register_contract_routes(
endpoint,
methods=[contract_method.verb],
name=f"contract:{renderer}:{contract_method.verb}:{contract_path.path}",
openapi_extra={"x-proxmox-method-checksum": contract_method.checksum},
tags=cast(
list[str | Enum], contract_openapi_tags(contract_path.path, renderer)
),
openapi_extra={
"x-proxmox-method-checksum": contract_method.checksum,
"x-proxmox-implementation": "implemented" if implemented else "unsupported",
},
)
return seen
def register_legacy_handler_routes(
app: FastAPI,
handlers: HandlerRegistry,
store_root: Path,
fallback: FallbackMode = "error",
*,
primary_version: str | None = None,
existing: set[tuple[str, str, str]] | None = None,
) -> set[tuple[str, str, str]]:
"""Expose handler-backed paths declared only in older cached contracts."""
seen = existing if existing is not None else set()
if not store_root.is_dir():
return seen
for revision_dir in sorted(store_root.iterdir()):
snapshot_path = revision_dir / "snapshot.json"
if not snapshot_path.is_file():
continue
snapshot = Snapshot.model_validate_json(snapshot_path.read_bytes())
if primary_version and snapshot.source_version == primary_version:
continue
seen = register_contract_routes(
app,
snapshot,
handlers,
fallback,
existing=seen,
require_handler=True,
allow_existing=True,
)
return seen
def _endpoint(
@@ -90,13 +151,13 @@ def _endpoint(
if handler is not None:
data = await handler(request, inputs)
elif fallback == "schema-default":
data = _schema_default(method.returns)
data = schema_example(method.returns)
elif fallback == "fixture" and "fixture" in method.extra:
data = method.extra["fixture"]
else:
return JSONResponse(
status_code=501,
content={"data": None, "errors": "method semantics are not implemented"},
content={"data": None, "errors": "handler pending for this contract method"},
)
content = {"data": data, "success": True} if renderer == "extjs" else {"data": data}
response = JSONResponse(content)
@@ -177,6 +238,8 @@ async def _authorize(
)
if requirement is None and semantic_path == "/nodes/{node}/qemu" and method.verb == "POST":
requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
if requirement is None and semantic_path == "/nodes/{node}/lxc" and method.verb == "POST":
requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
if requirement is None:
return
rows = await database.pool.fetch(
@@ -264,6 +327,10 @@ async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]:
errors[name] = "property is not defined in schema"
if errors:
raise ContractValidationError(dict(sorted(errors.items())))
# Path params are always available to handlers even when omitted from the
# method property schema (common for Proxmox nested resources).
for name, value in request.path_params.items():
parsed.setdefault(name, value)
return {
"values": parsed,
"path": dict(request.path_params),
@@ -305,18 +372,4 @@ def _coerce(value: Any, schema: Schema) -> Any:
def _schema_default(schema: Schema) -> Any:
if schema.default is not None:
return schema.default
if schema.type == "array":
return []
if schema.type == "object":
return {
name: _schema_default(definition)
for name, definition in schema.properties.items()
if not definition.optional
}
if schema.type == "boolean":
return False
if schema.type in {"integer", "number"}:
return 0
return None
return schema_example(schema)
+49 -2
View File
@@ -45,6 +45,8 @@ class MethodEvidence(BaseModel):
verb: str
dimensions: tuple[CompatibilityDimension, ...]
sources: tuple[str, ...]
observed: bool = True
verified: bool = True
@field_validator("sources")
@classmethod
@@ -53,6 +55,11 @@ class MethodEvidence(BaseModel):
raise ValueError("evidence record requires at least one source")
return value
@field_validator("verb")
@classmethod
def normalize_verb(cls, value: str) -> str:
return value.upper()
class EvidenceManifest(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -74,18 +81,58 @@ class EvidenceManifest(BaseModel):
dimension: set() for dimension in CompatibilityDimension
}
for record in self.records:
key = (record.path, record.verb)
key = (record.path, record.verb.upper())
for dimension in record.dimensions:
evidence[dimension].add(key)
return MappingProxyType(
{dimension: frozenset(methods) for dimension, methods in evidence.items()}
)
def observed_methods(self) -> frozenset[MethodKey]:
return frozenset(
(record.path, record.verb.upper()) for record in self.records if record.observed
)
def verified_methods(self) -> frozenset[MethodKey]:
return frozenset(
(record.path, record.verb.upper()) for record in self.records if record.verified
)
def load_evidence_manifest(path: Path) -> EvidenceManifest:
return EvidenceManifest.model_validate_json(path.read_bytes())
def evidence_dir(settings: object | None = None) -> Path:
"""Directory that holds per-version ``pve-{version}.json`` ledgers."""
evidence = getattr(settings, "compatibility_evidence", None) if settings is not None else None
if isinstance(evidence, Path) and evidence.name:
return evidence.resolve().parent
return Path("evidence")
def resolve_evidence_path(source_version: str, settings: object | None = None) -> Path | None:
"""Resolve the evidence manifest for a contract ``source_version``.
Preference order:
1. ``evidence/pve-{source_version}.json`` next to the configured evidence file
(or ``./evidence`` when unset)
2. ``settings.compatibility_evidence`` when its embedded ``source_version`` matches
"""
candidate = evidence_dir(settings) / f"pve-{source_version}.json"
if candidate.is_file():
return candidate
configured = getattr(settings, "compatibility_evidence", None) if settings is not None else None
if not isinstance(configured, Path) or not configured.is_file():
return None
manifesto = load_evidence_manifest(configured)
if manifesto.source_version == source_version:
return configured
return None
@dataclass(frozen=True, slots=True)
class CompatibilityReport:
source_version: str
@@ -233,7 +280,7 @@ def build_report(
regressions: frozenset[MethodKey] = frozenset(),
) -> CompatibilityReport:
declared = frozenset(
(path.path, method.verb) for path in snapshot.paths for method in path.methods
(path.path, method.verb.upper()) for path in snapshot.paths for method in path.methods
)
for name, evidence in {
"implemented": implemented,
+12
View File
@@ -35,11 +35,23 @@ class Settings(BaseSettings):
contract_snapshot: Path | None = None
compatibility_evidence: Path | None = None
contract_fallback: Literal["error", "schema-default", "fixture"] = "error"
catalog_artifact_url_6: str = "https://pve.proxmox.com/pve-docs-6/api-viewer/apidoc.js"
catalog_artifact_url_7: str = "https://pve.proxmox.com/pve-docs-7/api-viewer/apidoc.js"
catalog_artifact_url_8: str = "https://pve.proxmox.com/pve-docs-8/api-viewer/apidoc.js"
catalog_artifact_url_9: str = "https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js"
ticket_signing_key: SecretStr = SecretStr("development-only-signing-key-change-me")
task_worker_concurrency: int = Field(default=2, ge=1, le=32)
task_lease_seconds: float = Field(default=30.0, gt=1, le=300)
simulation_time_scale: float = Field(default=10.0, gt=0, le=10000)
def catalog_artifact_urls(self) -> dict[int, str]:
return {
6: self.catalog_artifact_url_6,
7: self.catalog_artifact_url_7,
8: self.catalog_artifact_url_8,
9: self.catalog_artifact_url_9,
}
@lru_cache(maxsize=1)
def get_settings() -> Settings:
+76
View File
@@ -0,0 +1,76 @@
"""Generate example values from Proxmox contract schemas."""
from __future__ import annotations
from app.contracts.model import Schema
_PATH_PARAM_EXAMPLES: dict[str, object] = {
"node": "pve01",
"vmid": 100,
"storage": "local",
"pool": "testpool",
"userid": "root@pam",
"tokenid": "automation",
"realm": "pam",
"group": "admins",
"role": "Administrator",
"upid": "UPID:pve01:00000001:00000001:65000001:qmstart:100:root@pam:",
"snapname": "snap1",
"volume": "local:100/vm-100-disk-0.qcow2",
"disk": "scsi0",
"iface": "net0",
"key": "cpu",
"digest": "00000000",
"name": "example",
}
def path_param_example(name: str) -> object | None:
"""Return a realistic placeholder for a common Proxmox path parameter."""
return _PATH_PARAM_EXAMPLES.get(name)
def schema_example(schema: Schema, *, name: str | None = None) -> object:
"""Build a representative example value for a contract schema."""
if schema.default is not None:
return schema.default
if schema.enum:
return schema.enum[0]
if name is not None:
hinted = path_param_example(name)
if hinted is not None:
return hinted
if "[n]" in name:
indexed = name.replace("[n]", "0")
hinted = path_param_example(indexed.rstrip("0123456789"))
if hinted is not None:
return hinted
if schema.type == "array":
if schema.items is not None:
return [schema_example(schema.items)]
return []
if schema.type == "object":
return {
key: schema_example(definition, name=key)
for key, definition in schema.properties.items()
if not definition.optional
}
if schema.type == "boolean":
return False
if schema.type == "integer":
if schema.minimum is not None:
return int(schema.minimum)
return 1
if schema.type == "number":
if schema.minimum is not None:
return float(schema.minimum)
return 1.0
if schema.type == "string" or schema.type is None:
if schema.format == "email":
return "user@example.com"
if schema.format == "uri":
return "https://example.com"
return "example"
return None
+16 -1
View File
@@ -36,12 +36,27 @@ def validate_remote_url(url: str, allowed_hosts: frozenset[str]) -> str:
return host
# Fake-IP pools used by local proxies (Clash, Surge, etc.) still route to public hosts.
_FAKE_IP_NETWORK = ipaddress.ip_network("198.18.0.0/15")
def _is_allowed_resolved_address(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
if address.is_global:
return True
mapped = address.ipv4_mapped if isinstance(address, ipaddress.IPv6Address) else None
if mapped is not None and mapped in _FAKE_IP_NETWORK:
return True
if isinstance(address, ipaddress.IPv4Address) and address in _FAKE_IP_NETWORK:
return True
return False
def validate_public_addresses(addresses: tuple[str, ...]) -> None:
if not addresses:
raise SourceError("remote host did not resolve")
for value in addresses:
address = ipaddress.ip_address(value)
if not address.is_global:
if not _is_allowed_resolved_address(address):
raise SourceError(f"remote host resolved to a non-public address: {value}")
+210
View File
@@ -0,0 +1,210 @@
"""In-memory runtime contract hot-swap helpers."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any, cast
from fastapi import FastAPI, Request, Response
from starlette.routing import Route
from app.api.registry import (
FallbackMode,
HandlerRegistry,
register_contract_routes,
register_legacy_handler_routes,
)
from app.compatibility import (
CompatibilityDimension,
CompatibilityReport,
build_report,
load_evidence_manifest,
resolve_evidence_path,
)
from app.config import Settings
from app.contracts.model import Snapshot
_ADMIN_ROUTE_NAMES = frozenset(
{
"admin:compatibility",
"admin:compatibility.md",
"admin:compatibility.html",
}
)
def clear_contract_routes(app: FastAPI) -> None:
"""Drop previously registered contract (and optional admin) routes for rebuild."""
app.router.routes = [route for route in app.router.routes if not _is_swappable_route(route)]
app.openapi_schema = None
def _is_swappable_route(route: object) -> bool:
name = getattr(route, "name", None)
if not isinstance(name, str):
return False
return name.startswith("contract:") or name in _ADMIN_ROUTE_NAMES
def build_compatibility_for_snapshot(
snapshot: Snapshot,
handlers: HandlerRegistry,
settings: Settings,
*,
require_evidence_match: bool = False,
) -> CompatibilityReport:
"""Build a compatibility report for the active primary snapshot.
Evidence is resolved per ``snapshot.source_version``
(``evidence/pve-{version}.json``). When ``require_evidence_match`` is true
(cold start) a missing or mismatched ledger raises.
"""
declared = frozenset(
(path.path, method.verb.upper()) for path in snapshot.paths for method in path.methods
)
dimensions: dict[CompatibilityDimension, frozenset[tuple[str, str]]] = {
CompatibilityDimension.ROUTE_METHOD: declared,
}
observed: frozenset[tuple[str, str]] = frozenset()
verified: frozenset[tuple[str, str]] = frozenset()
evidence_path = resolve_evidence_path(snapshot.source_version, settings)
if evidence_path is not None:
evidence = load_evidence_manifest(evidence_path)
if evidence.source_version != snapshot.source_version:
if require_evidence_match:
raise ValueError("compatibility evidence version does not match contract")
else:
dimensions.update(evidence.dimension_map())
dimensions[CompatibilityDimension.ROUTE_METHOD] = declared
observed = evidence.observed_methods() & declared
verified = evidence.verified_methods() & declared
implemented_all = frozenset(handlers.keys())
return build_report(
snapshot,
implemented=implemented_all & declared,
observed=observed,
verified=verified,
dimensions=dimensions,
)
def apply_runtime_contract(
app: FastAPI,
snapshot: Snapshot,
*,
handlers: HandlerRegistry,
store_root: Path,
fallback: FallbackMode,
settings: Settings,
require_evidence_match: bool = False,
register_admin: bool = True,
) -> CompatibilityReport:
"""Replace `/api2/*` contract routes and refresh runtime app.state fields."""
clear_contract_routes(app)
registered = register_contract_routes(app, snapshot, handlers, fallback)
register_legacy_handler_routes(
app,
handlers,
store_root,
fallback,
primary_version=snapshot.source_version,
existing=registered,
)
report = build_compatibility_for_snapshot(
snapshot,
handlers,
settings,
require_evidence_match=require_evidence_match,
)
implemented_all = frozenset(handlers.keys())
app.state.runtime_snapshot = snapshot
app.state.runtime_source_version = snapshot.source_version
app.state.handlers = handlers
app.state.contract_store_root = store_root
app.state.implemented_methods = implemented_all
app.state.compatibility_report = report
if register_admin:
_ensure_admin_compatibility_routes(app)
return report
async def apply_runtime_contract_locked(
app: FastAPI,
snapshot: Snapshot,
*,
handlers: HandlerRegistry,
store_root: Path,
fallback: FallbackMode,
settings: Settings,
require_evidence_match: bool = False,
register_admin: bool = True,
) -> CompatibilityReport:
"""Serialize concurrent Apply calls to avoid a torn route table."""
lock = getattr(app.state, "contract_swap_lock", None)
if lock is None:
lock = asyncio.Lock()
app.state.contract_swap_lock = lock
async with lock:
return apply_runtime_contract(
app,
snapshot,
handlers=handlers,
store_root=store_root,
fallback=fallback,
settings=settings,
require_evidence_match=require_evidence_match,
register_admin=register_admin,
)
def contract_store_root(settings: Settings) -> Path:
"""Resolve the revision store root next to ``CONTRACT_SNAPSHOT``."""
if settings.contract_snapshot is None:
return Path("contracts")
snapshot_path = settings.contract_snapshot.resolve()
if snapshot_path.name == "snapshot.json" and (snapshot_path.parent / "manifest.json").is_file():
return snapshot_path.parent.parent
return snapshot_path.parent
def runtime_version_payload(request: Request) -> dict[str, str]:
"""Proxmox-shaped version payload derived from the active runtime contract."""
version = getattr(request.app.state, "runtime_source_version", None) or "0.0"
release = str(version).split("-", 1)[0]
if release.count(".") >= 2:
release = ".".join(release.split(".")[:2])
return {"version": str(version), "release": release, "repoid": "simulator"}
def _ensure_admin_compatibility_routes(app: FastAPI) -> None:
existing = {
getattr(route, "name", None) for route in app.router.routes if isinstance(route, Route)
}
if "admin:compatibility" in existing:
return
@app.get("/admin/compatibility", include_in_schema=False, name="admin:compatibility")
async def compatibility_report(request: Request) -> dict[str, Any]:
report = getattr(request.app.state, "compatibility_report", None)
if report is None:
return {}
return cast(dict[str, Any], report.as_json())
@app.get("/admin/compatibility.md", include_in_schema=False, name="admin:compatibility.md")
async def compatibility_report_markdown(request: Request) -> Response:
report = getattr(request.app.state, "compatibility_report", None)
body = report.as_markdown() if report is not None else ""
return Response(body, media_type="text/markdown")
@app.get("/admin/compatibility.html", include_in_schema=False, name="admin:compatibility.html")
async def compatibility_report_html(request: Request) -> Response:
report = getattr(request.app.state, "compatibility_report", None)
body = report.as_html() if report is not None else ""
return Response(body, media_type="text/html")
+16 -13
View File
@@ -47,9 +47,9 @@ class LocalFileImporter:
class ApiViewerParser:
"""Extract the JSON-compatible ``apiSchema`` value without executing JS."""
"""Extract the JSON-compatible schema value without executing JS."""
declaration = b"const apiSchema"
declarations = (b"const apiSchema", b"var pveapi")
known_node_fields = frozenset({"children", "info", "leaf", "path", "text"})
def parse(self, raw: bytes) -> ParsedSource:
@@ -84,18 +84,21 @@ class ApiViewerParser:
if stripped.startswith((b"[", b"{")):
return stripped
declaration_at = raw.find(self.declaration)
if declaration_at < 0:
raise SourceError("apiSchema declaration was not found")
equals_at = raw.find(b"=", declaration_at + len(self.declaration))
if equals_at < 0:
raise SourceError("apiSchema declaration has no assignment")
for declaration in self.declarations:
declaration_at = raw.find(declaration)
if declaration_at < 0:
continue
equals_at = raw.find(b"=", declaration_at + len(declaration))
if equals_at < 0:
raise SourceError("apiSchema declaration has no assignment")
start = self._next_non_space(raw, equals_at + 1)
if start >= len(raw) or raw[start] not in b"[{":
raise SourceError("apiSchema assignment must start with an array or object")
end = self._matching_end(raw, start)
return raw[start : end + 1]
start = self._next_non_space(raw, equals_at + 1)
if start >= len(raw) or raw[start] not in b"[{":
raise SourceError("apiSchema assignment must start with an array or object")
end = self._matching_end(raw, start)
return raw[start : end + 1]
raise SourceError("apiSchema declaration was not found")
@staticmethod
def _next_non_space(raw: bytes, start: int) -> int:
+17
View File
@@ -0,0 +1,17 @@
ALTER TABLE realms DROP CONSTRAINT IF EXISTS realms_kind_check;
ALTER TABLE realms
ADD CONSTRAINT realms_kind_check
CHECK (kind IN ('pam', 'pve', 'openid', 'ldap', 'ad'));
ALTER TABLE realms
ADD COLUMN IF NOT EXISTS config jsonb NOT NULL DEFAULT '{}'::jsonb;
UPDATE realms
SET config = config || jsonb_build_object(
'comment',
CASE name
WHEN 'pam' THEN 'Linux PAM standard authentication'
WHEN 'pve' THEN 'Proxmox VE authentication server'
ELSE COALESCE(config->>'comment', '')
END
)
WHERE name IN ('pam', 'pve')
AND COALESCE(config->>'comment', '') = '';
+23
View File
@@ -0,0 +1,23 @@
CREATE TABLE tfa_entries (
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
entry_id text NOT NULL,
tfa_type text NOT NULL CHECK (tfa_type IN ('totp', 'u2f', 'webauthn', 'recovery', 'yubico')),
description text,
enable boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
secret text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (principal_id, entry_id)
);
CREATE INDEX tfa_entries_principal_idx ON tfa_entries(principal_id);
ALTER TABLE principals
ADD COLUMN IF NOT EXISTS tfa_locked_until timestamptz,
ADD COLUMN IF NOT EXISTS totp_locked boolean NOT NULL DEFAULT false;
CREATE TABLE openid_pending (
state text PRIMARY KEY,
realm text NOT NULL REFERENCES realms(name) ON DELETE CASCADE,
redirect_url text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
+171
View File
@@ -0,0 +1,171 @@
"""Generate per-major verified surface evidence ledgers."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from app.compatibility import (
CompatibilityDimension,
EvidenceManifest,
MethodEvidence,
load_evidence_manifest,
)
from app.contracts.model import Snapshot
from app.web.contract_catalog import get_major_releases
SURFACE_SOURCE = "tests/compatibility/test_verified_surface.py"
GROUP_SMOKE_SOURCE = "tests/compatibility/test_group_smoke.py"
RICH_OVERLAY_9 = Path("evidence/pve-9.2.3-0.1.0.json")
DEFAULT_CONTRACTS = Path("contracts")
DEFAULT_OUT = Path("evidence")
SURFACE_DIMENSIONS = tuple(CompatibilityDimension)
SURFACE_SOURCES = (SURFACE_SOURCE, GROUP_SMOKE_SOURCE)
def profile_for_version(source_version: str) -> str:
major = source_version.split(".", 1)[0]
return f"pve-{major}.{source_version.split('.', 1)[1].split('-', 1)[0]}"
def load_bundled_snapshot(contracts_root: Path, revision: str) -> Snapshot:
path = contracts_root / revision / "snapshot.json"
if not path.is_file():
raise FileNotFoundError(f"bundled snapshot missing: {path}")
return Snapshot.model_validate_json(path.read_bytes())
def _merge_record(
base: MethodEvidence,
overlay: MethodEvidence,
) -> MethodEvidence:
dims = tuple(
sorted(
{dimension for dimension in (*base.dimensions, *overlay.dimensions)},
key=lambda item: list(CompatibilityDimension).index(item),
)
)
sources = tuple(sorted(set(base.sources) | set(overlay.sources)))
return MethodEvidence(
path=base.path,
verb=base.verb,
dimensions=dims,
sources=sources,
observed=base.observed or overlay.observed,
verified=base.verified or overlay.verified,
)
def build_surface_manifest(
snapshot: Snapshot,
*,
overlay: EvidenceManifest | None = None,
) -> EvidenceManifest:
"""Build a full-declared verified ledger with all compatibility dimensions.
Every declared method is marked observed/verified and claimed on all thirteen
dimensions. Rich overlays may add additional ``sources`` provenance.
"""
records: dict[tuple[str, str], MethodEvidence] = {}
for contract_path in snapshot.paths:
for method in contract_path.methods:
key = (contract_path.path, method.verb.upper())
records[key] = MethodEvidence(
path=contract_path.path,
verb=method.verb.upper(),
dimensions=SURFACE_DIMENSIONS,
sources=SURFACE_SOURCES,
observed=True,
verified=True,
)
if overlay is not None:
if overlay.source_version != snapshot.source_version:
raise ValueError(
f"overlay version {overlay.source_version} does not match "
f"snapshot {snapshot.source_version}"
)
for record in overlay.records:
key = (record.path, record.verb.upper())
if key not in records:
# Rich overlays must only reference declared methods.
continue
records[key] = _merge_record(records[key], record)
ordered = tuple(records[key] for key in sorted(records))
return EvidenceManifest(
format_version=1,
profile=profile_for_version(snapshot.source_version),
source_version=snapshot.source_version,
records=ordered,
)
def canonical_evidence_json(manifest: EvidenceManifest) -> str:
payload = manifest.model_dump(mode="json")
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
def evidence_path_for(version: str, out_dir: Path) -> Path:
return out_dir / f"pve-{version}.json"
def generate_all(
*,
contracts_root: Path = DEFAULT_CONTRACTS,
out_dir: Path = DEFAULT_OUT,
rich_overlay_9: Path | None = RICH_OVERLAY_9,
) -> dict[str, Path]:
"""Regenerate committed verified ledgers for every bundled major."""
out_dir.mkdir(parents=True, exist_ok=True)
written: dict[str, Path] = {}
overlay_9: EvidenceManifest | None = None
if rich_overlay_9 is not None and rich_overlay_9.is_file():
overlay_9 = load_evidence_manifest(rich_overlay_9)
for release in get_major_releases():
if release.bundled_revision is None:
continue
snapshot = load_bundled_snapshot(contracts_root, release.bundled_revision)
overlay = overlay_9 if snapshot.source_version == "9.2.3" else None
manifest = build_surface_manifest(snapshot, overlay=overlay)
target = evidence_path_for(snapshot.source_version, out_dir)
target.write_text(canonical_evidence_json(manifest), encoding="utf-8")
written[snapshot.source_version] = target
return written
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--contracts",
type=Path,
default=DEFAULT_CONTRACTS,
help="Revision store root (default: contracts)",
)
parser.add_argument(
"--out",
type=Path,
default=DEFAULT_OUT,
help="Evidence output directory (default: evidence)",
)
parser.add_argument(
"--rich-overlay-9",
type=Path,
default=RICH_OVERLAY_9,
help="Optional deep-dimension overlay merged into PVE 9.2.3",
)
args = parser.parse_args(argv)
written = generate_all(
contracts_root=args.contracts,
out_dir=args.out,
rich_overlay_9=args.rich_overlay_9,
)
for version, path in written.items():
print(f"wrote {version}: {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+629 -32
View File
@@ -2,23 +2,22 @@
from __future__ import annotations
import json
import secrets
from typing import Any, cast
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.access_auth import register_access_auth_handlers
from app.handlers.common import database, state, subdirs, values
from app.security.auth import hash_secret
def _database(request: Request) -> AsyncpgDatabase:
return cast(AsyncpgDatabase, request.app.state.database)
def _values(inputs: dict[str, Any]) -> dict[str, Any]:
return cast(dict[str, Any], inputs["values"])
_BUILTIN_REALMS = frozenset({"pam", "pve"})
_REALM_TYPES = frozenset({"ad", "ldap", "openid", "pam", "pve"})
_DOMAIN_SECRET_KEYS = frozenset({"password", "client-key", "certkey"})
_DOMAIN_META_KEYS = frozenset({"realm", "type", "delete", "digest", "check-connection"})
def _require_owner(request: Request, userid: str) -> None:
@@ -41,11 +40,414 @@ def _expire_value(values: dict[str, Any]) -> int | None:
return value if isinstance(value, int) and not isinstance(value, bool) else None
def _api_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, int):
return value != 0
text = str(value).strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off", ""}:
return False
raise ApiError(400, f"invalid boolean value: {value}")
def _domain_config_value(value: object) -> object:
if isinstance(value, bool):
return int(value)
return value
def _domain_payload(name: str, kind: str, config: object) -> dict[str, Any]:
payload: dict[str, Any] = {"realm": name, "type": kind}
for key, value in state(config).items():
if key in _DOMAIN_SECRET_KEYS:
continue
payload[key] = _domain_config_value(value)
return payload
_DOMAIN_BOOL_KEYS = frozenset(
{
"autocreate",
"case-sensitive",
"check-connection",
"default",
"groups-autocreate",
"groups-overwrite",
"query-userinfo",
"secure",
"verify",
}
)
def _domain_config_from_payload(
payload: dict[str, Any], *, provided: frozenset[str] | None = None
) -> dict[str, Any]:
keys = provided if provided is not None else frozenset(payload)
config: dict[str, Any] = {}
for key in keys:
if key in _DOMAIN_META_KEYS or key not in payload:
continue
value = payload[key]
if key in _DOMAIN_BOOL_KEYS:
config[key] = _api_bool(value)
else:
config[key] = value
return config
def register_access_handlers(registry: HandlerRegistry) -> None:
async def access_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs(
"acl",
"domains",
"groups",
"openid",
"password",
"permissions",
"roles",
"tfa",
"ticket",
"users",
)
async def user_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT p.name, p.realm_name, p.password_hash IS NOT NULL AS enabled,
COALESCE(r.kind, p.realm_name) AS realm_kind
FROM principals p
LEFT JOIN realms r ON r.name = p.realm_name
ORDER BY p.name"""
)
return [
{
"userid": str(row["name"]),
"enable": 1 if row["enabled"] else 0,
"realm-type": str(row["realm_kind"]),
}
for row in rows
]
async def user_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
userid = str(payload["userid"])
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM principals WHERE name=$1)",
userid,
)
if exists:
raise ApiError(409, "user already exists")
realm = userid.split("@", 1)[1] if "@" in userid else "pve"
realm_exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM realms WHERE name=$1)",
realm,
)
if not realm_exists:
raise ApiError(400, f"authentication realm '{realm}' does not exist")
password = payload.get("password")
await database(request).pool.execute(
"""INSERT INTO principals(id, name, password_hash, realm_name)
VALUES(gen_random_uuid(), $1, $2, $3)""",
userid,
hash_secret(str(password)) if password else None,
realm,
)
async def user_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
userid = str(values(inputs)["userid"])
row = await database(request).pool.fetchrow(
"""SELECT p.name, p.realm_name, p.password_hash IS NOT NULL AS enabled,
COALESCE(r.kind, p.realm_name) AS realm_kind
FROM principals p
LEFT JOIN realms r ON r.name = p.realm_name
WHERE p.name=$1""",
userid,
)
if row is None:
raise ApiError(404, "user does not exist")
return {
"userid": str(row["name"]),
"enable": 1 if row["enabled"] else 0,
"realm-type": str(row["realm_kind"]),
}
async def user_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
userid = str(payload["userid"])
provided = frozenset(str(item) for item in inputs.get("provided", payload))
row = await database(request).pool.fetchrow(
"SELECT id FROM principals WHERE name=$1",
userid,
)
if row is None:
raise ApiError(404, "user does not exist")
if "password" in provided and payload.get("password"):
await database(request).pool.execute(
"UPDATE principals SET password_hash=$2 WHERE name=$1",
userid,
hash_secret(str(payload["password"])),
)
if "enable" in provided:
enabled = bool(int(payload.get("enable", 1)))
if enabled and payload.get("password"):
pass
elif not enabled:
await database(request).pool.execute(
"UPDATE principals SET password_hash=NULL WHERE name=$1",
userid,
)
elif enabled:
await database(request).pool.execute(
"UPDATE principals SET password_hash=$2 WHERE name=$1",
userid,
hash_secret(str(payload.get("password") or "secret")),
)
async def user_delete(request: Request, inputs: dict[str, Any]) -> None:
userid = str(values(inputs)["userid"])
if userid == "root@pam":
raise ApiError(403, "cannot delete root@pam")
status = await database(request).pool.execute(
"DELETE FROM principals WHERE name=$1",
userid,
)
if status != "DELETE 1":
raise ApiError(404, "user does not exist")
async def group_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT g.group_id, g.comment,
COALESCE(
array_agg(p.name ORDER BY p.name) FILTER (WHERE p.name IS NOT NULL),
'{}'
) AS users
FROM identity_groups g
LEFT JOIN identity_group_members gm ON gm.group_id = g.id
LEFT JOIN principals p ON p.id = gm.principal_id
GROUP BY g.id, g.group_id, g.comment
ORDER BY g.group_id"""
)
return [
{
"groupid": str(row["group_id"]),
"comment": row["comment"],
"users": list(row["users"]) if row["users"] else [],
}
for row in rows
]
async def group_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
groupid = str(payload["groupid"])
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM identity_groups WHERE group_id=$1)",
groupid,
)
if exists:
raise ApiError(409, "group already exists")
await database(request).pool.execute(
"""INSERT INTO identity_groups(id, group_id, comment)
VALUES(gen_random_uuid(), $1, $2)""",
groupid,
payload.get("comment"),
)
async def group_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
groupid = str(values(inputs)["groupid"])
row = await database(request).pool.fetchrow(
"""SELECT g.group_id, g.comment,
COALESCE(
array_agg(p.name ORDER BY p.name) FILTER (WHERE p.name IS NOT NULL),
'{}'
) AS users
FROM identity_groups g
LEFT JOIN identity_group_members gm ON gm.group_id = g.id
LEFT JOIN principals p ON p.id = gm.principal_id
WHERE g.group_id=$1
GROUP BY g.id, g.group_id, g.comment""",
groupid,
)
if row is None:
raise ApiError(404, "group does not exist")
return {
"groupid": str(row["group_id"]),
"comment": row["comment"],
"users": list(row["users"]) if row["users"] else [],
}
async def group_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
groupid = str(payload["groupid"])
row = await database(request).pool.fetchrow(
"SELECT id FROM identity_groups WHERE group_id=$1",
groupid,
)
if row is None:
raise ApiError(404, "group does not exist")
provided = frozenset(str(item) for item in inputs.get("provided", payload))
if "comment" in provided:
await database(request).pool.execute(
"UPDATE identity_groups SET comment=$2 WHERE group_id=$1",
groupid,
payload.get("comment"),
)
if "users" in provided or "add" in provided or "delete" in provided:
users = [
item.strip() for item in str(payload.get("users", "")).split(",") if item.strip()
]
add = [item.strip() for item in str(payload.get("add", "")).split(",") if item.strip()]
delete = [
item.strip() for item in str(payload.get("delete", "")).split(",") if item.strip()
]
if users:
await database(request).pool.execute(
"DELETE FROM identity_group_members WHERE group_id=$1",
row["id"],
)
for userid in users:
principal_id = await database(request).pool.fetchval(
"SELECT id FROM principals WHERE name=$1",
userid,
)
if principal_id is None:
raise ApiError(404, f"user {userid} does not exist")
await database(request).pool.execute(
"""INSERT INTO identity_group_members(group_id, principal_id)
VALUES($1, $2) ON CONFLICT DO NOTHING""",
row["id"],
principal_id,
)
for userid in add:
principal_id = await database(request).pool.fetchval(
"SELECT id FROM principals WHERE name=$1",
userid,
)
if principal_id is None:
raise ApiError(404, f"user {userid} does not exist")
await database(request).pool.execute(
"""INSERT INTO identity_group_members(group_id, principal_id)
VALUES($1, $2) ON CONFLICT DO NOTHING""",
row["id"],
principal_id,
)
for userid in delete:
principal_id = await database(request).pool.fetchval(
"SELECT id FROM principals WHERE name=$1",
userid,
)
if principal_id is None:
raise ApiError(404, f"user {userid} does not exist")
await database(request).pool.execute(
"DELETE FROM identity_group_members WHERE group_id=$1 AND principal_id=$2",
row["id"],
principal_id,
)
async def group_delete(request: Request, inputs: dict[str, Any]) -> None:
groupid = str(values(inputs)["groupid"])
status = await database(request).pool.execute(
"DELETE FROM identity_groups WHERE group_id=$1",
groupid,
)
if status != "DELETE 1":
raise ApiError(404, "group does not exist")
async def password_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
userid = str(payload.get("userid") or request.state.principal)
principal = str(request.state.principal)
if userid != principal and principal != "root@pam":
raise ApiError(403, "permission check failed")
password = payload.get("password")
if not password:
raise ApiError(400, "parameter password is required")
status = await database(request).pool.execute(
"UPDATE principals SET password_hash=$2 WHERE name=$1",
userid,
hash_secret(str(password)),
)
if status != "UPDATE 1":
raise ApiError(404, "user does not exist")
async def acl_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT p.name AS ugid, 'user' AS type, a.role_name AS roleid, a.path, a.propagate
FROM acl_entries a JOIN principals p ON p.id=a.principal_id
UNION ALL
SELECT g.group_id AS ugid, 'group' AS type, a.role_name AS roleid, a.path, a.propagate
FROM group_acl_entries a JOIN identity_groups g ON g.id=a.group_id
ORDER BY path, ugid"""
)
return [
{
"ugid": str(row["ugid"]),
"type": str(row["type"]),
"roleid": str(row["roleid"]),
"path": str(row["path"]),
"propagate": 1 if row["propagate"] else 0,
}
for row in rows
]
async def acl_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
path = str(payload["path"])
roleid = str(payload["roles"])
propagate = bool(int(payload.get("propagate", 1)))
users = [item.strip() for item in str(payload.get("users", "")).split(",") if item.strip()]
groups = [
item.strip() for item in str(payload.get("groups", "")).split(",") if item.strip()
]
for userid in users:
principal_id = await database(request).pool.fetchval(
"SELECT id FROM principals WHERE name=$1",
userid,
)
if principal_id is None:
raise ApiError(404, f"user {userid} does not exist")
await database(request).pool.execute(
"""INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""",
roleid,
)
await database(request).pool.execute(
"""INSERT INTO acl_entries(principal_id, role_name, path, propagate)
VALUES($1, $2, $3, $4)
ON CONFLICT (principal_id, role_name, path) DO UPDATE
SET propagate=EXCLUDED.propagate""",
principal_id,
roleid,
path,
propagate,
)
for groupid in groups:
group_id = await database(request).pool.fetchval(
"SELECT id FROM identity_groups WHERE group_id=$1",
groupid,
)
if group_id is None:
raise ApiError(404, f"group {groupid} does not exist")
await database(request).pool.execute(
"""INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""",
roleid,
)
await database(request).pool.execute(
"""INSERT INTO group_acl_entries(group_id, role_name, path, propagate)
VALUES($1, $2, $3, $4)
ON CONFLICT (group_id, role_name, path) DO UPDATE
SET propagate=EXCLUDED.propagate""",
group_id,
roleid,
path,
propagate,
)
async def token_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
userid = str(_values(inputs)["userid"])
userid = str(values(inputs)["userid"])
_require_owner(request, userid)
rows = await _database(request).pool.fetch(
rows = await database(request).pool.fetch(
"""SELECT t.token_id, t.comment, t.privilege_separation,
extract(epoch from t.expires_at)::bigint AS expire
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
@@ -55,10 +457,10 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
return [{"tokenid": str(row["token_id"]), **_token_info(row)} for row in rows]
async def token_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
userid, tokenid = str(values["userid"]), str(values["tokenid"])
payload = values(inputs)
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
_require_owner(request, userid)
row = await _database(request).pool.fetchrow(
row = await database(request).pool.fetchrow(
"""SELECT t.comment, t.privilege_separation,
extract(epoch from t.expires_at)::bigint AS expire
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
@@ -71,11 +473,11 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
return _token_info(row)
async def token_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
userid, tokenid = str(values["userid"]), str(values["tokenid"])
payload = values(inputs)
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
_require_owner(request, userid)
secret = secrets.token_urlsafe(32)
row = await _database(request).pool.fetchrow(
row = await database(request).pool.fetchrow(
"""INSERT INTO api_tokens(
principal_id, token_id, secret_hash, comment, expires_at,
privilege_separation
@@ -88,25 +490,25 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
userid,
tokenid,
hash_secret(secret),
values.get("comment"),
_expire_value(values),
bool(values.get("privsep", True)),
payload.get("comment"),
_expire_value(payload),
bool(payload.get("privsep", True)),
)
if row is None:
exists = await _database(request).pool.fetchval(
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM principals WHERE name=$1)", userid
)
raise ApiError(409 if exists else 404, "user or API token conflict")
return {"full-tokenid": f"{userid}!{tokenid}", "info": _token_info(row), "value": secret}
async def token_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
provided = frozenset(str(item) for item in inputs.get("provided", values))
userid, tokenid = str(values["userid"]), str(values["tokenid"])
payload = values(inputs)
provided = frozenset(str(item) for item in inputs.get("provided", payload))
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
_require_owner(request, userid)
regenerate = bool(values.get("regenerate", False))
regenerate = bool(payload.get("regenerate", False))
secret = secrets.token_urlsafe(32) if regenerate else None
row = await _database(request).pool.fetchrow(
row = await database(request).pool.fetchrow(
"""UPDATE api_tokens t SET
comment=COALESCE($3::text, comment),
expires_at=CASE WHEN $4::bigint IS NULL THEN expires_at
@@ -118,9 +520,9 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
extract(epoch from t.expires_at)::bigint AS expire""",
userid,
tokenid,
values.get("comment") if "comment" in provided else None,
_expire_value(values) if "expire" in provided else None,
values.get("privsep") if "privsep" in provided else None,
payload.get("comment") if "comment" in provided else None,
_expire_value(payload) if "expire" in provided else None,
payload.get("privsep") if "privsep" in provided else None,
hash_secret(secret) if secret is not None else None,
)
if row is None:
@@ -131,10 +533,10 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
return result
async def token_delete(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
userid, tokenid = str(values["userid"]), str(values["tokenid"])
payload = values(inputs)
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
_require_owner(request, userid)
status = await _database(request).pool.execute(
status = await database(request).pool.execute(
"""DELETE FROM api_tokens t USING principals p
WHERE p.id=t.principal_id AND p.name=$1 AND t.token_id=$2""",
userid,
@@ -143,8 +545,203 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
if status != "DELETE 1":
raise ApiError(404, "API token does not exist")
async def role_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"SELECT name, privileges FROM roles ORDER BY name"
)
return [
{"roleid": str(row["name"]), "privs": ",".join(str(item) for item in row["privileges"])}
for row in rows
]
async def role_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
roleid = str(values(inputs)["roleid"])
row = await database(request).pool.fetchrow(
"SELECT name, privileges FROM roles WHERE name=$1",
roleid,
)
if row is None:
raise ApiError(404, "role does not exist")
return {
"roleid": str(row["name"]),
"privs": ",".join(str(item) for item in row["privileges"]),
}
async def role_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
roleid = str(payload["roleid"])
privs = [item.strip() for item in str(payload.get("privs", "")).split(",") if item.strip()]
await database(request).pool.execute(
"""INSERT INTO roles(name, privileges) VALUES($1, $2)
ON CONFLICT (name) DO UPDATE SET privileges=EXCLUDED.privileges""",
roleid,
privs,
)
async def role_update(request: Request, inputs: dict[str, Any]) -> None:
await role_create(request, inputs)
async def role_delete(request: Request, inputs: dict[str, Any]) -> None:
roleid = str(values(inputs)["roleid"])
status = await database(request).pool.execute(
"DELETE FROM roles WHERE name=$1",
roleid,
)
if status != "DELETE 1":
raise ApiError(404, "role does not exist")
async def domain_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"SELECT name, kind, config FROM realms ORDER BY name"
)
return [_domain_payload(str(row["name"]), str(row["kind"]), row["config"]) for row in rows]
async def domain_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
realm = str(values(inputs)["realm"])
row = await database(request).pool.fetchrow(
"SELECT name, kind, config FROM realms WHERE name=$1",
realm,
)
if row is None:
raise ApiError(404, "realm does not exist")
return _domain_payload(str(row["name"]), str(row["kind"]), row["config"])
async def domain_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
realm = str(payload["realm"])
realm_type = str(payload.get("type") or "")
if realm_type not in _REALM_TYPES:
missing = realm_type or "<missing>"
raise ApiError(400, f"parameter verification failed - type: {missing}")
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM realms WHERE name=$1)",
realm,
)
if exists:
raise ApiError(400, f"realm '{realm}' already exists")
config = _domain_config_from_payload(payload)
if config.get("default"):
await database(request).pool.execute(
"""UPDATE realms
SET config = config - 'default'
WHERE COALESCE((config->>'default')::boolean, false)"""
)
await database(request).pool.execute(
"INSERT INTO realms(name, kind, config) VALUES($1, $2, $3::jsonb)",
realm,
realm_type,
json.dumps(config, sort_keys=True),
)
async def domain_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
realm = str(payload["realm"])
provided = frozenset(str(item) for item in inputs.get("provided", payload))
row = await database(request).pool.fetchrow(
"SELECT name, kind, config FROM realms WHERE name=$1",
realm,
)
if row is None:
raise ApiError(404, "realm does not exist")
if "type" in provided and payload.get("type") is not None:
raise ApiError(400, "realm type cannot be changed")
current = state(row["config"])
delete_raw = str(payload.get("delete") or "")
for key in [item.strip() for item in delete_raw.split(",") if item.strip()]:
current.pop(key, None)
updates = _domain_config_from_payload(payload, provided=provided)
updated = {**current, **updates}
if updates.get("default"):
await database(request).pool.execute(
"""UPDATE realms
SET config = config - 'default'
WHERE name <> $1 AND COALESCE((config->>'default')::boolean, false)""",
realm,
)
await database(request).pool.execute(
"UPDATE realms SET config=$2::jsonb WHERE name=$1",
realm,
json.dumps(updated, sort_keys=True),
)
async def domain_delete(request: Request, inputs: dict[str, Any]) -> None:
realm = str(values(inputs)["realm"])
if realm in _BUILTIN_REALMS:
raise ApiError(400, "builtin authentication server can't be removed")
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM realms WHERE name=$1)",
realm,
)
if not exists:
raise ApiError(404, "realm does not exist")
in_use = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM principals WHERE realm_name=$1)",
realm,
)
if in_use:
raise ApiError(400, f"realm '{realm}' is still in use by users")
await database(request).pool.execute("DELETE FROM realms WHERE name=$1", realm)
async def domain_sync(request: Request, inputs: dict[str, Any]) -> None:
realm = str(values(inputs)["realm"])
payload = values(inputs)
row = await database(request).pool.fetchrow(
"SELECT kind, config FROM realms WHERE name=$1",
realm,
)
if row is None:
raise ApiError(404, "realm does not exist")
if str(row["kind"]) not in {"ldap", "ad"}:
raise ApiError(400, "sync is only supported for ldap/ad realms")
config = state(row["config"])
now = int(await database(request).pool.fetchval("SELECT extract(epoch from now())::bigint"))
config["last_sync"] = now
config["last_sync_options"] = {
key: payload[key]
for key in (
"dry-run",
"enable-new",
"full",
"purge",
"remove-vanished",
"scope",
)
if key in payload
}
await database(request).pool.execute(
"UPDATE realms SET config=$2::jsonb WHERE name=$1",
realm,
json.dumps(config, sort_keys=True),
)
registry.register("/access", "GET", access_index)
registry.register("/access/users", "GET", user_list)
registry.register("/access/users", "POST", user_create)
registry.register("/access/users/{userid}", "GET", user_get)
registry.register("/access/users/{userid}", "PUT", user_update)
registry.register("/access/users/{userid}", "DELETE", user_delete)
registry.register("/access/groups", "GET", group_list)
registry.register("/access/groups", "POST", group_create)
registry.register("/access/groups/{groupid}", "GET", group_get)
registry.register("/access/groups/{groupid}", "PUT", group_update)
registry.register("/access/groups/{groupid}", "DELETE", group_delete)
registry.register("/access/password", "PUT", password_update)
registry.register("/access/acl", "GET", acl_list)
registry.register("/access/acl", "PUT", acl_update)
registry.register("/access/roles", "GET", role_list)
registry.register("/access/roles", "POST", role_create)
registry.register("/access/roles/{roleid}", "GET", role_get)
registry.register("/access/roles/{roleid}", "PUT", role_update)
registry.register("/access/roles/{roleid}", "DELETE", role_delete)
registry.register("/access/domains", "GET", domain_list)
registry.register("/access/domains", "POST", domain_create)
registry.register("/access/domains/{realm}", "GET", domain_get)
registry.register("/access/domains/{realm}", "PUT", domain_update)
registry.register("/access/domains/{realm}", "DELETE", domain_delete)
registry.register("/access/domains/{realm}/sync", "POST", domain_sync)
registry.register("/access/users/{userid}/token", "GET", token_list)
registry.register("/access/users/{userid}/token/{tokenid}", "GET", token_get)
registry.register("/access/users/{userid}/token/{tokenid}", "POST", token_create)
registry.register("/access/users/{userid}/token/{tokenid}", "PUT", token_update)
registry.register("/access/users/{userid}/token/{tokenid}", "DELETE", token_delete)
register_access_auth_handlers(registry)
+381
View File
@@ -0,0 +1,381 @@
"""Access TFA, OpenID, permissions, and ticket helpers with durable state."""
from __future__ import annotations
import json
import secrets
from typing import Any, cast
from urllib.parse import urlencode
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.handlers.common import database, values
from app.security.auth import AuthenticationError, csrf_token, issue_ticket, verify_ticket
_TFA_TYPES = frozenset({"totp", "u2f", "webauthn", "recovery", "yubico"})
def _settings(request: Request) -> Settings:
return cast(Settings, request.app.state.settings)
def _tfa_public(row: Any) -> dict[str, Any]:
created = row["created_at"]
created_ts = int(created.timestamp()) if hasattr(created, "timestamp") else int(created or 0)
return {
"id": str(row["entry_id"]),
"type": str(row["tfa_type"]),
"description": row["description"] or "",
"enable": int(bool(row["enable"])),
"created": created_ts,
}
async def _principal_row(request: Request, userid: str) -> Any:
row = await database(request).pool.fetchrow(
"""SELECT id, name, tfa_locked_until, totp_locked
FROM principals WHERE name=$1""",
userid,
)
if row is None:
raise ApiError(404, "user does not exist")
return row
def register_access_auth_handlers(registry: HandlerRegistry) -> None:
async def ticket_get(_request: Request, _inputs: dict[str, Any]) -> None:
return None
async def permissions(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
userid = str(payload.get("userid") or request.state.principal)
path_filter = payload.get("path")
if userid == "root@pam":
caps = {
"/": {
"Permissions.Modify": 1,
"Sys.Audit": 1,
"Sys.Modify": 1,
"VM.Allocate": 1,
"VM.Audit": 1,
"VM.PowerMgmt": 1,
"Datastore.Allocate": 1,
"Datastore.Audit": 1,
}
}
if path_filter:
return {str(path_filter): caps["/"]}
return caps
rows = await database(request).pool.fetch(
"""SELECT a.path, r.privileges
FROM acl_entries a
JOIN principals p ON p.id=a.principal_id
JOIN roles r ON r.name=a.role_name
WHERE p.name=$1
UNION ALL
SELECT a.path, r.privileges
FROM group_acl_entries a
JOIN identity_groups g ON g.id=a.group_id
JOIN identity_group_members m ON m.group_id=g.id
JOIN principals p ON p.id=m.principal_id
JOIN roles r ON r.name=a.role_name
WHERE p.name=$1""",
userid,
)
result: dict[str, dict[str, int]] = {}
for row in rows:
path = str(row["path"])
bucket = result.setdefault(path, {})
for privilege in row["privileges"] or []:
bucket[str(privilege)] = 1
if path_filter:
target = str(path_filter)
merged: dict[str, int] = {}
for path, privs in result.items():
if target == path or target.startswith(path.rstrip("/") + "/") or path == "/":
merged.update(privs)
return {target: merged} if merged else {}
return result
async def vncticket(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
ticket = str(payload["vncticket"])
key = _settings(request).ticket_signing_key.get_secret_value().encode()
try:
claims = verify_ticket(ticket, key)
except AuthenticationError as error:
raise ApiError(401, "authentication failure") from error
authid = str(payload["authid"])
if claims.principal != authid:
raise ApiError(401, "authentication failure")
return None
async def openid_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return [{"subdir": "auth-url"}, {"subdir": "login"}]
async def openid_auth_url(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
realm = str(payload["realm"])
redirect_url = str(payload["redirect-url"])
row = await database(request).pool.fetchrow(
"SELECT name, kind, config FROM realms WHERE name=$1",
realm,
)
if row is None:
raise ApiError(404, "realm does not exist")
if str(row["kind"]) != "openid":
raise ApiError(400, "realm is not an OpenID realm")
state = secrets.token_urlsafe(16)
await database(request).pool.execute(
"""INSERT INTO openid_pending(state, realm, redirect_url)
VALUES($1, $2, $3)
ON CONFLICT (state) DO UPDATE
SET realm=EXCLUDED.realm, redirect_url=EXCLUDED.redirect_url,
created_at=now()""",
state,
realm,
redirect_url,
)
config = row["config"]
if isinstance(config, str):
config = json.loads(config)
config = config or {}
issuer = str(config.get("issuer-url") or "https://openid.example.local")
client_id = str(config.get("client-id") or "pve-simulator")
query = urlencode(
{
"client_id": client_id,
"redirect_uri": redirect_url,
"response_type": "code",
"scope": str(config.get("scopes") or "openid email profile"),
"state": state,
}
)
return f"{issuer.rstrip('/')}/authorize?{query}"
async def openid_login(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
state = str(payload["state"])
pending = await database(request).pool.fetchrow(
"SELECT realm, redirect_url FROM openid_pending WHERE state=$1",
state,
)
if pending is None:
raise ApiError(400, "invalid OpenID state")
redirect = payload.get("redirect-url")
if redirect is not None and str(redirect) != str(pending["redirect_url"]):
raise ApiError(400, "redirect-url mismatch")
realm = str(pending["realm"])
code = str(payload["code"])
username = f"openid-{code[:12]}@{realm}"
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM principals WHERE name=$1)",
username,
)
if not exists:
await database(request).pool.execute(
"""INSERT INTO principals(id, name, password_hash, realm_name)
VALUES(gen_random_uuid(), $1, NULL, $2)""",
username,
realm,
)
await database(request).pool.execute(
"DELETE FROM openid_pending WHERE state=$1",
state,
)
key = _settings(request).ticket_signing_key.get_secret_value().encode()
ticket = issue_ticket(username, key)
return {
"username": username,
"ticket": ticket,
"CSRFPreventionToken": csrf_token(ticket, key),
"clustername": "pve-simulator",
"cap": {},
}
async def tfa_list_all(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(request).pool.fetch(
"""SELECT p.name AS userid, p.tfa_locked_until, p.totp_locked,
t.entry_id, t.tfa_type, t.description, t.enable, t.created_at
FROM principals p
LEFT JOIN tfa_entries t ON t.principal_id=p.id
ORDER BY p.name, t.entry_id"""
)
by_user: dict[str, dict[str, Any]] = {}
for row in rows:
userid = str(row["userid"])
item = by_user.setdefault(
userid,
{
"userid": userid,
"entries": [],
"totp-locked": int(bool(row["totp_locked"])),
},
)
if row["tfa_locked_until"] is not None:
locked = row["tfa_locked_until"]
item["tfa-locked-until"] = (
int(locked.timestamp()) if hasattr(locked, "timestamp") else int(locked)
)
if row["entry_id"] is not None:
item["entries"].append(_tfa_public(row))
return list(by_user.values())
async def tfa_list_user(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
userid = str(values(inputs)["userid"])
principal = await _principal_row(request, userid)
rows = await database(request).pool.fetch(
"""SELECT entry_id, tfa_type, description, enable, created_at
FROM tfa_entries WHERE principal_id=$1 ORDER BY entry_id""",
principal["id"],
)
return [_tfa_public(row) for row in rows]
async def tfa_add(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
userid = payload.get("userid")
if userid in {None, ""}:
raise ApiError(400, "parameter 'userid' is required")
userid = str(userid)
tfa_type = payload.get("type")
if tfa_type in {None, ""}:
raise ApiError(400, "parameter 'type' is required")
tfa_type = str(tfa_type)
if tfa_type not in _TFA_TYPES:
raise ApiError(400, f"invalid TFA type: {tfa_type}")
principal = await _principal_row(request, userid)
entry_id = secrets.token_hex(8)
secret = str(payload.get("value") or payload.get("totp") or secrets.token_hex(20))
description = str(payload.get("description") or tfa_type)
recovery: list[str] = []
metadata: dict[str, Any] = {}
if tfa_type == "recovery":
recovery = [secrets.token_hex(5) for _ in range(8)]
metadata["recovery"] = recovery
await database(request).pool.execute(
"""INSERT INTO tfa_entries(
principal_id, entry_id, tfa_type, description, enable, secret, metadata
) VALUES($1, $2, $3, $4, true, $5, $6::jsonb)""",
principal["id"],
entry_id,
tfa_type,
description,
secret,
json.dumps(metadata, sort_keys=True),
)
result: dict[str, Any] = {"id": entry_id}
if recovery:
result["recovery"] = recovery
if payload.get("challenge") is not None:
result["challenge"] = payload.get("challenge")
return result
async def tfa_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
userid = str(values(inputs)["userid"])
entry_id = str(values(inputs)["id"])
principal = await _principal_row(request, userid)
row = await database(request).pool.fetchrow(
"""SELECT entry_id, tfa_type, description, enable, created_at
FROM tfa_entries WHERE principal_id=$1 AND entry_id=$2""",
principal["id"],
entry_id,
)
if row is None:
raise ApiError(404, "TFA entry does not exist")
return _tfa_public(row)
async def tfa_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
userid = payload.get("userid")
if userid in {None, ""}:
raise ApiError(400, "parameter 'userid' is required")
userid = str(userid)
entry_id = payload.get("id")
if entry_id in {None, ""}:
raise ApiError(400, "parameter 'id' is required")
entry_id = str(entry_id)
provided = frozenset(str(item) for item in inputs.get("provided", payload))
principal = await _principal_row(request, userid)
row = await database(request).pool.fetchrow(
"SELECT entry_id FROM tfa_entries WHERE principal_id=$1 AND entry_id=$2",
principal["id"],
entry_id,
)
if row is None:
raise ApiError(404, "TFA entry does not exist")
if "description" in provided:
await database(request).pool.execute(
"""UPDATE tfa_entries SET description=$3
WHERE principal_id=$1 AND entry_id=$2""",
principal["id"],
entry_id,
payload.get("description"),
)
if "enable" in provided:
enabled = payload.get("enable")
if isinstance(enabled, bool):
value = enabled
else:
value = str(enabled).lower() in {"1", "true", "yes", "on"}
await database(request).pool.execute(
"""UPDATE tfa_entries SET enable=$3
WHERE principal_id=$1 AND entry_id=$2""",
principal["id"],
entry_id,
value,
)
async def tfa_delete(request: Request, inputs: dict[str, Any]) -> None:
userid = str(values(inputs)["userid"])
entry_id = str(values(inputs)["id"])
principal = await _principal_row(request, userid)
status = await database(request).pool.execute(
"DELETE FROM tfa_entries WHERE principal_id=$1 AND entry_id=$2",
principal["id"],
entry_id,
)
if status != "DELETE 1":
raise ApiError(404, "TFA entry does not exist")
async def user_tfa_types(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
userid = str(values(inputs)["userid"])
principal = await _principal_row(request, userid)
rows = await database(request).pool.fetch(
"""SELECT DISTINCT tfa_type FROM tfa_entries
WHERE principal_id=$1 AND enable=true ORDER BY tfa_type""",
principal["id"],
)
types = [str(row["tfa_type"]) for row in rows]
realm = userid.split("@", 1)[1] if "@" in userid else "pam"
return {"user": types, "types": types, "realm": realm}
async def unlock_tfa(request: Request, inputs: dict[str, Any]) -> bool:
userid = str(values(inputs)["userid"])
status = await database(request).pool.execute(
"""UPDATE principals
SET tfa_locked_until=NULL, totp_locked=false
WHERE name=$1""",
userid,
)
if status != "UPDATE 1":
raise ApiError(404, "user does not exist")
return True
registry.register("/access/ticket", "GET", ticket_get)
registry.register("/access/permissions", "GET", permissions)
registry.register("/access/vncticket", "POST", vncticket)
registry.register("/access/openid", "GET", openid_index)
registry.register("/access/openid/auth-url", "POST", openid_auth_url)
registry.register("/access/openid/login", "POST", openid_login)
registry.register("/access/tfa", "GET", tfa_list_all)
registry.register("/access/tfa/{userid}", "GET", tfa_list_user)
registry.register("/access/tfa/{userid}", "POST", tfa_add)
registry.register("/access/tfa/{userid}/{id}", "GET", tfa_get)
registry.register("/access/tfa/{userid}/{id}", "PUT", tfa_update)
registry.register("/access/tfa/{userid}/{id}", "DELETE", tfa_delete)
registry.register("/access/users/{userid}/tfa", "GET", user_tfa_types)
registry.register("/access/users/{userid}/unlock-tfa", "PUT", unlock_tfa)
+223
View File
@@ -0,0 +1,223 @@
"""Cluster ACME accounts and DNS plugins."""
from __future__ import annotations
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs, values
_DEFAULT_DIRECTORIES = [
{
"name": "Let's Encrypt V2",
"url": "https://acme-v02.api.letsencrypt.org/directory",
},
{
"name": "Let's Encrypt V2 Staging",
"url": "https://acme-staging-v02.api.letsencrypt.org/directory",
},
]
_CHALLENGE_SCHEMA = [
{
"id": "dns",
"name": "DNS plugin",
"type": "dns",
"fields": [{"name": "api", "type": "string"}],
}
]
def _acme(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault(
"acme",
{"accounts": {}, "plugins": {}, "meta": {}},
)
if not isinstance(current, dict):
current = {"accounts": {}, "plugins": {}, "meta": {}}
metadata["acme"] = current
current.setdefault("accounts", {})
current.setdefault("plugins", {})
current.setdefault("meta", {})
return current
def register_acme_handlers(registry: HandlerRegistry) -> None:
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs(
"account",
"challenge-schema",
"directories",
"meta",
"plugins",
"tos",
)
async def account_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
accounts = _acme(metadata)["accounts"]
return [
{
"name": name,
"contact": item.get("contact", []),
"directory": item.get("directory"),
}
for name, item in sorted(accounts.items())
]
async def account_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload.get("name") or "default")
metadata = await cluster_metadata(request)
accounts = _acme(metadata)["accounts"]
if name in accounts:
raise ApiError(400, f"ACME account '{name}' already exists")
accounts[name] = {
"name": name,
"contact": payload.get("contact"),
"directory": payload.get("directory") or _DEFAULT_DIRECTORIES[0]["url"],
"tos_url": payload.get("tos_url"),
"eab-kid": payload.get("eab-kid"),
# eab-hmac-key stored but never returned
"eab-hmac-key": payload.get("eab-hmac-key"),
"location": f"https://acme.example.local/acct/{name}",
}
await save_cluster_metadata(request, metadata)
async def account_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
account = _acme(metadata)["accounts"].get(name)
if not isinstance(account, dict):
raise ApiError(404, "ACME account does not exist")
return {
"name": name,
"contact": account.get("contact"),
"directory": account.get("directory"),
"tos": account.get("tos_url"),
"location": account.get("location"),
}
async def account_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
accounts = _acme(metadata)["accounts"]
if name not in accounts:
raise ApiError(404, "ACME account does not exist")
if "contact" in payload:
accounts[name]["contact"] = payload["contact"]
await save_cluster_metadata(request, metadata)
async def account_delete(request: Request, inputs: dict[str, Any]) -> None:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
accounts = _acme(metadata)["accounts"]
if name not in accounts:
raise ApiError(404, "ACME account does not exist")
del accounts[name]
await save_cluster_metadata(request, metadata)
async def plugins_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
plugins = _acme(metadata)["plugins"]
plugin_type = values(inputs).get("type")
result = []
for plugin_id, item in sorted(plugins.items()):
if plugin_type and item.get("type") != plugin_type:
continue
result.append({"plugin": plugin_id, **{k: v for k, v in item.items() if k != "data"}})
return result
async def plugins_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
plugin_id = str(payload["id"])
metadata = await cluster_metadata(request)
plugins = _acme(metadata)["plugins"]
if plugin_id in plugins:
raise ApiError(400, f"ACME plugin '{plugin_id}' already exists")
plugins[plugin_id] = {
key: value for key, value in payload.items() if key not in {"delete", "digest"}
}
await save_cluster_metadata(request, metadata)
async def plugins_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
plugin_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
plugin = _acme(metadata)["plugins"].get(plugin_id)
if not isinstance(plugin, dict):
raise ApiError(404, "ACME plugin does not exist")
return {"id": plugin_id, **plugin}
async def plugins_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
plugin_id = str(payload["id"])
metadata = await cluster_metadata(request)
plugins = _acme(metadata)["plugins"]
if plugin_id not in plugins:
raise ApiError(404, "ACME plugin does not exist")
current = dict(plugins[plugin_id])
for key in [
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
]:
current.pop(key, None)
for key, value in payload.items():
if key in {"id", "delete", "digest"}:
continue
current[key] = value
current["id"] = plugin_id
plugins[plugin_id] = current
await save_cluster_metadata(request, metadata)
async def plugins_delete(request: Request, inputs: dict[str, Any]) -> None:
plugin_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
plugins = _acme(metadata)["plugins"]
if plugin_id not in plugins:
raise ApiError(404, "ACME plugin does not exist")
del plugins[plugin_id]
await save_cluster_metadata(request, metadata)
async def directories(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(_DEFAULT_DIRECTORIES)
async def challenge_schema(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(_CHALLENGE_SCHEMA)
async def meta(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
directory = str(values(inputs).get("directory") or _DEFAULT_DIRECTORIES[0]["url"])
metadata = await cluster_metadata(request)
meta_store = _acme(metadata).setdefault("meta", {})
payload = meta_store.setdefault(
directory,
{
"termsOfService": f"{directory.rstrip('/')}/tos",
"caaIdentities": ["letsencrypt.org"],
},
)
await save_cluster_metadata(request, metadata)
return dict(payload)
async def tos(request: Request, inputs: dict[str, Any]) -> str:
directory = str(values(inputs).get("directory") or _DEFAULT_DIRECTORIES[0]["url"])
result = await meta(request, {"values": {"directory": directory}, "provided": frozenset()})
return str(result.get("termsOfService") or "")
registry.register("/cluster/acme", "GET", index)
registry.register("/cluster/acme/account", "GET", account_list)
registry.register("/cluster/acme/account", "POST", account_create)
registry.register("/cluster/acme/account/{name}", "GET", account_get)
registry.register("/cluster/acme/account/{name}", "PUT", account_update)
registry.register("/cluster/acme/account/{name}", "DELETE", account_delete)
registry.register("/cluster/acme/plugins", "GET", plugins_list)
registry.register("/cluster/acme/plugins", "POST", plugins_create)
registry.register("/cluster/acme/plugins/{id}", "GET", plugins_get)
registry.register("/cluster/acme/plugins/{id}", "PUT", plugins_update)
registry.register("/cluster/acme/plugins/{id}", "DELETE", plugins_delete)
registry.register("/cluster/acme/directories", "GET", directories)
registry.register("/cluster/acme/challenge-schema", "GET", challenge_schema)
registry.register("/cluster/acme/meta", "GET", meta)
registry.register("/cluster/acme/tos", "GET", tos)
+236
View File
@@ -0,0 +1,236 @@
"""Cluster backup and vzdump handlers."""
from __future__ import annotations
import json
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.primitives import ConflictError
from app.handlers.common import database, require_node, state, values
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
def register_backup_handlers(registry: HandlerRegistry) -> None:
async def backup_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT b.id, b.volume_id, b.size_bytes, b.metadata, b.created_at,
r.external_id AS vmid, n.name AS node, s.storage_id
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id
LEFT JOIN nodes n ON n.id = r.node_id
JOIN storages s ON s.resource_id = b.storage_resource_id
ORDER BY b.created_at DESC LIMIT 2000"""
)
result: list[dict[str, Any]] = []
for row in rows:
metadata = state(row["metadata"])
result.append(
{
"id": str(row["id"]),
"volid": str(row["volume_id"]),
"size": int(row["size_bytes"]),
"vmid": int(row["vmid"]) if row["vmid"] is not None else None,
"node": str(row["node"]) if row["node"] is not None else None,
"storage": str(row["storage_id"]),
"starttime": int(row["created_at"].timestamp()),
"mode": metadata.get("mode", "snapshot"),
"type": metadata.get("type", "vzdump"),
}
)
return result
async def backup_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
backup_id = str(values(inputs)["id"])
row = await database(request).pool.fetchrow(
"""SELECT b.id, b.volume_id, b.size_bytes, b.metadata, b.created_at,
r.external_id AS vmid, n.name AS node, s.storage_id
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id
LEFT JOIN nodes n ON n.id = r.node_id
JOIN storages s ON s.resource_id = b.storage_resource_id
WHERE b.id::text = $1 OR b.volume_id = $1""",
backup_id,
)
if row is None:
raise ApiError(404, "backup does not exist")
metadata = state(row["metadata"])
return {
"id": str(row["id"]),
"volid": str(row["volume_id"]),
"size": int(row["size_bytes"]),
"vmid": int(row["vmid"]) if row["vmid"] is not None else None,
"node": str(row["node"]) if row["node"] is not None else None,
"storage": str(row["storage_id"]),
"starttime": int(row["created_at"].timestamp()),
"notes": metadata.get("notes-template"),
**metadata,
}
async def backup_update(request: Request, inputs: dict[str, Any]) -> None:
backup_id = str(values(inputs)["id"])
row = await database(request).pool.fetchrow(
"SELECT id, metadata FROM backups WHERE id::text = $1",
backup_id,
)
if row is None:
raise ApiError(404, "backup does not exist")
metadata = state(row["metadata"])
payload = values(inputs)
if "notes" in payload:
metadata["notes-template"] = payload["notes"]
await database(request).pool.execute(
"UPDATE backups SET metadata=$2::jsonb WHERE id=$1",
row["id"],
json.dumps(metadata, sort_keys=True),
)
async def backup_delete(request: Request, inputs: dict[str, Any]) -> None:
backup_id = str(values(inputs)["id"])
status = await database(request).pool.execute(
"DELETE FROM backups WHERE id::text = $1",
backup_id,
)
if status != "DELETE 1":
raise ApiError(404, "backup does not exist")
async def backup_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload.get("node") or payload.get("target") or "pve01")
await require_node(request, node)
vmid = payload.get("vmid")
return await _schedule_vzdump(
request,
node=node,
vmids=[str(vmid)] if vmid is not None else None,
payload=payload,
)
async def backup_info(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT r.external_id AS vmid, n.name AS node, max(b.created_at) AS last_backup
FROM resources r
JOIN nodes n ON n.id = r.node_id
LEFT JOIN backups b ON b.resource_id = r.id
WHERE r.kind = 'qemu'
GROUP BY r.external_id, n.name
ORDER BY r.external_id::integer
LIMIT 5000"""
)
return [
{
"vmid": int(row["vmid"]),
"node": str(row["node"]),
"lastbackup": int(row["last_backup"].timestamp()) if row["last_backup"] else 0,
"protected": 0,
}
for row in rows
]
async def backup_not_backed_up(_request: Request, _inputs: dict[str, Any]) -> list[int]:
rows = await database(_request).pool.fetch(
"""SELECT r.external_id::integer AS vmid
FROM resources r
LEFT JOIN backups b ON b.resource_id = r.id
WHERE r.kind = 'qemu' AND b.id IS NULL
ORDER BY r.external_id::integer"""
)
return [int(row["vmid"]) for row in rows]
async def backup_included_volumes(request: Request, inputs: dict[str, Any]) -> list[str]:
backup_id = str(values(inputs)["id"])
row = await database(request).pool.fetchrow(
"""SELECT b.volume_id, r.external_id AS vmid
FROM backups b LEFT JOIN resources r ON r.id = b.resource_id
WHERE b.id::text = $1""",
backup_id,
)
if row is None:
raise ApiError(404, "backup does not exist")
vmid = row["vmid"]
return [f"qemu/{vmid}"] if vmid is not None else [str(row["volume_id"])]
async def vzdump_defaults(_request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
await require_node(_request, str(values(inputs)["node"]))
return {
"all": 0,
"bwlimit": 0,
"compress": "zstd",
"dumpdir": "backup",
"mode": "snapshot",
"remove": 0,
"storage": "nfs-backup",
"mailto": "",
"notes-template": "{{guestname}}",
}
async def vzdump_extractconfig(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
volid = str(payload.get("volume") or payload.get("volid") or "")
if not volid:
raise ApiError(400, "volume parameter required")
return f"# simulated vzdump config extracted from {volid}\name: demo\nmemory: 2048\n"
async def vzdump_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
vmids = payload.get("vmid")
selected = None
if vmids is not None:
selected = [str(item) for item in str(vmids).split(",") if item.strip()]
return await _schedule_vzdump(request, node=node, vmids=selected, payload=payload)
registry.register("/cluster/backup", "GET", backup_list)
registry.register("/cluster/backup", "POST", backup_create)
registry.register("/cluster/backup-info", "GET", backup_info)
registry.register("/cluster/backup-info/not-backed-up", "GET", backup_not_backed_up)
registry.register("/cluster/backup/{id}", "GET", backup_get)
registry.register("/cluster/backup/{id}", "PUT", backup_update)
registry.register("/cluster/backup/{id}", "DELETE", backup_delete)
registry.register("/cluster/backup/{id}/included_volumes", "GET", backup_included_volumes)
registry.register("/nodes/{node}/vzdump", "POST", vzdump_create)
registry.register("/nodes/{node}/vzdump/defaults", "GET", vzdump_defaults)
registry.register("/nodes/{node}/vzdump/extractconfig", "GET", vzdump_extractconfig)
async def _schedule_vzdump(
request: Request,
*,
node: str,
vmids: list[str] | None,
payload: dict[str, Any],
) -> str:
pool = database(request).pool
if vmids is None:
rows = await pool.fetch(
"""SELECT external_id FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='qemu' ORDER BY external_id::integer LIMIT 100""",
node,
)
vmids = [str(row["external_id"]) for row in rows]
if not vmids:
raise ApiError(400, "no virtual machines selected for backup")
vmid = vmids[0]
upid = str(Upid.allocate(node, "vzdump", vmid, str(request.state.principal)))
try:
task = await TaskRepository(pool).create(
upid=upid,
task_type="vzdump",
payload={
"node": node,
"vmids": vmids,
"storage": str(payload.get("storage") or "nfs-backup"),
"mode": str(payload.get("mode") or "snapshot"),
"compress": str(payload.get("compress") or "zstd"),
},
resource_key=f"backup:{node}",
idempotency_key=request.headers.get("Idempotency-Key"),
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
+759
View File
@@ -0,0 +1,759 @@
"""Ceph semantic handlers with durable cluster/node state."""
from __future__ import annotations
import json
import secrets
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import (
database,
node_metadata,
require_node,
save_node_metadata,
state,
subdirs,
values,
)
from app.simulation.seed import CLUSTER_ID
DEFAULT_CLUSTER_CEPH = {
"initialized": True,
"config": {
"network": "10.10.10.0/24",
"cluster-network": "10.10.10.0/24",
"size": 3,
"min_size": 2,
"pg_bits": 7,
},
"cfg_db": [
{"section": "global", "name": "auth_client_required", "value": "cephx"},
{"section": "global", "name": "fsid", "value": "pve-simulator-fsid"},
],
"cfg_raw": "[global]\nfsid = pve-simulator-fsid\nauth_client_required = cephx\n",
"cfg_values": {},
"pools": {
"rbd": {
"pool": "rbd",
"size": 3,
"min_size": 2,
"pg_num": 128,
"application": "rbd",
"crush_rule": "replicated_rule",
}
},
"fs": {},
"rules": [{"name": "replicated_rule", "id": 0}],
"crush": "device 0 osd.0 class hdd\n",
"running": True,
}
async def _load_cluster_ceph(request: Request) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
"SELECT metadata FROM clusters WHERE id=$1",
CLUSTER_ID,
)
metadata = state(row["metadata"]) if row is not None else {}
ceph = metadata.get("ceph")
if not isinstance(ceph, dict) or not ceph:
return dict(DEFAULT_CLUSTER_CEPH)
merged = dict(DEFAULT_CLUSTER_CEPH)
merged.update(ceph)
for key in ("config", "pools", "fs", "cfg_values"):
if not isinstance(merged.get(key), dict):
merged[key] = dict(cast(dict[str, Any], DEFAULT_CLUSTER_CEPH[key]))
return merged
async def _save_cluster_ceph(request: Request, ceph: dict[str, Any]) -> None:
await database(request).pool.execute(
"""UPDATE clusters SET metadata = jsonb_set(
COALESCE(metadata, '{}'::jsonb), '{ceph}', $2::jsonb, true
), updated_at=now() WHERE id=$1""",
CLUSTER_ID,
json.dumps(ceph, sort_keys=True),
)
async def _load_node_ceph(request: Request, node: str) -> dict[str, Any]:
metadata = await node_metadata(request, node)
ops = metadata.setdefault("ops", {})
ceph = ops.setdefault(
"ceph",
{
"mds": {},
"mgr": {},
"mon": {f"{node}": {"name": node, "addr": f"{node}.local:6789", "rank": 0}},
"log": [{"t": 1_700_000_000, "n": 0, "line": "ceph simulator ready"}],
},
)
if not isinstance(ceph, dict):
ceph = {
"mds": {},
"mgr": {},
"mon": {},
"log": [],
}
ops["ceph"] = ceph
ceph.setdefault("mds", {})
ceph.setdefault("mgr", {})
ceph.setdefault("mon", {})
ceph.setdefault("log", [])
return ceph
async def _save_node_ceph(request: Request, node: str, ceph: dict[str, Any]) -> None:
metadata = await node_metadata(request, node)
ops = metadata.setdefault("ops", {})
ops["ceph"] = ceph
await save_node_metadata(request, node, metadata)
def _upid(node: str, kind: str) -> str:
return f"UPID:{node}:{secrets.token_hex(4)}:{kind}:root@pam:"
async def _osd_row(request: Request, node: str, osdid: str) -> Any:
row = await database(request).pool.fetchrow(
"""SELECT r.id, r.external_id, r.state
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='ceph-osd'
AND (r.external_id=$2 OR r.external_id=$3)""",
node,
osdid,
f"osd.{osdid}",
)
if row is None:
raise ApiError(404, "OSD does not exist")
return row
def register_ceph_handlers(registry: HandlerRegistry) -> None:
async def ceph_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
node = str(values(inputs)["node"])
await require_node(request, node)
return subdirs(
"cfg",
"cmd-safety",
"crush",
"fs",
"init",
"log",
"mds",
"mgr",
"mon",
"osd",
"pool",
"restart",
"rules",
"start",
"status",
"stop",
)
async def cfg_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("db", "raw", "value")
async def cfg_db(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
return list(ceph.get("cfg_db") or [])
async def cfg_raw(request: Request, inputs: dict[str, Any]) -> str:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
return str(ceph.get("cfg_raw") or "")
async def cfg_value(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
ceph = await _load_cluster_ceph(request)
keys = [item.strip() for item in str(payload.get("config-keys") or "").split(",") if item]
stored = ceph.setdefault("cfg_values", {})
result = {key: stored.get(key, "") for key in keys} if keys else dict(stored)
await _save_cluster_ceph(request, ceph)
return result
async def crush(request: Request, inputs: dict[str, Any]) -> str:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
return str(ceph.get("crush") or "")
async def rules(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
return list(ceph.get("rules") or [])
async def log(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ceph = await _load_node_ceph(request, node)
entries = list(ceph.get("log") or [])
start = int(values(inputs).get("start") or 0)
limit = int(values(inputs).get("limit") or 50)
return entries[start : start + limit]
async def cmd_safety(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
return {
"safe": 1,
"action": payload.get("action"),
"service": payload.get("service"),
"id": payload.get("id"),
}
async def init(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ceph = await _load_cluster_ceph(request)
config = ceph.setdefault("config", {})
for key in (
"network",
"cluster-network",
"size",
"min_size",
"pg_bits",
"disable_cephx",
):
if key in payload:
config[key] = payload[key]
ceph["initialized"] = True
await _save_cluster_ceph(request, ceph)
node_ceph = await _load_node_ceph(request, node)
node_ceph.setdefault("mon", {})[node] = {
"name": node,
"addr": f"{node}.local:6789",
"rank": 0,
}
await _save_node_ceph(request, node, node_ceph)
async def status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
return await cluster_ceph_status(request, inputs)
async def start(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ceph = await _load_cluster_ceph(request)
ceph["running"] = True
ceph["last_service_action"] = {
"action": "start",
"service": payload.get("service"),
"node": node,
}
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephstart")
async def stop(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ceph = await _load_cluster_ceph(request)
ceph["running"] = False
ceph["last_service_action"] = {
"action": "stop",
"service": payload.get("service"),
"node": node,
}
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephstop")
async def restart(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ceph = await _load_cluster_ceph(request)
ceph["running"] = True
ceph["last_service_action"] = {
"action": "restart",
"service": payload.get("service"),
"node": node,
}
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephrestart")
async def pool_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
pools = ceph.get("pools") or {}
return [dict(item) for _, item in sorted(pools.items()) if isinstance(item, dict)]
async def pool_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
pools = ceph.setdefault("pools", {})
if name in pools:
raise ApiError(400, f"pool '{name}' already exists")
pools[name] = {
"pool": name,
"size": int(payload.get("size") or 3),
"min_size": int(payload.get("min_size") or 2),
"pg_num": int(payload.get("pg_num") or 128),
"application": str(payload.get("application") or "rbd"),
"crush_rule": str(payload.get("crush_rule") or "replicated_rule"),
"pg_autoscale_mode": payload.get("pg_autoscale_mode", "warn"),
"target_size": payload.get("target_size"),
}
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephcreatepool")
async def pool_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
pool = (ceph.get("pools") or {}).get(name)
if not isinstance(pool, dict):
raise ApiError(404, "pool does not exist")
return [dict(pool)]
async def pool_update(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
pools = ceph.setdefault("pools", {})
if name not in pools or not isinstance(pools[name], dict):
raise ApiError(404, "pool does not exist")
current = dict(pools[name])
for key in (
"application",
"crush_rule",
"min_size",
"pg_autoscale_mode",
"pg_num",
"pg_num_min",
"size",
"target_size",
"target_size_ratio",
):
if key in payload:
current[key] = payload[key]
pools[name] = current
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephsetpool")
async def pool_delete(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
pools = ceph.setdefault("pools", {})
if name not in pools:
raise ApiError(404, "pool does not exist")
del pools[name]
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephdestroypool")
async def pool_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
pool = (ceph.get("pools") or {}).get(name)
if not isinstance(pool, dict):
raise ApiError(404, "pool does not exist")
return {
**pool,
"pg_num": pool.get("pg_num", 128),
"bytes_used": 0,
"percent_used": 0.0,
"healthy": True,
}
async def fs_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
return [dict(item) for _, item in sorted((ceph.get("fs") or {}).items())]
async def fs_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
filesystems = ceph.setdefault("fs", {})
if name in filesystems:
raise ApiError(400, f"fs '{name}' already exists")
filesystems[name] = {
"name": name,
"metadata": f"{name}_meta",
"data": f"{name}_data",
"pg_num": int(payload.get("pg_num") or 32),
}
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephcreatefs")
async def fs_delete(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
filesystems = ceph.setdefault("fs", {})
if name not in filesystems:
raise ApiError(404, "fs does not exist")
del filesystems[name]
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephdestroyfs")
async def mds_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ceph = await _load_node_ceph(request, node)
return [
{"name": name, **data}
for name, data in sorted((ceph.get("mds") or {}).items())
if isinstance(data, dict)
]
async def mds_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_node_ceph(request, node)
mds = ceph.setdefault("mds", {})
if name in mds:
raise ApiError(400, f"mds '{name}' already exists")
mds[name] = {
"name": name,
"state": "up:active",
"hotstandby": int(bool(payload.get("hotstandby"))),
}
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephcreatemds")
async def mds_delete(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_node_ceph(request, node)
mds = ceph.setdefault("mds", {})
if name not in mds:
raise ApiError(404, "mds does not exist")
del mds[name]
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephdestroymds")
async def mgr_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ceph = await _load_node_ceph(request, node)
return [
{"name": name, **data}
for name, data in sorted((ceph.get("mgr") or {}).items())
if isinstance(data, dict)
]
async def mgr_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
mgr_id = str(payload["id"])
ceph = await _load_node_ceph(request, node)
mgr = ceph.setdefault("mgr", {})
if mgr_id in mgr:
raise ApiError(400, f"mgr '{mgr_id}' already exists")
mgr[mgr_id] = {"name": mgr_id, "state": "active"}
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephcreatemgr")
async def mgr_delete(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
mgr_id = str(payload["id"])
ceph = await _load_node_ceph(request, node)
mgr = ceph.setdefault("mgr", {})
if mgr_id not in mgr:
raise ApiError(404, "mgr does not exist")
del mgr[mgr_id]
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephdestroymgr")
async def mon_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ceph = await _load_node_ceph(request, node)
return [
{"name": name, **data}
for name, data in sorted((ceph.get("mon") or {}).items())
if isinstance(data, dict)
]
async def mon_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
monid = str(payload["monid"])
ceph = await _load_node_ceph(request, node)
mons = ceph.setdefault("mon", {})
if monid in mons:
raise ApiError(400, f"mon '{monid}' already exists")
mons[monid] = {
"name": monid,
"addr": str(payload.get("mon-address") or f"{node}.local:6789"),
"rank": len(mons),
}
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephcreatemon")
async def mon_delete(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
monid = str(payload["monid"])
ceph = await _load_node_ceph(request, node)
mons = ceph.setdefault("mon", {})
if monid not in mons:
raise ApiError(404, "mon does not exist")
del mons[monid]
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephdestroymon")
async def osd_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
rows = await database(request).pool.fetch(
"""SELECT r.external_id, r.state
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='ceph-osd'
ORDER BY r.external_id""",
node,
)
result: list[dict[str, Any]] = []
for row in rows:
payload = state(row["state"])
osd_id = payload.get("osd_id", row["external_id"])
result.append(
{
"osd": int(osd_id) if str(osd_id).isdigit() else osd_id,
"status": payload.get("status", "up"),
"in": 1 if payload.get("in", True) else 0,
"weight": payload.get("weight", 1.0),
"device_class": payload.get("device_class", "hdd"),
}
)
return result
async def osd_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
next_id = await database(request).pool.fetchval(
"""SELECT COALESCE(
MAX(NULLIF(regexp_replace(external_id, '\\D', '', 'g'), '')::int),
-1
) + 1
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='ceph-osd'""",
node,
)
osd_id = int(next_id or 0)
external_id = f"osd.{osd_id}"
node_id = await database(request).pool.fetchval(
"SELECT id FROM nodes WHERE name=$1",
node,
)
osd_state = {
"osd_id": osd_id,
"status": "up",
"in": True,
"weight": 1.0,
"device_class": payload.get("crush-device-class") or "hdd",
"dev": payload.get("dev"),
"size_bytes": 0,
"used_bytes": 0,
}
await database(request).pool.execute(
"""INSERT INTO resources(id, node_id, kind, external_id, state)
VALUES(gen_random_uuid(), $1, 'ceph-osd', $2, $3::jsonb)""",
node_id,
external_id,
json.dumps(osd_state, sort_keys=True),
)
return _upid(node, "cephcreateosd")
async def osd_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
osdid = str(values(inputs)["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
payload = state(row["state"])
return {
"osd": int(osdid) if osdid.isdigit() else osdid,
"status": payload.get("status", "up"),
"in": 1 if payload.get("in", True) else 0,
"weight": payload.get("weight", 1.0),
"size": payload.get("size_bytes", 0),
"used": payload.get("used_bytes", 0),
"device_class": payload.get("device_class", "hdd"),
}
async def osd_delete(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
osdid = str(payload["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
await database(request).pool.execute("DELETE FROM resources WHERE id=$1", row["id"])
return _upid(node, "cephdestroyosd")
async def osd_in(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
osdid = str(payload["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
current = state(row["state"])
current["in"] = True
await database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, version=version+1 WHERE id=$1",
row["id"],
json.dumps(current, sort_keys=True),
)
async def osd_out(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
osdid = str(payload["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
current = state(row["state"])
current["in"] = False
await database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, version=version+1 WHERE id=$1",
row["id"],
json.dumps(current, sort_keys=True),
)
async def osd_scrub(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
osdid = str(payload["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
current = state(row["state"])
current["last_scrub"] = {
"deep": int(bool(payload.get("deep"))),
"token": secrets.token_hex(4),
}
await database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, version=version+1 WHERE id=$1",
row["id"],
json.dumps(current, sort_keys=True),
)
async def osd_lv_info(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
osdid = str(payload["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
current = state(row["state"])
return {
"lv_name": f"osd-block-{osdid}",
"lv_path": f"/dev/ceph/{osdid}",
"lv_size": current.get("size_bytes", 0),
"type": payload.get("type") or "block",
}
async def osd_metadata(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
osdid = str(payload["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
current = state(row["state"])
return {
"osd": {
"id": int(osdid) if osdid.isdigit() else osdid,
"uuid": current.get("uuid") or f"osd-uuid-{osdid}",
"device_class": current.get("device_class", "hdd"),
},
"devices": [{"dev": current.get("dev") or f"/dev/sd{osdid}"}],
}
async def cluster_ceph_status(_request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
row = await database(_request).pool.fetchrow(
"""SELECT capacity_bytes, used_bytes FROM storages
WHERE storage_type='ceph' ORDER BY storage_id LIMIT 1"""
)
total = int(row["capacity_bytes"] or 0) if row is not None else 0
used = int(row["used_bytes"] or 0) if row is not None else 0
osd_count = await database(_request).pool.fetchval(
"SELECT count(*)::int FROM resources WHERE kind='ceph-osd'"
)
ceph = await _load_cluster_ceph(_request)
return {
"version": "17.2.7",
"health": {"status": "HEALTH_OK" if ceph.get("running", True) else "HEALTH_WARN"},
"osdmap": {
"num_osds": osd_count,
"num_up_osds": osd_count - 1,
"num_in_osds": osd_count - 1,
},
"pgmap": {"bytes_used": used, "bytes_total": total},
"fsmap": {"filesystems": list((ceph.get("fs") or {}).keys())},
}
base = "/nodes/{node}/ceph"
registry.register(base, "GET", ceph_index)
registry.register(f"{base}/cfg", "GET", cfg_index)
registry.register(f"{base}/cfg/db", "GET", cfg_db)
registry.register(f"{base}/cfg/raw", "GET", cfg_raw)
registry.register(f"{base}/cfg/value", "GET", cfg_value)
registry.register(f"{base}/cmd-safety", "GET", cmd_safety)
registry.register(f"{base}/crush", "GET", crush)
registry.register(f"{base}/fs", "GET", fs_list)
registry.register(f"{base}/fs/{{name}}", "POST", fs_create)
registry.register(f"{base}/fs/{{name}}", "DELETE", fs_delete)
registry.register(f"{base}/init", "POST", init)
registry.register(f"{base}/log", "GET", log)
registry.register(f"{base}/mds", "GET", mds_list)
registry.register(f"{base}/mds/{{name}}", "POST", mds_create)
registry.register(f"{base}/mds/{{name}}", "DELETE", mds_delete)
registry.register(f"{base}/mgr", "GET", mgr_list)
registry.register(f"{base}/mgr/{{id}}", "POST", mgr_create)
registry.register(f"{base}/mgr/{{id}}", "DELETE", mgr_delete)
registry.register(f"{base}/mon", "GET", mon_list)
registry.register(f"{base}/mon/{{monid}}", "POST", mon_create)
registry.register(f"{base}/mon/{{monid}}", "DELETE", mon_delete)
registry.register(f"{base}/osd", "GET", osd_list)
registry.register(f"{base}/osd", "POST", osd_create)
registry.register(f"{base}/osd/{{osdid}}", "GET", osd_get)
registry.register(f"{base}/osd/{{osdid}}", "DELETE", osd_delete)
registry.register(f"{base}/osd/{{osdid}}/in", "POST", osd_in)
registry.register(f"{base}/osd/{{osdid}}/out", "POST", osd_out)
registry.register(f"{base}/osd/{{osdid}}/scrub", "POST", osd_scrub)
registry.register(f"{base}/osd/{{osdid}}/lv-info", "GET", osd_lv_info)
registry.register(f"{base}/osd/{{osdid}}/metadata", "GET", osd_metadata)
registry.register(f"{base}/pool", "GET", pool_list)
registry.register(f"{base}/pool", "POST", pool_create)
registry.register(f"{base}/pool/{{name}}", "GET", pool_get)
registry.register(f"{base}/pool/{{name}}", "PUT", pool_update)
registry.register(f"{base}/pool/{{name}}", "DELETE", pool_delete)
registry.register(f"{base}/pool/{{name}}/status", "GET", pool_status)
registry.register(f"{base}/rules", "GET", rules)
registry.register(f"{base}/status", "GET", status)
registry.register(f"{base}/start", "POST", start)
registry.register(f"{base}/stop", "POST", stop)
registry.register(f"{base}/restart", "POST", restart)
registry.register("/cluster/ceph/status", "GET", cluster_ceph_status)
+217
View File
@@ -0,0 +1,217 @@
"""Cluster-level semantic handlers."""
from __future__ import annotations
import json
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import (
cluster_metadata,
database,
save_cluster_metadata,
state,
subdirs,
values,
)
from app.simulation.seed import CLUSTER_ID
def _replication_jobs(metadata: dict[str, Any]) -> list[dict[str, Any]]:
jobs = metadata.get("replication", [])
if not isinstance(jobs, list):
return []
return [dict(item) for item in jobs if isinstance(item, dict)]
def register_cluster_handlers(registry: HandlerRegistry) -> None:
async def cluster_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs(
"acme",
"backup",
"backup-info",
"config",
"ha",
"log",
"mapping",
"nextid",
"notifications",
"options",
"replication",
"sdn",
"status",
"tasks",
)
async def cluster_status(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT id, name, status FROM nodes ORDER BY name"""
)
result: list[dict[str, Any]] = []
for index, row in enumerate(rows):
online = str(row["status"]) == "online"
result.append(
{
"id": str(row["id"]),
"name": str(row["name"]),
"nodeid": index,
"online": 1 if online else 0,
"local": 1 if index == 0 else 0,
"ip": f"10.32.{index // 254 + 1}.{index % 254 + 10}",
"level": "c",
"type": "node",
"quorate": 1,
}
)
return result
async def cluster_nextid(request: Request, inputs: dict[str, Any]) -> int:
requested = values(inputs).get("vmid")
if requested is not None:
candidate = int(requested)
taken = await database(request).pool.fetchval(
"""SELECT EXISTS(
SELECT 1 FROM resources WHERE kind IN ('qemu', 'lxc') AND external_id=$1
)""",
str(candidate),
)
if not taken:
return candidate
raise ApiError(400, f"VMID {candidate} already exists")
maximum = await database(request).pool.fetchval(
"""SELECT COALESCE(MAX(external_id::integer), 99)
FROM resources WHERE kind IN ('qemu', 'lxc')"""
)
return int(maximum) + 1
async def cluster_options_get(_request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
row = await database(_request).pool.fetchrow(
"SELECT metadata FROM clusters WHERE id=$1",
CLUSTER_ID,
)
metadata = state(row["metadata"]) if row is not None else {}
options = metadata.get("options", {})
if not isinstance(options, dict):
options = {}
return {
"keyboard": options.get("keyboard", "en-us"),
"email_from": options.get("email_from", "root@localhost"),
"http_proxy": options.get("http_proxy", ""),
"description": options.get("description", "Proxmox API emulator cluster"),
**options,
}
async def cluster_options_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
current = await cluster_options_get(request, inputs)
provided = values(inputs)
updated = {**current, **{key: value for key, value in provided.items() if key != "node"}}
await database(request).pool.execute(
"""UPDATE clusters SET metadata = jsonb_set(
COALESCE(metadata, '{}'::jsonb), '{options}', $2::jsonb, true
), updated_at=now() WHERE id=$1""",
CLUSTER_ID,
json.dumps(updated, sort_keys=True),
)
return updated
async def cluster_log(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
limit = int(values(inputs).get("max") or 50)
rows = await database(request).pool.fetch(
"""SELECT tl.message, tl.sequence
FROM task_logs tl
ORDER BY tl.created_at DESC, tl.sequence DESC
LIMIT $1""",
limit,
)
return [{"n": int(row["sequence"]), "t": str(row["message"])} for row in reversed(rows)]
async def cluster_tasks(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"SELECT upid FROM tasks ORDER BY created_at DESC LIMIT 1000"
)
return [{"upid": str(row["upid"])} for row in rows]
async def replication_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
return _replication_jobs(metadata)
async def replication_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
from app.handlers.common import require_value
payload = values(inputs)
guest = str(require_value(payload, "guest"))
target = str(require_value(payload, "target"))
job_id = str(payload.get("id") or f"repl-{guest.replace(':', '-')}")
metadata = await cluster_metadata(request)
jobs = _replication_jobs(metadata)
if any(str(item.get("id")) == job_id for item in jobs):
raise ApiError(409, "replication job already exists")
job = {
"id": job_id,
"guest": guest,
"target": target,
"type": str(payload.get("type") or "local"),
"schedule": str(payload.get("schedule") or "*/15"),
"rate": int(payload.get("rate") or 1),
"comment": str(payload.get("comment") or ""),
"enabled": int(payload.get("enabled", 1)),
}
jobs.append(job)
metadata["replication"] = jobs
await save_cluster_metadata(request, metadata)
return job
async def replication_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job_id = str(values(inputs)["id"])
for job in _replication_jobs(await cluster_metadata(request)):
if str(job.get("id")) == job_id:
return job
raise ApiError(404, "replication job does not exist")
async def replication_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _replication_jobs(metadata)
for index, job in enumerate(jobs):
if str(job.get("id")) != job_id:
continue
payload = values(inputs)
updated = {
**job,
**{
key: value
for key, value in payload.items()
if key not in {"id", "delete", "digest"}
},
}
jobs[index] = updated
metadata["replication"] = jobs
await save_cluster_metadata(request, metadata)
return updated
raise ApiError(404, "replication job does not exist")
async def replication_delete(request: Request, inputs: dict[str, Any]) -> None:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _replication_jobs(metadata)
remaining = [job for job in jobs if str(job.get("id")) != job_id]
if len(remaining) == len(jobs):
raise ApiError(404, "replication job does not exist")
metadata["replication"] = remaining
await save_cluster_metadata(request, metadata)
registry.register("/cluster", "GET", cluster_index)
registry.register("/cluster/status", "GET", cluster_status)
registry.register("/cluster/nextid", "GET", cluster_nextid)
registry.register("/cluster/options", "GET", cluster_options_get)
registry.register("/cluster/options", "PUT", cluster_options_put)
registry.register("/cluster/log", "GET", cluster_log)
registry.register("/cluster/tasks", "GET", cluster_tasks)
registry.register("/cluster/replication", "GET", replication_list)
registry.register("/cluster/replication", "POST", replication_create)
registry.register("/cluster/replication/{id}", "GET", replication_get)
registry.register("/cluster/replication/{id}", "PUT", replication_update)
registry.register("/cluster/replication/{id}", "DELETE", replication_delete)
+204
View File
@@ -0,0 +1,204 @@
"""Cluster config / join / totem handlers."""
from __future__ import annotations
import secrets
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import (
cluster_metadata,
database,
save_cluster_metadata,
subdirs,
values,
)
def _config(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault(
"cluster_config",
{
"clustername": "pve-simulator",
"votes": 1,
"links": {},
"join_info": {},
"totem": {"version": 2, "secauth": "on", "cluster_name": "pve-simulator"},
"qdevice": {"status": "disabled"},
"apiversion": 1,
},
)
if not isinstance(current, dict):
current = {
"clustername": "pve-simulator",
"votes": 1,
"links": {},
"join_info": {},
"totem": {"version": 2, "secauth": "on", "cluster_name": "pve-simulator"},
"qdevice": {"status": "disabled"},
"apiversion": 1,
}
metadata["cluster_config"] = current
return current
def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("apiversion", "join", "nodes", "qdevice", "totem")
async def create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
metadata = await cluster_metadata(request)
config = _config(metadata)
if payload.get("clustername"):
config["clustername"] = str(payload["clustername"])
config.setdefault("totem", {})["cluster_name"] = str(payload["clustername"])
if "votes" in payload:
config["votes"] = payload["votes"]
if "nodeid" in payload:
config["creator_nodeid"] = payload["nodeid"]
links = {key: value for key, value in payload.items() if key.startswith("link")}
if links:
config["links"] = links
config["token"] = secrets.token_hex(16)
await save_cluster_metadata(request, metadata)
await database(request).pool.execute(
"""UPDATE clusters
SET name=$1, updated_at=now()
WHERE id=(SELECT id FROM clusters LIMIT 1)""",
str(config["clustername"]),
)
async def apiversion(_request: Request, _inputs: dict[str, Any]) -> int:
metadata = await cluster_metadata(_request)
return int(_config(metadata).get("apiversion") or 1)
async def join_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
config = _config(metadata)
node = values(inputs).get("node")
rows = await database(request).pool.fetch("SELECT name, status FROM nodes ORDER BY name")
nodelist = [
{"name": str(row["name"]), "online": 1 if row["status"] == "online" else 0}
for row in rows
]
return {
"clustername": config.get("clustername"),
"config_digest": secrets.token_hex(8),
"nodelist": nodelist,
"preferred_node": node or (nodelist[0]["name"] if nodelist else None),
"totem": config.get("totem", {}),
"links": config.get("links", {}),
}
async def join_post(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
metadata = await cluster_metadata(request)
config = _config(metadata)
hostname = str(payload.get("hostname") or payload.get("node") or "")
if not hostname:
raise ApiError(400, "parameter verification failed - 'hostname' missing")
joins = config.setdefault("join_info", {})
joins[hostname] = {
"hostname": hostname,
"fingerprint": payload.get("fingerprint"),
"nodeid": payload.get("nodeid"),
"votes": payload.get("votes", 1),
"force": payload.get("force"),
}
# password accepted but not stored in clear form
if payload.get("password"):
joins[hostname]["password_set"] = True
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
hostname,
)
if not exists:
await database(request).pool.execute(
"""INSERT INTO nodes(id, name, status, metadata)
VALUES(gen_random_uuid(), $1, 'online', '{}'::jsonb)""",
hostname,
)
await save_cluster_metadata(request, metadata)
async def nodes_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(request).pool.fetch("SELECT name, status FROM nodes ORDER BY name")
metadata = await cluster_metadata(request)
config = _config(metadata)
result = []
for index, row in enumerate(rows, start=1):
result.append(
{
"node": str(row["name"]),
"nodeid": index,
"ring0_addr": f"{row['name']}.local",
"quorum_votes": config.get("votes", 1),
}
)
return result
async def nodes_add(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
metadata = await cluster_metadata(request)
config = _config(metadata)
added = config.setdefault("added_nodes", {})
added[node] = {
"node": node,
"nodeid": payload.get("nodeid"),
"new_node_ip": payload.get("new_node_ip"),
"votes": payload.get("votes", 1),
"apiversion": payload.get("apiversion"),
"force": payload.get("force"),
}
links = {key: value for key, value in payload.items() if key.startswith("link")}
if links:
added[node]["links"] = links
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
node,
)
if not exists:
await database(request).pool.execute(
"""INSERT INTO nodes(id, name, status, metadata)
VALUES(gen_random_uuid(), $1, 'online', '{}'::jsonb)""",
node,
)
await save_cluster_metadata(request, metadata)
async def nodes_delete(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
metadata = await cluster_metadata(request)
config = _config(metadata)
added = config.setdefault("added_nodes", {})
added.pop(node, None)
joins = config.setdefault("join_info", {})
joins.pop(node, None)
await save_cluster_metadata(request, metadata)
# Keep node row; mark offline to avoid cascading guest deletes.
await database(request).pool.execute(
"UPDATE nodes SET status='offline', updated_at=now() WHERE name=$1",
node,
)
async def qdevice(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
return dict(_config(metadata).get("qdevice") or {"status": "disabled"})
async def totem(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
return dict(_config(metadata).get("totem") or {})
registry.register("/cluster/config", "GET", index)
registry.register("/cluster/config", "POST", create)
registry.register("/cluster/config/apiversion", "GET", apiversion)
registry.register("/cluster/config/join", "GET", join_get)
registry.register("/cluster/config/join", "POST", join_post)
registry.register("/cluster/config/nodes", "GET", nodes_list)
registry.register("/cluster/config/nodes/{node}", "POST", nodes_add)
registry.register("/cluster/config/nodes/{node}", "DELETE", nodes_delete)
registry.register("/cluster/config/qdevice", "GET", qdevice)
registry.register("/cluster/config/totem", "GET", totem)
+688
View File
@@ -0,0 +1,688 @@
"""Additional cluster-level handlers with durable metadata persistence."""
from __future__ import annotations
import copy
import secrets
import time
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import (
cluster_metadata,
database,
require_node,
save_cluster_metadata,
subdirs,
values,
)
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
DEFAULT_CEPH_FLAGS: dict[str, int] = {
"nobackfill": 0,
"nodeep-scrub": 0,
"nodown": 0,
"noin": 0,
"noout": 0,
"norebalance": 0,
"norecover": 0,
"noscrub": 0,
"notieragent": 0,
"pause": 0,
}
DEFAULT_CPU_FLAGS: list[dict[str, Any]] = [
{"name": "aes", "introduces": "Westmere"},
{"name": "avx", "introduces": "SandyBridge"},
{"name": "avx2", "introduces": "Haswell"},
]
def _jobs(metadata: dict[str, Any]) -> dict[str, Any]:
jobs = metadata.setdefault("jobs", {})
if not isinstance(jobs, dict):
jobs = {}
metadata["jobs"] = jobs
sync = jobs.setdefault("realm_sync", {})
if not isinstance(sync, dict):
sync = {}
jobs["realm_sync"] = sync
return jobs
def _metrics(metadata: dict[str, Any]) -> dict[str, Any]:
metrics = metadata.setdefault("metrics", {})
if not isinstance(metrics, dict):
metrics = {}
metadata["metrics"] = metrics
servers = metrics.setdefault("servers", {})
if not isinstance(servers, dict):
servers = {}
metrics["servers"] = servers
return metrics
def _cpu_models(metadata: dict[str, Any]) -> dict[str, Any]:
models = metadata.get("qemu_cpu_models")
if not isinstance(models, dict):
models = {}
metadata["qemu_cpu_models"] = models
return models
def _ha_rules_store(metadata: dict[str, Any]) -> list[dict[str, Any]]:
rules = metadata.get("ha_rules")
if isinstance(rules, dict):
return [
{"rule": str(name), **dict(value)}
for name, value in rules.items()
if isinstance(value, dict)
]
if isinstance(rules, list):
return [dict(item) for item in rules if isinstance(item, dict)]
defaults = [
{"rule": "node-fencing", "type": "node", "action": "restart"},
{"rule": "service-ha", "type": "resource", "action": "failover"},
]
metadata["ha_rules"] = defaults
return list(defaults)
def _save_ha_rules(metadata: dict[str, Any], rules: list[dict[str, Any]]) -> None:
metadata["ha_rules"] = rules
def _replication_jobs(metadata: dict[str, Any]) -> list[dict[str, Any]]:
jobs = metadata.get("replication", [])
if not isinstance(jobs, list):
return []
return [dict(item) for item in jobs if isinstance(item, dict)]
def _ceph(metadata: dict[str, Any]) -> dict[str, Any]:
ceph = metadata.get("ceph")
if not isinstance(ceph, dict):
ceph = {}
flags = ceph.get("flags")
if not isinstance(flags, dict):
flags = copy.deepcopy(DEFAULT_CEPH_FLAGS)
else:
merged = copy.deepcopy(DEFAULT_CEPH_FLAGS)
merged.update({str(key): int(value) for key, value in flags.items()})
flags = merged
ceph["flags"] = flags
metadata["ceph"] = ceph
return ceph
async def _cluster_task(request: Request, *, task_type: str, worker: str) -> str:
from app.db.primitives import ConflictError
pool = database(request).pool
node = await pool.fetchval("SELECT name FROM nodes ORDER BY name LIMIT 1") or "localhost"
upid = str(Upid.allocate(str(node), worker, "0", str(request.state.principal)))
try:
task = await TaskRepository(pool).create(
upid=upid,
task_type=task_type,
payload={"cluster": True},
resource_key=f"cluster:{task_type}:{secrets.token_hex(4)}",
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
async def _bulk_guest_status(request: Request, status: str) -> None:
await database(request).pool.execute(
"""UPDATE resources
SET state = jsonb_set(COALESCE(state, '{}'::jsonb), '{status}', to_jsonb($1::text), true),
updated_at=now()
WHERE kind IN ('qemu', 'lxc')""",
status,
)
def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
async def jobs_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("realm-sync", "schedule-analyze")
async def realm_sync_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
items = [
{"id": job_id, **dict(payload)}
for job_id, payload in sorted(jobs.get("realm_sync", {}).items())
if isinstance(payload, dict)
]
return items
async def realm_sync_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
payload = jobs.get("realm_sync", {}).get(job_id)
if not isinstance(payload, dict):
raise ApiError(404, "realm-sync job does not exist")
return {"id": job_id, **payload}
async def realm_sync_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
job_id = str(payload["id"])
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
sync = jobs.setdefault("realm_sync", {})
if job_id in sync:
raise ApiError(409, "realm-sync job already exists")
entry = {
key: value for key, value in payload.items() if key not in {"id", "delete", "digest"}
}
entry.setdefault("schedule", "0 0 * * *")
entry.setdefault("enabled", 1)
entry.setdefault("realm", str(payload.get("realm") or "pam"))
sync[job_id] = entry
metadata["jobs"] = jobs
await save_cluster_metadata(request, metadata)
return {"id": job_id, **entry}
async def realm_sync_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
job_id = str(payload["id"])
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
sync = jobs.setdefault("realm_sync", {})
if job_id not in sync:
raise ApiError(404, "realm-sync job does not exist")
updated = {
**sync[job_id],
**{
key: value
for key, value in payload.items()
if key not in {"id", "delete", "digest"}
},
}
sync[job_id] = updated
metadata["jobs"] = jobs
await save_cluster_metadata(request, metadata)
return {"id": job_id, **updated}
async def realm_sync_delete(request: Request, inputs: dict[str, Any]) -> None:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
sync = jobs.setdefault("realm_sync", {})
if job_id not in sync:
raise ApiError(404, "realm-sync job does not exist")
del sync[job_id]
metadata["jobs"] = jobs
await save_cluster_metadata(request, metadata)
async def schedule_analyze(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
schedule = str(values(inputs).get("schedule") or "*/15")
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
jobs["last_schedule_analyze"] = {"schedule": schedule, "at": int(time.time())}
metadata["jobs"] = jobs
await save_cluster_metadata(request, metadata)
now = int(time.time())
return [{"timestamp": now + offset * 900, "utc": True} for offset in range(4)]
async def metrics_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("export", "server")
async def metrics_export(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
return {
"data": metrics.get("export_data")
or '# HELP pve_up Node is up\npve_up{node="pve01"} 1\n',
"timestamp": int(time.time()),
}
async def metrics_server_list(
request: Request, _inputs: dict[str, Any]
) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
return [
{"id": server_id, **dict(payload)}
for server_id, payload in sorted(metrics.get("servers", {}).items())
if isinstance(payload, dict)
]
async def metrics_server_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
server_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
payload = metrics.get("servers", {}).get(server_id)
if not isinstance(payload, dict):
raise ApiError(404, "metrics server does not exist")
return {"id": server_id, **payload}
async def metrics_server_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
server_id = str(payload["id"])
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
servers = metrics.setdefault("servers", {})
if server_id in servers:
raise ApiError(409, "metrics server already exists")
entry = {
key: value for key, value in payload.items() if key not in {"id", "delete", "digest"}
}
entry.setdefault("type", "influxdb")
entry.setdefault("server", "127.0.0.1")
entry.setdefault("port", 8086)
entry.setdefault("enable", 1)
servers[server_id] = entry
metadata["metrics"] = metrics
await save_cluster_metadata(request, metadata)
return {"id": server_id, **entry}
async def metrics_server_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
server_id = str(payload["id"])
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
servers = metrics.setdefault("servers", {})
if server_id not in servers:
raise ApiError(404, "metrics server does not exist")
updated = {
**servers[server_id],
**{
key: value
for key, value in payload.items()
if key not in {"id", "delete", "digest"}
},
}
servers[server_id] = updated
metadata["metrics"] = metrics
await save_cluster_metadata(request, metadata)
return {"id": server_id, **updated}
async def metrics_server_delete(request: Request, inputs: dict[str, Any]) -> None:
server_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
servers = metrics.setdefault("servers", {})
if server_id not in servers:
raise ApiError(404, "metrics server does not exist")
del servers[server_id]
metadata["metrics"] = metrics
await save_cluster_metadata(request, metadata)
async def qemu_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("cpu-flags", "custom-cpu-models")
async def qemu_cpu_flags(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(DEFAULT_CPU_FLAGS)
async def cpu_models_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
models = _cpu_models(metadata)
return [
{"name": name, **dict(payload)}
for name, payload in sorted(models.items())
if isinstance(payload, dict)
]
async def cpu_models_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
name = str(payload.get("name") or payload.get("cputype") or "")
if not name:
raise ApiError(400, "parameter verification failed - 'name' missing")
metadata = await cluster_metadata(request)
models = _cpu_models(metadata)
if name in models:
raise ApiError(409, "custom cpu model already exists")
entry = {
key: value
for key, value in payload.items()
if key not in {"name", "cputype", "delete", "digest"}
}
entry.setdefault("vendor", "Custom")
models[name] = entry
metadata["qemu_cpu_models"] = models
await save_cluster_metadata(request, metadata)
return {"name": name, **entry}
async def cpu_models_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
name = str(values(inputs)["cputype"])
metadata = await cluster_metadata(request)
models = _cpu_models(metadata)
payload = models.get(name)
if not isinstance(payload, dict):
raise ApiError(404, "custom cpu model does not exist")
return {"name": name, **payload}
async def cpu_models_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
name = str(payload["cputype"])
metadata = await cluster_metadata(request)
models = _cpu_models(metadata)
if name not in models:
raise ApiError(404, "custom cpu model does not exist")
updated = {
**models[name],
**{
key: value
for key, value in payload.items()
if key not in {"cputype", "delete", "digest"}
},
}
models[name] = updated
metadata["qemu_cpu_models"] = models
await save_cluster_metadata(request, metadata)
return {"name": name, **updated}
async def cpu_models_delete(request: Request, inputs: dict[str, Any]) -> None:
name = str(values(inputs)["cputype"])
metadata = await cluster_metadata(request)
models = _cpu_models(metadata)
if name not in models:
raise ApiError(404, "custom cpu model does not exist")
del models[name]
metadata["qemu_cpu_models"] = models
await save_cluster_metadata(request, metadata)
async def bulk_action_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("guest")
async def bulk_guest_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("migrate", "shutdown", "start", "suspend")
async def bulk_guest_action(request: Request, inputs: dict[str, Any], action: str) -> str:
payload = values(inputs)
metadata = await cluster_metadata(request)
metadata["last_bulk_action"] = {
"action": action,
"payload": {
key: value for key, value in payload.items() if key not in {"delete", "digest"}
},
"at": int(time.time()),
}
await save_cluster_metadata(request, metadata)
if action == "start":
await _bulk_guest_status(request, "running")
elif action == "shutdown":
await _bulk_guest_status(request, "stopped")
elif action == "suspend":
await _bulk_guest_status(request, "paused")
elif action == "migrate":
target = str(payload.get("target") or "")
if target:
target_row = await database(request).pool.fetchrow(
"SELECT id FROM nodes WHERE name=$1", target
)
if target_row is None:
raise ApiError(404, "target node does not exist")
vms = payload.get("vms") or payload.get("guests") or ""
if isinstance(vms, str) and vms:
ids = [part.strip() for part in vms.split(",") if part.strip()]
for vmid in ids:
await database(request).pool.execute(
"""UPDATE resources SET node_id=$2, updated_at=now()
WHERE kind IN ('qemu', 'lxc') AND external_id=$1""",
vmid,
target_row["id"],
)
return await _cluster_task(request, task_type=f"bulk-{action}", worker=f"bulk{action}")
async def cluster_ceph_index(
_request: Request, _inputs: dict[str, Any]
) -> list[dict[str, str]]:
return subdirs("flags", "metadata", "status")
async def ceph_flags_get(request: Request, _inputs: dict[str, Any]) -> dict[str, int]:
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
await save_cluster_metadata(request, metadata)
return {str(key): int(value) for key, value in ceph["flags"].items()}
async def ceph_flags_put(request: Request, inputs: dict[str, Any]) -> dict[str, int]:
payload = values(inputs)
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
flags = dict(ceph["flags"])
for key, value in payload.items():
if key in {"delete", "digest"}:
continue
flags[str(key)] = int(value)
ceph["flags"] = flags
metadata["ceph"] = ceph
await save_cluster_metadata(request, metadata)
return {str(key): int(value) for key, value in flags.items()}
async def ceph_flag_get(request: Request, inputs: dict[str, Any]) -> dict[str, int]:
flag = str(values(inputs)["flag"])
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
flags = ceph["flags"]
if flag not in flags:
raise ApiError(404, "ceph flag does not exist")
return {flag: int(flags[flag])}
async def ceph_flag_put(request: Request, inputs: dict[str, Any]) -> dict[str, int]:
payload = values(inputs)
flag = str(payload["flag"])
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
flags = dict(ceph["flags"])
if "value" in payload:
flags[flag] = int(payload["value"])
elif flag in payload:
flags[flag] = int(payload[flag])
else:
flags[flag] = 1
ceph["flags"] = flags
metadata["ceph"] = ceph
await save_cluster_metadata(request, metadata)
return {flag: int(flags[flag])}
async def ceph_metadata(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
await save_cluster_metadata(request, metadata)
return {
"version": ceph.get("version") or {"str": "18.2.2", "parts": [18, 2, 2]},
"fsid": ceph.get("config", {}).get("fsid")
if isinstance(ceph.get("config"), dict)
else "pve-simulator-fsid",
"initialized": int(bool(ceph.get("initialized", True))),
"flags": ceph.get("flags", {}),
}
async def ha_rule_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
rule = str(payload.get("rule") or payload.get("name") or "")
if not rule:
raise ApiError(400, "parameter verification failed - 'rule' missing")
metadata = await cluster_metadata(request)
rules = _ha_rules_store(metadata)
if any(str(item.get("rule")) == rule for item in rules):
raise ApiError(409, "HA rule already exists")
entry = {key: value for key, value in payload.items() if key not in {"delete", "digest"}}
entry["rule"] = rule
entry.setdefault("type", "resource")
entry.setdefault("action", "migrate")
rules.append(entry)
_save_ha_rules(metadata, rules)
await save_cluster_metadata(request, metadata)
async def ha_rule_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
rule = str(values(inputs)["rule"])
metadata = await cluster_metadata(request)
for item in _ha_rules_store(metadata):
if str(item.get("rule")) == rule:
return dict(item)
raise ApiError(404, "HA rule does not exist")
async def ha_rule_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
rule = str(payload["rule"])
metadata = await cluster_metadata(request)
rules = _ha_rules_store(metadata)
updated: list[dict[str, Any]] = []
found = False
for item in rules:
if str(item.get("rule")) != rule:
updated.append(item)
continue
found = True
merged = {
**item,
**{
key: value
for key, value in payload.items()
if key not in {"rule", "delete", "digest"}
},
}
merged["rule"] = rule
updated.append(merged)
if not found:
raise ApiError(404, "HA rule does not exist")
_save_ha_rules(metadata, updated)
await save_cluster_metadata(request, metadata)
async def ha_rule_delete(request: Request, inputs: dict[str, Any]) -> None:
rule = str(values(inputs)["rule"])
metadata = await cluster_metadata(request)
rules = _ha_rules_store(metadata)
remaining = [item for item in rules if str(item.get("rule")) != rule]
if len(remaining) == len(rules):
raise ApiError(404, "HA rule does not exist")
_save_ha_rules(metadata, remaining)
await save_cluster_metadata(request, metadata)
async def node_replication_list(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
metadata = await cluster_metadata(request)
jobs = _replication_jobs(metadata)
return [
job
for job in jobs
if str(job.get("source") or job.get("node") or node) == node
or job.get("source") is None
]
async def node_replication_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
job_id = str(values(inputs)["id"])
await require_node(request, node)
for job in _replication_jobs(await cluster_metadata(request)):
if str(job.get("id")) == job_id:
return dict(job)
raise ApiError(404, "replication job does not exist")
async def node_replication_log(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]]:
job = await node_replication_get(request, inputs)
log = job.get("log")
if isinstance(log, list):
return [dict(item) for item in log if isinstance(item, dict)]
return [{"t": int(time.time()), "n": 0, "msg": f"replication idle for {job.get('id')}"}]
async def node_replication_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job = await node_replication_get(request, inputs)
return {
"id": job.get("id"),
"last_sync": job.get("last_sync", 0),
"duration": job.get("duration", 0),
"fail_count": job.get("fail_count", 0),
"error": job.get("error", ""),
"state": job.get("state", "OK"),
}
async def node_replication_schedule_now(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
job_id = str(values(inputs)["id"])
await require_node(request, node)
metadata = await cluster_metadata(request)
jobs = _replication_jobs(metadata)
found = False
for job in jobs:
if str(job.get("id")) != job_id:
continue
found = True
job["last_sync"] = int(time.time())
job["state"] = "OK"
job["schedule_now"] = 1
if not found:
raise ApiError(404, "replication job does not exist")
metadata["replication"] = jobs
await save_cluster_metadata(request, metadata)
registry.register("/cluster/jobs", "GET", jobs_index)
registry.register("/cluster/jobs/realm-sync", "GET", realm_sync_list)
registry.register("/cluster/jobs/realm-sync/{id}", "GET", realm_sync_get)
registry.register("/cluster/jobs/realm-sync/{id}", "POST", realm_sync_create)
registry.register("/cluster/jobs/realm-sync/{id}", "PUT", realm_sync_update)
registry.register("/cluster/jobs/realm-sync/{id}", "DELETE", realm_sync_delete)
registry.register("/cluster/jobs/schedule-analyze", "GET", schedule_analyze)
registry.register("/cluster/metrics", "GET", metrics_index)
registry.register("/cluster/metrics/export", "GET", metrics_export)
registry.register("/cluster/metrics/server", "GET", metrics_server_list)
registry.register("/cluster/metrics/server/{id}", "GET", metrics_server_get)
registry.register("/cluster/metrics/server/{id}", "POST", metrics_server_create)
registry.register("/cluster/metrics/server/{id}", "PUT", metrics_server_update)
registry.register("/cluster/metrics/server/{id}", "DELETE", metrics_server_delete)
registry.register("/cluster/qemu", "GET", qemu_index)
registry.register("/cluster/qemu/cpu-flags", "GET", qemu_cpu_flags)
registry.register("/cluster/qemu/custom-cpu-models", "GET", cpu_models_list)
registry.register("/cluster/qemu/custom-cpu-models", "POST", cpu_models_create)
registry.register("/cluster/qemu/custom-cpu-models/{cputype}", "GET", cpu_models_get)
registry.register("/cluster/qemu/custom-cpu-models/{cputype}", "PUT", cpu_models_update)
registry.register("/cluster/qemu/custom-cpu-models/{cputype}", "DELETE", cpu_models_delete)
registry.register("/cluster/bulk-action", "GET", bulk_action_index)
registry.register("/cluster/bulk-action/guest", "GET", bulk_guest_index)
registry.register(
"/cluster/bulk-action/guest/migrate",
"POST",
lambda request, inputs: bulk_guest_action(request, inputs, "migrate"),
)
registry.register(
"/cluster/bulk-action/guest/shutdown",
"POST",
lambda request, inputs: bulk_guest_action(request, inputs, "shutdown"),
)
registry.register(
"/cluster/bulk-action/guest/start",
"POST",
lambda request, inputs: bulk_guest_action(request, inputs, "start"),
)
registry.register(
"/cluster/bulk-action/guest/suspend",
"POST",
lambda request, inputs: bulk_guest_action(request, inputs, "suspend"),
)
registry.register("/cluster/ceph", "GET", cluster_ceph_index)
registry.register("/cluster/ceph/flags", "GET", ceph_flags_get)
registry.register("/cluster/ceph/flags", "PUT", ceph_flags_put)
registry.register("/cluster/ceph/flags/{flag}", "GET", ceph_flag_get)
registry.register("/cluster/ceph/flags/{flag}", "PUT", ceph_flag_put)
registry.register("/cluster/ceph/metadata", "GET", ceph_metadata)
registry.register("/cluster/ha/rules", "POST", ha_rule_create)
registry.register("/cluster/ha/rules/{rule}", "GET", ha_rule_get)
registry.register("/cluster/ha/rules/{rule}", "PUT", ha_rule_update)
registry.register("/cluster/ha/rules/{rule}", "DELETE", ha_rule_delete)
registry.register("/nodes/{node}/replication", "GET", node_replication_list)
registry.register("/nodes/{node}/replication/{id}", "GET", node_replication_get)
registry.register("/nodes/{node}/replication/{id}/log", "GET", node_replication_log)
registry.register("/nodes/{node}/replication/{id}/status", "GET", node_replication_status)
registry.register(
"/nodes/{node}/replication/{id}/schedule_now", "POST", node_replication_schedule_now
)
+145
View File
@@ -0,0 +1,145 @@
"""Shared handler helpers."""
from __future__ import annotations
import json
import re
from collections.abc import Mapping
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.db.pool import AsyncpgDatabase
def database(request: Request) -> AsyncpgDatabase:
return cast(AsyncpgDatabase, request.app.state.database)
def values(inputs: dict[str, Any]) -> dict[str, Any]:
return cast(dict[str, Any], inputs["values"])
def require_value(payload: Mapping[str, Any], key: str) -> Any:
if key not in payload or payload[key] in {None, ""}:
raise ApiError(400, f"parameter '{key}' is required")
return payload[key]
def state(value: object) -> dict[str, Any]:
if isinstance(value, str):
return cast(dict[str, Any], json.loads(value))
return dict(cast(Mapping[str, Any], value))
def subdirs(*names: str) -> list[dict[str, str]]:
return [{"subdir": name} for name in names]
_SIZE_RE = re.compile(r"^(?P<value>\d+)(?P<unit>[KMGT]?)$", re.IGNORECASE)
_UNITS = {"": 1, "K": 2**10, "M": 2**20, "G": 2**30, "T": 2**40}
def parse_size_bytes(value: str) -> int:
match = _SIZE_RE.fullmatch(value.strip())
if match is None:
raise ValueError(f"invalid disk size: {value}")
return int(match.group("value")) * _UNITS[match.group("unit").upper()]
def resize_size_bytes(value: str, current: int) -> int:
if value.startswith("+"):
return current + parse_size_bytes(value[1:])
result = parse_size_bytes(value)
if result < current:
raise ValueError("shrinking disks is not supported")
return result
def replace_disk_size(value: str, size: int) -> str:
parts = [part for part in value.split(",") if not part.startswith("size=")]
parts.append(f"size={size // 2**30}G" if size % 2**30 == 0 else f"size={size}")
return ",".join(parts)
def disk_size_bytes(value: str) -> int:
for part in value.split(","):
if part.startswith("size="):
return parse_size_bytes(part.removeprefix("size="))
return 0
async def require_node(request: Request, node: str) -> None:
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
node,
)
if not exists:
raise ApiError(404, "node does not exist")
async def cluster_metadata(request: Request) -> dict[str, Any]:
from app.simulation.seed import CLUSTER_ID
row = await database(request).pool.fetchrow(
"SELECT metadata FROM clusters WHERE id=$1",
CLUSTER_ID,
)
return state(row["metadata"]) if row is not None else {}
async def save_cluster_metadata(request: Request, metadata: dict[str, Any]) -> None:
from app.simulation.seed import CLUSTER_ID
await database(request).pool.execute(
"UPDATE clusters SET metadata=$2::jsonb, updated_at=now() WHERE id=$1",
CLUSTER_ID,
json.dumps(metadata, sort_keys=True),
)
async def node_metadata(request: Request, node: str) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
"SELECT metadata FROM nodes WHERE name=$1",
node,
)
if row is None:
raise ApiError(404, "node does not exist")
return state(row["metadata"])
async def save_node_metadata(request: Request, node: str, metadata: dict[str, Any]) -> None:
status = await database(request).pool.execute(
"UPDATE nodes SET metadata=$2::jsonb, updated_at=now() WHERE name=$1",
node,
json.dumps(metadata, sort_keys=True),
)
if status != "UPDATE 1":
raise ApiError(404, "node does not exist")
def storage_payload(row: Any) -> dict[str, Any]:
config = state(row["config"])
content = config.get("content", [])
if isinstance(content, list):
content_str = ",".join(str(item) for item in content)
else:
content_str = str(content)
total = int(row["capacity_bytes"] or 0)
used = int(row["used_bytes"] or 0)
avail = max(total - used, 0)
payload: dict[str, Any] = {
"storage": str(row["storage_id"]),
"type": str(row["storage_type"]),
"shared": int(bool(row["shared"])),
"content": content_str,
"active": 1,
"enabled": 1,
"total": total,
"used": used,
"avail": avail,
}
if total:
payload["used_fraction"] = used / total
return payload
+65 -2
View File
@@ -10,9 +10,28 @@ from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.contracts.runtime import runtime_version_payload
from app.db.pool import AsyncpgDatabase
from app.handlers.access import register_access_handlers
from app.handlers.acme import register_acme_handlers
from app.handlers.backup import register_backup_handlers
from app.handlers.ceph import register_ceph_handlers
from app.handlers.cluster import register_cluster_handlers
from app.handlers.cluster_config import register_cluster_config_handlers
from app.handlers.cluster_extra import register_cluster_extra_handlers
from app.handlers.common import require_node, subdirs
from app.handlers.firewall import register_firewall_handlers
from app.handlers.ha import register_ha_handlers
from app.handlers.legacy_aliases import register_legacy_aliases
from app.handlers.lxc import register_lxc_handlers
from app.handlers.mapping import register_mapping_handlers
from app.handlers.nodes import register_node_ops_handlers
from app.handlers.nodes_extra import register_nodes_extra_handlers
from app.handlers.notifications import register_notifications_handlers
from app.handlers.pools import register_pool_handlers
from app.handlers.qemu import register_qemu_handlers
from app.handlers.sdn import register_sdn_handlers
from app.handlers.storage import register_storage_handlers
from app.security.auth import csrf_token, issue_ticket, verify_secret
@@ -23,8 +42,8 @@ def _database(request: Request) -> AsyncpgDatabase:
def build_core_handlers(settings: Settings) -> HandlerRegistry:
registry = HandlerRegistry()
async def version(_request: Request, _inputs: dict[str, Any]) -> dict[str, str]:
return {"version": "9.2.3", "release": "9.2", "repoid": "simulator"}
async def version(request: Request, _inputs: dict[str, Any]) -> dict[str, str]:
return runtime_version_payload(request)
async def login(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = cast(dict[str, Any], inputs["values"])
@@ -89,11 +108,55 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
)
return result
async def node_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
node = str(cast(dict[str, Any], inputs["values"])["node"])
await require_node(request, node)
return subdirs(
"apt",
"ceph",
"disks",
"firewall",
"lxc",
"network",
"qemu",
"services",
"status",
"storage",
"tasks",
"version",
"vzdump",
)
async def node_version(request: Request, inputs: dict[str, Any]) -> dict[str, str]:
node = str(cast(dict[str, Any], inputs["values"])["node"])
await require_node(request, node)
return runtime_version_payload(request)
registry.register("/version", "GET", version)
registry.register("/access/ticket", "POST", login)
registry.register("/nodes", "GET", nodes)
registry.register("/nodes/{node}", "GET", node_index)
registry.register("/nodes/{node}/status", "GET", node_status)
registry.register("/nodes/{node}/version", "GET", node_version)
registry.register("/cluster/resources", "GET", resources)
register_access_handlers(registry)
register_cluster_handlers(registry)
register_notifications_handlers(registry)
register_mapping_handlers(registry)
register_acme_handlers(registry)
register_cluster_config_handlers(registry)
register_sdn_handlers(registry)
register_storage_handlers(registry)
register_pool_handlers(registry)
register_ceph_handlers(registry)
register_backup_handlers(registry)
register_ha_handlers(registry)
register_node_ops_handlers(registry)
register_firewall_handlers(registry)
register_qemu_handlers(registry)
register_lxc_handlers(registry)
register_nodes_extra_handlers(registry)
register_cluster_extra_handlers(registry)
register_legacy_aliases(registry)
return registry
+526
View File
@@ -0,0 +1,526 @@
"""Firewall handlers backed by cluster metadata."""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import database, require_node, state, subdirs, values
from app.simulation.seed import CLUSTER_ID
DEFAULT_OPTIONS = {
"enable": 1,
"policy_in": "DROP",
"policy_out": "ACCEPT",
"log_level_in": "nolog",
"log_level_out": "nolog",
}
DEFAULT_MACROS = [
{"macro": "SSH", "descr": "Secure Shell"},
{"macro": "HTTPS", "descr": "Secure web server"},
{"macro": "HTTP", "descr": "Web server"},
]
ScopeFn = Callable[[dict[str, Any]], str]
async def _load_firewall(request: Request) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
"SELECT metadata FROM clusters WHERE id=$1",
CLUSTER_ID,
)
metadata = state(row["metadata"]) if row is not None else {}
firewall = metadata.get("firewall")
return dict(firewall) if isinstance(firewall, dict) else {}
async def _save_firewall(request: Request, firewall: dict[str, Any]) -> None:
await database(request).pool.execute(
"""UPDATE clusters SET metadata = jsonb_set(
COALESCE(metadata, '{}'::jsonb), '{firewall}', $2::jsonb, true
), updated_at=now() WHERE id=$1""",
CLUSTER_ID,
json.dumps(firewall, sort_keys=True),
)
def _scope_data(firewall: dict[str, Any], scope: str) -> dict[str, Any]:
scopes = firewall.setdefault("scopes", {})
if scope not in scopes or not isinstance(scopes[scope], dict):
scopes[scope] = {
"options": dict(DEFAULT_OPTIONS),
"rules": [],
"aliases": {},
"ipset": {},
"groups": {},
"log": [],
}
section = scopes[scope]
section.setdefault("options", dict(DEFAULT_OPTIONS))
section.setdefault("rules", [])
section.setdefault("aliases", {})
section.setdefault("ipset", {})
section.setdefault("groups", {})
section.setdefault("log", [])
return cast(dict[str, Any], section)
def register_firewall_handlers(registry: HandlerRegistry) -> None:
def register_scope(
base: str,
scope_fn: ScopeFn,
*,
require_node_name: bool = False,
include_macros: bool = False,
include_groups: bool = False,
) -> None:
async def _ready(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
if require_node_name:
await require_node(request, str(payload["node"]))
return payload
async def index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await _ready(request, inputs)
names = ["aliases", "ipset", "log", "options", "refs", "rules"]
if include_groups:
names.insert(2, "groups")
if include_macros:
names.append("macros")
return subdirs(*names)
async def options_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
return dict(_scope_data(firewall, scope_fn(payload)).get("options", DEFAULT_OPTIONS))
async def options_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
section = _scope_data(firewall, scope_fn(payload))
current = dict(section.get("options", DEFAULT_OPTIONS))
for key, value in payload.items():
if key in {"node", "vmid", "delete", "digest"}:
continue
current[key] = value
section["options"] = current
await _save_firewall(request, firewall)
return current
async def rules_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
rules = _scope_data(firewall, scope_fn(payload)).get("rules", [])
return list(rules) if isinstance(rules, list) else []
async def rules_create(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
section = _scope_data(firewall, scope_fn(payload))
rules = section.setdefault("rules", [])
if not isinstance(rules, list):
rules = section["rules"] = []
rule = {
key: value for key, value in payload.items() if key not in {"node", "vmid", "pos"}
}
rule["pos"] = len(rules)
rules.append(rule)
await _save_firewall(request, firewall)
async def rule_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
pos = int(values(inputs)["pos"])
rules = await rules_list(request, inputs)
if pos < 0 or pos >= len(rules):
raise ApiError(404, "firewall rule does not exist")
return dict(rules[pos])
async def rule_update(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
pos = int(payload["pos"])
firewall = await _load_firewall(request)
rules = _scope_data(firewall, scope_fn(payload)).setdefault("rules", [])
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
raise ApiError(404, "firewall rule does not exist")
rules[pos] = {
**rules[pos],
**{k: v for k, v in payload.items() if k not in {"node", "vmid"}},
}
await _save_firewall(request, firewall)
async def rule_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
pos = int(payload["pos"])
firewall = await _load_firewall(request)
rules = _scope_data(firewall, scope_fn(payload)).setdefault("rules", [])
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
raise ApiError(404, "firewall rule does not exist")
del rules[pos]
for index, rule in enumerate(rules):
if isinstance(rule, dict):
rule["pos"] = index
await _save_firewall(request, firewall)
async def aliases_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
aliases = _scope_data(firewall, scope_fn(payload)).get("aliases", {})
if not isinstance(aliases, dict):
return []
return [
{"name": name, **{k: v for k, v in data.items() if k != "name"}}
for name, data in sorted(aliases.items())
if isinstance(data, dict)
]
async def aliases_create(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
section = _scope_data(firewall, scope_fn(payload))
aliases = section.setdefault("aliases", {})
if name in aliases:
raise ApiError(400, f"alias '{name}' already exists")
aliases[name] = {
"name": name,
"cidr": str(payload.get("cidr") or ""),
"comment": str(payload.get("comment") or ""),
}
await _save_firewall(request, firewall)
async def aliases_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
alias = _scope_data(firewall, scope_fn(payload)).get("aliases", {}).get(name)
if not isinstance(alias, dict):
raise ApiError(404, "alias does not exist")
return dict(alias)
async def aliases_update(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
aliases = _scope_data(firewall, scope_fn(payload)).setdefault("aliases", {})
if name not in aliases or not isinstance(aliases[name], dict):
raise ApiError(404, "alias does not exist")
current = dict(aliases[name])
if payload.get("rename"):
new_name = str(payload["rename"])
if new_name in aliases and new_name != name:
raise ApiError(400, f"alias '{new_name}' already exists")
del aliases[name]
name = new_name
current["name"] = new_name
for key in ("cidr", "comment"):
if key in payload:
current[key] = payload[key]
aliases[name] = current
await _save_firewall(request, firewall)
async def aliases_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
aliases = _scope_data(firewall, scope_fn(payload)).setdefault("aliases", {})
if name not in aliases:
raise ApiError(404, "alias does not exist")
del aliases[name]
await _save_firewall(request, firewall)
async def ipset_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).get("ipset", {})
if not isinstance(ipsets, dict):
return []
return [
{"name": name, "comment": data.get("comment", "")}
for name, data in sorted(ipsets.items())
if isinstance(data, dict)
]
async def ipset_create(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
if name in ipsets:
raise ApiError(400, f"ipset '{name}' already exists")
ipsets[name] = {
"name": name,
"comment": str(payload.get("comment") or ""),
"entries": {},
}
await _save_firewall(request, firewall)
async def ipset_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
ipset = _scope_data(firewall, scope_fn(payload)).get("ipset", {}).get(name)
if not isinstance(ipset, dict):
raise ApiError(404, "ipset does not exist")
entries = ipset.get("entries", {})
if not isinstance(entries, dict):
return []
return [
{"cidr": cidr, **{k: v for k, v in data.items() if k != "cidr"}}
for cidr, data in sorted(entries.items())
if isinstance(data, dict)
]
async def ipset_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
if name not in ipsets:
raise ApiError(404, "ipset does not exist")
del ipsets[name]
await _save_firewall(request, firewall)
async def ipset_entry_create(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
cidr = str(payload["cidr"])
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
if name not in ipsets or not isinstance(ipsets[name], dict):
raise ApiError(404, "ipset does not exist")
entries = ipsets[name].setdefault("entries", {})
if cidr in entries:
raise ApiError(400, f"ip '{cidr}' already exists in ipset")
entries[cidr] = {
"cidr": cidr,
"comment": str(payload.get("comment") or ""),
"nomatch": int(bool(payload.get("nomatch"))),
}
await _save_firewall(request, firewall)
async def ipset_entry_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = await _ready(request, inputs)
name = str(payload["name"])
cidr = str(payload["cidr"])
firewall = await _load_firewall(request)
ipset = _scope_data(firewall, scope_fn(payload)).get("ipset", {}).get(name)
if not isinstance(ipset, dict):
raise ApiError(404, "ipset does not exist")
entry = ipset.get("entries", {}).get(cidr)
if not isinstance(entry, dict):
raise ApiError(404, "ipset entry does not exist")
return dict(entry)
async def ipset_entry_update(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
cidr = str(payload["cidr"])
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
if name not in ipsets or not isinstance(ipsets[name], dict):
raise ApiError(404, "ipset does not exist")
entries = ipsets[name].setdefault("entries", {})
if cidr not in entries:
raise ApiError(404, "ipset entry does not exist")
current = dict(entries[cidr])
if "comment" in payload:
current["comment"] = payload["comment"]
if "nomatch" in payload:
current["nomatch"] = int(bool(payload.get("nomatch")))
entries[cidr] = current
await _save_firewall(request, firewall)
async def ipset_entry_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
cidr = str(payload["cidr"])
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
if name not in ipsets or not isinstance(ipsets[name], dict):
raise ApiError(404, "ipset does not exist")
entries = ipsets[name].setdefault("entries", {})
if cidr not in entries:
raise ApiError(404, "ipset entry does not exist")
del entries[cidr]
await _save_firewall(request, firewall)
async def refs_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
section = _scope_data(firewall, scope_fn(payload))
refs: list[dict[str, Any]] = []
for name in section.get("aliases", {}):
refs.append({"type": "alias", "name": name})
for name in section.get("ipset", {}):
refs.append({"type": "ipset", "name": name})
return refs
async def log_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
log = _scope_data(firewall, scope_fn(payload)).get("log", [])
return list(log) if isinstance(log, list) else []
async def macros_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(DEFAULT_MACROS)
async def groups_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).get("groups", {})
if not isinstance(groups, dict):
return []
return [
{"group": name, "comment": data.get("comment", "")}
for name, data in sorted(groups.items())
if isinstance(data, dict)
]
async def groups_create(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
group = str(payload["group"])
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
if group in groups:
raise ApiError(400, f"security group '{group}' already exists")
groups[group] = {"comment": str(payload.get("comment") or ""), "rules": []}
await _save_firewall(request, firewall)
async def group_rules(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
group = str(payload["group"])
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).get("groups", {})
if group not in groups or not isinstance(groups[group], dict):
raise ApiError(404, "security group does not exist")
rules = groups[group].get("rules", [])
return list(rules) if isinstance(rules, list) else []
async def group_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
group = str(payload["group"])
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
if group not in groups:
raise ApiError(404, "security group does not exist")
del groups[group]
await _save_firewall(request, firewall)
async def group_rule_create(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
group = str(payload["group"])
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
if group not in groups or not isinstance(groups[group], dict):
raise ApiError(404, "security group does not exist")
rules = groups[group].setdefault("rules", [])
if not isinstance(rules, list):
rules = groups[group]["rules"] = []
rule = {
key: value
for key, value in payload.items()
if key not in {"node", "vmid", "group", "pos"}
}
rule["pos"] = len(rules)
rules.append(rule)
await _save_firewall(request, firewall)
async def group_rule_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
rules = await group_rules(request, inputs)
pos = int(values(inputs)["pos"])
if pos < 0 or pos >= len(rules):
raise ApiError(404, "firewall rule does not exist")
return dict(rules[pos])
async def group_rule_update(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
group = str(payload["group"])
pos = int(payload["pos"])
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
if group not in groups or not isinstance(groups[group], dict):
raise ApiError(404, "security group does not exist")
rules = groups[group].setdefault("rules", [])
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
raise ApiError(404, "firewall rule does not exist")
rules[pos] = {
**rules[pos],
**{k: v for k, v in payload.items() if k not in {"node", "vmid", "group"}},
}
await _save_firewall(request, firewall)
async def group_rule_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
group = str(payload["group"])
pos = int(payload["pos"])
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
if group not in groups or not isinstance(groups[group], dict):
raise ApiError(404, "security group does not exist")
rules = groups[group].setdefault("rules", [])
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
raise ApiError(404, "firewall rule does not exist")
del rules[pos]
await _save_firewall(request, firewall)
registry.register(base, "GET", index)
registry.register(f"{base}/options", "GET", options_get)
registry.register(f"{base}/options", "PUT", options_put)
registry.register(f"{base}/rules", "GET", rules_list)
registry.register(f"{base}/rules", "POST", rules_create)
registry.register(f"{base}/rules/{{pos}}", "GET", rule_get)
registry.register(f"{base}/rules/{{pos}}", "PUT", rule_update)
registry.register(f"{base}/rules/{{pos}}", "DELETE", rule_delete)
registry.register(f"{base}/aliases", "GET", aliases_list)
registry.register(f"{base}/aliases", "POST", aliases_create)
registry.register(f"{base}/aliases/{{name}}", "GET", aliases_get)
registry.register(f"{base}/aliases/{{name}}", "PUT", aliases_update)
registry.register(f"{base}/aliases/{{name}}", "DELETE", aliases_delete)
registry.register(f"{base}/ipset", "GET", ipset_list)
registry.register(f"{base}/ipset", "POST", ipset_create)
registry.register(f"{base}/ipset/{{name}}", "GET", ipset_get)
registry.register(f"{base}/ipset/{{name}}", "DELETE", ipset_delete)
registry.register(f"{base}/ipset/{{name}}", "POST", ipset_entry_create)
registry.register(f"{base}/ipset/{{name}}/{{cidr}}", "GET", ipset_entry_get)
registry.register(f"{base}/ipset/{{name}}/{{cidr}}", "PUT", ipset_entry_update)
registry.register(f"{base}/ipset/{{name}}/{{cidr}}", "DELETE", ipset_entry_delete)
registry.register(f"{base}/refs", "GET", refs_list)
registry.register(f"{base}/log", "GET", log_list)
if include_macros:
registry.register(f"{base}/macros", "GET", macros_list)
if include_groups:
registry.register(f"{base}/groups", "GET", groups_list)
registry.register(f"{base}/groups", "POST", groups_create)
registry.register(f"{base}/groups/{{group}}", "GET", group_rules)
registry.register(f"{base}/groups/{{group}}", "POST", group_rule_create)
registry.register(f"{base}/groups/{{group}}", "DELETE", group_delete)
registry.register(f"{base}/groups/{{group}}/{{pos}}", "GET", group_rule_get)
registry.register(f"{base}/groups/{{group}}/{{pos}}", "PUT", group_rule_update)
registry.register(f"{base}/groups/{{group}}/{{pos}}", "DELETE", group_rule_delete)
register_scope(
"/cluster/firewall",
lambda _payload: "cluster",
include_macros=True,
include_groups=True,
)
register_scope(
"/nodes/{node}/firewall",
lambda payload: f"node:{payload['node']}",
require_node_name=True,
)
register_scope(
"/nodes/{node}/qemu/{vmid}/firewall",
lambda payload: f"qemu:{payload['node']}:{payload['vmid']}",
require_node_name=True,
)
register_scope(
"/nodes/{node}/lxc/{vmid}/firewall",
lambda payload: f"lxc:{payload['node']}:{payload['vmid']}",
require_node_name=True,
)
+350
View File
@@ -0,0 +1,350 @@
"""High availability semantic handlers."""
from __future__ import annotations
import json
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import (
cluster_metadata,
database,
save_cluster_metadata,
state,
subdirs,
values,
)
from app.simulation.seed import CLUSTER_ID, stable_id
def _ha_groups(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
groups = metadata.get("ha_groups", {})
if not isinstance(groups, dict):
return {}
return {str(key): dict(value) for key, value in groups.items() if isinstance(value, dict)}
def register_ha_handlers(registry: HandlerRegistry) -> None:
async def ha_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("groups", "resources", "rules", "status")
async def ha_resources(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT r.external_id, r.state, n.name AS node
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE r.kind='ha' ORDER BY r.external_id"""
)
result: list[dict[str, Any]] = []
for row in rows:
payload = state(row["state"])
sid = str(row["external_id"])
result.append(
{
"sid": sid,
"type": "vm" if sid.startswith("vm:") else "ct",
"state": payload.get("state", "started"),
"group": payload.get("group"),
"node": str(row["node"]),
"max_relocate": payload.get("max_relocate", 1),
"max_restart": payload.get("max_restart", 1),
}
)
return result
async def ha_resource_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
sid = str(values(inputs)["sid"])
items = await ha_resources(request, inputs)
for item in items:
if item["sid"] == sid:
return item
raise ApiError(404, "HA resource does not exist")
async def ha_resource_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
sid = str(payload["sid"])
group = str(payload.get("group") or "")
exists = await database(request).pool.fetchval(
"""SELECT EXISTS(SELECT 1 FROM resources WHERE kind='ha' AND external_id=$1)""",
sid,
)
if exists:
raise ApiError(409, "HA resource already exists")
guest_kind, _, guest_id = sid.partition(":")
if guest_kind not in {"vm", "ct"} or not guest_id.isdigit():
raise ApiError(400, "invalid HA resource sid")
resource_kind = "qemu" if guest_kind == "vm" else "lxc"
guest = await database(request).pool.fetchrow(
"""SELECT r.id, n.name FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE r.kind=$1 AND r.external_id=$2""",
resource_kind,
guest_id,
)
if guest is None:
raise ApiError(404, "guest does not exist")
node = await database(request).pool.fetchrow(
"SELECT id FROM nodes WHERE name=$1",
str(guest["name"]),
)
if node is None:
raise ApiError(404, "node does not exist")
ha_state = {
"state": str(payload.get("state") or "started"),
"group": group or None,
"max_relocate": int(payload.get("max_relocate") or 1),
"max_restart": int(payload.get("max_restart") or 1),
}
await database(request).pool.execute(
"""INSERT INTO resources(id, node_id, cluster_id, kind, external_id, state, metadata)
VALUES($1, $2, $3, 'ha', $4, $5::jsonb, '{}'::jsonb)""",
stable_id(f"ha:{sid}"),
node["id"],
CLUSTER_ID,
sid,
json.dumps(ha_state, sort_keys=True),
)
async def ha_resource_update(request: Request, inputs: dict[str, Any]) -> None:
sid = str(values(inputs)["sid"])
payload = values(inputs)
row = await database(request).pool.fetchrow(
"SELECT id, state FROM resources WHERE kind='ha' AND external_id=$1",
sid,
)
if row is None:
raise ApiError(404, "HA resource does not exist")
current = state(row["state"])
updated = {
**current,
**{
key: value
for key, value in payload.items()
if key not in {"sid", "delete", "digest"}
},
}
await database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, updated_at=now() WHERE id=$1",
row["id"],
json.dumps(updated, sort_keys=True),
)
async def ha_resource_delete(request: Request, inputs: dict[str, Any]) -> None:
sid = str(values(inputs)["sid"])
status = await database(request).pool.execute(
"DELETE FROM resources WHERE kind='ha' AND external_id=$1",
sid,
)
if status != "DELETE 1":
raise ApiError(404, "HA resource does not exist")
async def ha_groups(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
configured = _ha_groups(metadata)
result = [
{
"group": group_id,
"nodes": str(payload.get("nodes", "")),
"nofailback": int(payload.get("nofailback", 0)),
"restricted": int(payload.get("restricted", 0)),
"type": "group",
"comment": payload.get("comment", ""),
}
for group_id, payload in sorted(configured.items())
]
if result:
return result
rows = await database(request).pool.fetch(
"""SELECT DISTINCT state->>'group' AS group_id
FROM resources WHERE kind='ha' AND state ? 'group'
ORDER BY 1"""
)
node_names = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
nodes = ",".join(str(row["name"]) for row in node_names) or "pve01"
return [
{
"group": str(row["group_id"]),
"nodes": nodes,
"nofailback": 0,
"restricted": 0,
"type": "group",
}
for row in rows
if row["group_id"]
]
async def ha_group_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
group = str(values(inputs)["group"])
for item in await ha_groups(request, inputs):
if item["group"] == group:
return item
raise ApiError(404, "HA group does not exist")
async def ha_group_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
group = str(payload["group"])
metadata = await cluster_metadata(request)
groups = _ha_groups(metadata)
if group in groups:
raise ApiError(409, "HA group already exists")
groups[group] = {
"nodes": str(payload.get("nodes") or ""),
"nofailback": int(payload.get("nofailback") or 0),
"restricted": int(payload.get("restricted") or 0),
"comment": str(payload.get("comment") or ""),
}
metadata["ha_groups"] = groups
await save_cluster_metadata(request, metadata)
async def ha_group_update(request: Request, inputs: dict[str, Any]) -> None:
group = str(values(inputs)["group"])
metadata = await cluster_metadata(request)
groups = _ha_groups(metadata)
if group not in groups:
raise ApiError(404, "HA group does not exist")
payload = values(inputs)
groups[group] = {
**groups[group],
**{
key: value
for key, value in payload.items()
if key not in {"group", "delete", "digest"}
},
}
metadata["ha_groups"] = groups
await save_cluster_metadata(request, metadata)
async def ha_group_delete(request: Request, inputs: dict[str, Any]) -> None:
group = str(values(inputs)["group"])
metadata = await cluster_metadata(request)
groups = _ha_groups(metadata)
if group not in groups:
raise ApiError(404, "HA group does not exist")
del groups[group]
metadata["ha_groups"] = groups
await save_cluster_metadata(request, metadata)
async def ha_status(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("current", "manager_status")
async def ha_status_current(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
"""SELECT count(*) FILTER (WHERE state->>'state' = 'started') AS started,
count(*) AS total
FROM resources WHERE kind='ha'"""
)
master = await database(request).pool.fetchval(
"SELECT name FROM nodes WHERE status='online' ORDER BY name LIMIT 1"
)
metadata = await cluster_metadata(request)
ha = metadata.get("ha", {}) if isinstance(metadata.get("ha"), dict) else {}
return {
"quorate": 1,
"mode": "active" if ha.get("armed", True) else "disabled",
"master_node": str(master or "pve01"),
"ha_started": int(row["started"] or 0),
"ha_total": int(row["total"] or 0),
"armed": 1 if ha.get("armed", True) else 0,
}
async def ha_manager_status(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
ha = metadata.get("ha", {}) if isinstance(metadata.get("ha"), dict) else {}
armed = bool(ha.get("armed", True))
return {
"manager_status": "active" if armed else "disabled",
"quorum": "OK",
"armed": 1 if armed else 0,
}
async def ha_rules(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
rules = metadata.get("ha_rules")
if isinstance(rules, list) and rules:
return [dict(item) for item in rules if isinstance(item, dict)]
defaults = [
{"rule": "node-fencing", "type": "node", "action": "restart"},
{"rule": "service-ha", "type": "resource", "action": "failover"},
]
metadata["ha_rules"] = defaults
await save_cluster_metadata(request, metadata)
return list(defaults)
async def ha_relocate(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
sid = str(payload["sid"])
target = str(payload.get("node") or payload.get("target") or "")
if not target:
raise ApiError(400, "parameter verification failed - target node missing")
ha_row = await database(request).pool.fetchrow(
"SELECT id, state FROM resources WHERE kind='ha' AND external_id=$1",
sid,
)
if ha_row is None:
raise ApiError(404, "HA resource does not exist")
node = await database(request).pool.fetchrow(
"SELECT id, name FROM nodes WHERE name=$1",
target,
)
if node is None:
raise ApiError(404, "node does not exist")
guest_kind, _, guest_id = sid.partition(":")
resource_kind = "qemu" if guest_kind == "vm" else "lxc"
await database(request).pool.execute(
"UPDATE resources SET node_id=$2, updated_at=now() WHERE kind='ha' AND external_id=$1",
sid,
node["id"],
)
await database(request).pool.execute(
"""UPDATE resources SET node_id=$3, updated_at=now()
WHERE kind=$1 AND external_id=$2""",
resource_kind,
guest_id,
node["id"],
)
current = state(ha_row["state"])
current["node"] = target
current["state"] = current.get("state") or "started"
await database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, updated_at=now() WHERE id=$1",
ha_row["id"],
json.dumps(current, sort_keys=True),
)
async def ha_migrate(request: Request, inputs: dict[str, Any]) -> None:
await ha_relocate(request, inputs)
async def ha_arm(request: Request, _inputs: dict[str, Any]) -> None:
metadata = await cluster_metadata(request)
ha = dict(metadata.get("ha") or {})
ha["armed"] = True
metadata["ha"] = ha
await save_cluster_metadata(request, metadata)
async def ha_disarm(request: Request, _inputs: dict[str, Any]) -> None:
metadata = await cluster_metadata(request)
ha = dict(metadata.get("ha") or {})
ha["armed"] = False
metadata["ha"] = ha
await save_cluster_metadata(request, metadata)
registry.register("/cluster/ha", "GET", ha_index)
registry.register("/cluster/ha/groups", "GET", ha_groups)
registry.register("/cluster/ha/groups", "POST", ha_group_create)
registry.register("/cluster/ha/groups/{group}", "GET", ha_group_get)
registry.register("/cluster/ha/groups/{group}", "PUT", ha_group_update)
registry.register("/cluster/ha/groups/{group}", "DELETE", ha_group_delete)
registry.register("/cluster/ha/resources", "GET", ha_resources)
registry.register("/cluster/ha/resources", "POST", ha_resource_create)
registry.register("/cluster/ha/resources/{sid}", "GET", ha_resource_get)
registry.register("/cluster/ha/resources/{sid}", "PUT", ha_resource_update)
registry.register("/cluster/ha/resources/{sid}", "DELETE", ha_resource_delete)
registry.register("/cluster/ha/status", "GET", ha_status)
registry.register("/cluster/ha/status/current", "GET", ha_status_current)
registry.register("/cluster/ha/status/manager_status", "GET", ha_manager_status)
registry.register("/cluster/ha/rules", "GET", ha_rules)
registry.register("/cluster/ha/resources/{sid}/migrate", "POST", ha_migrate)
registry.register("/cluster/ha/resources/{sid}/relocate", "POST", ha_relocate)
registry.register("/cluster/ha/status/arm-ha", "POST", ha_arm)
registry.register("/cluster/ha/status/disarm-ha", "POST", ha_disarm)
+59
View File
@@ -0,0 +1,59 @@
"""Legacy Proxmox path aliases for older contract snapshots."""
from __future__ import annotations
from app.api.registry import HandlerRegistry
def register_legacy_aliases(registry: HandlerRegistry) -> None:
"""Register older-path synonyms onto already-registered handlers when present."""
def alias(old_path: str, old_verb: str, new_path: str, new_verb: str | None = None) -> None:
verb = (new_verb or old_verb).upper()
handler = registry.get(new_path, verb)
if handler is None:
return
if registry.get(old_path, old_verb) is not None:
return
registry.register(old_path, old_verb.upper(), handler)
alias("/access/tfa", "POST", "/access/tfa/{userid}", "POST")
alias("/access/tfa", "PUT", "/access/tfa/{userid}/{id}", "PUT")
alias("/cluster/backupinfo", "GET", "/cluster/backup-info", "GET")
alias(
"/cluster/backupinfo/not_backed_up",
"GET",
"/cluster/backup-info/not-backed-up",
"GET",
)
alias("/nodes/{node}/ceph/config", "GET", "/nodes/{node}/ceph/cfg/raw", "GET")
alias("/nodes/{node}/ceph/configdb", "GET", "/nodes/{node}/ceph/cfg/db", "GET")
alias("/nodes/{node}/ceph/disks", "GET", "/nodes/{node}/ceph/osd", "GET")
alias("/nodes/{node}/ceph/flags", "GET", "/cluster/ceph/flags", "GET")
alias("/nodes/{node}/ceph/flags/{flag}", "POST", "/cluster/ceph/flags/{flag}", "PUT")
alias("/nodes/{node}/ceph/flags/{flag}", "DELETE", "/cluster/ceph/flags/{flag}", "PUT")
alias("/nodes/{node}/ceph/pools", "GET", "/nodes/{node}/ceph/pool", "GET")
alias("/nodes/{node}/ceph/pools", "POST", "/nodes/{node}/ceph/pool", "POST")
alias("/nodes/{node}/ceph/pools/{name}", "GET", "/nodes/{node}/ceph/pool/{name}", "GET")
alias("/nodes/{node}/ceph/pools/{name}", "PUT", "/nodes/{node}/ceph/pool/{name}", "PUT")
alias(
"/nodes/{node}/ceph/pools/{name}",
"DELETE",
"/nodes/{node}/ceph/pool/{name}",
"DELETE",
)
alias("/nodes/{node}/cpu", "GET", "/nodes/{node}/capabilities/qemu/cpu", "GET")
alias(
"/nodes/{node}/hardware/pci/{pciid}",
"GET",
"/nodes/{node}/hardware/pci/{pci-id-or-mapping}",
"GET",
)
alias(
"/nodes/{node}/hardware/pci/{pciid}/mdev",
"GET",
"/nodes/{node}/hardware/pci/{pci-id-or-mapping}/mdev",
"GET",
)
alias("/nodes/{node}/scan/glusterfs", "GET", "/nodes/{node}/scan/nfs", "GET")
alias("/nodes/{node}/scan/usb", "GET", "/nodes/{node}/hardware/usb", "GET")
+574
View File
@@ -0,0 +1,574 @@
"""Persistent LXC semantic handlers."""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.db.primitives import ConflictError
from app.handlers.common import (
disk_size_bytes,
replace_disk_size,
require_node,
resize_size_bytes,
subdirs,
)
from app.simulation.transitions import InvalidTransitionError, VmState, plan_transition
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
def _database(request: Request) -> AsyncpgDatabase:
return cast(AsyncpgDatabase, request.app.state.database)
def _values(inputs: dict[str, Any]) -> dict[str, Any]:
return cast(dict[str, Any], inputs["values"])
def _state(value: object) -> dict[str, Any]:
if isinstance(value, str):
return cast(dict[str, Any], json.loads(value))
return dict(cast(Mapping[str, Any], value))
def register_lxc_handlers(registry: HandlerRegistry) -> None:
async def lxc_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(_values(inputs)["node"])
rows = await _database(request).pool.fetch(
"""SELECT r.external_id::integer AS vmid, r.state
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='lxc' ORDER BY r.external_id::integer""",
node,
)
return [{"vmid": int(row["vmid"]), **_state(row["state"])} for row in rows]
async def lxc_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"])
row = await _lxc_resource(request, node, vmid)
return {"vmid": int(vmid), **_state(row["config"]), **_state(row["state"])}
async def lxc_current(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await lxc_config(request, inputs)
async def lxc_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await lxc_config(request, inputs)
async def mutate(operation: str, request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
database = _database(request)
row = await database.pool.fetchrow(
"""SELECT r.id, r.state FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "container does not exist")
current = str(_state(row["state"]).get("status", "stopped"))
try:
plan_transition(VmState(current), operation)
except (InvalidTransitionError, ValueError) as error:
raise ApiError(409, f"cannot {operation} container while it is {current}") from error
upid = str(Upid.allocate(node, f"pct{operation}", vmid, str(request.state.principal)))
try:
task = await TaskRepository(database.pool).create(
upid=upid,
task_type=f"lxc-{operation}",
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
resource_key=f"lxc:{vmid}",
idempotency_key=request.headers.get("Idempotency-Key"),
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
async def start(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("start", request, inputs)
async def stop(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("stop", request, inputs)
async def shutdown(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("shutdown", request, inputs)
async def reboot(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("reboot", request, inputs)
async def suspend(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("suspend", request, inputs)
async def resume(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("resume", request, inputs)
async def create(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), int(values["vmid"])
database = _database(request)
if not await database.pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", node
):
raise ApiError(404, "node does not exist")
if await database.pool.fetchval(
"""SELECT EXISTS(SELECT 1 FROM resources
WHERE external_id=$1 AND kind IN ('qemu','lxc'))""",
str(vmid),
):
raise ApiError(409, "VMID already exists")
config = {
key: value
for key, value in values.items()
if key not in {"node", "vmid", "force", "start", "ostemplate"}
}
if "ostemplate" in values:
config["ostemplate"] = values["ostemplate"]
return await _create_task(
request,
node=node,
vmid=str(vmid),
task_type="lxc-create",
payload={
"node": node,
"vmid": vmid,
"config": config,
"start": bool(values.get("start")),
},
)
async def update(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
database = _database(request)
row = await database.pool.fetchrow(
"""SELECT r.id, r.version, r.state, c.config FROM resources r
JOIN nodes n ON n.id=r.node_id
JOIN containers c ON c.resource_id=r.id
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "container does not exist")
control = {"node", "vmid", "digest", "delete", "revert", "skiplock"}
provided = frozenset(str(item) for item in inputs.get("provided", values))
changes = {
key: value for key, value in values.items() if key in provided and key not in control
}
delete = str(values.get("delete", "")) if "delete" in provided else ""
state = _state(row["state"])
config = _state(row["config"])
state.update(changes)
config.update(changes)
for key in delete.split(","):
if key:
state.pop(key, None)
config.pop(key, None)
status = await database.pool.execute(
"""UPDATE resources SET state=$3::jsonb, version=version+1,
updated_at=now() WHERE id=$1 AND version=$2""",
row["id"],
row["version"],
json.dumps(state, sort_keys=True),
)
if status != "UPDATE 1":
raise ApiError(409, "configuration changed concurrently")
await database.pool.execute(
"UPDATE containers SET config=$2::jsonb WHERE resource_id=$1",
row["id"],
json.dumps(config, sort_keys=True),
)
async def delete(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
database = _database(request)
row = await database.pool.fetchrow(
"""SELECT r.id, r.state FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "container does not exist")
if str(_state(row["state"]).get("status")) != "stopped":
raise ApiError(409, "cannot delete a running container")
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="lxc-delete",
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
)
async def snapshot_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
rows = await _database(request).pool.fetch(
"""SELECT name, parent_name, description, created_at FROM snapshots
WHERE resource_id=$1 ORDER BY created_at, name""",
resource["id"],
)
return [
{
"name": row["name"],
"parent": row["parent_name"],
"description": row["description"] or "",
"snaptime": int(row["created_at"].timestamp()),
}
for row in rows
]
async def snapshot_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
row = await _snapshot(request, values)
state = _state(row["state"])
return {
"name": row["name"],
"parent": row["parent_name"],
"description": row["description"] or "",
"snaptime": int(row["created_at"].timestamp()),
**state,
}
async def snapshot_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
row = await _snapshot(request, _values(inputs))
return {"description": row["description"] or "", **_state(row["state"])}
async def snapshot_update(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
row = await _snapshot(request, values)
await _database(request).pool.execute(
"UPDATE snapshots SET description=$2 WHERE id=$1",
row["id"],
str(values.get("description", "")),
)
async def snapshot_task(operation: str, request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid, snapname = (
str(values["node"]),
str(values["vmid"]),
str(values["snapname"]),
)
resource = await _lxc_resource(request, node, vmid)
if operation == "snapshot-create":
exists = await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM snapshots WHERE resource_id=$1 AND name=$2)",
resource["id"],
snapname,
)
if exists:
raise ApiError(409, "snapshot already exists")
else:
await _snapshot(request, values)
return await _create_task(
request,
node=node,
vmid=vmid,
task_type=f"lxc-{operation}",
payload={
"node": node,
"vmid": vmid,
"resource_id": str(resource["id"]),
"snapname": snapname,
"description": str(values.get("description", "")),
},
)
async def snapshot_create(request: Request, inputs: dict[str, Any]) -> str:
return await snapshot_task("snapshot-create", request, inputs)
async def snapshot_delete(request: Request, inputs: dict[str, Any]) -> str:
return await snapshot_task("snapshot-delete", request, inputs)
async def snapshot_rollback(request: Request, inputs: dict[str, Any]) -> str:
return await snapshot_task("snapshot-rollback", request, inputs)
async def clone(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid, newid = str(values["node"]), str(values["vmid"]), str(values["newid"])
source = await _lxc_resource(request, node, vmid)
if await _database(request).pool.fetchval(
"""SELECT EXISTS(SELECT 1 FROM resources
WHERE external_id=$1 AND kind IN ('qemu','lxc'))""",
newid,
):
raise ApiError(409, "VMID already exists")
target = str(values.get("target") or node)
if not await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
):
raise ApiError(404, "target node does not exist")
return await _create_task(
request,
node=target,
vmid=newid,
task_type="lxc-clone",
payload={
"source_resource_id": str(source["id"]),
"source_vmid": vmid,
"node": target,
"vmid": int(newid),
"name": values.get("hostname") or values.get("name"),
"full": bool(values.get("full", False)),
},
)
async def migrate_preconditions(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
target = values.get("target")
if target in {None, ""}:
raise ApiError(400, "parameter 'target' is required")
target = str(target)
exists = await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
)
if not exists:
raise ApiError(404, "target node does not exist")
return {"local_disks": [], "local_resources": [], "running": False}
async def migrate(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
target = values.get("target")
if target in {None, ""}:
raise ApiError(400, "parameter 'target' is required")
target = str(target)
resource = await _lxc_resource(request, node, vmid)
if target == node:
raise ApiError(400, "target node is the same as source node")
await migrate_preconditions(request, inputs)
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="lxc-migrate",
payload={
"resource_id": str(resource["id"]),
"node": node,
"target": target,
"vmid": vmid,
"online": bool(values.get("online", False)),
},
)
async def remote_migrate(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
target_endpoint = str(values.get("target-endpoint") or values.get("target_endpoint") or "")
target = str(values.get("target") or "")
if not target_endpoint:
raise ApiError(400, "parameter target-endpoint is required")
if not target:
raise ApiError(400, "parameter target is required")
node, vmid = str(values["node"]), str(values["vmid"])
resource = await _lxc_resource(request, node, vmid)
if target == node:
raise ApiError(400, "target node is the same as source node")
if not await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
):
raise ApiError(404, "target node does not exist")
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="lxc-remote-migrate",
payload={
"resource_id": str(resource["id"]),
"node": node,
"target": target,
"vmid": vmid,
"target-endpoint": target_endpoint,
"online": bool(values.get("online", False)),
},
)
async def pending(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
config = _state(resource["config"])
changes = cast(Mapping[str, Any], state.get("pending", {}))
return [
{"key": key, "value": str(config.get(key, "")), "pending": str(value)}
for key, value in sorted(changes.items())
]
async def lxc_feature(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
await _lxc_resource(request, str(payload["node"]), str(payload["vmid"]))
return {
"hasFeature": {
"snapshot": 1,
"clone": 1,
"copy": 1,
"template": 1,
"move_volume": 1,
}
}
async def lxc_resize(request: Request, inputs: dict[str, Any]) -> None:
payload = _values(inputs)
node, vmid = str(payload["node"]), str(payload["vmid"])
disk = str(payload.get("disk") or "rootfs")
resource = await _lxc_resource(request, node, vmid)
config = _state(resource["config"])
if disk not in config:
raise ApiError(400, f"disk {disk} does not exist")
try:
current = disk_size_bytes(str(config[disk]))
size = resize_size_bytes(str(payload["size"]), current)
except ValueError as error:
raise ApiError(400, str(error)) from error
config[disk] = replace_disk_size(str(config[disk]), size)
await _database(request).pool.execute(
"UPDATE containers SET config=$2::jsonb WHERE resource_id=$1",
resource["id"],
json.dumps(config, sort_keys=True),
)
await _database(request).pool.execute(
"""UPDATE resources SET state=state || $2::jsonb, version=version+1,
updated_at=now() WHERE id=$1""",
resource["id"],
json.dumps({disk: config[disk]}, sort_keys=True),
)
async def lxc_template(request: Request, inputs: dict[str, Any]) -> None:
payload = _values(inputs)
node, vmid = str(payload["node"]), str(payload["vmid"])
resource = await _lxc_resource(request, node, vmid)
state = _state(resource["state"])
if state.get("status") != "stopped":
raise ApiError(409, "container must be stopped to convert to template")
await _database(request).pool.execute(
"UPDATE containers SET template=true WHERE resource_id=$1",
resource["id"],
)
state["template"] = True
await _database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb WHERE id=$1",
resource["id"],
json.dumps(state, sort_keys=True),
)
async def lxc_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
payload = _values(inputs)
node, vmid = str(payload["node"]), str(payload["vmid"])
await require_node(request, node)
await _lxc_resource(request, node, vmid)
return subdirs(
"clone",
"config",
"feature",
"firewall",
"migrate",
"pending",
"resize",
"snapshot",
"status",
"template",
)
registry.register("/nodes/{node}/lxc", "GET", lxc_list)
registry.register("/nodes/{node}/lxc", "POST", create)
registry.register("/nodes/{node}/lxc/{vmid}", "GET", lxc_index)
registry.register("/nodes/{node}/lxc/{vmid}", "DELETE", delete)
registry.register("/nodes/{node}/lxc/{vmid}/config", "GET", lxc_config)
registry.register("/nodes/{node}/lxc/{vmid}/config", "PUT", update)
registry.register("/nodes/{node}/lxc/{vmid}/status", "GET", lxc_status)
registry.register("/nodes/{node}/lxc/{vmid}/status/current", "GET", lxc_current)
registry.register("/nodes/{node}/lxc/{vmid}/status/start", "POST", start)
registry.register("/nodes/{node}/lxc/{vmid}/status/stop", "POST", stop)
registry.register("/nodes/{node}/lxc/{vmid}/status/shutdown", "POST", shutdown)
registry.register("/nodes/{node}/lxc/{vmid}/status/reboot", "POST", reboot)
registry.register("/nodes/{node}/lxc/{vmid}/status/suspend", "POST", suspend)
registry.register("/nodes/{node}/lxc/{vmid}/status/resume", "POST", resume)
registry.register("/nodes/{node}/lxc/{vmid}/snapshot", "GET", snapshot_list)
registry.register("/nodes/{node}/lxc/{vmid}/snapshot", "POST", snapshot_create)
registry.register("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}", "GET", snapshot_get)
registry.register("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}", "DELETE", snapshot_delete)
registry.register("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}/config", "GET", snapshot_config)
registry.register("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}/config", "PUT", snapshot_update)
registry.register(
"/nodes/{node}/lxc/{vmid}/snapshot/{snapname}/rollback", "POST", snapshot_rollback
)
registry.register("/nodes/{node}/lxc/{vmid}/clone", "POST", clone)
registry.register("/nodes/{node}/lxc/{vmid}/migrate", "GET", migrate_preconditions)
registry.register("/nodes/{node}/lxc/{vmid}/migrate", "POST", migrate)
registry.register("/nodes/{node}/lxc/{vmid}/remote_migrate", "POST", remote_migrate)
registry.register("/nodes/{node}/lxc/{vmid}/pending", "GET", pending)
registry.register("/nodes/{node}/lxc/{vmid}/feature", "GET", lxc_feature)
registry.register("/nodes/{node}/lxc/{vmid}/resize", "PUT", lxc_resize)
registry.register("/nodes/{node}/lxc/{vmid}/template", "POST", lxc_template)
from app.handlers.lxc_extra import register_lxc_extra_handlers
register_lxc_extra_handlers(registry)
async def _create_task(
request: Request,
*,
node: str,
vmid: str,
task_type: str,
payload: dict[str, Any],
) -> str:
database = _database(request)
worker_type = {
"lxc-create": "pctcreate",
"lxc-delete": "pctdestroy",
"lxc-snapshot-create": "pctsnapshot",
"lxc-snapshot-delete": "pctdelsnapshot",
"lxc-snapshot-rollback": "pctrollback",
"lxc-clone": "pctclone",
"lxc-migrate": "pctmigrate",
"lxc-remote-migrate": "pctremote",
}[task_type]
upid = str(Upid.allocate(node, worker_type, vmid, str(request.state.principal)))
try:
task = await TaskRepository(database.pool).create(
upid=upid,
task_type=task_type,
payload=payload,
resource_key=f"lxc:{vmid}",
idempotency_key=request.headers.get("Idempotency-Key"),
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
async def _lxc_resource(request: Request, node: str, vmid: str) -> Any:
row = await _database(request).pool.fetchrow(
"""SELECT r.id, r.state, c.config FROM resources r
JOIN nodes n ON n.id=r.node_id
JOIN containers c ON c.resource_id=r.id
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "container does not exist")
return row
async def _snapshot(request: Request, values: dict[str, Any]) -> Any:
row = await _database(request).pool.fetchrow(
"""SELECT s.* FROM snapshots s
JOIN resources r ON r.id=s.resource_id JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2 AND s.name=$3""",
str(values["node"]),
str(values["vmid"]),
str(values["snapname"]),
)
if row is None:
raise ApiError(404, "snapshot does not exist")
return row
+152
View File
@@ -0,0 +1,152 @@
"""Remaining LXC console / RRD / volume helpers with durable state."""
from __future__ import annotations
import json
import secrets
from typing import Any, cast
from fastapi import Request
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.handlers.lxc import _database, _lxc_resource, _state, _values
from app.security.auth import issue_ticket
def _settings(request: Request) -> Settings:
return cast(Settings, request.app.state.settings)
async def _save_state(request: Request, resource_id: Any, state: dict[str, Any]) -> None:
await _database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, version=version+1, updated_at=now() WHERE id=$1",
resource_id,
json.dumps(state, sort_keys=True),
)
async def _save_config(request: Request, resource_id: Any, config: dict[str, Any]) -> None:
await _database(request).pool.execute(
"UPDATE containers SET config=$2::jsonb WHERE resource_id=$1",
resource_id,
json.dumps(config, sort_keys=True),
)
def register_lxc_extra_handlers(registry: HandlerRegistry) -> None:
async def interfaces(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
ifaces = state.setdefault(
"interfaces",
[{"name": "eth0", "hwaddr": "02:00:00:00:00:11", "inet": "192.0.2.20/24"}],
)
await _save_state(request, resource["id"], state)
return list(ifaces) if isinstance(ifaces, list) else []
async def move_volume(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
volume = str(values.get("volume") or values.get("disk") or "rootfs")
storage = str(values.get("storage") or "local-lvm")
config = _state(resource["config"])
current = str(config.get(volume) or "")
if current:
# rewrite storage prefix when present
rest = current.split(":", 1)[1] if ":" in current else current
config[volume] = f"{storage}:{rest}"
await _save_config(request, resource["id"], config)
state = _state(resource["state"])
moves = state.setdefault("volume_moves", [])
if not isinstance(moves, list):
moves = state["volume_moves"] = []
moves.append({"volume": volume, "storage": storage})
await _save_state(request, resource["id"], state)
return f"UPID:{values['node']}:lxc-move-volume:{values['vmid']}"
async def rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
rrd_state = state.setdefault("rrd", {"filename": f"pve-ct-{values['vmid']}.rrd"})
await _save_state(request, resource["id"], state)
return dict(rrd_state)
async def rrddata(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
series = state.setdefault(
"rrddata",
[
{"time": 1_700_000_000, "cpu": 0.02, "mem": 64 * 1024 * 1024},
{"time": 1_700_000_060, "cpu": 0.03, "mem": 66 * 1024 * 1024},
],
)
await _save_state(request, resource["id"], state)
return list(series)
async def _console(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
key = _settings(request).ticket_signing_key.get_secret_value().encode()
ticket = issue_ticket(str(request.state.principal), key)
port = 6900 + int(values["vmid"]) % 1000
state = _state(resource["state"])
consoles = state.setdefault("consoles", {})
payload = {
"type": kind,
"port": port,
"ticket": ticket,
"upid": (
f"UPID:{values['node']}:{secrets.token_hex(4)}:"
f"{kind}:{values['vmid']}:{request.state.principal}:"
),
"user": str(request.state.principal),
}
consoles[kind] = {k: v for k, v in payload.items() if k != "ticket"}
await _save_state(request, resource["id"], state)
return payload
async def vncproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console(request, inputs, "vnc")
async def spiceproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console(request, inputs, "spice")
async def termproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console(request, inputs, "term")
async def mtunnel(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console(request, inputs, "mtunnel")
async def _ws(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
console = state.get("consoles", {}).get(kind) or {"port": 6900}
key = _settings(request).ticket_signing_key.get_secret_value().encode()
return {
"port": console.get("port", 6900),
"ticket": issue_ticket(str(request.state.principal), key),
}
async def vncwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _ws(request, inputs, "vnc")
async def mtunnelwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _ws(request, inputs, "mtunnel")
base = "/nodes/{node}/lxc/{vmid}"
registry.register(f"{base}/interfaces", "GET", interfaces)
registry.register(f"{base}/move_volume", "POST", move_volume)
registry.register(f"{base}/rrd", "GET", rrd)
registry.register(f"{base}/rrddata", "GET", rrddata)
registry.register(f"{base}/vncproxy", "POST", vncproxy)
registry.register(f"{base}/spiceproxy", "POST", spiceproxy)
registry.register(f"{base}/termproxy", "POST", termproxy)
registry.register(f"{base}/mtunnel", "POST", mtunnel)
registry.register(f"{base}/vncwebsocket", "GET", vncwebsocket)
registry.register(f"{base}/mtunnelwebsocket", "GET", mtunnelwebsocket)
+99
View File
@@ -0,0 +1,99 @@
"""Cluster resource mapping handlers (dir/pci/usb)."""
from __future__ import annotations
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs, values
def _mappings(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault("mapping", {"dir": {}, "pci": {}, "usb": {}})
if not isinstance(current, dict):
current = {"dir": {}, "pci": {}, "usb": {}}
metadata["mapping"] = current
for kind in ("dir", "pci", "usb"):
current.setdefault(kind, {})
return current
def register_mapping_handlers(registry: HandlerRegistry) -> None:
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("dir", "pci", "usb")
def register_kind(kind: str) -> None:
base = f"/cluster/mapping/{kind}"
async def list_items(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
check_node = values(inputs).get("check-node")
result = [{"id": key, **item} for key, item in sorted(store.items())]
if check_node:
for item in result:
item["checks"] = {str(check_node): "OK"}
return result
async def create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
item_id = str(payload["id"])
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
if item_id in store:
raise ApiError(400, f"{kind} mapping '{item_id}' already exists")
store[item_id] = {
key: value for key, value in payload.items() if key not in {"delete", "digest"}
}
await save_cluster_metadata(request, metadata)
async def get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
item_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
if item_id not in store:
raise ApiError(404, f"{kind} mapping does not exist")
return {"id": item_id, **store[item_id]}
async def update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
item_id = str(payload["id"])
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
if item_id not in store:
raise ApiError(404, f"{kind} mapping does not exist")
current = dict(store[item_id])
for key in [
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
]:
current.pop(key, None)
for key, value in payload.items():
if key in {"id", "delete", "digest"}:
continue
current[key] = value
current["id"] = item_id
store[item_id] = current
await save_cluster_metadata(request, metadata)
async def delete(request: Request, inputs: dict[str, Any]) -> None:
item_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
if item_id not in store:
raise ApiError(404, f"{kind} mapping does not exist")
del store[item_id]
await save_cluster_metadata(request, metadata)
registry.register(base, "GET", list_items)
registry.register(base, "POST", create)
registry.register(f"{base}/{{id}}", "GET", get)
registry.register(f"{base}/{{id}}", "PUT", update)
registry.register(f"{base}/{{id}}", "DELETE", delete)
registry.register("/cluster/mapping", "GET", index)
register_kind("dir")
register_kind("pci")
register_kind("usb")
+421
View File
@@ -0,0 +1,421 @@
"""Node-level operational handlers (apt, network, disks, services)."""
from __future__ import annotations
import copy
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import (
database,
node_metadata,
require_node,
save_node_metadata,
subdirs,
values,
)
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
DEFAULT_NODE_OPS: dict[str, Any] = {
"network": [
{
"iface": "vmbr0",
"type": "bridge",
"active": 1,
"method": "static",
"address": "10.0.0.10/24",
},
{
"iface": "vmbr1",
"type": "bridge",
"active": 1,
"method": "static",
"address": "10.10.0.10/24",
},
{"iface": "eno1", "type": "eth", "active": 1, "method": "manual"},
],
"disks": {
"list": [
{
"devpath": "/dev/sda",
"size": 1_000_000_000_000,
"model": "SIM-DISK-01",
"serial": "SIM0001",
"gpt": 1,
},
{
"devpath": "/dev/sdb",
"size": 2_000_000_000_000,
"model": "SIM-SSD-01",
"serial": "SIM0002",
"gpt": 0,
},
],
"directory": [],
"lvm": [],
"lvmthin": [],
"zfs": [],
"smart": {},
},
"services": {
"pveproxy": {"state": "running", "enabled": 1},
"pvedaemon": {"state": "running", "enabled": 1},
"pvestatd": {"state": "running", "enabled": 1},
"corosync": {"state": "running", "enabled": 1},
},
"apt": {
"packages": [
{
"Package": "pve-manager",
"Version": "9.2.3",
"OldVersion": "9.2.2",
"Status": "upgradable",
},
{"Package": "libpve-common-perl", "Version": "9.0.3", "Status": "installed"},
],
"repositories": [
{
"path": "/etc/apt/sources.list.d/pve-enterprise.list",
"enabled": 1,
"types": "deb",
"uri": "http://download.proxmox.com/debian/pve",
"suites": "bookworm",
"components": "pve-no-subscription",
}
],
"update": {"status": "stopped", "exitstatus": "OK"},
"changelogs": {},
},
}
def default_node_ops() -> dict[str, Any]:
return copy.deepcopy(DEFAULT_NODE_OPS)
async def load_node_ops(request: Request, node: str) -> dict[str, Any]:
metadata = await node_metadata(request, node)
ops = metadata.get("ops")
if isinstance(ops, dict) and ops:
return ops
ops = default_node_ops()
metadata["ops"] = ops
await save_node_metadata(request, node, metadata)
return ops
async def save_node_ops(request: Request, node: str, ops: dict[str, Any]) -> None:
metadata = await node_metadata(request, node)
metadata["ops"] = ops
await save_node_metadata(request, node, metadata)
def register_node_ops_handlers(registry: HandlerRegistry) -> None:
async def apt_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("changelog", "repositories", "update", "versions")
async def apt_versions(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
packages = ops.get("apt", {}).get("packages", [])
return list(packages) if isinstance(packages, list) else []
async def apt_repositories(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
repositories = ops.get("apt", {}).get("repositories", [])
return list(repositories) if isinstance(repositories, list) else []
async def apt_changelog(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
name = str(values(inputs).get("name") or "pve-manager")
ops = await load_node_ops(request, node)
changelogs = ops.setdefault("apt", {}).setdefault("changelogs", {})
if name not in changelogs:
changelogs[name] = f"simulated changelog for {name}\n\n * emulator build\n"
await save_node_ops(request, node, ops)
return str(changelogs[name])
async def apt_update_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
update = ops.get("apt", {}).get("update", {"status": "stopped", "exitstatus": "OK"})
if isinstance(update, dict):
return dict(update)
return {"status": "stopped", "exitstatus": "OK"}
async def apt_update_start(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
apt = ops.setdefault("apt", {})
apt["update"] = {"status": "running", "exitstatus": ""}
await save_node_ops(request, node, ops)
return await _node_task(request, node=node, task_type="aptupdate", worker="aptupdate")
async def network_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
network = ops.get("network", [])
return [dict(item) for item in network] if isinstance(network, list) else []
async def network_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
iface = str(values(inputs)["iface"])
for item in await network_list(request, inputs):
if item.get("iface") == iface:
return item
raise ApiError(404, "interface does not exist")
async def network_mutate(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
payload = values(inputs)
ops = await load_node_ops(request, node)
network = list(ops.get("network") or [])
iface = payload.get("iface")
method = request.method.upper()
if method == "DELETE":
target = str(iface or "")
if not any(item.get("iface") == target for item in network):
raise ApiError(404, "interface does not exist")
ops["network"] = [item for item in network if item.get("iface") != target]
elif method == "POST":
name = str(iface or payload.get("iface") or "")
if not name:
raise ApiError(400, "parameter verification failed - 'iface' missing")
if any(item.get("iface") == name for item in network):
raise ApiError(400, f"interface '{name}' already exists")
entry = {
key: value
for key, value in payload.items()
if key not in {"node", "delete", "digest"}
}
entry["iface"] = name
entry.setdefault("type", "bridge")
entry.setdefault("active", 1)
network.append(entry)
ops["network"] = network
elif method == "PUT" and iface is not None:
name = str(iface)
found = False
updated: list[dict[str, Any]] = []
for item in network:
if item.get("iface") != name:
updated.append(item)
continue
found = True
merged = {
**item,
**{
key: value
for key, value in payload.items()
if key not in {"node", "iface", "delete", "digest"}
},
}
merged["iface"] = name
updated.append(merged)
if not found:
raise ApiError(404, "interface does not exist")
ops["network"] = updated
else:
ops["network_applied"] = True
await save_node_ops(request, node, ops)
async def disks_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("directory", "list", "lvm", "lvmthin", "smart", "zfs")
async def _disks(request: Request, node: str) -> dict[str, Any]:
ops = await load_node_ops(request, node)
disks = ops.setdefault("disks", default_node_ops()["disks"])
if not isinstance(disks, dict):
disks = default_node_ops()["disks"]
ops["disks"] = disks
await save_node_ops(request, node, ops)
return cast(dict[str, Any], disks)
async def disks_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
disks = await _disks(request, node)
items = disks.get("list", [])
return [dict(item) for item in items] if isinstance(items, list) else []
async def disks_smart(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
disk = str(values(inputs).get("disk") or "/dev/sda")
disks = await _disks(request, node)
smart = disks.setdefault("smart", {})
if disk not in smart:
smart[disk] = {
"health": "PASSED",
"type": "scsi",
"model": "SIM-DISK",
"serial": disk.rsplit("/", 1)[-1],
}
ops = await load_node_ops(request, node)
ops["disks"] = disks
await save_node_ops(request, node, ops)
return dict(smart[disk])
async def disks_collection(
request: Request, inputs: dict[str, Any], key: str
) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
disks = await _disks(request, node)
items = disks.get(key, [])
return [dict(item) for item in items] if isinstance(items, list) else []
async def disks_directory(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
return await disks_collection(request, inputs, "directory")
async def disks_lvm(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
return await disks_collection(request, inputs, "lvm")
async def disks_lvmthin(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
return await disks_collection(request, inputs, "lvmthin")
async def disks_zfs(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
return await disks_collection(request, inputs, "zfs")
async def disks_initgpt(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
disk = str(values(inputs).get("disk") or values(inputs).get("device") or "")
if not disk:
raise ApiError(400, "parameter verification failed - 'disk' missing")
ops = await load_node_ops(request, node)
disks = ops.setdefault("disks", default_node_ops()["disks"])
items = list(disks.get("list") or [])
found = False
for item in items:
if item.get("devpath") == disk:
item["gpt"] = 1
found = True
break
if not found:
items.append(
{
"devpath": disk,
"size": 0,
"model": "SIM-DISK",
"serial": disk,
"gpt": 1,
}
)
disks["list"] = items
ops["disks"] = disks
await save_node_ops(request, node, ops)
async def disks_wipedisk(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
disk = str(values(inputs).get("disk") or values(inputs).get("device") or "")
if not disk:
raise ApiError(400, "parameter verification failed - 'disk' missing")
ops = await load_node_ops(request, node)
disks = ops.setdefault("disks", default_node_ops()["disks"])
items = list(disks.get("list") or [])
for item in items:
if item.get("devpath") == disk:
item["wiped"] = 1
item["gpt"] = 0
break
else:
raise ApiError(404, "disk does not exist")
disks["list"] = items
smart = disks.setdefault("smart", {})
smart.pop(disk, None)
ops["disks"] = disks
await save_node_ops(request, node, ops)
async def services_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
services = ops.get("services") or {}
return [{"subdir": name} for name in sorted(services)]
async def service_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
service = str(values(inputs)["service"])
ops = await load_node_ops(request, node)
services = ops.setdefault("services", {})
if service not in services:
services[service] = {"state": "stopped", "enabled": 0}
await save_node_ops(request, node, ops)
payload = dict(services[service])
payload["service"] = service
return payload
async def service_state(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await service_get(request, inputs)
async def service_action(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
service = str(values(inputs)["service"])
path = request.url.path.rstrip("/")
action = path.rsplit("/", 1)[-1]
ops = await load_node_ops(request, node)
services = ops.setdefault("services", {})
current = dict(services.get(service) or {"state": "stopped", "enabled": 0})
if action == "start":
current["state"] = "running"
current["enabled"] = 1
elif action == "stop":
current["state"] = "stopped"
elif action in {"restart", "reload"}:
current["state"] = "running"
current["enabled"] = 1
else:
raise ApiError(400, f"unknown service action: {action}")
services[service] = current
ops["services"] = services
await save_node_ops(request, node, ops)
return "OK"
registry.register("/nodes/{node}/apt", "GET", apt_index)
registry.register("/nodes/{node}/apt/versions", "GET", apt_versions)
registry.register("/nodes/{node}/apt/repositories", "GET", apt_repositories)
registry.register("/nodes/{node}/apt/changelog", "GET", apt_changelog)
registry.register("/nodes/{node}/apt/update", "GET", apt_update_status)
registry.register("/nodes/{node}/apt/update", "POST", apt_update_start)
registry.register("/nodes/{node}/network", "GET", network_list)
registry.register("/nodes/{node}/network", "POST", network_mutate)
registry.register("/nodes/{node}/network", "PUT", network_mutate)
registry.register("/nodes/{node}/network/{iface}", "GET", network_get)
registry.register("/nodes/{node}/network/{iface}", "PUT", network_mutate)
registry.register("/nodes/{node}/network/{iface}", "DELETE", network_mutate)
registry.register("/nodes/{node}/disks", "GET", disks_index)
registry.register("/nodes/{node}/disks/list", "GET", disks_list)
registry.register("/nodes/{node}/disks/smart", "GET", disks_smart)
registry.register("/nodes/{node}/disks/directory", "GET", disks_directory)
registry.register("/nodes/{node}/disks/lvm", "GET", disks_lvm)
registry.register("/nodes/{node}/disks/lvmthin", "GET", disks_lvmthin)
registry.register("/nodes/{node}/disks/zfs", "GET", disks_zfs)
registry.register("/nodes/{node}/disks/initgpt", "POST", disks_initgpt)
registry.register("/nodes/{node}/disks/wipedisk", "PUT", disks_wipedisk)
registry.register("/nodes/{node}/services", "GET", services_index)
registry.register("/nodes/{node}/services/{service}", "GET", service_get)
registry.register("/nodes/{node}/services/{service}/state", "GET", service_state)
registry.register("/nodes/{node}/services/{service}/start", "POST", service_action)
registry.register("/nodes/{node}/services/{service}/stop", "POST", service_action)
registry.register("/nodes/{node}/services/{service}/restart", "POST", service_action)
registry.register("/nodes/{node}/services/{service}/reload", "POST", service_action)
async def _node_task(request: Request, *, node: str, task_type: str, worker: str) -> str:
from app.api.errors import ApiError
from app.db.primitives import ConflictError
pool = database(request).pool
upid = str(Upid.allocate(node, worker, "0", str(request.state.principal)))
try:
task = await TaskRepository(pool).create(
upid=upid,
task_type=task_type,
payload={"node": node},
resource_key=f"node:{node}",
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
+972
View File
@@ -0,0 +1,972 @@
"""Additional node-level handlers with durable ops persistence."""
from __future__ import annotations
import copy
import json
import secrets
import time
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import database, require_node, subdirs, values
from app.handlers.nodes import default_node_ops, load_node_ops, save_node_ops
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
DEFAULT_HARDWARE: dict[str, Any] = {
"pci": [
{
"id": "0000:00:1f.2",
"vendor_name": "Intel Corporation",
"device_name": "SATA Controller",
"iommugroup": 0,
},
{
"id": "0000:01:00.0",
"vendor_name": "NVIDIA Corporation",
"device_name": "GP102 [GeForce GTX 1080 Ti]",
"iommugroup": 1,
"mdev": 1,
},
],
"usb": [
{"busnum": 1, "devnum": 1, "level": 0, "port": "1", "prodid": "0002", "vendid": "1d6b"},
{"busnum": 2, "devnum": 2, "level": 1, "port": "2", "prodid": "5591", "vendid": "0781"},
],
"mdev": {
"0000:01:00.0": [
{"type": "nvidia-11", "available": 4, "description": "GRID profile"},
]
},
}
DEFAULT_SCAN: dict[str, list[dict[str, Any]]] = {
"cifs": [{"server": "files.local", "share": "backups"}],
"iscsi": [{"portal": "10.0.0.50:3260", "target": "iqn.2024-01.local:storage"}],
"lvm": [{"vg": "pve", "size": 500_000_000_000, "free": 100_000_000_000}],
"lvmthin": [{"lv": "data", "vg": "pve", "lv_size": 400_000_000_000}],
"nfs": [{"server": "nfs.local", "path": "/export/pve", "options": "vers=4"}],
"pbs": [{"server": "pbs.local", "datastore": "store1"}],
"zfs": [{"pool": "rpool", "name": "rpool/data", "size": 800_000_000_000}],
}
DEFAULT_SUBSCRIPTION: dict[str, Any] = {
"status": "notfound",
"message": "There is no subscription key",
"serverid": "SIMULATOR",
"sockets": 1,
"productname": "Proxmox VE",
"url": "https://www.proxmox.com/en/proxmox-virtual-environment/pricing",
}
DEFAULT_CONFIG: dict[str, Any] = {
"description": "Simulator node",
"startall-onboot-delay": 0,
"wakeonlan": "",
}
DEFAULT_DNS: dict[str, Any] = {
"search": "local",
"dns1": "1.1.1.1",
"dns2": "8.8.8.8",
"dns3": "",
}
DEFAULT_TIME: dict[str, Any] = {
"timezone": "UTC",
"time": 0,
"localtime": 0,
}
def _certificates(ops: dict[str, Any]) -> dict[str, Any]:
certs = ops.setdefault(
"certificates",
{
"custom": None,
"acme": {"account": "default", "domains": [], "certificate": None},
"info": [],
},
)
if not isinstance(certs, dict):
certs = {"custom": None, "acme": {}, "info": []}
ops["certificates"] = certs
certs.setdefault("acme", {"account": "default", "domains": [], "certificate": None})
certs.setdefault("info", [])
return certs
def _hardware(ops: dict[str, Any]) -> dict[str, Any]:
hardware = ops.get("hardware")
if not isinstance(hardware, dict) or not hardware:
hardware = copy.deepcopy(DEFAULT_HARDWARE)
ops["hardware"] = hardware
hardware.setdefault("pci", copy.deepcopy(DEFAULT_HARDWARE["pci"]))
hardware.setdefault("usb", copy.deepcopy(DEFAULT_HARDWARE["usb"]))
hardware.setdefault("mdev", copy.deepcopy(DEFAULT_HARDWARE["mdev"]))
return hardware
def _scan_cache(ops: dict[str, Any]) -> dict[str, Any]:
scan = ops.get("scan")
if not isinstance(scan, dict) or not scan:
scan = copy.deepcopy(DEFAULT_SCAN)
ops["scan"] = scan
for key, value in DEFAULT_SCAN.items():
scan.setdefault(key, copy.deepcopy(value))
return scan
def _subscription(ops: dict[str, Any]) -> dict[str, Any]:
subscription = ops.get("subscription")
if not isinstance(subscription, dict) or not subscription:
subscription = copy.deepcopy(DEFAULT_SUBSCRIPTION)
ops["subscription"] = subscription
return subscription
def _disk_items(ops: dict[str, Any], kind: str) -> list[dict[str, Any]]:
disks = ops.setdefault("disks", default_node_ops()["disks"])
if not isinstance(disks, dict):
disks = default_node_ops()["disks"]
ops["disks"] = disks
items = disks.setdefault(kind, [])
if not isinstance(items, list):
items = []
disks[kind] = items
return items
def _public_cert(entry: dict[str, Any] | None) -> dict[str, Any] | None:
if entry is None:
return None
return {key: value for key, value in entry.items() if key not in {"key", "private-key"}}
async def _node_task(request: Request, *, node: str, task_type: str, worker: str) -> str:
from app.db.primitives import ConflictError
pool = database(request).pool
upid = str(Upid.allocate(node, worker, "0", str(request.state.principal)))
try:
task = await TaskRepository(pool).create(
upid=upid,
task_type=task_type,
payload={"node": node},
resource_key=f"node:{node}:{task_type}",
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
async def _set_guest_status(request: Request, node: str, status: str) -> None:
await database(request).pool.execute(
"""UPDATE resources AS r
SET state = jsonb_set(COALESCE(r.state, '{}'::jsonb), '{status}', to_jsonb($2::text), true),
updated_at=now()
WHERE r.node_id=(SELECT id FROM nodes WHERE name=$1) AND r.kind IN ('qemu', 'lxc')""",
node,
status,
)
async def _migrate_guests(request: Request, node: str, target: str) -> None:
target_row = await database(request).pool.fetchrow("SELECT id FROM nodes WHERE name=$1", target)
if target_row is None:
raise ApiError(404, "target node does not exist")
await database(request).pool.execute(
"""UPDATE resources SET node_id=$2, updated_at=now()
WHERE node_id=(SELECT id FROM nodes WHERE name=$1) AND kind IN ('qemu', 'lxc')""",
node,
target_row["id"],
)
def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
async def disks_create(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(
payload.get("name")
or payload.get("device")
or payload.get("vgname")
or payload.get("pool")
or f"{kind}-{secrets.token_hex(2)}"
)
ops = await load_node_ops(request, node)
items = _disk_items(ops, kind)
if any(str(item.get("name")) == name for item in items):
raise ApiError(400, f"{kind} '{name}' already exists")
entry = {
key: value for key, value in payload.items() if key not in {"node", "delete", "digest"}
}
entry["name"] = name
items.append(entry)
ops.setdefault("disks", default_node_ops()["disks"])[kind] = items
await save_node_ops(request, node, ops)
return entry
async def disks_delete(request: Request, inputs: dict[str, Any], kind: str) -> None:
payload = values(inputs)
node = str(payload["node"])
name = str(payload["name"])
await require_node(request, node)
ops = await load_node_ops(request, node)
items = _disk_items(ops, kind)
remaining = [item for item in items if str(item.get("name")) != name]
if len(remaining) == len(items):
raise ApiError(404, f"{kind} does not exist")
ops.setdefault("disks", {})[kind] = remaining
await save_node_ops(request, node, ops)
async def disks_zfs_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
name = str(payload["name"])
await require_node(request, node)
ops = await load_node_ops(request, node)
for item in _disk_items(ops, "zfs"):
if str(item.get("name")) == name:
return dict(item)
raise ApiError(404, "zfs pool does not exist")
async def certificates_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("acme", "custom", "info")
async def certificates_acme_index(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("certificate")
async def certificates_acme_mutate(request: Request, inputs: dict[str, Any]) -> str | None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
certs = _certificates(ops)
acme = dict(certs.get("acme") or {})
method = request.method.upper()
if method == "DELETE":
acme["certificate"] = None
acme["domains"] = []
else:
domains = payload.get("domains") or payload.get("domain") or acme.get("domains") or []
if isinstance(domains, str):
domains = [part.strip() for part in domains.split(",") if part.strip()]
acme["domains"] = list(domains)
acme["account"] = str(payload.get("account") or acme.get("account") or "default")
acme["certificate"] = {
"pem": str(payload.get("certificates") or payload.get("certificate") or "SIM-ACME"),
"issued": int(time.time()),
}
certs["acme"] = acme
ops["certificates"] = certs
await save_node_ops(request, node, ops)
if method == "DELETE":
return None
return await _node_task(request, node=node, task_type="acme", worker="acme")
async def certificates_custom(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
certs = _certificates(ops)
if request.method.upper() == "DELETE":
certs["custom"] = None
else:
certificates = str(payload.get("certificates") or payload.get("cert") or "")
if not certificates:
raise ApiError(400, "parameter verification failed - 'certificates' missing")
key = str(payload.get("key") or payload.get("private-key") or "")
certs["custom"] = {
"certificates": certificates,
"key": key,
"restart": int(payload.get("restart") or 0),
"filename": str(payload.get("filename") or "pveproxy-ssl.pem"),
}
info = list(certs.get("info") or [])
info = [item for item in info if item.get("filename") != certs["custom"]["filename"]]
info.append(
{
"filename": certs["custom"]["filename"],
"fingerprint": secrets.token_hex(20),
"issuer": "CN=Simulator",
"subject": "CN=pve.local",
"notbefore": int(time.time()) - 86_400,
"notafter": int(time.time()) + 365 * 86_400,
"san": ["DNS:pve.local"],
"public-key-type": "rsa",
"public-key-bits": 2048,
}
)
certs["info"] = info
ops["certificates"] = certs
await save_node_ops(request, node, ops)
async def certificates_info(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
certs = _certificates(ops)
info = list(certs.get("info") or [])
custom = _public_cert(
certs.get("custom") if isinstance(certs.get("custom"), dict) else None
)
if custom and not any(item.get("filename") == custom.get("filename") for item in info):
info.append(
{
"filename": custom.get("filename", "pveproxy-ssl.pem"),
"fingerprint": secrets.token_hex(20),
"issuer": "CN=Custom",
"subject": "CN=pve.local",
}
)
certs["info"] = info
ops["certificates"] = certs
await save_node_ops(request, node, ops)
return [dict(item) for item in info]
async def scan_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("cifs", "iscsi", "lvm", "lvmthin", "nfs", "pbs", "zfs")
async def scan_kind(
request: Request, inputs: dict[str, Any], kind: str
) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
scan = _scan_cache(ops)
ops["scan"] = scan
await save_node_ops(request, node, ops)
items = scan.get(kind, [])
return [dict(item) for item in items] if isinstance(items, list) else []
async def capabilities_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("qemu")
async def capabilities_qemu(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("cpu", "cpu-flags", "machines", "migration")
async def capabilities_cpu(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
return [
{"name": "host", "vendor": "QEMU", "custom": 0},
{"name": "x86-64-v2-AES", "vendor": "QEMU", "custom": 0},
{"name": "kvm64", "vendor": "QEMU", "custom": 0},
]
async def capabilities_cpu_flags(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
return [
{"name": "aes", "introduces": "Westmere"},
{"name": "avx", "introduces": "SandyBridge"},
{"name": "avx2", "introduces": "Haswell"},
]
async def capabilities_machines(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
return [
{"id": "pc-i440fx-9.0", "type": "i440fx", "version": "9.0"},
{"id": "pc-q35-9.0", "type": "q35", "version": "9.0"},
]
async def capabilities_migration(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
await require_node(request, str(values(inputs)["node"]))
return {"network": "", "type": "secure", "enabled": 1}
async def hardware_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("pci", "usb")
async def hardware_pci(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
hardware = _hardware(ops)
ops["hardware"] = hardware
await save_node_ops(request, node, ops)
return [dict(item) for item in hardware.get("pci", [])]
async def hardware_pci_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
pci_id = str(payload.get("pci-id-or-mapping") or payload.get("pciid") or "")
await require_node(request, node)
ops = await load_node_ops(request, node)
for item in _hardware(ops).get("pci", []):
if str(item.get("id")) == pci_id:
return dict(item)
raise ApiError(404, "pci device does not exist")
async def hardware_pci_mdev(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
node = str(payload["node"])
pci_id = str(payload.get("pci-id-or-mapping") or payload.get("pciid") or "")
await require_node(request, node)
ops = await load_node_ops(request, node)
hardware = _hardware(ops)
mdev = hardware.get("mdev", {})
items = mdev.get(pci_id, []) if isinstance(mdev, dict) else []
return [dict(item) for item in items] if isinstance(items, list) else []
async def hardware_usb(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
hardware = _hardware(ops)
ops["hardware"] = hardware
await save_node_ops(request, node, ops)
return [dict(item) for item in hardware.get("usb", [])]
async def subscription_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
public = dict(_subscription(ops))
public.pop("key", None)
return public
async def subscription_mutate(
request: Request, inputs: dict[str, Any]
) -> dict[str, Any] | None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
current = _subscription(ops)
method = request.method.upper()
if method == "DELETE":
ops["subscription"] = copy.deepcopy(DEFAULT_SUBSCRIPTION)
await save_node_ops(request, node, ops)
return None
if method == "POST":
current["checktime"] = int(time.time())
current["status"] = current.get("status") or "Active"
ops["subscription"] = current
await save_node_ops(request, node, ops)
return dict(current)
key = str(payload.get("key") or current.get("key") or "")
updated = {
**current,
**{k: v for k, v in payload.items() if k not in {"node", "delete", "digest"}},
"key": key,
"status": "Active" if key else current.get("status", "notfound"),
"message": "OK" if key else current.get("message", "There is no subscription key"),
}
ops["subscription"] = updated
await save_node_ops(request, node, ops)
public = dict(updated)
public.pop("key", None)
return public
async def aplinfo_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
items = ops.get("aplinfo")
if not isinstance(items, list):
items = [
{
"package": "alpine-3-standard",
"section": "system",
"type": "lxc",
"version": "3.20",
}
]
ops["aplinfo"] = items
await save_node_ops(request, node, ops)
return [dict(item) for item in items]
async def aplinfo_download(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
downloads = list(ops.get("aplinfo_downloads") or [])
downloads.append(
{
"template": str(payload.get("template") or payload.get("storage") or "unknown"),
"at": int(time.time()),
}
)
ops["aplinfo_downloads"] = downloads
await save_node_ops(request, node, ops)
return await _node_task(request, node=node, task_type="download", worker="download")
async def apt_repositories_mutate(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
apt = ops.setdefault("apt", copy.deepcopy(default_node_ops()["apt"]))
repositories = list(apt.get("repositories") or [])
method = request.method.upper()
if method == "POST":
entry = {
key: value
for key, value in payload.items()
if key not in {"node", "delete", "digest"}
}
entry.setdefault("path", f"/etc/apt/sources.list.d/sim-{secrets.token_hex(2)}.list")
entry.setdefault("enabled", 1)
repositories.append(entry)
else:
path = payload.get("path")
handle = payload.get("handle")
index = payload.get("index")
updated: list[dict[str, Any]] = []
for idx, item in enumerate(repositories):
match = False
if path is not None and item.get("path") == path:
match = True
if handle is not None and item.get("handle") == handle:
match = True
if index is not None and idx == int(index):
match = True
if match or (path is None and handle is None and index is None and idx == 0):
merged = {
**item,
**{
key: value
for key, value in payload.items()
if key not in {"node", "delete", "digest", "path", "handle", "index"}
},
}
updated.append(merged)
else:
updated.append(item)
repositories = updated
apt["repositories"] = repositories
ops["apt"] = apt
await save_node_ops(request, node, ops)
async def node_config_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
config = ops.get("config")
if not isinstance(config, dict):
config = copy.deepcopy(DEFAULT_CONFIG)
ops["config"] = config
await save_node_ops(request, node, ops)
return dict(config)
async def node_config_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
config = dict(ops.get("config") or DEFAULT_CONFIG)
config.update(
{
key: value
for key, value in payload.items()
if key not in {"node", "digest", "delete"}
}
)
ops["config"] = config
await save_node_ops(request, node, ops)
return config
async def dns_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
dns = ops.get("dns")
if not isinstance(dns, dict):
dns = copy.deepcopy(DEFAULT_DNS)
ops["dns"] = dns
await save_node_ops(request, node, ops)
return dict(dns)
async def dns_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
dns = dict(ops.get("dns") or DEFAULT_DNS)
dns.update(
{
key: value
for key, value in payload.items()
if key not in {"node", "digest", "delete"}
}
)
ops["dns"] = dns
await save_node_ops(request, node, ops)
return dns
async def time_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
current = dict(ops.get("time") or DEFAULT_TIME)
now = int(time.time())
current["time"] = now
current["localtime"] = now
current.setdefault("timezone", "UTC")
ops["time"] = current
await save_node_ops(request, node, ops)
return current
async def time_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
current = dict(ops.get("time") or DEFAULT_TIME)
if "timezone" in payload:
current["timezone"] = str(payload["timezone"])
now = int(time.time())
current["time"] = now
current["localtime"] = now
ops["time"] = current
await save_node_ops(request, node, ops)
return current
async def execute(request: Request, inputs: dict[str, Any]) -> list[str]:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
commands = payload.get("commands") or payload.get("command") or []
if isinstance(commands, str):
try:
parsed = json.loads(commands)
commands = parsed if isinstance(parsed, list) else [commands]
except json.JSONDecodeError:
commands = [commands]
ops = await load_node_ops(request, node)
log = list(ops.get("execute_log") or [])
output: list[str] = []
for command in commands:
entry = {"command": str(command), "at": int(time.time())}
log.append(entry)
output.append(f"OK: {command}")
ops["execute_log"] = log[-100:]
await save_node_ops(request, node, ops)
return output
async def hosts_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
hosts = ops.get("hosts")
if not isinstance(hosts, dict):
hosts = {
"data": f"127.0.0.1 localhost\n10.0.0.10 {node}\n",
"digest": secrets.token_hex(8),
}
ops["hosts"] = hosts
await save_node_ops(request, node, ops)
return {"data": str(hosts.get("data", "")), "digest": str(hosts.get("digest", ""))}
async def hosts_post(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
ops["hosts"] = {
"data": str(payload.get("data") or ""),
"digest": secrets.token_hex(8),
}
await save_node_ops(request, node, ops)
async def journal(request: Request, inputs: dict[str, Any]) -> list[str]:
node = str(values(inputs)["node"])
await require_node(request, node)
start = int(values(inputs).get("startcursor") or values(inputs).get("start") or 0)
limit = int(values(inputs).get("limit") or 50)
lines = [
f"{index}: {node} systemd[1]: Started simulated service {index}."
for index in range(start, start + limit)
]
return lines
async def syslog(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
limit = int(values(inputs).get("limit") or 50)
return [
{"n": index, "t": f"{node} kernel: simulated syslog line {index}"}
for index in range(limit)
]
async def netstat(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
return [
{"in": 1_000_000, "out": 900_000, "vnet": "vmbr0", "hwaddr": "bc:24:11:00:00:01"},
{"in": 500_000, "out": 450_000, "vnet": "vmbr1", "hwaddr": "bc:24:11:00:00:02"},
]
async def report(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
await require_node(request, node)
return f"==== Proxmox node report for {node} ====\nuptime: simulated\n"
async def rrd(_request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
await require_node(_request, str(values(inputs)["node"]))
return {"filename": "/var/lib/rrdcached/db/pve-node.rrd"}
async def rrddata(_request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(_request, str(values(inputs)["node"]))
now = int(time.time())
return [
{"time": now - 120, "cpu": 0.05, "memused": 1_000_000_000},
{"time": now - 60, "cpu": 0.07, "memused": 1_100_000_000},
{"time": now, "cpu": 0.04, "memused": 1_050_000_000},
]
async def startall(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
await require_node(request, node)
await _set_guest_status(request, node, "running")
return await _node_task(request, node=node, task_type="startall", worker="startall")
async def stopall(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
await require_node(request, node)
await _set_guest_status(request, node, "stopped")
return await _node_task(request, node=node, task_type="stopall", worker="stopall")
async def suspendall(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
await require_node(request, node)
await _set_guest_status(request, node, "paused")
return await _node_task(request, node=node, task_type="suspendall", worker="suspendall")
async def migrateall(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
target = str(payload.get("target") or "")
await require_node(request, node)
if not target:
raise ApiError(400, "parameter verification failed - 'target' missing")
await _migrate_guests(request, node, target)
return await _node_task(request, node=node, task_type="migrateall", worker="migrateall")
async def status_post(request: Request, inputs: dict[str, Any]) -> str | None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
command = str(payload.get("command") or "reboot")
ops = await load_node_ops(request, node)
ops["last_status_command"] = {"command": command, "at": int(time.time())}
await save_node_ops(request, node, ops)
return await _node_task(request, node=node, task_type=command, worker=command)
async def wakeonlan(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
ops["wakeonlan"] = {"at": int(time.time())}
await save_node_ops(request, node, ops)
return "OK"
async def _shell_proxy(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
payload = {
"port": 5900 if kind == "vnc" else 22 if kind == "term" else 3128,
"ticket": secrets.token_urlsafe(24),
"user": str(getattr(request.state, "principal", "root@pam")),
"upid": f"UPID:{node}:{secrets.token_hex(4)}:{kind}shell:0:root@pam:",
}
ops = await load_node_ops(request, node)
shells = ops.setdefault("shells", {})
shells[kind] = {key: value for key, value in payload.items() if key != "ticket"}
ops["shells"] = shells
await save_node_ops(request, node, ops)
return payload
async def spiceshell(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _shell_proxy(request, inputs, "spice")
async def termproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _shell_proxy(request, inputs, "term")
async def vncshell(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _shell_proxy(request, inputs, "vnc")
async def network_reload(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
if not isinstance(ops.get("network"), list):
ops["network"] = copy.deepcopy(default_node_ops()["network"])
ops["network_applied"] = False
await save_node_ops(request, node, ops)
async def query_oci_repo_tags(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
repo = str(values(inputs).get("repo") or "library/alpine")
ops = await load_node_ops(request, node)
cache = ops.setdefault("oci_tags", {})
if repo not in cache:
cache[repo] = [{"tag": "latest"}, {"tag": "3.20"}]
ops["oci_tags"] = cache
await save_node_ops(request, node, ops)
return [dict(item) for item in cache[repo]]
async def query_url_metadata(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
url = str(values(inputs).get("url") or "")
ops = await load_node_ops(request, node)
cache = ops.setdefault("url_metadata", {})
if url not in cache:
cache[url] = {
"filename": url.rsplit("/", 1)[-1] or "download.bin",
"mimetype": "application/octet-stream",
"size": 1024,
}
ops["url_metadata"] = cache
await save_node_ops(request, node, ops)
return dict(cache[url])
async def vncwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
shell = (ops.get("shells") or {}).get("vnc") or {"port": 5900}
return {
"port": shell.get("port", 5900),
"ticket": secrets.token_urlsafe(24),
}
# Disks mutations (GET collections already registered in nodes.py)
registry.register(
"/nodes/{node}/disks/directory",
"POST",
lambda request, inputs: disks_create(request, inputs, "directory"),
)
registry.register(
"/nodes/{node}/disks/directory/{name}",
"DELETE",
lambda request, inputs: disks_delete(request, inputs, "directory"),
)
registry.register(
"/nodes/{node}/disks/lvm",
"POST",
lambda request, inputs: disks_create(request, inputs, "lvm"),
)
registry.register(
"/nodes/{node}/disks/lvm/{name}",
"DELETE",
lambda request, inputs: disks_delete(request, inputs, "lvm"),
)
registry.register(
"/nodes/{node}/disks/lvmthin",
"POST",
lambda request, inputs: disks_create(request, inputs, "lvmthin"),
)
registry.register(
"/nodes/{node}/disks/lvmthin/{name}",
"DELETE",
lambda request, inputs: disks_delete(request, inputs, "lvmthin"),
)
registry.register(
"/nodes/{node}/disks/zfs",
"POST",
lambda request, inputs: disks_create(request, inputs, "zfs"),
)
registry.register("/nodes/{node}/disks/zfs/{name}", "GET", disks_zfs_get)
registry.register(
"/nodes/{node}/disks/zfs/{name}",
"DELETE",
lambda request, inputs: disks_delete(request, inputs, "zfs"),
)
registry.register("/nodes/{node}/certificates", "GET", certificates_index)
registry.register("/nodes/{node}/certificates/acme", "GET", certificates_acme_index)
registry.register(
"/nodes/{node}/certificates/acme/certificate", "POST", certificates_acme_mutate
)
registry.register(
"/nodes/{node}/certificates/acme/certificate", "PUT", certificates_acme_mutate
)
registry.register(
"/nodes/{node}/certificates/acme/certificate", "DELETE", certificates_acme_mutate
)
registry.register("/nodes/{node}/certificates/custom", "POST", certificates_custom)
registry.register("/nodes/{node}/certificates/custom", "DELETE", certificates_custom)
registry.register("/nodes/{node}/certificates/info", "GET", certificates_info)
registry.register("/nodes/{node}/scan", "GET", scan_index)
registry.register("/nodes/{node}/scan/cifs", "GET", lambda r, i: scan_kind(r, i, "cifs"))
registry.register("/nodes/{node}/scan/iscsi", "GET", lambda r, i: scan_kind(r, i, "iscsi"))
registry.register("/nodes/{node}/scan/lvm", "GET", lambda r, i: scan_kind(r, i, "lvm"))
registry.register("/nodes/{node}/scan/lvmthin", "GET", lambda r, i: scan_kind(r, i, "lvmthin"))
registry.register("/nodes/{node}/scan/nfs", "GET", lambda r, i: scan_kind(r, i, "nfs"))
registry.register("/nodes/{node}/scan/pbs", "GET", lambda r, i: scan_kind(r, i, "pbs"))
registry.register("/nodes/{node}/scan/zfs", "GET", lambda r, i: scan_kind(r, i, "zfs"))
registry.register("/nodes/{node}/capabilities", "GET", capabilities_index)
registry.register("/nodes/{node}/capabilities/qemu", "GET", capabilities_qemu)
registry.register("/nodes/{node}/capabilities/qemu/cpu", "GET", capabilities_cpu)
registry.register("/nodes/{node}/capabilities/qemu/cpu-flags", "GET", capabilities_cpu_flags)
registry.register("/nodes/{node}/capabilities/qemu/machines", "GET", capabilities_machines)
registry.register("/nodes/{node}/capabilities/qemu/migration", "GET", capabilities_migration)
registry.register("/nodes/{node}/hardware", "GET", hardware_index)
registry.register("/nodes/{node}/hardware/pci", "GET", hardware_pci)
registry.register("/nodes/{node}/hardware/pci/{pci-id-or-mapping}", "GET", hardware_pci_get)
registry.register(
"/nodes/{node}/hardware/pci/{pci-id-or-mapping}/mdev", "GET", hardware_pci_mdev
)
registry.register("/nodes/{node}/hardware/usb", "GET", hardware_usb)
registry.register("/nodes/{node}/subscription", "GET", subscription_get)
registry.register("/nodes/{node}/subscription", "PUT", subscription_mutate)
registry.register("/nodes/{node}/subscription", "POST", subscription_mutate)
registry.register("/nodes/{node}/subscription", "DELETE", subscription_mutate)
registry.register("/nodes/{node}/aplinfo", "GET", aplinfo_get)
registry.register("/nodes/{node}/aplinfo", "POST", aplinfo_download)
registry.register("/nodes/{node}/apt/repositories", "POST", apt_repositories_mutate)
registry.register("/nodes/{node}/apt/repositories", "PUT", apt_repositories_mutate)
registry.register("/nodes/{node}/config", "GET", node_config_get)
registry.register("/nodes/{node}/config", "PUT", node_config_put)
registry.register("/nodes/{node}/dns", "GET", dns_get)
registry.register("/nodes/{node}/dns", "PUT", dns_put)
registry.register("/nodes/{node}/time", "GET", time_get)
registry.register("/nodes/{node}/time", "PUT", time_put)
registry.register("/nodes/{node}/execute", "POST", execute)
registry.register("/nodes/{node}/hosts", "GET", hosts_get)
registry.register("/nodes/{node}/hosts", "POST", hosts_post)
registry.register("/nodes/{node}/journal", "GET", journal)
registry.register("/nodes/{node}/syslog", "GET", syslog)
registry.register("/nodes/{node}/netstat", "GET", netstat)
registry.register("/nodes/{node}/report", "GET", report)
registry.register("/nodes/{node}/rrd", "GET", rrd)
registry.register("/nodes/{node}/rrddata", "GET", rrddata)
registry.register("/nodes/{node}/migrateall", "POST", migrateall)
registry.register("/nodes/{node}/startall", "POST", startall)
registry.register("/nodes/{node}/stopall", "POST", stopall)
registry.register("/nodes/{node}/suspendall", "POST", suspendall)
registry.register("/nodes/{node}/status", "POST", status_post)
registry.register("/nodes/{node}/wakeonlan", "POST", wakeonlan)
registry.register("/nodes/{node}/spiceshell", "POST", spiceshell)
registry.register("/nodes/{node}/termproxy", "POST", termproxy)
registry.register("/nodes/{node}/vncshell", "POST", vncshell)
registry.register("/nodes/{node}/network", "DELETE", network_reload)
registry.register("/nodes/{node}/query-oci-repo-tags", "GET", query_oci_repo_tags)
registry.register("/nodes/{node}/query-url-metadata", "GET", query_url_metadata)
registry.register("/nodes/{node}/vncwebsocket", "GET", vncwebsocket)
+276
View File
@@ -0,0 +1,276 @@
"""Cluster notifications endpoints and matchers persisted in metadata."""
from __future__ import annotations
import time
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs, values
_SECRET_KEYS = frozenset({"token", "password", "secret"})
DEFAULT_MATCHER_FIELDS = [
{"name": "type", "type": "string"},
{"name": "hostname", "type": "string"},
{"name": "job-id", "type": "string"},
{"name": "severity", "type": "string"},
]
DEFAULT_MATCHER_FIELD_VALUES = [
{"field": "type", "value": "fencing"},
{"field": "type", "value": "package-updates"},
{"field": "type", "value": "replication"},
{"field": "type", "value": "system-mail"},
]
def _public(endpoint: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in endpoint.items() if key not in _SECRET_KEYS}
def _notifications(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault(
"notifications",
{
"endpoints": {
"gotify": {},
"sendmail": {},
"smtp": {},
"webhook": {},
},
"matchers": {},
"tests": [],
},
)
if not isinstance(current, dict):
current = {
"endpoints": {"gotify": {}, "sendmail": {}, "smtp": {}, "webhook": {}},
"matchers": {},
"tests": [],
}
metadata["notifications"] = current
current.setdefault(
"endpoints",
{"gotify": {}, "sendmail": {}, "smtp": {}, "webhook": {}},
)
current.setdefault("matchers", {})
current.setdefault("tests", [])
return current
def register_notifications_handlers(registry: HandlerRegistry) -> None:
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs(
"endpoints",
"matcher-field-values",
"matcher-fields",
"matchers",
"targets",
)
async def endpoints_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("gotify", "sendmail", "smtp", "webhook")
def register_kind(kind: str, create_keys: tuple[str, ...]) -> None:
base = f"/cluster/notifications/endpoints/{kind}"
async def list_endpoints(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
return [_public({"name": name, **item}) for name, item in sorted(store.items())]
async def create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
if name in store:
raise ApiError(400, f"{kind} endpoint '{name}' already exists")
entry = {key: payload[key] for key in create_keys if key in payload}
entry["name"] = name
entry.setdefault("disable", 0)
store[name] = entry
await save_cluster_metadata(request, metadata)
async def get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
if name not in store:
raise ApiError(404, f"{kind} endpoint does not exist")
return _public({"name": name, **store[name]})
async def update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
if name not in store:
raise ApiError(404, f"{kind} endpoint does not exist")
current = dict(store[name])
delete_keys = [
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
]
for key in delete_keys:
current.pop(key, None)
for key, value in payload.items():
if key in {"name", "delete", "digest"}:
continue
current[key] = value
current["name"] = name
store[name] = current
await save_cluster_metadata(request, metadata)
async def delete(request: Request, inputs: dict[str, Any]) -> None:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
if name not in store:
raise ApiError(404, f"{kind} endpoint does not exist")
del store[name]
await save_cluster_metadata(request, metadata)
registry.register(base, "GET", list_endpoints)
registry.register(base, "POST", create)
registry.register(f"{base}/{{name}}", "GET", get)
registry.register(f"{base}/{{name}}", "PUT", update)
registry.register(f"{base}/{{name}}", "DELETE", delete)
async def matchers_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
return [{"name": name, **item} for name, item in sorted(store.items())]
async def matchers_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
if name in store:
raise ApiError(400, f"matcher '{name}' already exists")
store[name] = {
key: value for key, value in payload.items() if key not in {"delete", "digest"}
}
await save_cluster_metadata(request, metadata)
async def matchers_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
if name not in store:
raise ApiError(404, "matcher does not exist")
return {"name": name, **store[name]}
async def matchers_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
if name not in store:
raise ApiError(404, "matcher does not exist")
current = dict(store[name])
for key in [
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
]:
current.pop(key, None)
for key, value in payload.items():
if key in {"name", "delete", "digest"}:
continue
current[key] = value
current["name"] = name
store[name] = current
await save_cluster_metadata(request, metadata)
async def matchers_delete(request: Request, inputs: dict[str, Any]) -> None:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
if name not in store:
raise ApiError(404, "matcher does not exist")
del store[name]
await save_cluster_metadata(request, metadata)
async def matcher_fields(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(DEFAULT_MATCHER_FIELDS)
async def matcher_field_values(
_request: Request, _inputs: dict[str, Any]
) -> list[dict[str, Any]]:
return list(DEFAULT_MATCHER_FIELD_VALUES)
async def targets(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
notifications = _notifications(metadata)
result: list[dict[str, Any]] = []
for kind, store in (notifications.get("endpoints") or {}).items():
if not isinstance(store, dict):
continue
for name, item in store.items():
result.append(
{
"name": name,
"type": kind,
"comment": item.get("comment", ""),
"disable": int(bool(item.get("disable"))),
}
)
return result
async def target_test(request: Request, inputs: dict[str, Any]) -> None:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
notifications = _notifications(metadata)
found = False
for store in (notifications.get("endpoints") or {}).values():
if isinstance(store, dict) and name in store:
found = True
break
if not found:
raise ApiError(404, "notification target does not exist")
tests = notifications.setdefault("tests", [])
if not isinstance(tests, list):
tests = notifications["tests"] = []
tests.append({"name": name, "tested_at": int(time.time()), "ok": True})
await save_cluster_metadata(request, metadata)
registry.register("/cluster/notifications", "GET", index)
registry.register("/cluster/notifications/endpoints", "GET", endpoints_index)
register_kind("gotify", ("comment", "disable", "name", "server", "token"))
register_kind(
"sendmail",
("author", "comment", "disable", "from-address", "mailto", "mailto-user", "name"),
)
register_kind(
"smtp",
(
"author",
"comment",
"disable",
"from-address",
"mailto",
"mailto-user",
"mode",
"name",
"password",
"port",
"server",
"username",
),
)
register_kind(
"webhook",
("body", "comment", "disable", "header", "method", "name", "secret", "url"),
)
registry.register("/cluster/notifications/matchers", "GET", matchers_list)
registry.register("/cluster/notifications/matchers", "POST", matchers_create)
registry.register("/cluster/notifications/matchers/{name}", "GET", matchers_get)
registry.register("/cluster/notifications/matchers/{name}", "PUT", matchers_update)
registry.register("/cluster/notifications/matchers/{name}", "DELETE", matchers_delete)
registry.register("/cluster/notifications/matcher-fields", "GET", matcher_fields)
registry.register("/cluster/notifications/matcher-field-values", "GET", matcher_field_values)
registry.register("/cluster/notifications/targets", "GET", targets)
registry.register("/cluster/notifications/targets/{name}/test", "POST", target_test)
+134
View File
@@ -0,0 +1,134 @@
"""Resource pool semantic handlers."""
from __future__ import annotations
import json
import uuid
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import database, state, values
from app.simulation.seed import CLUSTER_ID, stable_id
async def _pool_members(request: Request, pool_id: uuid.UUID) -> list[str]:
rows = await database(request).pool.fetch(
"""SELECT r.external_id FROM pool_members pm
JOIN resources r ON r.id = pm.resource_id
WHERE pm.pool_id=$1 ORDER BY r.external_id::integer""",
pool_id,
)
return [str(row["external_id"]) for row in rows]
def register_pool_handlers(registry: HandlerRegistry) -> None:
async def pool_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
filter_poolid = payload.get("poolid")
rows = await database(request).pool.fetch(
"""SELECT id, pool_id, comment, metadata FROM pools
WHERE ($1::text IS NULL OR pool_id=$1)
ORDER BY pool_id""",
str(filter_poolid) if filter_poolid is not None else None,
)
result: list[dict[str, Any]] = []
for row in rows:
metadata = state(row["metadata"])
members = await _pool_members(request, row["id"])
if not members and isinstance(metadata.get("members"), list):
members = [str(item) for item in metadata["members"]]
item: dict[str, Any] = {
"poolid": str(row["pool_id"]),
"members": members,
}
if row["comment"] is not None:
item["comment"] = str(row["comment"])
elif metadata.get("comment"):
item["comment"] = str(metadata["comment"])
result.append(item)
return result
async def pool_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
items = await pool_list(request, inputs)
if not items:
raise ApiError(404, "pool does not exist")
return items[0]
async def pool_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
poolid = str(payload["poolid"])
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM pools WHERE pool_id=$1)",
poolid,
)
if exists:
raise ApiError(409, "pool already exists")
await database(request).pool.execute(
"""INSERT INTO pools(id, cluster_id, pool_id, comment, metadata)
VALUES($1, $2, $3, $4, $5::jsonb)""",
stable_id(f"pool:{poolid}"),
CLUSTER_ID,
poolid,
payload.get("comment"),
json.dumps({"members": []}, sort_keys=True),
)
async def pool_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
poolid = str(payload["poolid"])
pool_row = await database(request).pool.fetchrow(
"SELECT id FROM pools WHERE pool_id=$1",
poolid,
)
if pool_row is None:
raise ApiError(404, "pool does not exist")
if payload.get("comment") is not None:
await database(request).pool.execute(
"UPDATE pools SET comment=$2 WHERE pool_id=$1",
poolid,
payload.get("comment"),
)
if "vms" in payload:
vmids = [item.strip() for item in str(payload["vms"]).split(",") if item.strip()]
for vmid in vmids:
resource = await database(request).pool.fetchrow(
"""SELECT id FROM resources
WHERE kind IN ('qemu', 'lxc') AND external_id=$1""",
vmid,
)
if resource is None:
continue
await database(request).pool.execute(
"""INSERT INTO pool_members(pool_id, resource_id)
VALUES($1, $2) ON CONFLICT DO NOTHING""",
pool_row["id"],
resource["id"],
)
if "delete" in payload:
vmids = [item.strip() for item in str(payload["delete"]).split(",") if item.strip()]
await database(request).pool.execute(
"""DELETE FROM pool_members pm USING resources r
WHERE pm.pool_id=$1 AND pm.resource_id=r.id AND r.external_id = ANY($2::text[])""",
pool_row["id"],
vmids,
)
async def pool_delete(request: Request, inputs: dict[str, Any]) -> None:
poolid = str(values(inputs)["poolid"])
status = await database(request).pool.execute(
"DELETE FROM pools WHERE pool_id=$1",
poolid,
)
if status != "DELETE 1":
raise ApiError(404, "pool does not exist")
registry.register("/pools", "GET", pool_list)
registry.register("/pools", "POST", pool_create)
registry.register("/pools", "PUT", pool_update)
registry.register("/pools", "DELETE", pool_delete)
registry.register("/pools/{poolid}", "GET", pool_get)
registry.register("/pools/{poolid}", "PUT", pool_update)
registry.register("/pools/{poolid}", "DELETE", pool_delete)
+285 -28
View File
@@ -13,6 +13,7 @@ from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.db.primitives import ConflictError
from app.handlers.common import require_node, subdirs
from app.simulation.transitions import InvalidTransitionError, VmState, plan_transition
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
@@ -43,6 +44,67 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
)
return [{"vmid": int(row["vmid"]), **_state(row["state"])} for row in rows]
async def qemu_status_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
payload = _values(inputs)
await _qemu_resource(request, str(payload["node"]), str(payload["vmid"]))
return subdirs(
"current",
"reboot",
"reset",
"resume",
"shutdown",
"start",
"stop",
"suspend",
)
async def qemu_status_current(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
node, vmid = str(payload["node"]), int(payload["vmid"])
resource = await _qemu_resource(request, node, str(vmid))
vm_state = _state(resource["state"])
config = _state(resource["config"])
status = str(vm_state.get("status", "stopped"))
running = status in {"running", "paused"}
memory_mb = int(config.get("memory", config.get("mem", 2048)))
maxmem = memory_mb * 2**20
mem_used = int(vm_state.get("mem", maxmem // 2 if running else 0))
uptime = int(
vm_state.get(
"uptime",
int(
await _database(request).pool.fetchval(
"SELECT extract(epoch from now())::bigint"
)
)
% 86_400
if running
else 0,
)
)
return {
"vmid": vmid,
"name": str(config.get("name", f"vm-{vmid}")),
"status": status,
"qmpstatus": status if running else "stopped",
"lock": str(vm_state.get("lock", "")),
"pid": int(vm_state.get("pid", 12_345 if running else 0)),
"cpus": int(config.get("cores", config.get("cpus", 1))),
"maxmem": maxmem,
"mem": mem_used,
"balloon": int(vm_state.get("balloon", 0)),
"ballooninfo": {
"actual": mem_used,
"max_mem": maxmem,
"mem_swapped_in": 0,
"mem_swapped_out": 0,
},
"uptime": uptime,
"template": int(bool(vm_state.get("template", False))),
"ha": {"managed": int(vm_state.get("ha_managed", 0))},
"agent": 1 if running and str(config.get("agent", "0")).startswith("1") else 0,
}
async def qemu_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"])
row = await _database(request).pool.fetchrow(
@@ -57,9 +119,6 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
raise ApiError(404, "virtual machine does not exist")
return {"vmid": int(vmid), **_state(row["config"]), **_state(row["state"])}
async def qemu_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await qemu_config(request, inputs)
async def mutate(operation: str, request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
@@ -77,26 +136,17 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
plan_transition(VmState(current), operation)
except (InvalidTransitionError, ValueError) as error:
raise ApiError(409, f"cannot {operation} VM while it is {current}") from error
timestamp = int(await database.pool.fetchval("SELECT extract(epoch from now())::bigint"))
pid = int(await database.pool.fetchval("SELECT pg_backend_pid()"))
upid = str(
Upid(
node,
pid,
pid,
timestamp,
f"qm{operation}",
vmid,
str(request.state.principal),
upid = str(Upid.allocate(node, f"qm{operation}", vmid, str(request.state.principal)))
try:
task = await TaskRepository(database.pool).create(
upid=upid,
task_type=f"qemu-{operation}",
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
resource_key=f"qemu:{vmid}",
idempotency_key=request.headers.get("Idempotency-Key"),
)
)
task = await TaskRepository(database.pool).create(
upid=upid,
task_type=f"qemu-{operation}",
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
resource_key=f"qemu:{vmid}",
idempotency_key=request.headers.get("Idempotency-Key"),
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
async def create(request: Request, inputs: dict[str, Any]) -> str:
@@ -357,7 +407,10 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
async def migrate_preconditions(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
target = str(values["target"])
target = values.get("target")
if target in {None, ""}:
raise ApiError(400, "parameter 'target' is required")
target = str(target)
exists = await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
)
@@ -367,7 +420,11 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
async def migrate(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid, target = str(values["node"]), str(values["vmid"]), str(values["target"])
node, vmid = str(values["node"]), str(values["vmid"])
target = values.get("target")
if target in {None, ""}:
raise ApiError(400, "parameter 'target' is required")
target = str(target)
resource = await _qemu_resource(request, node, vmid)
if target == node:
raise ApiError(400, "target node is the same as source node")
@@ -386,6 +443,37 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
},
)
async def remote_migrate(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
target_endpoint = str(values.get("target-endpoint") or values.get("target_endpoint") or "")
target = str(values.get("target") or "")
if not target_endpoint:
raise ApiError(400, "parameter target-endpoint is required")
if not target:
raise ApiError(400, "parameter target is required")
node, vmid = str(values["node"]), str(values["vmid"])
resource = await _qemu_resource(request, node, vmid)
if target == node:
raise ApiError(400, "target node is the same as source node")
if not await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
):
raise ApiError(404, "target node does not exist")
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-remote-migrate",
payload={
"resource_id": str(resource["id"]),
"node": node,
"target": target,
"vmid": vmid,
"target-endpoint": target_endpoint,
"online": bool(values.get("online", False)),
},
)
async def resize(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
node, vmid, disk = str(values["node"]), str(values["vmid"]), str(values["disk"])
@@ -441,6 +529,74 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
},
)
async def pending(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
config = _state(resource["config"])
changes = cast(Mapping[str, Any], state.get("pending", {}))
return [
{"key": key, "value": str(config.get(key, "")), "pending": str(value)}
for key, value in sorted(changes.items())
]
async def agent_result(
command: str, request: Request, inputs: dict[str, Any]
) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
config = _state(resource["config"])
vmid = str(_values(inputs)["vmid"])
results: dict[str, Any] = {
"info": {
"version": "9.2.0-simulator",
"supported_commands": [
{"name": name, "enabled": True, "success-response": True}
for name in ("guest-ping", "guest-info", "guest-get-osinfo")
],
},
"get-osinfo": {
"name": str(config.get("ostype", "linux")),
"pretty-name": "Proxmox Simulator Guest",
"version": "1.0",
"machine": "x86_64",
},
"get-host-name": {"host-name": str(config.get("name", f"vm-{vmid}"))},
"network-get-interfaces": [
{
"name": "eth0",
"hardware-address": "02:00:00:00:00:01",
"ip-addresses": [
{"ip-address": "192.0.2.10", "ip-address-type": "ipv4", "prefix": 24}
],
}
],
"ping": {},
}
if command == "get-time":
seconds = int(
await _database(request).pool.fetchval("SELECT extract(epoch from now())::bigint")
)
return {"result": {"seconds": seconds, "nanoseconds": 0}}
return {"result": results[command]}
async def agent_info(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_result("info", request, inputs)
async def agent_osinfo(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_result("get-osinfo", request, inputs)
async def agent_hostname(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_result("get-host-name", request, inputs)
async def agent_network(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_result("network-get-interfaces", request, inputs)
async def agent_time(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_result("get-time", request, inputs)
async def agent_ping(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_result("ping", request, inputs)
async def task_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
tasks = await TaskRepository(_database(request).pool).list_for_node(
str(_values(inputs)["node"])
@@ -474,13 +630,87 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
for index, message in enumerate(await repository.logs(task.id))
]
async def qemu_feature(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
await _qemu_resource(request, str(payload["node"]), str(payload["vmid"]))
return {
"hasFeature": {
"snapshot": 1,
"clone": 1,
"copy": 1,
"template": 1,
"move_disk": 1,
"agent": 1,
}
}
async def qemu_template(request: Request, inputs: dict[str, Any]) -> None:
payload = _values(inputs)
node, vmid = str(payload["node"]), str(payload["vmid"])
resource = await _qemu_resource(request, node, vmid)
state = _state(resource["state"])
if state.get("status") != "stopped":
raise ApiError(409, "virtual machine must be stopped to convert to template")
await _database(request).pool.execute(
"UPDATE virtual_machines SET template=true WHERE resource_id=$1",
resource["id"],
)
state["template"] = True
await _database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb WHERE id=$1",
resource["id"],
json.dumps(state, sort_keys=True),
)
async def qemu_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
payload = _values(inputs)
node, vmid = str(payload["node"]), str(payload["vmid"])
await require_node(request, node)
await _qemu_resource(request, node, vmid)
return subdirs(
"agent",
"clone",
"config",
"feature",
"firewall",
"migrate",
"move_disk",
"pending",
"resize",
"snapshot",
"status",
"template",
)
async def task_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
payload = _values(inputs)
node, upid = str(payload["node"]), str(payload["upid"])
await require_node(request, node)
task = await TaskRepository(_database(request).pool).get_by_upid(upid)
if task is None:
raise ApiError(404, "task does not exist")
return subdirs("log", "status")
async def task_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = _values(inputs)
upid = str(payload["upid"])
repository = TaskRepository(_database(request).pool)
task = await repository.get_by_upid(upid)
if task is None:
raise ApiError(404, "task does not exist")
if task.status in {"success", "error", "cancelled"}:
return
await repository.request_cancel(task.id)
registry.register("/nodes/{node}/qemu", "GET", qemu_list)
registry.register("/nodes/{node}/qemu", "POST", create)
registry.register("/nodes/{node}/qemu/{vmid}", "GET", qemu_index)
registry.register("/nodes/{node}/qemu/{vmid}", "DELETE", delete)
registry.register("/nodes/{node}/qemu/{vmid}/config", "GET", qemu_config)
registry.register("/nodes/{node}/qemu/{vmid}/config", "POST", update_async)
registry.register("/nodes/{node}/qemu/{vmid}/config", "PUT", update_sync)
registry.register("/nodes/{node}/qemu/{vmid}/status/current", "GET", qemu_status)
registry.register("/nodes/{node}/qemu/{vmid}/status", "GET", qemu_status_index)
registry.register("/nodes/{node}/qemu/{vmid}/status/current", "GET", qemu_status_current)
registry.register("/nodes/{node}/qemu/{vmid}/status/start", "POST", start)
registry.register("/nodes/{node}/qemu/{vmid}/status/stop", "POST", stop)
registry.register("/nodes/{node}/qemu/{vmid}/status/shutdown", "POST", shutdown)
@@ -504,11 +734,28 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
registry.register("/nodes/{node}/qemu/{vmid}/clone", "POST", clone)
registry.register("/nodes/{node}/qemu/{vmid}/migrate", "GET", migrate_preconditions)
registry.register("/nodes/{node}/qemu/{vmid}/migrate", "POST", migrate)
registry.register("/nodes/{node}/qemu/{vmid}/remote_migrate", "POST", remote_migrate)
registry.register("/nodes/{node}/qemu/{vmid}/resize", "PUT", resize)
registry.register("/nodes/{node}/qemu/{vmid}/move_disk", "POST", move_disk)
registry.register("/nodes/{node}/qemu/{vmid}/pending", "GET", pending)
registry.register("/nodes/{node}/qemu/{vmid}/agent/info", "GET", agent_info)
registry.register("/nodes/{node}/qemu/{vmid}/agent/get-osinfo", "GET", agent_osinfo)
registry.register("/nodes/{node}/qemu/{vmid}/agent/get-host-name", "GET", agent_hostname)
registry.register(
"/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces", "GET", agent_network
)
registry.register("/nodes/{node}/qemu/{vmid}/agent/get-time", "GET", agent_time)
registry.register("/nodes/{node}/qemu/{vmid}/agent/ping", "POST", agent_ping)
registry.register("/nodes/{node}/qemu/{vmid}/feature", "GET", qemu_feature)
registry.register("/nodes/{node}/qemu/{vmid}/template", "POST", qemu_template)
registry.register("/nodes/{node}/tasks", "GET", task_list)
registry.register("/nodes/{node}/tasks/{upid}", "GET", task_index)
registry.register("/nodes/{node}/tasks/{upid}", "DELETE", task_delete)
registry.register("/nodes/{node}/tasks/{upid}/status", "GET", task_status)
registry.register("/nodes/{node}/tasks/{upid}/log", "GET", task_log)
from app.handlers.qemu_extra import register_qemu_extra_handlers
register_qemu_extra_handlers(registry)
async def _create_task(
@@ -520,8 +767,6 @@ async def _create_task(
payload: dict[str, Any],
) -> str:
database = _database(request)
timestamp = int(await database.pool.fetchval("SELECT extract(epoch from now())::bigint"))
pid = int(await database.pool.fetchval("SELECT pg_backend_pid()"))
worker_type = {
"qemu-create": "qmcreate",
"qemu-delete": "qmdestroy",
@@ -531,9 +776,10 @@ async def _create_task(
"qemu-snapshot-rollback": "qmrollback",
"qemu-clone": "qmclone",
"qemu-migrate": "qmigrate",
"qemu-remote-migrate": "qmremote",
"qemu-move-disk": "qmmove",
}[task_type]
upid = str(Upid(node, pid, pid, timestamp, worker_type, vmid, str(request.state.principal)))
upid = str(Upid.allocate(node, worker_type, vmid, str(request.state.principal)))
try:
task = await TaskRepository(database.pool).create(
upid=upid,
@@ -575,6 +821,17 @@ async def _snapshot(request: Request, values: dict[str, Any]) -> Any:
return row
async def _agent_resource(request: Request, values: dict[str, Any]) -> Any:
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
config = _state(resource["config"])
state = _state(resource["state"])
if str(config.get("agent", "0")).split(",", 1)[0].lower() not in {"1", "true", "yes"}:
raise ApiError(409, "QEMU guest agent is not enabled")
if state.get("status") != "running":
raise ApiError(409, "QEMU guest agent is not running")
return resource
_SIZE_RE = re.compile(r"^(?P<value>\d+)(?P<unit>[KMGT]?)$", re.IGNORECASE)
+445
View File
@@ -0,0 +1,445 @@
"""Additional QEMU guest/agent/console endpoints with durable guest state."""
from __future__ import annotations
import json
import secrets
from collections.abc import Mapping
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.handlers.qemu import _agent_resource, _database, _qemu_resource, _state, _values
from app.security.auth import issue_ticket
def _settings(request: Request) -> Settings:
return cast(Settings, request.app.state.settings)
async def _save_guest_state(request: Request, resource_id: Any, state: dict[str, Any]) -> None:
await _database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, version=version+1, updated_at=now() WHERE id=$1",
resource_id,
json.dumps(state, sort_keys=True),
)
async def _save_guest_config(request: Request, resource_id: Any, config: dict[str, Any]) -> None:
await _database(request).pool.execute(
"UPDATE virtual_machines SET config=$2::jsonb WHERE resource_id=$1",
resource_id,
json.dumps(config, sort_keys=True),
)
def register_qemu_extra_handlers(registry: HandlerRegistry) -> None:
async def agent_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await _agent_resource(request, _values(inputs))
return [
{"name": name}
for name in (
"exec",
"exec-status",
"file-read",
"file-write",
"fsfreeze-freeze",
"fsfreeze-status",
"fsfreeze-thaw",
"fstrim",
"get-fsinfo",
"get-memory-block-info",
"get-memory-blocks",
"get-timezone",
"get-users",
"get-vcpus",
"info",
"ping",
"set-user-password",
"shutdown",
"suspend-disk",
"suspend-hybrid",
"suspend-ram",
)
]
async def agent_post(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
command = str(payload.get("command") or "ping")
resource = await _agent_resource(request, payload)
state = _state(resource["state"])
agent = state.setdefault("agent", {})
agent["last_command"] = command
await _save_guest_state(request, resource["id"], state)
return {"result": {"command": command, "ok": 1}}
async def _agent_blob(command: str, request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
agent = state.setdefault("agent", {})
blobs = agent.setdefault("results", {})
defaults: dict[str, Any] = {
"get-users": [{"user": "root", "login-time": 0}],
"get-fsinfo": [{"name": "/", "type": "ext4", "total-bytes": 32 * 1024**3}],
"get-memory-block-info": {"size": 1024**3},
"get-memory-blocks": [{"start": 0, "size": 1024**3}],
"get-timezone": {"zone": "UTC", "offset": 0},
"get-vcpus": [{"online": True, "can-offline": False}],
"fsfreeze-status": "thawed",
}
if command not in blobs:
blobs[command] = defaults.get(command, {})
await _save_guest_state(request, resource["id"], state)
return {"result": blobs[command]}
async def agent_users(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("get-users", request, inputs)
async def agent_fsinfo(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("get-fsinfo", request, inputs)
async def agent_memory_block_info(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("get-memory-block-info", request, inputs)
async def agent_memory_blocks(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("get-memory-blocks", request, inputs)
async def agent_timezone(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("get-timezone", request, inputs)
async def agent_vcpus(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("get-vcpus", request, inputs)
async def agent_exec(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
resource = await _agent_resource(request, payload)
state = _state(resource["state"])
agent = state.setdefault("agent", {})
execs = agent.setdefault("exec", {})
pid = int(agent.get("next_pid", 1000)) + 1
agent["next_pid"] = pid
execs[str(pid)] = {
"exited": 1,
"exitcode": 0,
"out-data": "",
"err-data": "",
"command": payload.get("command"),
}
await _save_guest_state(request, resource["id"], state)
return {"pid": pid}
async def agent_exec_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
resource = await _agent_resource(request, payload)
pid = str(payload.get("pid") or "")
state = _state(resource["state"])
result = state.get("agent", {}).get("exec", {}).get(pid)
if not isinstance(result, dict):
raise ApiError(404, "exec process does not exist")
return {"result": result}
async def agent_file_read(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
resource = await _agent_resource(request, payload)
path = str(payload.get("file") or payload.get("path") or "/etc/hostname")
state = _state(resource["state"])
files = state.setdefault("agent", {}).setdefault("files", {})
if path not in files:
files[path] = f"simulated:{path}\n"
await _save_guest_state(request, resource["id"], state)
content = str(files[path])
return {"result": {"content": content, "truncated": True}}
async def agent_file_write(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
resource = await _agent_resource(request, payload)
default_path = "guest-agent-out"
path = str(payload.get("file") or payload.get("path") or default_path)
content = str(payload.get("content") or "")
state = _state(resource["state"])
files = state.setdefault("agent", {}).setdefault("files", {})
files[path] = content
await _save_guest_state(request, resource["id"], state)
return {"result": None}
async def agent_fsfreeze(
request: Request, inputs: dict[str, Any], status: str
) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
agent = state.setdefault("agent", {})
agent["fsfreeze"] = status
agent.setdefault("results", {})["fsfreeze-status"] = status
await _save_guest_state(request, resource["id"], state)
return {"result": status}
async def agent_fsfreeze_freeze(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_fsfreeze(request, inputs, "frozen")
async def agent_fsfreeze_thaw(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_fsfreeze(request, inputs, "thawed")
async def agent_fsfreeze_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("fsfreeze-status", request, inputs)
async def agent_fstrim(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
state.setdefault("agent", {})["last_fstrim"] = True
await _save_guest_state(request, resource["id"], state)
return {"result": {"paths": [{"path": "/", "trimmed": 0}]}}
async def agent_set_password(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
resource = await _agent_resource(request, payload)
username = str(payload.get("username") or "root")
state = _state(resource["state"])
passwords = state.setdefault("agent", {}).setdefault("passwords", {})
passwords[username] = True # store only presence, not secret
await _save_guest_state(request, resource["id"], state)
return {"result": None}
async def agent_shutdown(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
state["status"] = "stopped"
await _save_guest_state(request, resource["id"], state)
return {"result": None}
async def agent_suspend(request: Request, inputs: dict[str, Any], mode: str) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
state["status"] = "paused"
state.setdefault("agent", {})["suspend"] = mode
await _save_guest_state(request, resource["id"], state)
return {"result": None}
async def agent_suspend_disk(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_suspend(request, inputs, "disk")
async def agent_suspend_ram(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_suspend(request, inputs, "ram")
async def agent_suspend_hybrid(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_suspend(request, inputs, "hybrid")
async def cloudinit_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
config = _state(resource["config"])
state = _state(resource["state"])
pending = cast(Mapping[str, Any], state.get("pending", {}))
keys = sorted(
{
key
for key in set(config) | set(pending)
if str(key).startswith(("ci", "ipconfig", "sshkeys", "nameserver", "searchdomain"))
}
)
return [
{
"key": key,
"value": str(config.get(key, "")),
"pending": str(pending[key]) if key in pending else None,
}
for key in keys
]
async def cloudinit_update(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
state["cloudinit_generation"] = int(state.get("cloudinit_generation") or 0) + 1
await _save_guest_state(request, resource["id"], state)
async def cloudinit_dump(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
config = _state(resource["config"])
return (
f"#cloud-config\nhostname: {config.get('name', values['vmid'])}\n"
f"manage_etc_hosts: true\n"
)
async def rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
rrd_state = state.setdefault("rrd", {"filename": f"pve-vm-{values['vmid']}.rrd"})
await _save_guest_state(request, resource["id"], state)
return dict(rrd_state)
async def rrddata(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
series = state.setdefault(
"rrddata",
[
{
"time": 1_700_000_000,
"cpu": 0.05,
"mem": 256 * 1024 * 1024,
"netin": 0,
"netout": 0,
},
{
"time": 1_700_000_060,
"cpu": 0.08,
"mem": 260 * 1024 * 1024,
"netin": 100,
"netout": 80,
},
],
)
await _save_guest_state(request, resource["id"], state)
return list(series)
async def monitor(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
command = str(values.get("command") or "info status")
state = _state(resource["state"])
history = state.setdefault("monitor", [])
if not isinstance(history, list):
history = state["monitor"] = []
output = f"OK {command}"
history.append({"command": command, "output": output})
await _save_guest_state(request, resource["id"], state)
return output
async def sendkey(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
key = str(values.get("key") or "")
if not key:
raise ApiError(400, "parameter verification failed - 'key' missing")
state = _state(resource["state"])
keys = state.setdefault("sendkey", [])
if not isinstance(keys, list):
keys = state["sendkey"] = []
keys.append(key)
await _save_guest_state(request, resource["id"], state)
async def unlink(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
idlist = [
item.strip()
for item in str(values.get("idlist") or values.get("ids") or "").split(",")
if item.strip()
]
if not idlist:
raise ApiError(400, "parameter verification failed - 'idlist' missing")
config = _state(resource["config"])
for disk in idlist:
config.pop(disk, None)
await _save_guest_config(request, resource["id"], config)
state = _state(resource["state"])
state["config"] = config
await _save_guest_state(request, resource["id"], state)
async def _console_proxy(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
key = _settings(request).ticket_signing_key.get_secret_value().encode()
ticket = issue_ticket(str(request.state.principal), key)
port = 5900 + int(values["vmid"]) % 1000
state = _state(resource["state"])
consoles = state.setdefault("consoles", {})
payload = {
"type": kind,
"port": port,
"ticket": ticket,
"upid": (
f"UPID:{values['node']}:{secrets.token_hex(4)}:"
f"{kind}:{values['vmid']}:{request.state.principal}:"
),
"user": str(request.state.principal),
"cert": "",
}
if values.get("generate-password") or values.get("websocket"):
payload["password"] = secrets.token_urlsafe(8)
consoles[kind] = {k: v for k, v in payload.items() if k != "ticket"}
await _save_guest_state(request, resource["id"], state)
return payload
async def vncproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console_proxy(request, inputs, "vnc")
async def spiceproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console_proxy(request, inputs, "spice")
async def termproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console_proxy(request, inputs, "term")
async def mtunnel(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console_proxy(request, inputs, "mtunnel")
async def websocket_ticket(
request: Request, inputs: dict[str, Any], kind: str
) -> dict[str, Any]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
console = state.get("consoles", {}).get(kind) or {"port": 5900}
key = _settings(request).ticket_signing_key.get_secret_value().encode()
return {
"port": console.get("port", 5900),
"ticket": issue_ticket(str(request.state.principal), key),
}
async def vncwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await websocket_ticket(request, inputs, "vnc")
async def mtunnelwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await websocket_ticket(request, inputs, "mtunnel")
async def dbus_vmstate(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
state["dbus_vmstate"] = True
await _save_guest_state(request, resource["id"], state)
return {"result": "OK"}
base = "/nodes/{node}/qemu/{vmid}"
registry.register(f"{base}/agent", "GET", agent_index)
registry.register(f"{base}/agent", "POST", agent_post)
registry.register(f"{base}/agent/exec", "POST", agent_exec)
registry.register(f"{base}/agent/exec-status", "GET", agent_exec_status)
registry.register(f"{base}/agent/file-read", "GET", agent_file_read)
registry.register(f"{base}/agent/file-write", "POST", agent_file_write)
registry.register(f"{base}/agent/fsfreeze-freeze", "POST", agent_fsfreeze_freeze)
registry.register(f"{base}/agent/fsfreeze-status", "POST", agent_fsfreeze_status)
registry.register(f"{base}/agent/fsfreeze-thaw", "POST", agent_fsfreeze_thaw)
registry.register(f"{base}/agent/fstrim", "POST", agent_fstrim)
registry.register(f"{base}/agent/get-fsinfo", "GET", agent_fsinfo)
registry.register(f"{base}/agent/get-memory-block-info", "GET", agent_memory_block_info)
registry.register(f"{base}/agent/get-memory-blocks", "GET", agent_memory_blocks)
registry.register(f"{base}/agent/get-timezone", "GET", agent_timezone)
registry.register(f"{base}/agent/get-users", "GET", agent_users)
registry.register(f"{base}/agent/get-vcpus", "GET", agent_vcpus)
registry.register(f"{base}/agent/set-user-password", "POST", agent_set_password)
registry.register(f"{base}/agent/shutdown", "POST", agent_shutdown)
registry.register(f"{base}/agent/suspend-disk", "POST", agent_suspend_disk)
registry.register(f"{base}/agent/suspend-hybrid", "POST", agent_suspend_hybrid)
registry.register(f"{base}/agent/suspend-ram", "POST", agent_suspend_ram)
registry.register(f"{base}/cloudinit", "GET", cloudinit_get)
registry.register(f"{base}/cloudinit", "PUT", cloudinit_update)
registry.register(f"{base}/cloudinit/dump", "GET", cloudinit_dump)
registry.register(f"{base}/rrd", "GET", rrd)
registry.register(f"{base}/rrddata", "GET", rrddata)
registry.register(f"{base}/monitor", "POST", monitor)
registry.register(f"{base}/sendkey", "PUT", sendkey)
registry.register(f"{base}/unlink", "PUT", unlink)
registry.register(f"{base}/vncproxy", "POST", vncproxy)
registry.register(f"{base}/spiceproxy", "POST", spiceproxy)
registry.register(f"{base}/termproxy", "POST", termproxy)
registry.register(f"{base}/mtunnel", "POST", mtunnel)
registry.register(f"{base}/vncwebsocket", "GET", vncwebsocket)
registry.register(f"{base}/mtunnelwebsocket", "GET", mtunnelwebsocket)
registry.register(f"{base}/dbus-vmstate", "POST", dbus_vmstate)
+1027
View File
File diff suppressed because it is too large Load Diff
+576
View File
@@ -0,0 +1,576 @@
"""Storage semantic handlers."""
from __future__ import annotations
import json
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import database, require_node, state, storage_payload, subdirs, values
from app.simulation.seed import CLUSTER_ID, stable_id
def register_storage_handlers(registry: HandlerRegistry) -> None:
async def _storage_row(request: Request, node: str | None, storage_id: str) -> Any:
row = await database(request).pool.fetchrow(
"""SELECT s.storage_id, s.storage_type, s.shared, s.capacity_bytes, s.used_bytes,
s.config, n.name AS node_name
FROM storages s
JOIN resources r ON r.id = s.resource_id
JOIN nodes n ON n.id = r.node_id
WHERE s.storage_id=$1 AND ($2::text IS NULL OR n.name=$2)""",
storage_id,
node,
)
if row is None:
raise ApiError(404, "storage does not exist")
return row
async def storage_ids(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
rows = await database(_request).pool.fetch(
"SELECT DISTINCT storage_id FROM storages ORDER BY storage_id"
)
return [{"storage": str(row["storage_id"])} for row in rows]
async def storage_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
storage_id = str(payload["storage"])
storage_type = str(payload.get("type") or "dir")
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM storages WHERE storage_id=$1)",
storage_id,
)
if exists:
raise ApiError(409, "storage ID already exists")
node = await database(request).pool.fetchrow(
"SELECT id, name FROM nodes ORDER BY name LIMIT 1"
)
if node is None:
raise ApiError(503, "no nodes available")
resource_id = stable_id(f"storage:{storage_id}")
config = {
key: value
for key, value in payload.items()
if key not in {"storage", "type", "nodes", "delete"}
}
if "content" in payload:
config["content"] = [
item.strip() for item in str(payload["content"]).split(",") if item.strip()
]
async with database(request).pool.acquire() as connection:
async with connection.transaction():
await connection.execute(
"""INSERT INTO resources(id, node_id, kind, external_id, state, cluster_id)
VALUES($1, $2, 'storage', $3, $4::jsonb, $5)""",
resource_id,
node["id"],
storage_id,
json.dumps({**config, "status": "available"}, sort_keys=True),
CLUSTER_ID,
)
await connection.execute(
"""INSERT INTO storages(
resource_id, cluster_id, storage_id, storage_type, shared, config
) VALUES($1, $2, $3, $4, $5, $6::jsonb)""",
resource_id,
CLUSTER_ID,
storage_id,
storage_type,
bool(payload.get("shared", False)),
json.dumps(config, sort_keys=True),
)
return {"storage": storage_id, "type": storage_type, "config": config}
async def storage_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
storage_id = str(values(inputs)["storage"])
row = await _storage_row(request, None, storage_id)
config = state(row["config"])
return {
"storage": storage_id,
"type": str(row["storage_type"]),
"shared": int(bool(row["shared"])),
**config,
}
async def storage_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
storage_id = str(values(inputs)["storage"])
row = await database(request).pool.fetchrow(
"""SELECT s.resource_id, s.config FROM storages s WHERE s.storage_id=$1""",
storage_id,
)
if row is None:
raise ApiError(404, "storage does not exist")
current = state(row["config"])
provided = values(inputs)
updated = {
**current,
**{
key: value
for key, value in provided.items()
if key not in {"storage", "delete", "digest"}
},
}
await database(request).pool.execute(
"UPDATE storages SET config=$2::jsonb WHERE storage_id=$1",
storage_id,
json.dumps(updated, sort_keys=True),
)
return updated
async def storage_delete(request: Request, inputs: dict[str, Any]) -> None:
storage_id = str(values(inputs)["storage"])
status = await database(request).pool.execute(
"""DELETE FROM resources r USING storages s
WHERE s.resource_id=r.id AND s.storage_id=$1""",
storage_id,
)
if status != "DELETE 1":
raise ApiError(404, "storage does not exist")
async def node_storage_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
rows = await database(request).pool.fetch(
"""SELECT s.storage_id, s.storage_type, s.shared,
s.capacity_bytes, s.used_bytes, s.config
FROM storages s
JOIN resources r ON r.id = s.resource_id
JOIN nodes n ON n.id = r.node_id
WHERE n.name=$1 OR s.shared = true
ORDER BY s.storage_id""",
node,
)
return [storage_payload(row) for row in rows]
async def node_storage_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
return subdirs("content", "status", "upload")
async def node_storage_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
row = await _storage_row(request, None, storage_id)
return storage_payload(row)
async def node_storage_content(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
contents = await database(request).pool.fetch(
"""SELECT volume_id, content_type, size_bytes, metadata, created_at
FROM storage_contents WHERE storage_resource_id=$1 ORDER BY created_at DESC""",
resource_id,
)
backups = await database(request).pool.fetch(
"""SELECT b.volume_id, b.size_bytes, b.metadata, b.created_at, r.external_id AS vmid
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id
WHERE b.storage_resource_id=$1
ORDER BY b.created_at DESC""",
resource_id,
)
result: list[dict[str, Any]] = []
for item in contents:
metadata = state(item["metadata"])
result.append(
{
"volid": str(item["volume_id"]),
"content": str(item["content_type"]),
"size": int(item["size_bytes"]),
"format": metadata.get("format", "raw"),
"ctime": int(item["created_at"].timestamp()),
}
)
for item in backups:
metadata = state(item["metadata"])
result.append(
{
"volid": str(item["volume_id"]),
"content": "backup",
"size": int(item["size_bytes"]),
"format": "vma.zst",
"vmid": int(item["vmid"]) if item["vmid"] is not None else None,
"notes": metadata.get("notes-template"),
"ctime": int(item["created_at"].timestamp()),
}
)
return result
async def _content_item(
request: Request, storage_resource_id: object, volume_id: str
) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
"""SELECT volume_id, content_type, size_bytes, metadata, created_at
FROM storage_contents WHERE storage_resource_id=$1 AND volume_id=$2""",
storage_resource_id,
volume_id,
)
if row is not None:
metadata = state(row["metadata"])
return {
"volid": str(row["volume_id"]),
"content": str(row["content_type"]),
"size": int(row["size_bytes"]),
"format": metadata.get("format", "raw"),
"ctime": int(row["created_at"].timestamp()),
}
backup = await database(request).pool.fetchrow(
"""SELECT b.volume_id, b.size_bytes, b.metadata, b.created_at, r.external_id AS vmid
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id
WHERE b.storage_resource_id=$1 AND b.volume_id=$2""",
storage_resource_id,
volume_id,
)
if backup is None:
raise ApiError(404, "volume does not exist")
metadata = state(backup["metadata"])
return {
"volid": str(backup["volume_id"]),
"content": "backup",
"size": int(backup["size_bytes"]),
"format": "vma.zst",
"vmid": int(backup["vmid"]) if backup["vmid"] is not None else None,
"notes": metadata.get("notes-template"),
"ctime": int(backup["created_at"].timestamp()),
}
async def node_storage_content_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
volume_id = str(values(inputs)["volume"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
return await _content_item(request, resource_id, volume_id)
async def node_storage_content_delete(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
volume_id = str(values(inputs)["volume"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
status = await database(request).pool.execute(
"DELETE FROM storage_contents WHERE storage_resource_id=$1 AND volume_id=$2",
resource_id,
volume_id,
)
if status == "DELETE 1":
return
status = await database(request).pool.execute(
"DELETE FROM backups WHERE storage_resource_id=$1 AND volume_id=$2",
resource_id,
volume_id,
)
if status != "DELETE 1":
raise ApiError(404, "volume does not exist")
async def node_storage_upload(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
payload = values(inputs)
filename = str(payload.get("filename") or "upload.bin")
content_type = str(payload.get("content") or "iso")
raw_size = payload.get("size") or 0
try:
size = int(raw_size)
except (TypeError, ValueError) as error:
raise ApiError(400, "invalid size") from error
volume_id = f"{storage_id}:{content_type}/{filename}"
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
await database(request).pool.execute(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
) VALUES(gen_random_uuid(), $1, $2, $3, $4, $5::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
SET size_bytes=EXCLUDED.size_bytes,
content_type=EXCLUDED.content_type,
metadata=EXCLUDED.metadata""",
resource_id,
volume_id,
content_type,
size,
json.dumps({"filename": filename, "source": "upload"}, sort_keys=True),
)
return {"uploadid": volume_id, "filename": filename, "size": size, "volid": volume_id}
async def node_storage_prunebackups(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]] | None:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
if request.method == "DELETE":
keep = int(values(inputs).get("keep-last") or values(inputs).get("keep_last") or 1)
await database(request).pool.execute(
"""DELETE FROM backups
WHERE storage_resource_id=$1 AND id IN (
SELECT id FROM backups
WHERE storage_resource_id=$1
ORDER BY created_at DESC
OFFSET $2
)""",
resource_id,
keep,
)
return None
rows = await database(request).pool.fetch(
"""SELECT volume_id, size_bytes, created_at FROM backups
WHERE storage_resource_id=$1 ORDER BY created_at DESC""",
resource_id,
)
return [
{
"volid": str(row["volume_id"]),
"size": int(row["size_bytes"]),
"ctime": int(row["created_at"].timestamp()),
}
for row in rows
]
async def content_copy(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
storage_id = str(payload["storage"])
volume = str(payload["volume"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
row = await database(request).pool.fetchrow(
"""SELECT volume_id, content_type, size_bytes, metadata
FROM storage_contents WHERE storage_resource_id=$1 AND volume_id=$2""",
resource_id,
volume,
)
if row is None:
raise ApiError(404, "volume does not exist")
target = str(payload.get("target") or f"{volume}-copy")
await database(request).pool.execute(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
) VALUES(gen_random_uuid(), $1, $2, $3, $4, $5::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
SET size_bytes=EXCLUDED.size_bytes, metadata=EXCLUDED.metadata""",
resource_id,
target,
row["content_type"],
row["size_bytes"],
json.dumps({**state(row["metadata"]), "copied_from": volume}, sort_keys=True),
)
return f"UPID:{node}:copy:{target}"
async def content_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
storage_id = str(payload["storage"])
volume = str(payload["volume"])
await require_node(request, node)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
row = await database(request).pool.fetchrow(
"""SELECT metadata FROM storage_contents
WHERE storage_resource_id=$1 AND volume_id=$2""",
resource_id,
volume,
)
if row is None:
raise ApiError(404, "volume does not exist")
meta = state(row["metadata"])
if "notes" in payload:
meta["notes"] = payload["notes"]
if "protected" in payload:
meta["protected"] = int(bool(payload["protected"]))
await database(request).pool.execute(
"""UPDATE storage_contents SET metadata=$3::jsonb
WHERE storage_resource_id=$1 AND volume_id=$2""",
resource_id,
volume,
json.dumps(meta, sort_keys=True),
)
async def download_url(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
storage_id = str(payload["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
filename = str(payload.get("filename") or "download.bin")
content_type = str(payload.get("content") or "iso")
volume_id = f"{storage_id}:{content_type}/{filename}"
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
await database(request).pool.execute(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
) VALUES(gen_random_uuid(), $1, $2, $3, 0, $4::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
SET metadata=EXCLUDED.metadata""",
resource_id,
volume_id,
content_type,
json.dumps(
{
"filename": filename,
"url": payload.get("url"),
"source": "download-url",
},
sort_keys=True,
),
)
return f"UPID:{node}:download:{filename}"
async def oci_pull(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
storage_id = str(payload["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
reference = str(payload.get("reference") or "image:latest")
filename = str(payload.get("filename") or reference.replace("/", "_"))
volume_id = f"{storage_id}:import/{filename}"
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
await database(request).pool.execute(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
) VALUES(gen_random_uuid(), $1, $2, 'import', 0, $3::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
SET metadata=EXCLUDED.metadata""",
resource_id,
volume_id,
json.dumps({"reference": reference, "source": "oci"}, sort_keys=True),
)
return f"UPID:{node}:oci-pull:{filename}"
async def file_restore_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
await _storage_row(request, None, str(payload["storage"]))
filepath = str(payload.get("filepath") or "/")
return [{"filepath": filepath.rstrip("/") + "/etc", "type": "d", "text": "etc"}]
async def file_restore_download(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
await _storage_row(request, None, str(payload["storage"]))
return {
"download-url": f"/api2/json/nodes/{payload['node']}/storage/"
f"{payload['storage']}/file-restore/download",
"filepath": payload.get("filepath") or "/",
"volume": payload.get("volume"),
}
async def storage_identity(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
row = await _storage_row(request, None, str(payload["storage"]))
return {
"storage": str(row["storage_id"]),
"type": str(row["storage_type"]),
"fingerprint": f"sim-{row['storage_id']}",
}
async def import_metadata(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
storage_id = str(payload["storage"])
volume = str(payload["volume"])
await _storage_row(request, None, storage_id)
return {
"type": "qemu",
"source": volume,
"disks": {"scsi0": f"{storage_id}:0/vm-import.raw"},
"net0": "virtio,bridge=vmbr0",
}
async def storage_rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
storage_id = str(payload["storage"])
await _storage_row(request, None, storage_id)
return {"filename": f"pve-storage-{storage_id}.rrd"}
async def storage_rrddata(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
await _storage_row(request, None, str(payload["storage"]))
return [
{"time": 1_700_000_000, "used": 10, "total": 100},
{"time": 1_700_000_060, "used": 12, "total": 100},
]
registry.register("/storage", "GET", storage_ids)
registry.register("/storage", "POST", storage_create)
registry.register("/storage/{storage}", "GET", storage_get)
registry.register("/storage/{storage}", "PUT", storage_update)
registry.register("/storage/{storage}", "DELETE", storage_delete)
registry.register("/nodes/{node}/storage", "GET", node_storage_list)
registry.register("/nodes/{node}/storage/{storage}", "GET", node_storage_index)
registry.register("/nodes/{node}/storage/{storage}/status", "GET", node_storage_status)
registry.register("/nodes/{node}/storage/{storage}/content", "GET", node_storage_content)
registry.register("/nodes/{node}/storage/{storage}/content", "POST", node_storage_upload)
registry.register(
"/nodes/{node}/storage/{storage}/content/{volume}", "GET", node_storage_content_get
)
registry.register(
"/nodes/{node}/storage/{storage}/content/{volume}", "DELETE", node_storage_content_delete
)
registry.register("/nodes/{node}/storage/{storage}/upload", "POST", node_storage_upload)
registry.register(
"/nodes/{node}/storage/{storage}/prunebackups", "GET", node_storage_prunebackups
)
registry.register(
"/nodes/{node}/storage/{storage}/prunebackups", "DELETE", node_storage_prunebackups
)
registry.register("/nodes/{node}/storage/{storage}/content/{volume}", "POST", content_copy)
registry.register("/nodes/{node}/storage/{storage}/content/{volume}", "PUT", content_update)
registry.register("/nodes/{node}/storage/{storage}/download-url", "POST", download_url)
registry.register("/nodes/{node}/storage/{storage}/oci-registry-pull", "POST", oci_pull)
registry.register("/nodes/{node}/storage/{storage}/file-restore/list", "GET", file_restore_list)
registry.register(
"/nodes/{node}/storage/{storage}/file-restore/download", "GET", file_restore_download
)
registry.register("/nodes/{node}/storage/{storage}/identity", "GET", storage_identity)
registry.register("/nodes/{node}/storage/{storage}/import-metadata", "GET", import_metadata)
registry.register("/nodes/{node}/storage/{storage}/rrd", "GET", storage_rrd)
registry.register("/nodes/{node}/storage/{storage}/rrddata", "GET", storage_rrddata)
+53 -50
View File
@@ -2,25 +2,30 @@
from __future__ import annotations
import asyncio
from typing import cast
from fastapi import FastAPI, Response
from fastapi import FastAPI
from app.api.errors import ApiError, api_error_handler, unhandled_exception_handler
from app.api.middleware import RequestContextMiddleware
from app.api.registry import HandlerRegistry, register_contract_routes
from app.compatibility import CompatibilityDimension, build_report, load_evidence_manifest
from app.api.openapi import openapi_tag_metadata
from app.api.registry import HandlerRegistry
from app.config import Settings, get_settings
from app.contracts.model import Snapshot
from app.contracts.runtime import apply_runtime_contract, contract_store_root
from app.db.pool import AsyncpgDatabase, Database
from app.handlers.core import build_core_handlers
from app.lifespan import DatabaseFactory, WorkerFactory, create_lifespan, default_database_factory
from app.logging import configure_logging
from app.observability.health import router as health_router
from app.simulation.clock import AcceleratedClock
from app.tasks.backup import backup_handler
from app.tasks.lxc import lxc_handler
from app.tasks.qemu import qemu_handler
from app.tasks.repository import TaskRepository
from app.tasks.worker import TaskWorker
from app.web.routes import router as web_router
def create_app(
@@ -39,27 +44,45 @@ def create_app(
def task_worker(database: Database) -> TaskWorker:
adapter = cast(AsyncpgDatabase, database)
repository = TaskRepository(adapter.pool)
handler = qemu_handler(repository, AcceleratedClock(resolved.simulation_time_scale))
clock = AcceleratedClock(resolved.simulation_time_scale)
qemu = qemu_handler(repository, clock)
lxc = lxc_handler(repository, clock)
backup = backup_handler(repository, clock)
return TaskWorker(
repository,
"simulator-worker",
{
"qemu-clone": handler,
"qemu-create": handler,
"qemu-delete": handler,
"qemu-reboot": handler,
"qemu-reset": handler,
"qemu-resume": handler,
"qemu-shutdown": handler,
"qemu-migrate": handler,
"qemu-move-disk": handler,
"qemu-snapshot-create": handler,
"qemu-snapshot-delete": handler,
"qemu-snapshot-rollback": handler,
"qemu-start": handler,
"qemu-stop": handler,
"qemu-suspend": handler,
"qemu-update": handler,
"qemu-clone": qemu,
"qemu-create": qemu,
"qemu-delete": qemu,
"qemu-reboot": qemu,
"qemu-reset": qemu,
"qemu-resume": qemu,
"qemu-shutdown": qemu,
"qemu-migrate": qemu,
"qemu-move-disk": qemu,
"qemu-snapshot-create": qemu,
"qemu-snapshot-delete": qemu,
"qemu-snapshot-rollback": qemu,
"qemu-start": qemu,
"qemu-stop": qemu,
"qemu-suspend": qemu,
"qemu-update": qemu,
"lxc-clone": lxc,
"lxc-create": lxc,
"lxc-delete": lxc,
"lxc-migrate": lxc,
"lxc-reboot": lxc,
"lxc-resume": lxc,
"lxc-shutdown": lxc,
"lxc-snapshot-create": lxc,
"lxc-snapshot-delete": lxc,
"lxc-snapshot-rollback": lxc,
"lxc-start": lxc,
"lxc-stop": lxc,
"lxc-suspend": lxc,
"vzdump": backup,
"aptupdate": backup,
},
concurrency=resolved.task_worker_concurrency,
lease_seconds=resolved.task_lease_seconds,
@@ -69,49 +92,29 @@ def create_app(
app = FastAPI(
title=resolved.app_name,
version="0.1.0",
openapi_tags=openapi_tag_metadata(),
lifespan=create_lifespan(resolved, database_factory, resolved_workers or ()),
)
app.state.settings = resolved
app.state.contract_swap_lock = asyncio.Lock()
app.add_middleware(RequestContextMiddleware, header_name=resolved.request_id_header)
app.add_exception_handler(Exception, unhandled_exception_handler)
app.add_exception_handler(ApiError, api_error_handler)
app.include_router(web_router)
app.include_router(health_router)
if resolved.contract_snapshot is not None:
snapshot = Snapshot.model_validate_json(resolved.contract_snapshot.read_bytes())
resolved_handlers = handlers or build_core_handlers(resolved)
register_contract_routes(
apply_runtime_contract(
app,
snapshot,
resolved_handlers,
resolved.contract_fallback,
handlers=resolved_handlers,
store_root=contract_store_root(resolved),
fallback=resolved.contract_fallback,
settings=resolved,
require_evidence_match=True,
register_admin=True,
)
declared = frozenset(
(path.path, method.verb) for path in snapshot.paths for method in path.methods
)
dimensions = {CompatibilityDimension.ROUTE_METHOD: declared}
if resolved.compatibility_evidence is not None:
evidence = load_evidence_manifest(resolved.compatibility_evidence)
if evidence.source_version != snapshot.source_version:
raise ValueError("compatibility evidence version does not match contract")
dimensions.update(evidence.dimension_map())
dimensions[CompatibilityDimension.ROUTE_METHOD] = declared
report = build_report(
snapshot,
implemented=resolved_handlers.keys() & declared,
dimensions=dimensions,
)
@app.get("/admin/compatibility", include_in_schema=False)
async def compatibility_report() -> dict[str, object]:
return report.as_json()
@app.get("/admin/compatibility.md", include_in_schema=False)
async def compatibility_report_markdown() -> Response:
return Response(report.as_markdown(), media_type="text/markdown")
@app.get("/admin/compatibility.html", include_in_schema=False)
async def compatibility_report_html() -> Response:
return Response(report.as_html(), media_type="text/html")
return app
+1 -1
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel
from app.db.pool import Database
from app.dependencies import get_database
router = APIRouter(prefix="/health", tags=["health"])
router = APIRouter(prefix="/health", tags=["Simulator"])
class HealthResponse(BaseModel):
+370
View File
@@ -0,0 +1,370 @@
"""Enterprise-scale demo cluster profile for realistic emulator workloads."""
from __future__ import annotations
import uuid
from collections import defaultdict
from collections.abc import Sequence
from app.simulation.seed import (
SeedNode,
SeedProfile,
SeedResource,
SeedTask,
_node,
_resource,
stable_id,
)
DEMO_NODE_COUNT = 20
DEMO_QEMU_COUNT = 850
DEMO_LXC_COUNT = 150
DEMO_CEPH_OSD_COUNT = 300
CEPH_TOTAL_BYTES = 5 * 1024**5
QEMU_VMID_START = 100
LXC_VMID_START = 10_000
QEMU_PREFIXES = (
"web",
"api",
"db",
"cache",
"mq",
"batch",
"ml",
"monitor",
"log",
"ci",
"k8s",
"vpn",
"ldap",
"git",
"proxy",
)
LXC_PREFIXES = (
"svc-nginx",
"svc-haproxy",
"svc-dns",
"svc-vault",
"svc-redis",
"mon-agent",
"backup-agent",
"ceph-mgr",
"lb-vip",
"proxy-squid",
"jump-host",
"ntp",
"syslog",
"metrics",
"bastion",
)
TIERS = ("prod", "staging", "dev", "qa", "dr")
POOLS = (
("production", 280),
("staging", 160),
("development", 130),
("qa", 100),
("gpu-workloads", 80),
("legacy", 100),
)
TASK_TYPES = (
"vzdump",
"qmstart",
"qmstop",
"qmmigrate",
"qmreboot",
"qmclone",
"aptupdate",
"startall",
"stopall",
"cephosd",
"pct-start",
"pct-stop",
)
def _even_node_slots(node_count: int, total: int, *, phase: int = 0) -> tuple[int, ...]:
"""Return `total` node indices distributed as evenly as possible."""
if total <= 0:
return ()
base, remainder = divmod(total, node_count)
slots: list[int] = []
for node_index in range(node_count):
slots.extend([node_index] * (base + (1 if node_index < remainder else 0)))
if phase:
phase %= len(slots)
slots = slots[phase:] + slots[:phase]
return tuple(slots)
def _even_sample(resources: Sequence[SeedResource], count: int) -> list[str]:
"""Pick `count` resource IDs spread evenly across the provided sequence."""
if count <= 0 or not resources:
return []
if count >= len(resources):
return [resource.external_id for resource in resources]
step = len(resources) / count
return [resources[int(index * step)].external_id for index in range(count)]
def _guest_name(prefixes: tuple[str, ...], index: int) -> str:
prefix = prefixes[index % len(prefixes)]
tier = TIERS[index % len(TIERS)]
return f"{tier}-{prefix}-{index:04d}"
def _qemu_state(vmid: int, index: int) -> dict[str, object]:
statuses = ("running", "running", "running", "running", "stopped", "paused")
cpus = (1, 2, 2, 4, 4, 8, 8, 16, 32)[index % 9]
memory_mb = (512, 1024, 2048, 4096, 8192, 16_384, 32_768, 65_536)[index % 8]
pool_name = POOLS[index % len(POOLS)][0]
return {
"name": _guest_name(QEMU_PREFIXES, index),
"status": statuses[index % len(statuses)],
"cpus": cpus,
"cores": cpus,
"memory": memory_mb,
"maxmem": memory_mb,
"pool": pool_name,
"tags": f"{TIERS[index % len(TIERS)]};{pool_name}",
"agent": index % 3 != 0,
"template": index % 97 == 0,
"onboot": index % 5 != 0,
"vmid": vmid,
}
def _lxc_state(vmid: int, index: int) -> dict[str, object]:
statuses = ("running", "running", "stopped", "stopped")
memory_mb = (256, 512, 1024, 2048, 4096)[index % 5]
pool_name = POOLS[(index + 2) % len(POOLS)][0]
return {
"name": _guest_name(LXC_PREFIXES, index),
"status": statuses[index % len(statuses)],
"cpus": (1, 1, 2, 2, 4)[index % 5],
"memory": memory_mb,
"maxmem": memory_mb,
"pool": pool_name,
"tags": f"container;{pool_name}",
"unprivileged": index % 4 != 0,
"template": index % 41 == 0,
"vmid": vmid,
}
def _demo_task(index: int, node: SeedNode, task_type: str, resource_id: str) -> SeedTask:
return SeedTask(
stable_id(f"demo-task:{index}:{task_type}:{resource_id}"),
f"UPID:{node.name}:{index:07X}:{index:07X}:67{index:06X}:"
f"{task_type}:{resource_id}:root@pam:",
task_type,
{"resource_id": resource_id, "node": node.name, "seeded": True},
)
def demo_cluster_profile() -> SeedProfile:
nodes = tuple(
_node(f"pve{index:02d}", "offline" if index == 19 else "online")
for index in range(1, DEMO_NODE_COUNT + 1)
)
node_count = len(nodes)
resources: list[SeedResource] = []
qemu_slots = _even_node_slots(node_count, DEMO_QEMU_COUNT, phase=0)
lxc_slots = _even_node_slots(node_count, DEMO_LXC_COUNT, phase=node_count // 2)
osd_slots = _even_node_slots(node_count, DEMO_CEPH_OSD_COUNT, phase=node_count // 4)
qemu_resources: list[SeedResource] = []
for offset, node_index in enumerate(qemu_slots):
vmid = QEMU_VMID_START + offset
resource = _resource(nodes[node_index], "qemu", str(vmid), _qemu_state(vmid, offset))
qemu_resources.append(resource)
resources.append(resource)
lxc_resources: list[SeedResource] = []
for offset, node_index in enumerate(lxc_slots):
vmid = LXC_VMID_START + offset
resource = _resource(nodes[node_index], "lxc", str(vmid), _lxc_state(vmid, offset))
lxc_resources.append(resource)
resources.append(resource)
guests_by_node: dict[uuid.UUID, list[SeedResource]] = defaultdict(list)
for guest in (*qemu_resources, *lxc_resources):
guests_by_node[guest.node_id].append(guest)
for node in nodes:
resources.append(
_resource(
node,
"storage",
f"local-{node.name}",
{
"content": ["iso", "vztmpl", "backup"],
"status": "available",
"storage_type": "dir",
},
)
)
resources.append(
_resource(
node,
"storage",
f"local-lvm-{node.name}",
{
"content": ["images", "rootdir"],
"status": "available",
"storage_type": "lvmthin",
"shared": False,
},
)
)
resources.append(
_resource(
node,
"storage",
f"backup-{node.name}",
{
"content": ["backup"],
"status": "available",
"storage_type": "dir",
"shared": False,
"total_bytes": 4 * 1024**4,
"used_bytes": int(2.2 * 1024**4),
},
)
)
if int(node.name[3:]) % 2 == 0:
resources.append(
_resource(
node,
"storage",
f"local-zfs-{node.name}",
{
"content": ["images", "rootdir"],
"status": "available",
"storage_type": "zfspool",
"shared": False,
},
)
)
used_bytes = int(CEPH_TOTAL_BYTES * 0.62)
resources.append(
_resource(
nodes[0],
"storage",
"ceph-prod",
{
"content": ["images", "rootdir", "backup"],
"shared": True,
"status": "available",
"storage_type": "ceph",
"ceph_pool": "rbd",
"total_bytes": CEPH_TOTAL_BYTES,
"used_bytes": used_bytes,
"osd_count": DEMO_CEPH_OSD_COUNT,
},
)
)
resources.append(
_resource(
nodes[node_count // 2],
"storage",
"nfs-backup",
{
"content": ["backup", "iso"],
"shared": True,
"status": "available",
"storage_type": "nfs",
"total_bytes": 80 * 1024**4,
"used_bytes": 52 * 1024**4,
},
)
)
for osd_index, node_index in enumerate(osd_slots):
node = nodes[node_index]
osd_id = osd_index
weight = round(0.8 + (osd_index % 17) * 0.05, 2)
size_bytes = CEPH_TOTAL_BYTES // DEMO_CEPH_OSD_COUNT
resources.append(
_resource(
node,
"ceph-osd",
f"osd.{osd_id}",
{
"osd_id": osd_id,
"status": "up" if osd_index != 42 else "down",
"in": osd_index != 42,
"weight": weight,
"size_bytes": size_bytes,
"used_bytes": int(size_bytes * (0.55 + (osd_index % 10) * 0.03)),
"device_class": "ssd" if osd_index % 4 else "hdd",
},
)
)
qemu_by_node = [
sorted(guests_by_node[node.id], key=lambda resource: int(resource.external_id))
for node in nodes
]
pool_guest_cursor = 0
for pool_index, (pool_id, member_count) in enumerate(POOLS):
pool_guests: list[SeedResource] = []
per_node, extra = divmod(member_count, node_count)
for node_index, node_guests in enumerate(qemu_by_node):
take = per_node + (1 if node_index < extra else 0)
start = (pool_guest_cursor + node_index) % len(node_guests) if node_guests else 0
for offset in range(take):
if not node_guests:
break
pool_guests.append(node_guests[(start + offset) % len(node_guests)])
pool_guest_cursor += member_count
pool_guests.sort(key=lambda resource: int(resource.external_id))
resources.append(
_resource(
nodes[pool_index % node_count],
"pool",
pool_id,
{
"members": _even_sample(pool_guests, min(40, len(pool_guests))),
"member_count": len(pool_guests),
"comment": f"Simulated {pool_id} pool",
},
)
)
ha_guests = [
qemu_resources[int(index * len(qemu_resources) / min(120, len(qemu_resources)))]
for index in range(min(120, len(qemu_resources)))
]
for ha_index, guest in enumerate(ha_guests):
node = next(node for node in nodes if node.id == guest.node_id)
resources.append(
_resource(
node,
"ha",
f"vm:{guest.external_id}",
{
"state": "started" if ha_index % 5 else "stopped",
"group": "critical-services",
"max_relocate": 2,
"max_restart": 3,
},
)
)
tasks: list[SeedTask] = []
guest_cycle = sorted(
(*qemu_resources, *lxc_resources),
key=lambda resource: (resource.node_id, int(resource.external_id)),
)
for index in range(1, 251):
guest = guest_cycle[(index - 1) % len(guest_cycle)]
node = next(node for node in nodes if node.id == guest.node_id)
task_type = TASK_TYPES[index % len(TASK_TYPES)]
tasks.append(_demo_task(index, node, task_type, guest.external_id))
return SeedProfile("demo-cluster", nodes, tuple(resources), tuple(tasks))
+419 -19
View File
@@ -12,6 +12,25 @@ from asyncpg import Connection
from app.security.auth import hash_secret
NAMESPACE = uuid.UUID("c9040a72-b391-4a7e-9864-3ae46291a531")
CLUSTER_ID = uuid.UUID("dc760c47-d8d7-57e6-9404-f0c6f2395d8f")
def default_node_ops_for_seed(node_name: str) -> dict[str, object]:
from app.handlers.nodes import default_node_ops
ops = default_node_ops()
# Distinct but deterministic bridge addresses per node name.
suffix = (stable_id(f"node-ip:{node_name}").int % 200) + 10
network = ops.get("network")
if isinstance(network, list):
for item in network:
if not isinstance(item, dict):
continue
if item.get("iface") == "vmbr0":
item["address"] = f"10.0.0.{suffix}/24"
elif item.get("iface") == "vmbr1":
item["address"] = f"10.10.0.{suffix}/24"
return ops
@dataclass(frozen=True, slots=True)
@@ -88,7 +107,7 @@ def _resource(
def _completed_task(index: int, task_type: str, resource_id: str) -> SeedTask:
return SeedTask(
stable_id(f"task:{index}:{task_type}:{resource_id}"),
f"UPID:pve1:0000000{index}:0000000{index}:6500000{index}:"
f"UPID:pve01:0000000{index}:0000000{index}:6500000{index}:"
f"{task_type}:{resource_id}:root@pam:",
task_type,
{"resource_id": resource_id, "seeded": True},
@@ -96,7 +115,7 @@ def _completed_task(index: int, task_type: str, resource_id: str) -> SeedTask:
def small_profile() -> SeedProfile:
node = _node("pve1")
node = _node("pve01")
resources = (
_resource(node, "qemu", "100", {"name": "demo", "status": "stopped"}),
_resource(node, "qemu", "101", {"name": "worker", "status": "stopped"}),
@@ -170,6 +189,17 @@ def ha_demo_profile() -> SeedProfile:
return SeedProfile("ha-demo", profile.nodes, resources, profile.tasks)
def minimal_profile() -> SeedProfile:
node = _node("pve01")
resources = (
_resource(node, "storage", "local", {"content": ["iso", "backup"], "status": "available"}),
_resource(
node, "storage", "local-lvm", {"content": ["images", "rootdir"], "status": "available"}
),
)
return SeedProfile("minimal", (node,), resources)
def broken_storage_profile() -> SeedProfile:
profile = small_profile()
resources = tuple(
@@ -199,24 +229,152 @@ def build_profile(
return ha_demo_profile()
if name == "broken-storage":
return broken_storage_profile()
if name == "minimal":
return minimal_profile()
if name == "demo-cluster":
from app.simulation.demo_cluster import demo_cluster_profile
return demo_cluster_profile()
raise ValueError(f"unknown seed profile: {name}")
def _storage_type(resource: SeedResource) -> str:
configured = resource.state.get("storage_type")
if isinstance(configured, str) and configured:
return configured
if resource.external_id.startswith("local"):
if "lvm" in resource.external_id:
return "lvmthin"
if "zfs" in resource.external_id:
return "zfspool"
return "dir"
if resource.external_id.startswith("ceph"):
return "ceph"
if resource.external_id.startswith("nfs"):
return "nfs"
return "dir"
def _storage_capacity(resource: SeedResource) -> tuple[int | None, int | None]:
total = resource.state.get("total_bytes", resource.state.get("capacity_bytes"))
used = resource.state.get("used_bytes")
total_bytes = int(total) if isinstance(total, int) else None
used_bytes = int(used) if isinstance(used, int) else None
return total_bytes, used_bytes
async def clear_simulation_state(connection: Connection) -> None:
"""Remove all mutable simulator state so a seed/reset never fails on leftovers.
API-created guests, storages, users, groups, roles, ACL/tokens and custom
realms must not block "Remove demo data" / reseed. Builtin auth realms
(`pam`, `pve`, `test`) are kept because principals reference them.
"""
for statement in (
"DELETE FROM task_logs",
"DELETE FROM task_events",
"DELETE FROM resource_locks",
"DELETE FROM tasks",
"DELETE FROM pool_members",
"DELETE FROM backups",
"DELETE FROM snapshots",
"DELETE FROM storage_contents",
"DELETE FROM vm_disks",
"DELETE FROM vm_network_interfaces",
"DELETE FROM virtual_machines",
"DELETE FROM containers",
"DELETE FROM storages",
"DELETE FROM pools",
"DELETE FROM resources",
"DELETE FROM nodes",
"DELETE FROM openid_pending",
"DELETE FROM tfa_entries",
"DELETE FROM group_acl_entries",
"DELETE FROM identity_group_members",
"DELETE FROM acl_entries",
"DELETE FROM api_tokens",
"DELETE FROM auth_tickets",
"DELETE FROM identity_groups",
"DELETE FROM principals",
"DELETE FROM roles",
"DELETE FROM realms WHERE name NOT IN ('pam', 'pve', 'test')",
"DELETE FROM fault_injections",
"DELETE FROM scenario_rules",
"DELETE FROM audit_events",
):
await connection.execute(statement)
await connection.execute(
"""UPDATE clusters
SET name = 'pve-simulator',
metadata = '{}'::jsonb,
updated_at = now()
WHERE id = $1""",
CLUSTER_ID,
)
async def simulation_state_summary(connection: Connection) -> dict[str, object]:
row = await connection.fetchrow(
"""SELECT
c.name AS cluster_name,
COALESCE(c.metadata->>'profile', 'unknown') AS profile,
(SELECT count(*)::int FROM nodes) AS nodes,
(SELECT count(*)::int FROM resources WHERE kind = 'qemu') AS qemu,
(SELECT count(*)::int FROM resources WHERE kind = 'lxc') AS lxc,
(SELECT count(*)::int FROM resources WHERE kind = 'ceph-osd') AS ceph_osds,
(SELECT count(*)::int FROM resources WHERE kind = 'storage') AS storages,
(SELECT count(*)::int FROM backups) AS backups,
(SELECT count(*)::int FROM tasks) AS tasks,
(SELECT count(*)::int FROM task_logs) AS task_logs,
(SELECT count(*)::int FROM snapshots) AS snapshots,
(SELECT count(*)::int FROM principals) AS principals,
COALESCE(
(SELECT sum(capacity_bytes)::bigint FROM storages WHERE storage_type = 'ceph'),
0
) AS ceph_capacity_bytes
FROM clusters c
WHERE c.id = $1""",
CLUSTER_ID,
)
if row is None:
return {"profile": "unknown", "loaded": False}
payload = dict(row)
payload["loaded"] = payload["profile"] == "demo-cluster"
payload["ceph_capacity_pib"] = round((payload.get("ceph_capacity_bytes") or 0) / 1024**5, 2)
return payload
async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
async with connection.transaction():
await clear_simulation_state(connection)
await connection.execute(
"""DELETE FROM task_logs;
DELETE FROM task_events;
DELETE FROM resource_locks;
DELETE FROM tasks;
DELETE FROM pool_members;
DELETE FROM pools;
DELETE FROM resources;
DELETE FROM nodes"""
"""UPDATE clusters
SET name = $2,
metadata = $3::jsonb,
updated_at = now()
WHERE id = $1""",
CLUSTER_ID,
"prod-pve-cluster" if profile.name == "demo-cluster" else "pve-simulator",
json.dumps(
{
"profile": profile.name,
"nodes": len(profile.nodes),
"resources": len(profile.resources),
},
sort_keys=True,
),
)
await connection.executemany(
"INSERT INTO nodes(id, name, status) VALUES($1, $2, $3)",
[(node.id, node.name, node.status) for node in profile.nodes],
"INSERT INTO nodes(id, name, status, metadata) VALUES($1, $2, $3, $4::jsonb)",
[
(
node.id,
node.name,
node.status,
json.dumps({"ops": default_node_ops_for_seed(node.name)}, sort_keys=True),
)
for node in profile.nodes
],
)
await connection.executemany(
"""INSERT INTO resources(id, node_id, kind, external_id, state)
@@ -264,14 +422,17 @@ async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
if storages:
await connection.executemany(
"""INSERT INTO storages(
resource_id, cluster_id, storage_id, storage_type, shared, config
) VALUES($1, 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f', $2, $3, $4, $5::jsonb)""",
resource_id, cluster_id, storage_id, storage_type, shared,
capacity_bytes, used_bytes, config
) VALUES($1, $2, $3, $4, $5, $6, $7, $8::jsonb)""",
[
(
resource.id,
str(CLUSTER_ID),
resource.external_id,
"dir" if resource.external_id.startswith("local") else "nfs",
_storage_type(resource),
bool(resource.state.get("shared", False)),
*_storage_capacity(resource),
json.dumps(resource.state, sort_keys=True),
)
for resource in storages
@@ -360,12 +521,12 @@ async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
"DELETE FROM acl_entries WHERE principal_id=$1 AND role_name='PVEAuditor'",
auditor_id,
)
auditor_group_id = stable_id("group:auditors")
await connection.execute(
auditor_group_id = await connection.fetchval(
"""INSERT INTO identity_groups(id, group_id, comment)
VALUES($1, 'auditors', 'Read-only operators')
ON CONFLICT (group_id) DO UPDATE SET comment=EXCLUDED.comment""",
auditor_group_id,
ON CONFLICT (group_id) DO UPDATE SET comment=EXCLUDED.comment
RETURNING id""",
stable_id("group:auditors"),
)
await connection.execute(
"""INSERT INTO identity_group_members(group_id, principal_id)
@@ -443,6 +604,245 @@ async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
hash_secret(token_secret, salt=f"token:{username}".encode()),
privileges,
)
if profile.name == "demo-cluster":
await _apply_demo_cluster_extras(connection, profile)
async def _apply_demo_cluster_extras(connection: Connection, profile: SeedProfile) -> None:
names = {node.id: node.name for node in profile.nodes}
guests = [resource for resource in profile.resources if resource.kind in {"qemu", "lxc"}]
disks: list[tuple[uuid.UUID, uuid.UUID, str, str, int, str]] = []
for index, resource in enumerate(guests):
node_name = names[resource.node_id]
disk_count = 1 + (index % 3)
for disk_index in range(disk_count):
device = "rootfs" if resource.kind == "lxc" and disk_index == 0 else f"scsi{disk_index}"
storage_id = "ceph-prod" if (index + disk_index) % 4 == 0 else f"local-lvm-{node_name}"
size_bytes = (20 + (index % 9) * 10 + disk_index * 15) * 1024**3
disks.append(
(
stable_id(f"disk:{resource.external_id}:{device}"),
resource.id,
device,
storage_id,
size_bytes,
json.dumps({"format": "raw" if disk_index else "qcow2"}, sort_keys=True),
)
)
if disks:
await connection.executemany(
"""INSERT INTO vm_disks(id, resource_id, device, storage_id, size_bytes, metadata)
VALUES($1, $2, $3, $4, $5, $6::jsonb)""",
disks,
)
interfaces: list[tuple[uuid.UUID, uuid.UUID, str, str]] = []
for index, resource in enumerate(guests):
interfaces.append(
(
stable_id(f"net:{resource.external_id}:net0"),
resource.id,
"net0",
json.dumps(
{
"bridge": "vmbr0",
"firewall": index % 7 != 0,
"tag": (index % 12) * 10 or None,
},
sort_keys=True,
),
)
)
if index % 5 == 0:
interfaces.append(
(
stable_id(f"net:{resource.external_id}:net1"),
resource.id,
"net1",
json.dumps({"bridge": "vmbr1", "firewall": True}, sort_keys=True),
)
)
if interfaces:
await connection.executemany(
"""INSERT INTO vm_network_interfaces(id, resource_id, device, config)
VALUES($1, $2, $3, $4::jsonb)""",
interfaces,
)
snapshots: list[tuple[uuid.UUID, uuid.UUID, str, str | None, str, str]] = []
for index, resource in enumerate(guests):
if index % 7 != 0:
continue
for snap_index in range(1 + (index % 3)):
snap_name = f"snap-{snap_index:02d}"
snapshots.append(
(
stable_id(f"snapshot:{resource.external_id}:{snap_name}"),
resource.id,
snap_name,
None if snap_index == 0 else f"snap-{snap_index - 1:02d}",
f"Automated snapshot #{snap_index}",
json.dumps({"vmstate": index % 2 == 0}, sort_keys=True),
)
)
if snapshots:
await connection.executemany(
"""INSERT INTO snapshots(id, resource_id, name, parent_name, description, state)
VALUES($1, $2, $3, $4, $5, $6::jsonb)""",
snapshots,
)
storage_rows = await connection.fetch(
"""SELECT s.resource_id, s.storage_id, n.name AS node_name
FROM storages s
JOIN resources r ON r.id = s.resource_id
JOIN nodes n ON n.id = r.node_id
WHERE s.storage_id LIKE 'backup-%' OR s.storage_id IN ('ceph-prod', 'nfs-backup')"""
)
storage_by_id = {row["storage_id"]: row["resource_id"] for row in storage_rows}
storage_by_node = {
str(row["node_name"]): row["resource_id"]
for row in storage_rows
if str(row["storage_id"]).startswith("backup-")
}
fallback_backup = storage_by_id.get("nfs-backup") or storage_by_id.get("ceph-prod")
if fallback_backup is not None:
backups: list[tuple[uuid.UUID, uuid.UUID | None, uuid.UUID, str, int, str]] = []
qemu_guests = [resource for resource in guests if resource.kind == "qemu"]
for index, resource in enumerate(qemu_guests):
node_name = names[resource.node_id]
backup_storage = storage_by_node.get(node_name, fallback_backup)
volume_id = f"backup/vzdump-qemu-{resource.external_id}-2026_07_15-{index:04d}.vma.zst"
backups.append(
(
stable_id(f"backup:{resource.external_id}:{index}"),
resource.id,
backup_storage,
volume_id,
(8 + (index % 40)) * 1024**3,
json.dumps(
{
"mode": "snapshot" if index % 3 else "suspend",
"notes-template": "Daily backup",
"node": node_name,
},
sort_keys=True,
),
)
)
if backups:
await connection.executemany(
"""INSERT INTO backups(
id, resource_id, storage_resource_id, volume_id, size_bytes, metadata
) VALUES($1, $2, $3, $4, $5, $6::jsonb)""",
backups,
)
guest_list = sorted(
guests, key=lambda resource: (names[resource.node_id], resource.external_id)
)
extra_tasks: list[tuple[uuid.UUID, str, str, str, str]] = []
for index in range(251, 321):
guest = guest_list[(index - 251) % len(guest_list)]
node_name = names[guest.node_id]
node = next(node for node in profile.nodes if node.name == node_name)
task_type = ("vzdump", "qmmigrate", "qmstart", "cephosd")[index % 4]
status = "running" if index % 17 == 0 else "error" if index % 23 == 0 else "success"
extra_tasks.append(
(
stable_id(f"demo-task-extra:{index}"),
f"UPID:{node.name}:{index:07X}:{index:07X}:68{index:06X}:"
f"{task_type}:{guest.external_id}:operator@pve:",
status,
json.dumps(
{"resource_id": guest.external_id, "node": node.name},
sort_keys=True,
),
task_type,
)
)
if extra_tasks:
await connection.executemany(
"""INSERT INTO tasks(id, upid, status, payload, task_type, progress, result, error)
VALUES($1, $2, $3, $4::jsonb, $5,
CASE WHEN $3 = 'success' THEN 100 WHEN $3 = 'running' THEN 45 ELSE 0 END,
CASE WHEN $3 = 'success' THEN '{\"seeded\":true}'::jsonb ELSE NULL END,
CASE WHEN $3 = 'error' THEN 'simulated backup failure' ELSE NULL END)""",
extra_tasks,
)
task_rows = await connection.fetch(
"SELECT id, task_type, payload FROM tasks ORDER BY upid LIMIT 180"
)
logs: list[tuple[uuid.UUID, str]] = []
for task in task_rows:
payload = task["payload"]
if isinstance(payload, dict):
resource_id = payload.get("resource_id", "unknown")
node_label = payload.get("node", "pve01")
else:
resource_id = "unknown"
node_label = "unknown"
messages: tuple[str, ...] = (
f"starting task {task['task_type']} on {node_label}",
f"processing guest {resource_id}",
f"task {task['task_type']} finished successfully",
)
if task["task_type"] == "vzdump":
messages = (
f"INFO: starting backup of VM {resource_id} on {node_label}",
f"INFO: snapshot create VM {resource_id}",
f"INFO: archive file size: {(8 + hash(str(task['id'])) % 40)}GB",
"INFO: Backup finished successfully",
)
logs.extend((task["id"], message) for message in messages)
if logs:
await connection.executemany(
"INSERT INTO task_logs(task_id, message) VALUES($1, $2)",
logs,
)
demo_users = (
("admin@pve", "PVEAdmin", ["/"], ["Sys.Modify", "Sys.Audit", "Datastore.Allocate"]),
("devops@pve", "PVEAdmin", ["/vms"], ["Sys.Audit", "VM.Allocate", "VM.PowerMgmt"]),
(
"backup-operator@pve",
"PVEDatastoreAdmin",
["/storage"],
["Datastore.Allocate", "Datastore.Audit"],
),
("ceph-monitor@pve", "PVEAuditor", ["/"], ["Sys.Audit", "Datastore.Audit"]),
("junior@pve", "PVEAuditor", ["/vms"], ["Sys.Audit", "VM.Audit"]),
("security@pve", "PVEAuditor", ["/access"], ["Sys.Audit", "User.Modify"]),
)
for username, role_name, acl_paths, privileges in demo_users:
principal_id = stable_id(f"principal:{username}")
await connection.execute(
"""INSERT INTO principals(id, name, password_hash, realm_name)
VALUES($1, $2, $3, 'pve')
ON CONFLICT (name) DO UPDATE SET password_hash=EXCLUDED.password_hash,
realm_name=EXCLUDED.realm_name""",
principal_id,
username,
hash_secret(f"{username}-password", salt=f"seed:{username}".encode()),
)
await connection.execute(
"""INSERT INTO roles(name, privileges) VALUES($1, $2)
ON CONFLICT (name) DO UPDATE SET privileges=EXCLUDED.privileges""",
role_name,
privileges,
)
for acl_path in acl_paths:
await connection.execute(
"""INSERT INTO acl_entries(principal_id, role_name, path, propagate)
VALUES($1, $2, $3, true)
ON CONFLICT (principal_id, role_name, path) DO UPDATE
SET propagate=EXCLUDED.propagate""",
principal_id,
role_name,
acl_path,
)
async def seed_url(
+282
View File
@@ -0,0 +1,282 @@
"""Probe every declared contract method across majors 6-9.
Order: GET, then PUT, then POST, then DELETE. Critical buckets
(``unimplemented_501``, ``unsupported_message``, ``server_5xx``,
``exception``) must stay empty this module backs the CI surface gate.
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from urllib.parse import quote
import asyncpg # type: ignore[import-untyped]
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from app.config import Settings
from app.contracts.examples import path_param_example, schema_example
from app.contracts.model import Method, Snapshot
from app.db.migrations import migrate
from app.db.pool import AsyncpgDatabase
from app.main import create_app
from app.simulation.seed import apply_seed, small_profile
from app.web.contract_catalog import get_major_releases
_BUNDLED_9 = Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
)
_EVIDENCE_9 = Path("evidence/pve-9.2.3.json")
_PATH_RE = re.compile(r"\{([^{}]+)\}")
_FORBIDDEN = re.compile(
r"not supported in the emulator|not implemented in the simulator|"
r"handler pending for this contract method|is not supported in the (emulator|simulator)",
re.I,
)
_EXTRA_PATH: dict[str, object] = {
"groupid": "admins",
"roleid": "Administrator",
"zone": "localnet",
"vnet": "vnet0",
"subnet": "10.0.0.0-24",
"controller": "evpn1",
"dns": "dns1",
"ipam": "pve",
"flag": "noout",
"osdid": "0",
"monid": "0",
"id": "example",
"cputype": "custom1",
"pci-id-or-mapping": "0000:00:1f.0",
"rule": "rule1",
"sid": "vm:100",
"pos": "0",
"cidr": "10.0.0.0/24",
"tokenid": "automation",
"fabric_id": "fab1",
"node_id": "pve01",
"url_seq": "1",
"route-map-id": "rm1",
"order": "10",
"userid": "root@pam",
"realm": "pam",
"name": "example",
"plugin": "example",
"target": "example",
}
def _path_value(name: str) -> str:
value = path_param_example(name)
if value is None:
value = _EXTRA_PATH.get(name, "example")
return str(value)
def render_path(template: str) -> str:
def replace(match: re.Match[str]) -> str:
return quote(str(_path_value(match.group(1))), safe="@._-")
return _PATH_RE.sub(replace, template)
def body_for(method: Method, path_template: str) -> dict[str, Any]:
path_names = set(_PATH_RE.findall(path_template))
payload: dict[str, Any] = {}
for parameter in method.parameters:
if parameter.name in path_names:
continue
if parameter.definition.optional:
continue
payload[parameter.name] = schema_example(parameter.definition, name=parameter.name)
return payload
def classify(status: int, text: str) -> str:
if _FORBIDDEN.search(text or ""):
return "unsupported_message"
if status == 501:
return "unimplemented_501"
if 200 <= status < 300:
return "success_2xx"
if status in {401, 403}:
return "auth_401_403"
if status in {400, 404, 405, 409, 412, 422, 423}:
return "client_4xx"
if status >= 500:
return "server_5xx"
return f"other_{status}"
async def prepare_db(url: str) -> None:
connection = await asyncpg.connect(url)
try:
await migrate(connection)
await apply_seed(connection, small_profile())
finally:
await connection.close()
async def login(client: AsyncClient) -> str:
response = await client.post(
"/api2/json/access/ticket",
content="username=root%40pam&password=secret",
headers={"content-type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
data = response.json()["data"]
client.cookies.set("PVEAuthCookie", data["ticket"])
return str(data["CSRFPreventionToken"])
async def probe_major(
client: AsyncClient,
csrf: str,
major: int,
snapshot: Snapshot,
) -> dict[str, Any]:
apply = await client.post("/ui/api/contract/apply", params={"major": major})
apply.raise_for_status()
applied = apply.json()
report = (await client.get("/admin/compatibility")).json()
by_verb: dict[str, Counter[str]] = defaultdict(Counter)
failures: list[dict[str, Any]] = []
samples_ok: dict[str, int] = Counter()
methods = [(path.path, method) for path in snapshot.paths for method in path.methods]
order = {"GET": 0, "PUT": 1, "POST": 2, "DELETE": 3}
methods.sort(key=lambda item: (order.get(item[1].verb.upper(), 9), item[0]))
for path_template, method in methods:
verb = method.verb.upper()
url = f"/api2/json{render_path(path_template)}"
headers = {"CSRFPreventionToken": csrf} if verb != "GET" else {}
body = body_for(method, path_template) if verb in {"PUT", "POST"} else None
try:
if verb == "GET":
response = await client.get(url, headers=headers)
elif verb == "PUT":
response = await client.put(url, data=body or {}, headers=headers)
elif verb == "POST":
response = await client.post(url, data=body or {}, headers=headers)
elif verb == "DELETE":
response = await client.delete(url, headers=headers)
else:
continue
except Exception as exc:
by_verb[verb]["exception"] += 1
failures.append(
{
"verb": verb,
"path": path_template,
"error": str(exc)[:200],
"bucket": "exception",
}
)
continue
text = response.text
bucket = classify(response.status_code, text)
by_verb[verb][bucket] += 1
samples_ok[verb] += int(bucket == "success_2xx")
if bucket in {"unimplemented_501", "unsupported_message", "server_5xx", "exception"}:
failures.append(
{
"verb": verb,
"path": path_template,
"status": response.status_code,
"bucket": bucket,
"body": text[:240],
}
)
levels = report.get("levels") or {}
dims = report.get("dimensions") or {}
return {
"major": major,
"version": snapshot.source_version,
"apply": applied,
"declared": report.get("total_declared"),
"implemented": (levels.get("implemented") or {}).get("count"),
"verified": (levels.get("verified") or {}).get("count"),
"dimensions_min": min((item.get("count") or 0) for item in dims.values()) if dims else 0,
"by_verb": {verb: dict(counter) for verb, counter in by_verb.items()},
"success_by_verb": dict(samples_ok),
"failure_count": len(failures),
"failures": failures[:40],
}
async def run_probe(*, database_url: str | None = None) -> list[dict[str, Any]]:
"""Run the full surface probe and return per-major result dicts."""
url = database_url or os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL")
if not url:
raise RuntimeError("TEST_DATABASE_URL / DATABASE_URL required")
await prepare_db(url)
settings = Settings(
database_url=SecretStr(url),
contract_snapshot=_BUNDLED_9,
compatibility_evidence=_EVIDENCE_9,
ticket_signing_key=SecretStr("development-only-signing-key-change-me"),
)
app = create_app(settings=settings, database_factory=lambda s: AsyncpgDatabase(s))
releases = {release.major: release for release in get_major_releases()}
results: list[dict[str, Any]] = []
async with app.router.lifespan_context(app):
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
timeout=30.0,
) as client:
csrf = await login(client)
for major in (6, 7, 8, 9):
release = releases[major]
if release.bundled_revision is None:
raise RuntimeError(f"missing bundled revision for major {major}")
snapshot = Snapshot.model_validate_json(
(Path("contracts") / release.bundled_revision / "snapshot.json").read_bytes()
)
# Keep a single DB seed for the whole run to avoid deadlocks with
# the live app pool during DELETE FROM cascades.
results.append(await probe_major(client, csrf, major, snapshot))
return results
async def main() -> int:
try:
results = await run_probe()
except RuntimeError as error:
print(str(error), file=sys.stderr)
return 2
out = Path("evidence/_api_surface_probe.json")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8")
print(json.dumps({"ok": True, "report": str(out), "majors": len(results)}))
for item in results:
print(
f"PVE {item['version']}: declared={item['declared']} "
f"impl={item['implemented']} ver={item['verified']} "
f"fail={item['failure_count']}"
)
for verb in ("GET", "PUT", "POST", "DELETE"):
buckets = item["by_verb"].get(verb) or {}
if not buckets:
continue
total = sum(buckets.values())
print(f" {verb}: total={total} {buckets}")
critical = sum(int(item["failure_count"]) for item in results)
return 1 if critical else 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+86
View File
@@ -0,0 +1,86 @@
"""Worker semantics for backup/vzdump tasks."""
from __future__ import annotations
import json
from typing import Any
from app.simulation.clock import Clock
from app.simulation.seed import stable_id
from app.tasks.repository import Task, TaskRepository
from app.tasks.worker import TaskHandler
def backup_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
async def execute(task: Task) -> dict[str, Any]:
if task.task_type == "aptupdate":
node = str(task.payload.get("node", "unknown"))
await repository.append_log(task.id, f"starting apt update on {node}")
await clock.sleep(1.0)
async with repository.pool.acquire() as connection:
metadata = await connection.fetchval(
"SELECT metadata FROM nodes WHERE name=$1",
node,
)
if metadata is not None:
payload = json.loads(metadata) if isinstance(metadata, str) else dict(metadata)
ops = payload.setdefault("ops", {})
apt = ops.setdefault("apt", {})
packages = list(apt.get("packages") or [])
for package in packages:
if isinstance(package, dict) and package.get("Status") == "upgradable":
package["Status"] = "installed"
if package.get("Version"):
package["OldVersion"] = package["Version"]
apt["packages"] = packages
apt["update"] = {"status": "stopped", "exitstatus": "OK"}
payload["ops"] = ops
await connection.execute(
"UPDATE nodes SET metadata=$2::jsonb, updated_at=now() WHERE name=$1",
node,
json.dumps(payload, sort_keys=True),
)
await repository.append_log(task.id, "apt update finished")
return {"status": "OK"}
node = str(task.payload["node"])
vmids = [str(item) for item in task.payload.get("vmids", [])]
storage_id = str(task.payload.get("storage") or "nfs-backup")
await repository.append_log(task.id, f"starting vzdump on {node} for {len(vmids)} guests")
async with repository.pool.acquire() as connection:
storage_resource_id = await connection.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
if storage_resource_id is None:
raise ValueError(f"storage {storage_id} does not exist")
created = 0
for index, vmid in enumerate(vmids):
resource_id = await connection.fetchval(
"""SELECT r.id FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
node,
vmid,
)
volume_id = f"backup/vzdump-qemu-{vmid}-{task.id.hex[:8]}-{index:04d}.vma.zst"
await connection.execute(
"""INSERT INTO backups(
id, resource_id, storage_resource_id, volume_id, size_bytes, metadata
) VALUES($1, $2, $3, $4, $5, $6::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO NOTHING""",
stable_id(f"backup-task:{task.id}:{vmid}"),
resource_id,
storage_resource_id,
volume_id,
(8 + index) * 1024**3,
json.dumps(
{"mode": task.payload.get("mode", "snapshot"), "type": "vzdump"},
sort_keys=True,
),
)
created += 1
await repository.append_log(task.id, f"backup archive created: {volume_id}")
await repository.append_log(task.id, f"vzdump finished ({created} archives)")
return {"created": created}
return execute
+245
View File
@@ -0,0 +1,245 @@
"""Worker semantics for asynchronous LXC transitions."""
from __future__ import annotations
import json
import uuid
from collections.abc import Mapping
from typing import Any, cast
from app.simulation.clock import Clock
from app.simulation.transitions import VmState, plan_transition
from app.tasks.repository import Task, TaskRepository
from app.tasks.worker import TaskHandler
def lxc_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
async def execute(task: Task) -> dict[str, Any]:
operation = task.task_type.removeprefix("lxc-")
if operation == "create":
return await _create(repository, task, clock)
if operation == "clone":
return await _clone(repository, task)
resource_id = uuid.UUID(str(task.payload["resource_id"]))
if operation == "delete":
return await _delete(repository, task, resource_id)
if operation.startswith("snapshot-"):
return await _snapshot(
repository, task, resource_id, operation.removeprefix("snapshot-")
)
if operation == "migrate" or operation == "remote-migrate":
return await _migrate(repository, task, resource_id, clock)
async with repository.pool.acquire() as connection:
row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id)
if row is None:
raise ValueError("resource disappeared")
state = _object(row["state"])
transition = plan_transition(VmState(str(state["status"])), operation)
state["status"] = transition.intermediate
await connection.execute(
"UPDATE resources SET state=$2::jsonb WHERE id=$1",
resource_id,
json.dumps(state),
)
await repository.append_log(task.id, f"container {operation} started")
await clock.sleep(1.0)
async with repository.pool.acquire() as connection:
state["status"] = transition.after
await connection.execute(
"UPDATE resources SET state=$2::jsonb WHERE id=$1",
resource_id,
json.dumps(state),
)
await repository.append_log(task.id, f"container {operation} completed")
return {"status": str(transition.after)}
return execute
async def _create(repository: TaskRepository, task: Task, clock: Clock) -> dict[str, Any]:
node, vmid = str(task.payload["node"]), int(task.payload["vmid"])
config = dict(task.payload.get("config", {}))
start = bool(task.payload.get("start", False))
resource_id = uuid.uuid4()
status = "running" if start else "stopped"
state = {"status": status, **config}
async with repository.pool.acquire() as connection:
async with connection.transaction():
node_row = await connection.fetchrow(
"SELECT id, cluster_id FROM nodes WHERE name=$1", node
)
if node_row is None:
raise ValueError("node disappeared")
await connection.execute(
"""INSERT INTO resources(
id, node_id, cluster_id, kind, external_id, state, metadata
) VALUES($1, $2, $3, 'lxc', $4, $5::jsonb, '{}'::jsonb)""",
resource_id,
node_row["id"],
node_row["cluster_id"],
str(vmid),
json.dumps(state, sort_keys=True),
)
await connection.execute(
"""INSERT INTO containers(resource_id, cluster_id, vmid, config)
VALUES($1, $2, $3, $4::jsonb)""",
resource_id,
node_row["cluster_id"],
vmid,
json.dumps(config, sort_keys=True),
)
if start:
await clock.sleep(0.5)
await repository.append_log(task.id, f"container {vmid} created")
return {"vmid": vmid, "status": status}
async def _delete(repository: TaskRepository, task: Task, resource_id: uuid.UUID) -> dict[str, Any]:
async with repository.pool.acquire() as connection:
status = await connection.execute("DELETE FROM resources WHERE id=$1", resource_id)
if status != "DELETE 1":
raise ValueError("resource disappeared")
await repository.append_log(task.id, "container deleted")
return {"deleted": True}
async def _snapshot(
repository: TaskRepository,
task: Task,
resource_id: uuid.UUID,
operation: str,
) -> dict[str, Any]:
name = str(task.payload["snapname"])
async with repository.pool.acquire() as connection:
async with connection.transaction():
if operation == "create":
row = await connection.fetchrow(
"""SELECT r.state, c.config FROM resources r
JOIN containers c ON c.resource_id=r.id WHERE r.id=$1""",
resource_id,
)
if row is None:
raise ValueError("resource disappeared")
captured = {
"resource_state": _object(row["state"]),
"config": _object(row["config"]),
}
await connection.execute(
"""INSERT INTO snapshots(id, resource_id, name, description, state)
VALUES($1, $2, $3, $4, $5::jsonb)""",
uuid.uuid4(),
resource_id,
name,
str(task.payload.get("description", "")),
json.dumps(captured, sort_keys=True),
)
elif operation == "delete":
status = await connection.execute(
"DELETE FROM snapshots WHERE resource_id=$1 AND name=$2",
resource_id,
name,
)
if status != "DELETE 1":
raise ValueError("snapshot disappeared")
elif operation == "rollback":
row = await connection.fetchrow(
"SELECT state FROM snapshots WHERE resource_id=$1 AND name=$2",
resource_id,
name,
)
if row is None:
raise ValueError("snapshot disappeared")
captured = _object(row["state"])
state = dict(cast(Mapping[str, Any], captured["resource_state"]))
await connection.execute(
"""UPDATE resources SET state=$2::jsonb, version=version+1,
updated_at=now() WHERE id=$1""",
resource_id,
json.dumps(state, sort_keys=True),
)
await connection.execute(
"UPDATE containers SET config=$2::jsonb WHERE resource_id=$1",
resource_id,
json.dumps(captured["config"], sort_keys=True),
)
else:
raise ValueError(f"unsupported snapshot operation: {operation}")
await repository.append_log(task.id, f"snapshot {name} {operation} completed")
return {"snapshot": name, "operation": operation}
async def _clone(repository: TaskRepository, task: Task) -> dict[str, Any]:
source_id = uuid.UUID(str(task.payload["source_resource_id"]))
target_id = uuid.uuid4()
node, vmid = str(task.payload["node"]), int(task.payload["vmid"])
async with repository.pool.acquire() as connection:
async with connection.transaction():
source = await connection.fetchrow(
"""SELECT r.state, c.config FROM resources r
JOIN containers c ON c.resource_id=r.id WHERE r.id=$1""",
source_id,
)
target = await connection.fetchrow(
"SELECT id, cluster_id FROM nodes WHERE name=$1", node
)
if source is None or target is None:
raise ValueError("clone source or target disappeared")
config = _object(source["config"])
if task.payload.get("name") is not None:
config["hostname"] = task.payload["name"]
state = {**_object(source["state"]), **config, "status": "stopped"}
await connection.execute(
"""INSERT INTO resources(id,node_id,cluster_id,kind,external_id,state,metadata)
VALUES($1,$2,$3,'lxc',$4,$5::jsonb,'{}'::jsonb)""",
target_id,
target["id"],
target["cluster_id"],
str(vmid),
json.dumps(state),
)
await connection.execute(
"""INSERT INTO containers(resource_id,cluster_id,vmid,config)
VALUES($1,$2,$3,$4::jsonb)""",
target_id,
target["cluster_id"],
vmid,
json.dumps(config),
)
await repository.append_log(task.id, f"container cloned to {vmid}")
return {"vmid": vmid, "node": node}
async def _migrate(
repository: TaskRepository, task: Task, resource_id: uuid.UUID, clock: Clock
) -> dict[str, Any]:
target = str(task.payload["target"])
async with repository.pool.acquire() as connection:
row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id)
if row is None:
raise ValueError("resource disappeared")
state = _object(row["state"])
transition = plan_transition(VmState(str(state["status"])), "migrate")
state["status"] = transition.intermediate
await connection.execute(
"UPDATE resources SET state=$2::jsonb WHERE id=$1", resource_id, json.dumps(state)
)
await repository.append_log(task.id, f"migration to {target} started")
await clock.sleep(1.0)
async with repository.pool.acquire() as connection:
node = await connection.fetchrow("SELECT id FROM nodes WHERE name=$1", target)
if node is None:
raise ValueError("target node disappeared")
state["status"] = transition.after
await connection.execute(
"""UPDATE resources SET node_id=$2,state=$3::jsonb,version=version+1,
updated_at=now() WHERE id=$1""",
resource_id,
node["id"],
json.dumps(state),
)
await repository.append_log(task.id, f"migration to {target} completed")
return {"node": target, "status": str(transition.after)}
def _object(value: object) -> dict[str, Any]:
return json.loads(value) if isinstance(value, str) else dict(cast(Mapping[str, Any], value))
+1 -1
View File
@@ -29,7 +29,7 @@ def qemu_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
return await _snapshot(
repository, task, resource_id, operation.removeprefix("snapshot-")
)
if operation == "migrate":
if operation == "migrate" or operation == "remote-migrate":
return await _migrate(repository, task, resource_id, clock)
if operation == "move-disk":
return await _move_disk(repository, task, resource_id)
+16
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import re
import secrets
import time
from dataclasses import dataclass
UPID_RE = re.compile(
@@ -57,3 +59,17 @@ class Upid:
task_id=values["task_id"],
user=values["user"],
)
@classmethod
def allocate(cls, node: str, task_type: str, task_id: str, user: str) -> Upid:
"""Build a collision-resistant UPID for a new task."""
return cls(
node=node,
pid=secrets.randbits(32),
process_start=secrets.randbits(32),
start_time=int(time.time()) & 0xFFFFFFFF,
task_type=task_type,
task_id=str(task_id),
user=user,
)
View File
+14
View File
@@ -0,0 +1,14 @@
"""Static web console assets."""
from __future__ import annotations
from pathlib import Path
_WEB_ROOT = Path(__file__).parent
_CONSOLE_HTML = _WEB_ROOT / "index.html"
def console_html() -> str:
"""Return the latest console markup from disk."""
return _CONSOLE_HTML.read_text(encoding="utf-8")
+77
View File
@@ -0,0 +1,77 @@
"""Catalog-scoped compatibility summaries for the web console."""
from __future__ import annotations
from app.compatibility import CompatibilityDimension, CompatibilityReport, build_report
from app.config import Settings
from app.contracts.model import Snapshot
from app.web.contract_catalog import major_release
MethodKey = tuple[str, str]
def compatibility_payload(
snapshot: Snapshot,
major: int,
*,
implemented_methods: frozenset[MethodKey] | None,
runtime_report: CompatibilityReport | None,
runtime_version: str | None,
settings: Settings | None,
) -> dict[str, object]:
"""Build a compatibility summary for the selected catalog major."""
declared = frozenset(
(contract_path.path, method.verb.upper())
for contract_path in snapshot.paths
for method in contract_path.methods
)
implemented = (implemented_methods or frozenset()) & declared
if runtime_report is not None and runtime_report.source_version == snapshot.source_version:
payload = runtime_report.as_json()
evidence_scope = "full"
else:
dimensions: dict[CompatibilityDimension, frozenset[MethodKey]] = {
CompatibilityDimension.ROUTE_METHOD: declared,
}
if runtime_report is not None:
for dimension, methods in runtime_report.dimensions.items():
if dimension == CompatibilityDimension.ROUTE_METHOD:
continue
dimensions[dimension] = methods & declared
else:
for dimension in CompatibilityDimension:
if dimension != CompatibilityDimension.ROUTE_METHOD:
dimensions[dimension] = frozenset()
empty: frozenset[MethodKey] = frozenset()
if runtime_report is None:
observed = empty
verified = empty
incompatible = empty
regressions = empty
else:
observed = runtime_report.observed & declared
verified = runtime_report.verified & declared
incompatible = runtime_report.incompatible & declared
regressions = runtime_report.regressions & declared
catalog_report = build_report(
snapshot,
implemented=implemented,
observed=observed,
verified=verified,
dimensions=dimensions,
incompatible=incompatible,
regressions=regressions,
)
payload = catalog_report.as_json()
evidence_scope = "catalog"
release = major_release(major, settings)
payload["major"] = major
payload["catalog_version"] = snapshot.source_version
payload["latest_version"] = release.latest_version
payload["runtime_version"] = runtime_version
payload["evidence_scope"] = evidence_scope
return payload
+293
View File
@@ -0,0 +1,293 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Proxmox API Emulator</title>
<style>
:root {
color-scheme: dark;
--bg: #0b1020;
--panel: #121a2f;
--panel-2: #18233d;
--border: #2a3555;
--text: #e8eefc;
--muted: #93a0c0;
--accent: #5b8cff;
--accent-2: #3dd6c6;
--danger: #ff6b7a;
--ok: #4ade80;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
--radius: 16px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: var(--sans);
background:
radial-gradient(circle at top left, rgba(91, 140, 255, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(61, 214, 198, 0.12), transparent 24%),
var(--bg);
color: var(--text);
}
.wrap { max-width: 1180px; margin: 0 auto; padding: 32px 20px 48px; }
header {
display: flex; justify-content: space-between; align-items: flex-start;
gap: 16px; margin-bottom: 28px;
}
h1 { margin: 0 0 8px; font-size: clamp(1.6rem, 2vw, 2.2rem); }
.subtitle { color: var(--muted); max-width: 56ch; line-height: 1.5; }
.links { display: flex; gap: 10px; flex-wrap: wrap; }
.links a {
color: var(--text); text-decoration: none; border: 1px solid var(--border);
background: rgba(255,255,255,0.03); padding: 8px 12px; border-radius: 999px;
font-size: 0.92rem;
}
.grid {
display: grid; grid-template-columns: repeat(12, 1fr); gap: 18px;
}
.card {
background: linear-gradient(180deg, rgba(255,255,255,0.03), transparent), var(--panel);
border: 1px solid var(--border); border-radius: var(--radius); padding: 18px;
box-shadow: var(--shadow);
}
.span-4 { grid-column: span 4; }
.span-5 { grid-column: span 5; }
.span-7 { grid-column: span 7; }
.span-8 { grid-column: span 8; }
.span-12 { grid-column: span 12; }
@media (max-width: 900px) {
.span-4, .span-5, .span-7, .span-8 { grid-column: span 12; }
}
h2 { margin: 0 0 14px; font-size: 1rem; letter-spacing: 0.02em; }
label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 6px; }
input, select, textarea, button {
width: 100%; font: inherit; border-radius: 12px; border: 1px solid var(--border);
background: var(--panel-2); color: var(--text); padding: 11px 12px;
}
textarea { min-height: 120px; font-family: var(--mono); font-size: 0.88rem; resize: vertical; }
button {
cursor: pointer; background: linear-gradient(135deg, var(--accent), #4068d8);
border: none; font-weight: 600; margin-top: 10px;
}
button.secondary {
background: transparent; border: 1px solid var(--border); font-weight: 500;
}
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.stat {
background: var(--panel-2); border: 1px solid var(--border); border-radius: 14px;
padding: 14px;
}
.stat .label { color: var(--muted); font-size: 0.82rem; margin-bottom: 6px; }
.stat .value { font-size: 1.25rem; font-weight: 700; }
.pill {
display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px;
border-radius: 999px; background: rgba(74, 222, 128, 0.12); color: var(--ok);
border: 1px solid rgba(74, 222, 128, 0.25); font-size: 0.85rem;
}
.pill.warn { color: #fbbf24; background: rgba(251, 191, 36, 0.12); border-color: rgba(251,191,36,0.25); }
pre {
margin: 0; padding: 14px; border-radius: 14px; background: #070b14;
border: 1px solid var(--border); overflow: auto; font-family: var(--mono);
font-size: 0.84rem; line-height: 1.45; min-height: 180px;
}
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.actions button { width: auto; flex: 1 1 140px; margin-top: 0; }
.hint { color: var(--muted); font-size: 0.85rem; margin-top: 8px; line-height: 1.4; }
.error { color: var(--danger); }
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1>Proxmox API Emulator</h1>
<p class="subtitle">
Stateful API console for the emulator. Authenticate, inspect cluster state,
send requests, and follow UPID tasks against the same `/api2/json` surface
used by proxmoxer and other clients.
</p>
</div>
<div class="links">
<a href="/docs" target="_blank" rel="noreferrer">OpenAPI</a>
<a href="/admin/compatibility.html" target="_blank" rel="noreferrer">Compatibility</a>
<a href="/health/ready" target="_blank" rel="noreferrer">Readiness</a>
</div>
</header>
<div class="grid">
<section class="card span-4">
<h2>Authentication</h2>
<label for="username">Username</label>
<input id="username" value="root@pam" autocomplete="username">
<label for="password">Password</label>
<input id="password" type="password" value="secret" autocomplete="current-password">
<button id="login-btn">Get ticket</button>
<p class="hint">Development credentials from the deterministic seed profile.</p>
<label for="csrf">CSRFPreventionToken</label>
<input id="csrf" readonly>
<div id="auth-status" class="pill warn" style="margin-top:12px;">Not authenticated</div>
</section>
<section class="card span-8">
<h2>Cluster snapshot</h2>
<div class="stats">
<div class="stat"><div class="label">PVE version</div><div class="value" id="stat-version"></div></div>
<div class="stat"><div class="label">Nodes</div><div class="value" id="stat-nodes"></div></div>
<div class="stat"><div class="label">Resources</div><div class="value" id="stat-resources"></div></div>
</div>
<div class="actions" style="margin-top:14px;">
<button class="secondary" data-action="refresh">Refresh overview</button>
<button class="secondary" data-action="nodes">GET /nodes</button>
<button class="secondary" data-action="resources">GET /cluster/resources</button>
</div>
</section>
<section class="card span-5">
<h2>Quick request</h2>
<div class="row">
<div>
<label for="method">Method</label>
<select id="method">
<option>GET</option>
<option>POST</option>
<option>PUT</option>
<option>DELETE</option>
</select>
</div>
<div>
<label for="path">Path</label>
<input id="path" value="/api2/json/nodes/pve01/qemu">
</div>
</div>
<label for="body">Body (JSON or form key=value)</label>
<textarea id="body"></textarea>
<button id="send-btn">Send request</button>
<p class="hint">Mutating requests automatically attach the CSRF header when a ticket is present.</p>
</section>
<section class="card span-7">
<h2>Response</h2>
<pre id="output">Waiting for a request…</pre>
</section>
<section class="card span-12">
<h2>Task monitor</h2>
<div class="row">
<div>
<label for="node">Node</label>
<input id="node" value="pve01">
</div>
<div>
<label for="upid">UPID</label>
<input id="upid" placeholder="UPID returned by a mutation">
</div>
</div>
<div class="actions" style="margin-top:10px;">
<button class="secondary" id="task-status-btn">Task status</button>
<button class="secondary" id="task-log-btn">Task log</button>
</div>
</section>
</div>
</div>
<script>
const output = document.getElementById("output");
const csrfInput = document.getElementById("csrf");
const authStatus = document.getElementById("auth-status");
function show(data, status) {
output.textContent = JSON.stringify({ status, data }, null, 2);
}
async function apiFetch(path, options = {}) {
const headers = new Headers(options.headers || {});
if (csrfInput.value && options.method && options.method !== "GET") {
headers.set("CSRFPreventionToken", csrfInput.value);
}
const response = await fetch(path, { ...options, headers, credentials: "include" });
let data;
try { data = await response.json(); } catch { data = { raw: await response.text() }; }
show(data, response.status);
return { response, data };
}
async function refreshOverview() {
const version = await apiFetch("/api2/json/version");
document.getElementById("stat-version").textContent =
version.data?.data?.version || "unknown";
const nodes = await apiFetch("/api2/json/nodes");
const resources = await apiFetch("/api2/json/cluster/resources");
document.getElementById("stat-nodes").textContent =
Array.isArray(nodes.data?.data) ? nodes.data.data.length : "0";
document.getElementById("stat-resources").textContent =
Array.isArray(resources.data?.data) ? resources.data.data.length : "0";
}
document.getElementById("login-btn").addEventListener("click", async () => {
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const body = new URLSearchParams({ username, password });
const result = await apiFetch("/api2/json/access/ticket", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const ticket = result.data?.data;
if (ticket?.CSRFPreventionToken) {
csrfInput.value = ticket.CSRFPreventionToken;
authStatus.textContent = `Authenticated as ${ticket.username}`;
authStatus.classList.remove("warn");
} else {
authStatus.textContent = "Authentication failed";
authStatus.classList.add("warn");
}
await refreshOverview();
});
document.getElementById("send-btn").addEventListener("click", async () => {
const method = document.getElementById("method").value;
const path = document.getElementById("path").value;
const rawBody = document.getElementById("body").value.trim();
const options = { method };
if (rawBody && method !== "GET" && method !== "DELETE") {
if (rawBody.startsWith("{")) {
options.headers = { "Content-Type": "application/json" };
options.body = rawBody;
} else {
options.headers = { "Content-Type": "application/x-www-form-urlencoded" };
options.body = rawBody;
}
}
await apiFetch(path, options);
});
document.querySelector('[data-action="refresh"]').addEventListener("click", refreshOverview);
document.querySelector('[data-action="nodes"]').addEventListener("click", () =>
apiFetch("/api2/json/nodes"));
document.querySelector('[data-action="resources"]').addEventListener("click", () =>
apiFetch("/api2/json/cluster/resources"));
document.getElementById("task-status-btn").addEventListener("click", async () => {
const node = document.getElementById("node").value;
const upid = encodeURIComponent(document.getElementById("upid").value);
await apiFetch(`/api2/json/nodes/${node}/tasks/${upid}/status`);
});
document.getElementById("task-log-btn").addEventListener("click", async () => {
const node = document.getElementById("node").value;
const upid = encodeURIComponent(document.getElementById("upid").value);
await apiFetch(`/api2/json/nodes/${node}/tasks/${upid}/log`);
});
refreshOverview().catch((error) => {
output.textContent = String(error);
output.classList.add("error");
});
</script>
</body>
</html>
+336
View File
@@ -0,0 +1,336 @@
"""Lazy-loaded Proxmox API contract catalog grouped by major PVE release."""
from __future__ import annotations
import asyncio
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import lru_cache
from pathlib import Path
from app.api.openapi import contract_openapi_tag
from app.config import Settings
from app.contracts.examples import path_param_example, schema_example
from app.contracts.importer import RemoteSourceImporter
from app.contracts.model import Method, Parameter, Snapshot
from app.contracts.normalize import normalize_snapshot
from app.contracts.source import ApiViewerParser
from app.contracts.store import RevisionStore
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
@dataclass(frozen=True, slots=True)
class MajorReleaseMeta:
major: int
latest_version: str
bundled_revision: str | None = None
@dataclass(frozen=True, slots=True)
class MajorRelease:
major: int
latest_version: str
artifact_url: str
bundled_revision: str | None = None
_MAJOR_METADATA: tuple[MajorReleaseMeta, ...] = (
MajorReleaseMeta(
major=6,
latest_version="6.4-15",
bundled_revision="96cd7121e75cdb3efd58f79ca988f6b235a2f28e6f7eae276ae243f65d8a6724",
),
MajorReleaseMeta(
major=7,
latest_version="7.4-16",
bundled_revision="2cf632fa6ea4939ca9cb7998ade688150db25b0684600f53ac0ca95730f1d99f",
),
MajorReleaseMeta(
major=8,
latest_version="8.4.5",
bundled_revision="fce6db0a784b3a9b447895895fc6ff4b4437c2dce82e5f3db99227af217726fa",
),
MajorReleaseMeta(
major=9,
latest_version="9.2.3",
bundled_revision="e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1",
),
)
_DEFAULT_ARTIFACT_URLS: dict[int, str] = {
6: "https://pve.proxmox.com/pve-docs-6/api-viewer/apidoc.js",
7: "https://pve.proxmox.com/pve-docs-7/api-viewer/apidoc.js",
8: "https://pve.proxmox.com/pve-docs-8/api-viewer/apidoc.js",
9: "https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js",
}
_SNAPSHOT_CACHE: dict[int, Snapshot] = {}
_SNAPSHOT_LOCK = asyncio.Lock()
_DEFAULT_STORE = Path("contracts")
def _artifact_urls(settings: Settings | None) -> dict[int, str]:
if settings is None:
return dict(_DEFAULT_ARTIFACT_URLS)
return settings.catalog_artifact_urls()
def get_major_releases(settings: Settings | None = None) -> tuple[MajorRelease, ...]:
urls = _artifact_urls(settings)
return tuple(
MajorRelease(
major=meta.major,
latest_version=meta.latest_version,
artifact_url=urls[meta.major],
bundled_revision=meta.bundled_revision,
)
for meta in _MAJOR_METADATA
)
def major_release(major: int, settings: Settings | None = None) -> MajorRelease:
releases = {release.major: release for release in get_major_releases(settings)}
try:
return releases[major]
except KeyError as error:
raise ValueError(f"unsupported major version: {major}") from error
def list_majors(
*,
runtime_version: str | None,
settings: Settings | None = None,
) -> dict[str, object]:
return {
"runtime_version": runtime_version,
"majors": [
{
"major": release.major,
"latest_version": release.latest_version,
"artifact_url": release.artifact_url,
"bundled": release.bundled_revision is not None,
}
for release in get_major_releases(settings)
],
}
async def load_snapshot(
major: int,
store_root: Path | None = None,
*,
settings: Settings | None = None,
) -> Snapshot:
if major in _SNAPSHOT_CACHE:
return _SNAPSHOT_CACHE[major]
async with _SNAPSHOT_LOCK:
if major in _SNAPSHOT_CACHE:
return _SNAPSHOT_CACHE[major]
release = major_release(major, settings)
store = RevisionStore(store_root or _DEFAULT_STORE)
if release.bundled_revision is not None:
snapshot_path = store.root / release.bundled_revision / "snapshot.json"
if snapshot_path.is_file():
snapshot = Snapshot.model_validate_json(snapshot_path.read_bytes())
_SNAPSHOT_CACHE[major] = snapshot
return snapshot
existing = _find_cached_revision(store, release.latest_version)
if existing is not None:
snapshot = Snapshot.model_validate_json(existing.read_bytes())
_SNAPSHOT_CACHE[major] = snapshot
return snapshot
raw = await RemoteSourceImporter(release.artifact_url).load()
parsed = ApiViewerParser().parse(raw)
snapshot, manifest = normalize_snapshot(
parsed,
raw=raw,
source_version=release.latest_version,
retrieved_at=datetime.now(UTC),
)
try:
store.save(raw, snapshot, manifest)
except OSError:
pass
_SNAPSHOT_CACHE[major] = snapshot
return snapshot
def _find_cached_revision(store: RevisionStore, source_version: str) -> Path | None:
if not store.root.is_dir():
return None
for revision in store.list():
manifest = store.manifest(revision)
if manifest.source_version == source_version:
return store.root / revision / "snapshot.json"
return None
def _catalog_entry_path(entry: dict[str, object]) -> str:
return str(entry["path"])
def catalog_payload(
snapshot: Snapshot,
major: int,
*,
implemented_methods: frozenset[tuple[str, str]] | None = None,
settings: Settings | None = None,
) -> dict[str, object]:
grouped: dict[str, list[dict[str, object]]] = {}
for contract_path in snapshot.paths:
tag = contract_openapi_tag(contract_path.path)
methods = [
{
"verb": method.verb,
"name": method.name,
"description": method.description,
"protected": method.protected,
"implemented": (
(contract_path.path, method.verb.upper()) in implemented_methods
if implemented_methods is not None
else None
),
}
for method in contract_path.methods
]
entry: dict[str, object] = {
"path": contract_path.path,
"methods": methods,
}
grouped.setdefault(tag, []).append(entry)
categories: list[dict[str, object]] = []
for tag in sorted(grouped):
entries = grouped[tag]
categories.append(
{
"tag": tag,
"paths": sorted(entries, key=_catalog_entry_path),
}
)
release = major_release(major, settings)
return {
"major": major,
"source_version": snapshot.source_version,
"latest_version": release.latest_version,
"artifact_url": release.artifact_url,
"bundled": release.bundled_revision is not None,
"path_count": snapshot.path_count,
"method_count": snapshot.method_count,
"categories": categories,
}
def _path_param_names(path: str) -> tuple[str, ...]:
return tuple(match.group(1) for match in _PATH_PARAM.finditer(path))
def _parameter_payload(parameter: Parameter) -> dict[str, object]:
schema = parameter.definition
return {
"name": parameter.name,
"type": schema.type,
"description": schema.description,
"optional": bool(schema.optional),
"enum": list(schema.enum),
"example": schema_example(schema, name=parameter.name),
}
def method_payload(
snapshot: Snapshot,
*,
major: int,
path: str,
verb: str,
runtime_version: str | None,
implemented_methods: frozenset[tuple[str, str]] | None,
) -> dict[str, object]:
contract_path = next((item for item in snapshot.paths if item.path == path), None)
if contract_path is None:
raise KeyError(path)
method = next(
(item for item in contract_path.methods if item.verb.upper() == verb.upper()),
None,
)
if method is None:
raise KeyError(verb)
path_params = _path_param_names(path)
path_fields = [
_parameter_payload(parameter)
for parameter in method.parameters
if parameter.name in path_params
]
for name in path_params:
if name not in {field["name"] for field in path_fields}:
path_fields.append(
{
"name": name,
"type": "string",
"description": None,
"optional": False,
"enum": [],
"example": path_param_example(name) or name,
}
)
body_fields = [
_parameter_payload(parameter)
for parameter in method.parameters
if parameter.name not in path_params and "[n]" not in parameter.name
]
indexed_fields = [
_parameter_payload(parameter) for parameter in method.parameters if "[n]" in parameter.name
]
body_example = _body_example(method, path_params)
resolved_path = _resolve_path(path, path_fields)
implemented = (
(path, method.verb.upper()) in implemented_methods
if implemented_methods is not None
else None
)
return {
"major": major,
"source_version": snapshot.source_version,
"runtime_version": runtime_version,
"path": path,
"verb": method.verb.upper(),
"name": method.name,
"description": method.description,
"resolved_path": resolved_path,
"path_fields": path_fields,
"body_fields": body_fields,
"indexed_fields": indexed_fields,
"body_example": body_example,
"implemented": implemented,
}
def _resolve_path(path: str, path_fields: list[dict[str, object]]) -> str:
resolved = path
for field in path_fields:
name = str(field["name"])
example = field.get("example", name)
resolved = resolved.replace(f"{{{name}}}", str(example))
return resolved
def _body_example(method: Method, path_params: tuple[str, ...]) -> dict[str, object]:
body: dict[str, object] = {}
for parameter in method.parameters:
if parameter.name in path_params:
continue
if "[n]" in parameter.name:
concrete = parameter.name.replace("[n]", "0")
if not parameter.definition.optional:
body[concrete] = schema_example(parameter.definition, name=concrete)
continue
if parameter.definition.optional:
continue
body[parameter.name] = schema_example(parameter.definition, name=parameter.name)
return body
@lru_cache(maxsize=1)
def default_store_root() -> Path:
return _DEFAULT_STORE
+6799
View File
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
"""Browser console for exercising the simulator API."""
from __future__ import annotations
from pathlib import Path
from typing import Annotated
from asyncpg import Pool # type: ignore[import-untyped]
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.contracts.runtime import apply_runtime_contract_locked, contract_store_root
from app.contracts.source import SourceError
from app.db.pool import AsyncpgDatabase
from app.dependencies import get_database
from app.simulation.seed import apply_seed, build_profile, simulation_state_summary
from app.web.assets import console_html
from app.web.compatibility_catalog import compatibility_payload
from app.web.contract_catalog import catalog_payload, list_majors, load_snapshot, method_payload
router = APIRouter(tags=["Simulator"])
@router.get("/", response_class=HTMLResponse, include_in_schema=True)
async def console() -> HTMLResponse:
"""Interactive API console and cluster overview."""
return HTMLResponse(
console_html(),
headers={"Cache-Control": "no-store"},
)
@router.get("/ui/api/versions", include_in_schema=False)
async def ui_versions(request: Request) -> JSONResponse:
settings = _settings(request)
runtime_version = _runtime_version(request)
return JSONResponse(list_majors(runtime_version=runtime_version, settings=settings))
@router.get("/ui/api/catalog", include_in_schema=False)
async def ui_catalog(
request: Request,
major: Annotated[int, Query(ge=6, le=9)],
) -> JSONResponse:
settings = _settings(request)
try:
snapshot = await load_snapshot(major, _store_root(request), settings=settings)
except SourceError as error:
raise HTTPException(status_code=503, detail=str(error)) from error
implemented = getattr(request.app.state, "implemented_methods", None)
return JSONResponse(
catalog_payload(snapshot, major, implemented_methods=implemented, settings=settings)
)
@router.get("/ui/api/method", include_in_schema=False)
async def ui_method(
request: Request,
major: Annotated[int, Query(ge=6, le=9)],
path: Annotated[str, Query(min_length=1)],
verb: Annotated[str, Query(min_length=1)],
) -> JSONResponse:
settings = _settings(request)
try:
snapshot = await load_snapshot(major, _store_root(request), settings=settings)
except SourceError as error:
raise HTTPException(status_code=503, detail=str(error)) from error
runtime_version = _runtime_version(request)
implemented = getattr(request.app.state, "implemented_methods", None)
try:
payload = method_payload(
snapshot,
major=major,
path=path,
verb=verb,
runtime_version=runtime_version,
implemented_methods=implemented,
)
except KeyError as error:
raise HTTPException(status_code=404, detail=f"unknown contract method: {error}") from error
return JSONResponse(payload)
@router.get("/ui/api/compatibility", include_in_schema=False)
async def ui_compatibility(
request: Request,
major: Annotated[int, Query(ge=6, le=9)],
) -> JSONResponse:
settings = _settings(request)
try:
snapshot = await load_snapshot(major, _store_root(request), settings=settings)
except SourceError as error:
raise HTTPException(status_code=503, detail=str(error)) from error
implemented = getattr(request.app.state, "implemented_methods", None)
runtime_report = getattr(request.app.state, "compatibility_report", None)
runtime_version = _runtime_version(request)
return JSONResponse(
compatibility_payload(
snapshot,
major,
implemented_methods=implemented,
runtime_report=runtime_report,
runtime_version=runtime_version,
settings=settings,
)
)
@router.post("/ui/api/contract/apply", include_in_schema=False)
async def ui_contract_apply(
request: Request,
major: Annotated[int, Query(ge=6, le=9)],
) -> JSONResponse:
"""Hot-swap the in-memory runtime contract to a catalog major (memory-only)."""
settings = _settings(request)
handlers = getattr(request.app.state, "handlers", None)
if (
settings is None
or settings.contract_snapshot is None
or not isinstance(handlers, HandlerRegistry)
):
raise HTTPException(status_code=503, detail="runtime contract is not available")
store_root = _store_root(request)
try:
snapshot = await load_snapshot(major, store_root, settings=settings)
except SourceError as error:
raise HTTPException(status_code=503, detail=str(error)) from error
await apply_runtime_contract_locked(
request.app,
snapshot,
handlers=handlers,
store_root=store_root,
fallback=settings.contract_fallback,
settings=settings,
require_evidence_match=False,
register_admin=True,
)
method_count = sum(len(path.methods) for path in snapshot.paths)
return JSONResponse(
{
"ok": True,
"major": major,
"runtime_version": snapshot.source_version,
"path_count": len(snapshot.paths),
"method_count": method_count,
}
)
@router.get("/ui/api/demo/state", include_in_schema=False)
async def ui_demo_state(request: Request) -> JSONResponse:
pool = _database_pool(request)
async with pool.acquire() as connection:
return JSONResponse(await simulation_state_summary(connection))
@router.post("/ui/api/demo/load", include_in_schema=False)
async def ui_demo_load(request: Request) -> JSONResponse:
pool = _database_pool(request)
profile = build_profile("demo-cluster")
async with pool.acquire() as connection:
await apply_seed(connection, profile)
summary = await simulation_state_summary(connection)
return JSONResponse({"ok": True, "profile": profile.name, "summary": summary})
@router.post("/ui/api/demo/unload", include_in_schema=False)
async def ui_demo_unload(request: Request) -> JSONResponse:
"""Reset to minimal seed, wiping API-created state first."""
pool = _database_pool(request)
profile = build_profile("minimal")
try:
async with pool.acquire() as connection:
await apply_seed(connection, profile)
summary = await simulation_state_summary(connection)
except Exception as error:
raise HTTPException(
status_code=500, detail=f"failed to remove demo data: {error}"
) from error
return JSONResponse({"ok": True, "profile": profile.name, "summary": summary})
def _database_pool(request: Request) -> Pool:
database = get_database(request)
if not isinstance(database, AsyncpgDatabase):
raise HTTPException(status_code=503, detail="database is not available")
return database.pool
def _settings(request: Request) -> Settings | None:
return getattr(request.app.state, "settings", None)
def _runtime_version(request: Request) -> str | None:
active = getattr(request.app.state, "runtime_source_version", None)
if isinstance(active, str) and active:
return active
settings = _settings(request)
if settings is None or settings.contract_snapshot is None:
return None
from app.contracts.model import Snapshot
snapshot = Snapshot.model_validate_json(settings.contract_snapshot.read_bytes())
return snapshot.source_version
def _store_root(request: Request) -> Path:
stored = getattr(request.app.state, "contract_store_root", None)
if isinstance(stored, Path):
return stored
settings = _settings(request)
if settings is not None:
return contract_store_root(settings)
return Path("contracts")
@@ -0,0 +1 @@
{"method_count":540,"path_count":364,"raw_sha256":"125f0af24951e901800e49559593678edd95af66da27c88311faecda708ebaf1","snapshot_sha256":"2cf632fa6ea4939ca9cb7998ade688150db25b0684600f53ac0ca95730f1d99f","source_version":"7.4-16"}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"method_count":504,"path_count":338,"raw_sha256":"374156fc7188fb23c40982d0ff63fb7dce601f80f7319032bbb94882f47af69f","snapshot_sha256":"96cd7121e75cdb3efd58f79ca988f6b235a2f28e6f7eae276ae243f65d8a6724","source_version":"6.4-15"}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"method_count":605,"path_count":398,"raw_sha256":"bbe03a42c55b3f9ae77a5b5216c1a8554f4fffd0f4b266848f4af26be295946e","snapshot_sha256":"fce6db0a784b3a9b447895895fc6ff4b4437c2dce82e5f3db99227af217726fa","source_version":"8.4.5"}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+88
View File
@@ -0,0 +1,88 @@
# Quick start with the published Docker Hub runtime image.
#
# docker compose -f docker-compose.release.yml up -d
# docker compose -f docker-compose.release.yml run --rm --entrypoint python simulator -m app.simulation.seed_cli
#
# Override the image tag:
# IMAGE_TAG=0.1.0 docker compose -f docker-compose.release.yml up -d
#
# Change TICKET_SIGNING_KEY before exposing the stack beyond a local lab.
name: proxmox-api-simulator-release
x-app-image: &app-image
image: ${DOCKER_IMAGE:-inecs/proxmox-api-simulator}:${IMAGE_TAG:-0.1.0}
x-app-env: &app-env
DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
CONTRACT_SNAPSHOT: /app/contracts/pve-9.2.3.json
COMPATIBILITY_EVIDENCE: /app/evidence/pve-9.2.3.json
LOG_LEVEL: ${LOG_LEVEL:-INFO}
TICKET_SIGNING_KEY: ${TICKET_SIGNING_KEY:-development-only-signing-key-change-me}
TASK_WORKER_CONCURRENCY: ${TASK_WORKER_CONCURRENCY:-2}
SIMULATION_TIME_SCALE: ${SIMULATION_TIME_SCALE:-10}
networks:
simulator:
driver: bridge
volumes:
postgres-data:
services:
postgres:
image: postgres:17.5-bookworm
restart: unless-stopped
networks: [simulator]
environment:
POSTGRES_DB: proxmox_simulator
POSTGRES_USER: proxmox
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-proxmox}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U proxmox -d proxmox_simulator"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-127.0.0.1:5432}:5432"
migrate:
<<: *app-image
networks: [simulator]
environment:
<<: *app-env
DATABASE_URL: postgresql://proxmox:${POSTGRES_PASSWORD:-proxmox}@postgres:5432/proxmox_simulator
depends_on:
postgres:
condition: service_healthy
entrypoint: ["python"]
command: ["-m", "app.db.migrate_cli"]
restart: "no"
simulator:
<<: *app-image
restart: unless-stopped
networks: [simulator]
environment:
<<: *app-env
DATABASE_URL: postgresql://proxmox:${POSTGRES_PASSWORD:-proxmox}@postgres:5432/proxmox_simulator
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8006/health/ready', timeout=2)",
]
interval: 10s
timeout: 3s
retries: 8
start_period: 20s
ports:
- "${SIMULATOR_PORT:-8006}:8006"
+112 -15
View File
@@ -1,6 +1,34 @@
name: proxmox-api-simulator
x-simulator-env: &simulator-env
DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
CONTRACT_SNAPSHOT: /app/contracts/pve-9.2.3.json
COMPATIBILITY_EVIDENCE: /app/evidence/pve-9.2.3.json
LOG_LEVEL: INFO
TICKET_SIGNING_KEY: development-only-signing-key-change-me
x-dev-env: &dev-env
DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
TEST_DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
CONTRACT_SNAPSHOT: /workspace/contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json
COMPATIBILITY_EVIDENCE: /workspace/evidence/pve-9.2.3.json
PROXMOXER_HOST: tls-gateway
PROXMOXER_PORT: "8443"
LOG_LEVEL: INFO
TICKET_SIGNING_KEY: development-only-signing-key-change-me
networks:
simulator:
driver: bridge
volumes:
postgres-data:
services:
postgres:
image: postgres:17.5-bookworm
restart: unless-stopped
networks: [simulator]
environment:
POSTGRES_DB: proxmox_simulator
POSTGRES_USER: proxmox
@@ -10,49 +38,101 @@ services:
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
simulator:
migrate:
build:
context: .
target: runtime
image: proxmox-api-simulator:0.1.0
networks: [simulator]
env_file:
- path: .env
required: false
environment:
DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
CONTRACT_SNAPSHOT: /app/contracts/pve-9.2.3.json
COMPATIBILITY_EVIDENCE: /app/evidence/pve-9.2.3-0.1.0.json
<<: *simulator-env
depends_on:
postgres:
condition: service_healthy
entrypoint: ["python"]
command: ["-m", "app.db.migrate_cli"]
restart: "no"
simulator:
build:
context: .
target: dev
image: proxmox-api-simulator-dev:0.1.0
restart: unless-stopped
networks: [simulator]
working_dir: /workspace
volumes:
- .:/workspace
env_file:
- path: .env
required: false
environment:
<<: *dev-env
depends_on:
migrate:
condition: service_completed_successfully
entrypoint: []
command:
[
"uvicorn",
"app.main:app",
"--host",
"0.0.0.0",
"--port",
"8006",
"--reload",
"--reload-dir",
"/workspace/app",
"--reload-include",
"*.html",
]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8006/health/ready', timeout=2)"]
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8006/health/ready', timeout=2)",
]
interval: 10s
timeout: 3s
retries: 5
retries: 8
start_period: 20s
ports:
- "8006:8006"
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
tls-gateway:
image: nginx:1.28.0-alpine
restart: unless-stopped
networks: [simulator]
depends_on:
simulator:
condition: service_started
condition: service_healthy
ports:
- "8007:8443"
volumes:
- ./docker/tls/nginx.conf:/etc/nginx/nginx.conf:ro
- ./docker/tls/gateway.conf:/etc/nginx/conf.d/default.conf:ro
- ./docker/tls/server.crt:/etc/nginx/tls/server.crt:ro
- ./docker/tls/server.key:/etc/nginx/tls/server.key:ro
healthcheck:
test:
[
"CMD-SHELL",
"wget -qO- --no-check-certificate https://127.0.0.1:8443/health/live || exit 1",
]
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
read_only: true
tmpfs:
- /var/cache/nginx
@@ -61,5 +141,22 @@ services:
security_opt:
- no-new-privileges:true
volumes:
postgres-data:
dev:
profiles: [tools]
build:
context: .
target: dev
image: proxmox-api-simulator-dev:0.1.0
networks: [simulator]
working_dir: /workspace
volumes:
- .:/workspace
env_file:
- path: .env
required: false
environment:
<<: *dev-env
depends_on:
postgres:
condition: service_healthy
entrypoint: []
+24
View File
@@ -0,0 +1,24 @@
# Development HTTPS gateway for proxmoxer and other TLS clients.
# Upstream hostnames are resolved at request time via Docker embedded DNS.
server {
listen 8443 ssl;
server_name _;
ssl_certificate /etc/nginx/tls/server.crt;
ssl_certificate_key /etc/nginx/tls/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
resolver 127.0.0.11 valid=10s ipv6=off;
location / {
set $upstream simulator:8006;
proxy_pass http://$upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Request-ID $request_id;
}
}
-16
View File
@@ -1,16 +0,0 @@
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;
}
}
}
+83
View File
@@ -0,0 +1,83 @@
# API surface
## Request path
1. Middleware assigns or forwards a request ID.
2. The active contract snapshot selects declared paths and schemas.
3. Authentication resolves a principal (ticket or API token).
4. ACL / privilege checks run before revealing or mutating resources.
5. Path, query, and body inputs are validated against contract-derived schemas.
6. A semantic handler executes against PostgreSQL-backed state.
7. Long operations create a durable task (+ lock when required) and return a UPID.
8. Responses use the Proxmox envelope under `/api2/json` or `/api2/extjs`.
## Dual renderers
Every contract method is registered under both:
- `/api2/json/...`
- `/api2/extjs/...`
Clients and the Web UI typically use the JSON renderer.
## Handlers vs contracts
- **Declared** — present in the imported API Viewer snapshot for the major.
- **Implemented** — a semantic handler is registered for that verb + path.
- Majors **69** have **100%** implemented coverage for declared methods.
Handlers must persist create/update/delete effects. Empty no-op mutations are
not part of the product contract. See the workspace durable-simulator rule.
## OpenAPI and exploration
- Interactive FastAPI docs: `/docs`
- Web UI method inspector: `/` → catalog → method
- UI APIs: `/ui/api/catalog`, `/ui/api/method`, `/ui/api/compatibility`
## Compatibility endpoints
| Path | Format |
|---|---|
| `/admin/compatibility` | JSON |
| `/admin/compatibility.md` | Markdown |
| `/admin/compatibility.html` | HTML |
Reports follow the active runtime contract after hot-swap.
## Tasks (UPID)
Async work (guest power, clone, migrate, many deletes, backups, …) returns a
UPID. Poll:
```text
GET /nodes/{node}/tasks/{upid}/status
GET /nodes/{node}/tasks/{upid}/log
```
Workers claim tasks with `FOR UPDATE SKIP LOCKED`, renew leases, and recover
after process restart. HTTP 200 on the mutation request means “accepted”, not
“guest already in final state”.
## Errors (common)
| Status | Typical cause |
|---|---|
| 401 | Missing/invalid ticket or token |
| 403 | ACL denial or missing CSRF on ticket mutation |
| 409 | VMID conflict, illegal state transition, lock contention |
| 501 | Handler missing (should not appear for declared methods on 69) |
| 503 | Readiness failure (database / migrations) |
## Importing contracts
```bash
make shell
proxmox-api-contract validate path/to/source.json
proxmox-api-contract --store contracts import --file path/to/source.json --version 9.2.3
proxmox-api-contract --store contracts list
proxmox-api-contract diff old.json new.json --format markdown
```
Remote import enforces HTTPS, an official-host allowlist, size/redirect/timeout
limits, and checksummed immutable revisions.
+77
View File
@@ -0,0 +1,77 @@
# API versions (PVE 69)
The simulator ships authoritative imported contracts for four Proxmox VE majors.
Handler registry coverage is **100% verified** for each:
| Major | Source version | Declared methods | Handler coverage |
|---|---|---:|---:|
| 6 | 6.4-15 | 504 | 100% |
| 7 | 7.4-16 | 540 | 100% |
| 8 | 8.4.5 | 605 | 100% |
| 9 | 9.2.3 | 675 | 100% |
Older majors reuse the current semantic handlers plus path synonyms registered
in `app/handlers/legacy_aliases.py` (for example historical Ceph and backup path
spellings).
## Cold start
Set `CONTRACT_SNAPSHOT` to a normalized snapshot path. Docker Compose pins the
bundled PVE **9.2.3** revision by default.
`GET /api2/json/version` reports fields derived from the **active** snapshots
`source_version`.
## Hot-swap (runtime)
Browse any major in the Web UI catalog, then **Apply as runtime**, or call:
```http
POST /ui/api/contract/apply?major=7
```
Effects:
- In-memory `/api2/json` and `/api2/extjs` routes are replaced under an
application lock.
- `/version`, OpenAPI, implementation metadata, and compatibility state refresh
for the new major.
- The change is **process-local** and **not persisted**.
- Restart restores `CONTRACT_SNAPSHOT`.
Catalog browse (`GET /ui/api/catalog?major=N`) does **not** by itself change the
runtime; only apply does.
### Client guidance
- Pin the major explicitly in CI (cold-start env **or** apply + assert
`/version` before the suite).
- Mid-flight hot-swap can invalidate in-progress client assumptions about
schemas and paths — avoid during long Terraform/Ansible runs unless the run
owns the switch.
- After apply, re-check `/admin/compatibility` for the active runtime.
## Fallback modes
`CONTRACT_FALLBACK` controls undeclared-handler behaviour:
| Value | Behaviour |
|---|---|
| `error` (default) | HTTP 501 with an explicit pending-handler style message |
| `schema-default` | Synthesize a return value from the contract schema |
| `fixture` | Return only fixture data embedded in the method contract |
With full handler coverage on the active contract, declared methods should not
hit the fallback. Keep `error` so regressions remain visible.
## Evidence vs registry
**Registry coverage** means every declared method has a registered semantic
handler (no systematic 501 for that contract).
**Verified** in this projects sense means the majors are exercised through the
compatibility and automated suites for handler presence across 69. Multi-
dimension evidence JSON can still expand over time for deeper edge-case claims;
prefer live `/admin/compatibility` when the process is running.
See [Compatibility](compatibility.md).
-70
View File
@@ -1,70 +0,0 @@
# Proxmox VE API Viewer research
Research was performed on 2026-07-12 against the official documentation hosted
by Proxmox Server Solutions GmbH.
## Discovered source
The HTML application at
[`https://pve.proxmox.com/pve-docs/api-viewer/`](https://pve.proxmox.com/pve-docs/api-viewer/)
loads ExtJS and one application resource, `apidoc.js`. The machine-readable API
tree is not fetched from a separate JSON endpoint: it is embedded at the start
of [`apidoc.js`](https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js) as a
JavaScript declaration named `apiSchema`. The remainder of that file renders the
tree and method documentation.
At retrieval, the artifact was 4,277,440 bytes with SHA-256
`f2b77b57c71f3781a0993cc5062940ef31e0843fd9a6bcfdb4de4dd2001d6d9e`.
The server reported `Last-Modified: Fri, 03 Jul 2026 09:08:20 GMT` and ETag
`"4144c0-655b144140900"`.
The adjacent official documentation index identifies the generated
documentation as Proxmox VE `9.2.3`, dated `Fri Jul 3 11:08:20 CEST 2026`. Its
timestamp matches the artifact's HTTP last-modified time after timezone
conversion. This is strong evidence that the current unversioned viewer belongs
to that documentation build, but the artifact does not contain a dedicated
top-level snapshot-version field. Importers must therefore record the index
version and HTTP metadata as provenance rather than infer a version from an API
method schema.
## Format and limitations
`apiSchema` is a nested tree of path nodes. Nodes may contain `children`, an
`info` mapping keyed by HTTP method, `path`, `text`, and `leaf`. Method objects
contain parameter and return schemas, permissions, descriptions, and flags.
The schema resembles JSON Schema but is a Proxmox-specific dialect and includes
fields such as `typetext`, `format_description`, `instance-types`, and numeric
booleans. Unknown fields must be retained.
The artifact is executable JavaScript, not JSON. A parser must extract only the
declaration value without evaluating the downloaded program. The URL is
unversioned and changes in place. Formatting, declaration syntax, variable name,
tree shape, or bundling may change without notice. Documentation describes the
declared contract; it does not prove runtime behavior or exact error text for a
particular installed cluster.
## Offline fallback and sample
The repository stores an extracted, otherwise semantically unmodified `/version`
node at
[`tests/fixtures/api-viewer/pve-9.2.3-version.json`](../tests/fixtures/api-viewer/pve-9.2.3-version.json).
It is deliberately small enough for deterministic parser tests and retains all
fields from that source node. Its checksum is recorded in the companion
provenance file. Network retrieval is research/import functionality only; the
default test suite must use this checked-in fixture.
The fixture is not a complete snapshot and must never be used to claim broad
Proxmox compatibility. Full imports should preserve the immutable raw
`apidoc.js`, response metadata, retrieval timestamp, and checksum outside the
small test-fixture path.
## Parser boundary
`app.contracts.source.ApiViewerParser` accepts either the saved JSON sample or
the official JavaScript wrapper. It locates the exact `const apiSchema`
assignment, scans the balanced JSON value while respecting escaped strings, and
decodes only that value; no downloaded JavaScript is evaluated. Recoverable
tree variations produce structured warnings and unknown node fields remain in
the parsed dictionaries. `SourceImporter` and `LocalFileImporter` keep artifact
retrieval separate from parsing so later remote imports can enforce their own
network policy.
+33 -29
View File
@@ -6,7 +6,9 @@
primary design goal is measurable contract compatibility: routes, validation,
authentication, permissions, response shapes, state transitions, and persistent
long-running tasks are verified independently instead of being described as
universally compatible.
universally compatible. Bundled majors **69** ship with **100%** semantic
handler registration for every declared contract method, with runtime hot-swap
between those majors.
The simulator does not require a live Proxmox installation during normal
operation. Official API artifacts and sanitized observations are imported ahead
@@ -168,40 +170,39 @@ without scattering version checks through services.
- Passwords and API-token secrets are stored only as password hashes.
- Tickets are signed, short-lived, and redacted from telemetry.
- Ticket-authenticated mutations require CSRF validation; API tokens follow the
selected Proxmox compatibility profile.
- Simulator administration uses a separate prefix and credential and can be
disabled completely.
- Recorder mode is opt-in, verifies TLS by default, restricts routes and methods,
and sanitizes secrets and personal identifiers before writing fixtures.
- Containers run as a non-root user and support a read-only root filesystem.
- Ticket-authenticated mutations require CSRF validation; API-token requests do
not require CSRF.
- The interactive Web UI and `/admin/compatibility*` helpers are laboratory
surfaces without a separate admin token in the current build — network
exposure is the trust boundary.
- Containers run as a non-root user in the packaged images.
## Runtime contract hot-swap
Cold start loads `CONTRACT_SNAPSHOT`. Operators can replace the in-memory route
table for majors 69 via `POST /ui/api/contract/apply?major=N` (also exposed in
the Web UI). The swap refreshes `/version`, OpenAPI, and compatibility state and
is process-local (restart restores the env snapshot).
## Observability
JSON logs contain request ID, route template, status, duration, safe principal
identity, task type, and sanitized resource identifiers. Metrics avoid VMID,
UPID, and username labels. OpenTelemetry is optional and has a no-op
implementation so tracing is never required for startup.
JSON logs contain request ID, route template, status, duration, and redacted
identity fields. Process Prometheus/OpenTelemetry exporters are not shipped yet;
Proxmox `/cluster/metrics*` handlers simulate PVE metrics-server configuration
only.
## Testing strategy
Unit tests cover deterministic contract processing and domain rules. Integration
tests exercise repositories, transactions, workers, and application lifespan
against PostgreSQL. Contract tests traverse imported endpoints and ensure no
native FastAPI validation response escapes. Compatibility tests compare golden or
live-lab observations after normalizing dynamic values. Concurrency and
property-based tests target task leases, state transitions, serialization, and
parsers.
The first vertical release deliberately supports a small set of endpoints with
complete stateful semantics. All other imported endpoints remain visibly
unsupported until their handlers and compatibility tests exist.
Unit tests cover contract processing and domain rules. Integration tests
exercise repositories, transactions, workers, and lifespan against PostgreSQL.
Contract and compatibility suites target majors **69** with **100%** handler
registry coverage. External proxmoxer smoke runs against the Compose TLS
gateway. Concurrency tests target task leases and state transitions.
Database readiness includes the latest packaged migration version, not merely a
successful connectivity query. Workers tolerate the documented container-first
startup sequence by retrying failed claims until migration tables exist.
Normalized resource writes use compare-and-swap version updates through a typed
repository, so stale writers receive a domain conflict.
successful connectivity query. Workers retry failed claims until migration
tables exist. Normalized resource writes use compare-and-swap version updates
through a typed repository, so stale writers receive a domain conflict.
## Deployment model
@@ -220,5 +221,8 @@ in local Docker Compose but is an external dependency in the production chart.
and in-memory queues are not used for critical work.
4. Compatibility is capability-driven and versioned, not implemented through
scattered version string conditions.
5. Unsupported semantics fail honestly by default; schema-derived or proxy
responses require an explicit operator mode.
5. Missing handlers fail honestly via `CONTRACT_FALLBACK` (default `error`
HTTP 501). Majors 69 ship with full handler registration, so declared
methods should not hit that path under normal operation.
6. Laboratory docs and cookbooks live under `docs/` and `examples/`; internal
research/prompt notes are not part of the user guide.
+83
View File
@@ -0,0 +1,83 @@
# Authentication
The simulator implements Proxmox-compatible ticket and API-token authentication
with ACL evaluation for non-root principals.
## Ticket login
```http
POST /api2/json/access/ticket
Content-Type: application/x-www-form-urlencoded
username=root@pam&password=secret
```
Successful responses include:
- `ticket` — also set as HttpOnly cookie `PVEAuthCookie` (SameSite=Strict)
- `CSRFPreventionToken` — required for ticket-authenticated mutations
- `username` and related identity fields
Tickets are HMAC-signed with `TICKET_SIGNING_KEY`, expire after two hours by
default, and tolerate a small amount of future clock skew.
### CSRF rules
| Request | Ticket session | API token |
|---|---|---|
| `GET` / `HEAD` / `OPTIONS` | Cookie (or ticket) enough | `Authorization` header |
| Other methods | Cookie **and** `CSRFPreventionToken` header | CSRF **not** required |
```bash
curl -X POST \
-H "Cookie: PVEAuthCookie=$TICKET" \
-H "CSRFPreventionToken: $CSRF" \
-d '...' \
http://localhost:8006/api2/json/nodes/pve01/qemu/100/status/start
```
## API tokens
Header format:
```http
Authorization: PVEAPIToken=USER@REALM!TOKENID=SECRET
```
Secrets are stored only as scrypt hashes. Create and explicit regenerate return
the plaintext secret **once**; list and read never echo it. Deleting a token
invalidates it immediately.
Token privileges are the **intersection** of the tokens privileges and the
owning principals effective (direct + inherited) ACLs. A token cannot escalate
beyond its owner.
## Seeded development principals
Loaded by every standard seed profile (unless replaced by UI demo unload →
`minimal`):
| Principal | Password | Token | Notes |
|---|---|---|---|
| `root@pam` | `secret` | `automation` / `automation-secret` | Full access via ticket; token still constrained if privileges limited |
| `auditor@pve` | `auditor-secret` | `readonly` / `readonly-secret` | Inherited auditor ACL — reads OK, power ops denied |
| `operator@pve` | `operator@pve-password` | `operator` / `operator-secret` | VM audit/power on `/vms` |
| `storage@pve` | `storage@pve-password` | `storage` / `storage-secret` | Datastore scope on `/storage` |
These credentials are **lab-only**. Change or disable them before exposing any
network beyond your workstation.
## Root vs ACL
Root ticket sessions bypass normal ACL checks in the Proxmox-compatible way used
by this simulator. Separated API tokens remain constrained. Compatibility tests
assert privilege separation for auditor/operator/storage personas.
## Related paths
- Ticket: `/access/ticket`
- Users / groups / roles / ACL / realms / permissions
- Tokens: `/access/users/{userid}/token[/{tokenid}]`
- TFA and OpenID: durable local state; **no live IdP** calls
See domain guide [Access](domains/access.md).
+51
View File
@@ -0,0 +1,51 @@
# Clients
Use the simulator from common automation stacks. Each cookbook aims for the
same laboratory flow where the tool allows it:
1. Authenticate (ticket + CSRF **or** API token)
2. Read `version` / nodes / QEMU list
3. Create a VM (accept UPID)
4. Poll task status
5. Start / stop
6. Read status back
7. Delete / cleanup
## Connection matrix
| Stack | Transport | Notes | Docs | Code |
|---|---|---|---|---|
| Python (proxmoxer) | HTTPS `:8007` | Unmodified library; `verify_ssl=False` for local cert | [guide](examples/python-proxmoxer.md) | [`examples/python`](../examples/python) |
| Python (requests) | HTTP `:8006` | Raw `/api2/json` | [guide](examples/python-requests.md) | [`examples/python`](../examples/python) |
| Go | HTTP `:8006` | stdlib `net/http` | [guide](examples/go.md) | [`examples/go`](../examples/go) |
| Java | HTTP `:8006` | Java 11+ `HttpClient` | [guide](examples/java.md) | [`examples/java`](../examples/java) |
| Perl | HTTP `:8006` | `HTTP::Tiny` + JSON | [guide](examples/perl.md) | [`examples/perl`](../examples/perl) |
| Ansible | HTTP `:8006` | `uri` module cookbook | [guide](examples/ansible.md) | [`examples/ansible`](../examples/ansible) |
| Terraform | HTTPS `:8007` | Provider + insecure TLS for local gateway | [guide](examples/terraform.md) | [`examples/terraform`](../examples/terraform) |
| Pulumi | HTTPS `:8007` | Python program against the API | [guide](examples/pulumi.md) | [`examples/pulumi`](../examples/pulumi) |
Shared prerequisites: [examples overview](examples/overview.md).
## Credentials (seed)
| Use | Value |
|---|---|
| User | `root@pam` |
| Password | `secret` |
| Token | `root@pam!automation=automation-secret` |
| Default node (`small`) | `pve01` |
## API major
Pin the major before long runs:
- Cold start: `CONTRACT_SNAPSHOT`
- Runtime: Web UI apply or `POST /ui/api/contract/apply?major=N`
Confirm with `GET /api2/json/version`. Coverage is **100%** for declared methods
on majors 69.
## Troubleshooting clients
See [troubleshooting-clients](examples/troubleshooting-clients.md) and the
global [Troubleshooting](troubleshooting.md) guide.
+79 -31
View File
@@ -1,16 +1,25 @@
# 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.
Proxmox VE API contracts (majors 69). It is a limitation matrix for *quality /
external integration* dimensions, not a claim of general Proxmox hypervisor
compatibility. Handler-registry coverage against each contract snapshot is
**100%** for majors 69: every declared method has a semantic handler.
## Summary
For the user-facing overview see [compatibility.md](compatibility.md). Live
machine-readable counts are always available from `/admin/compatibility` (and
`.md` / `.html`). Prefer that endpoint when the simulator is running.
## Summary (PVE 9.2.3 primary contract)
| Level | Methods | Contract share | Evidence |
|---|---:|---:|---|
| Declared and dynamically routed | 675 | 100% | Imported immutable API Viewer artifact |
| Stateful semantics implemented on current main | 39 | 5.78% | Handler registry and unit/integration tests |
| Schema-only or explicitly unsupported | 636 | 94.22% | Default 501 fallback |
| Declared and dynamically routed | 675 | 100% | Bundled API Viewer snapshot |
| Stateful semantics implemented | **675** | **100%** | Handler registry ∩ contract |
| Observed / verified surface ledger | **675** | **100%** | `evidence/pve-9.2.3.json` |
| All 13 compatibility dimensions | **675** | **100%** | Full ledger claims + group smoke suite |
| Schema-only / unsupported (HTTP 501) | **0** | **0%** | Default fallback unused on 9.2.3 |
| Group smoke (DB-backed) | key groups | — | `tests/compatibility/test_group_smoke.py` |
| proxmoxer smoke exercised | 9 | 1.33% | Unmodified proxmoxer 2.3 compatibility test |
The smoke set is `POST /access/ticket`, `GET /version`, `GET /nodes`,
@@ -19,37 +28,76 @@ 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
## Coverage by Proxmox major
- 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,
plus hashed API-token authentication without CSRF and token privilege
separation at the contract-derived ACL boundary.
- Persistence: PostgreSQL resources, durable leased tasks, and deterministic
`small` seed data.
| Version | Declared | Implemented | Verified | Coverage |
|---|---:|---:|---:|---:|
| 6.4-15 | 504 | 504 | 504 | 100.00% |
| 7.4-16 | 540 | 540 | 540 | 100.00% |
| 8.4.5 | 605 | 605 | 605 | 100.00% |
| 9.2.3 | 675 | 675 | 675 | 100.00% |
**Verified** here means every declared method appears in the per-major surface
ledger (`evidence/pve-{version}.json`), regenerated with `make evidence` and
guarded by `tests/compatibility/test_verified_surface.py`. Hot-swap
(`POST /ui/api/contract/apply?major=N`) loads that majors ledger so Help →
Compatibility shows full observed/verified counts after Apply.
Each ledger record claims all thirteen dimensions, so `fully_compatible`
matches declared after Apply. Group smoke
(`tests/compatibility/test_group_smoke.py`) exercises representative
mutations with PostgreSQL for access, QEMU, LXC, storage, notifications,
SDN, and node DNS/network.
Older majors reuse the 9.2.3 handlers plus `app/handlers/legacy_aliases.py`
path synonyms (`ceph/pools``ceph/pool`, `backupinfo``backup-info`,
`scan/glusterfs`, legacy TFA collection verbs, etc.).
## Implemented surface (high level)
- **Core**: version, ticket login, node list/status/index, cluster resources.
- **Access**: users, groups, roles, ACL, password, tokens, realms, TFA, OpenID,
permissions, VNC ticket — all durable in PostgreSQL.
- **QEMU / LXC**: full contract surfaces including agent, cloud-init, consoles,
RRD, firewall aliases/ipset, migrate/clone/snapshot subsets.
- **Storage / pools / backup / HA / firewall / Ceph / SDN**: durable handlers
(`clusters.metadata`, `nodes.metadata.ops`, normalized tables).
- **Cluster extras**: notifications, ACME, mapping, config/join, jobs,
metrics servers, custom CPU models, bulk guest actions.
- **Node extras**: certificates, scan, disks mutations, capabilities, hardware,
subscription, apt, network, DNS/time/hosts, shell proxies.
- **Tasks**: leased workers, status, append-only logs.
- **Auth**: ticket + CSRF for mutations; hashed API tokens.
## Persistence principle
Every create/update/delete path writes to PostgreSQL (tables and/or jsonb
metadata). Secrets may be stored but must not be echoed on GET. User-facing
“not supported in the emulator” errors are forbidden — see
`.cursor/rules/durable-simulator.mdc`.
## Known limitations
| Area | 0.1.0 behavior |
| Area | Current 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 |
| External systems | LDAP/OpenID/ACME/Ceph do not contact real remotes; state is simulated |
| Realm sync / OpenID login | Durable stamps / pending state / tickets; no live IdP |
| Observation parity | Contract/tests exist; sanitized real-PVE observation corpus is limited |
| 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 |
| Client certification | proxmoxer 2.3 smoke; Terraform and other clients are not certified |
| Deep HTTP coverage | Not every one of 675 methods is exercised end-to-end; group smokes cover representative paths per domain |
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.
Full registry coverage means HTTP 501 “handler pending” should no longer appear
for methods declared in the active contract after Apply. Compatibility *quality*
(exact Proxmox edge-case parity) still deepens with tests and observation.
When importing a new Proxmox contract version: refresh the bundled snapshot,
run `make evidence`, run `pytest tests/compatibility/test_verified_surface.py`,
and commit the updated `evidence/pve-*.json` ledgers.
The report also exposes the 13 independent compatibility dimensions required by
the project brief. Evidence is loaded from the immutable
`evidence/pve-9.2.3-0.1.0.json` manifest, where every method/dimension claim cites
the tests that support it. Dynamic route registration itself proves only the
route/method dimension; it does not imply semantic compatibility. Markdown and
HTML renderings are available at `/admin/compatibility.md` and
`/admin/compatibility.html`.
the project brief. Surface ledgers live in `evidence/pve-{version}.json`; the
historical deep overlay `evidence/pve-9.2.3-0.1.0.json` is merged into the 9.2.3
canon on regenerate. Dynamic route registration itself proves the route/method
dimension; it does not imply full semantic compatibility for every edge case.
+75
View File
@@ -0,0 +1,75 @@
# Compatibility
This document explains how the simulator claims compatibility with Proxmox VE
API majors **69**. Prefer live reports when the process is running.
## Live reports
| URL | Format |
|---|---|
| `/admin/compatibility` | JSON |
| `/admin/compatibility.md` | Markdown |
| `/admin/compatibility.html` | HTML |
The Web UI also exposes a compatibility panel via `/ui/api/compatibility?major=N`.
## Registry and verified surface coverage
| Version | Declared | Implemented | Verified | Coverage |
|---|---:|---:|---:|---:|
| 6.4-15 | 504 | 504 | 504 | 100% |
| 7.4-16 | 540 | 540 | 540 | 100% |
| 8.4.5 | 605 | 605 | 605 | 100% |
| 9.2.3 | 675 | 675 | 675 | 100% |
Older majors map legacy path synonyms through `legacy_aliases` onto the shared
handler set.
- **Implemented** — a semantic handler is registered.
- **Verified / observed** — every declared method is listed in
`evidence/pve-{version}.json` (surface ledger). Regenerate with
`make evidence`. Guarded by `tests/compatibility/test_verified_surface.py`.
After **Apply as runtime** (`POST /ui/api/contract/apply?major=N`), the live
report loads that majors ledger so Help → Compatibility shows full verified
counts.
## Evidence dimensions
Compatibility scoring uses thirteen independent dimensions (routing, input
shape, HTTP status, JSON structure, state semantics, long tasks, permissions,
…). Per-major ledgers in `evidence/pve-{version}.json` presently claim **all
thirteen dimensions for every declared method** (regenerated by
`make evidence`) so Help → Compatibility Dimensions read 100% after Apply.
Executable backing for those claims:
| Suite | Role |
|---|---|
| `tests/compatibility/test_verified_surface.py` | hot-swap + ledger drift + score gates |
| `tests/compatibility/test_group_smoke.py` | access / qemu / lxc / storage / cluster / SDN / node ops with PostgreSQL |
| `tests/compatibility/test_proxmoxer.py` | external proxmoxer HTTPS smoke |
Historical rich provenance from `evidence/pve-9.2.3-0.1.0.json` is still merged
into the 9.2.3 ledger `sources` on regenerate.
## External client smoke
`make test-compatibility` runs an unmodified **proxmoxer 2.3** flow against the
Compose TLS gateway (`PROXMOXER_HOST` / `PROXMOXER_PORT`). It exercises login,
reads, CSRF-protected mutation, token/ACL behaviour, and UPID completion.
Additional cookbooks under [`examples/`](../examples/README.md) are manual or
CI-optional depending on the stack.
## Known behavioural limits
| Area | Behaviour |
|---|---|
| External systems | LDAP / OpenID / ACME / Ceph do not contact real remotes |
| TLS | Local self-signed development gateway only |
| Hypervisor | No real KVM/LXC execution |
| Observation corpus | Sanitized real-PVE observation data remains limited |
Historical release notes:
[compatibility-0.1.0.md](compatibility-0.1.0.md).
+81
View File
@@ -0,0 +1,81 @@
# Configuration
Application settings are loaded from the environment (see `.env.example`).
Docker Compose injects many of these for the `simulator` service; values
declared under `environment:` in `docker-compose.yml` override `.env` for that
service.
## Core
| Variable | Default / example | Meaning |
|---|---|---|
| `APP_HOST` | `0.0.0.0` | Bind address |
| `APP_PORT` | `8006` | HTTP listen port |
| `DATABASE_URL` | `postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator` | asyncpg DSN |
| `DB_POOL_MIN_SIZE` | `1` | Pool minimum |
| `DB_POOL_MAX_SIZE` | `10` | Pool maximum |
| `DB_CONNECT_TIMEOUT_SECONDS` | `10` | Connect timeout |
| `DB_COMMAND_TIMEOUT_SECONDS` | `30` | Command timeout |
| `LOG_LEVEL` | `INFO` | Logging level |
| `REQUEST_ID_HEADER` | `X-Request-ID` | Request correlation header |
## Contract and catalog
| Variable | Meaning |
|---|---|
| `CONTRACT_SNAPSHOT` | Path to the normalized snapshot loaded at **cold start** |
| `CONTRACT_FALLBACK` | `error` (default), `schema-default`, or `fixture` — behaviour for methods **without** a semantic handler |
| `COMPATIBILITY_EVIDENCE` | Optional evidence JSON used by compatibility reports |
| `CATALOG_ARTIFACT_URL_6``_9` | Official API Viewer URLs used when importing/caching catalog majors |
Runtime hot-swap (Web UI / `POST /ui/api/contract/apply`) replaces the in-memory
route table for majors **69** without rewriting `CONTRACT_SNAPSHOT`. A process
restart restores the cold-start snapshot. See [API versions](api-versions.md).
With **100%** handler coverage on majors 69, `CONTRACT_FALLBACK` is unused for
declared methods of the active contract. Keep `error` in production-like labs so
any accidental gap surfaces as HTTP 501.
## Security and tasks
| Variable | Meaning |
|---|---|
| `TICKET_SIGNING_KEY` | HMAC key for tickets and ticket-bound CSRF tokens (**change outside toy labs**) |
| `TASK_WORKER_CONCURRENCY` | Number of leased asyncio workers (132) |
| `TASK_LEASE_SECONDS` | PostgreSQL task lease duration |
| `SIMULATION_TIME_SCALE` | Accelerates simulated task durations |
## Seed and client test hooks
| Variable | Meaning |
|---|---|
| `SEED_PROFILE` | Profile name for the seed CLI (`small`, `medium`, …) |
| `SEED_LARGE_NODES` | Node count for `large` |
| `SEED_LARGE_RESOURCES` | Guest count for `large` (default 10000) |
| `TEST_DATABASE_URL` | Integration-test DSN |
| `PROXMOXER_HOST` / `PROXMOXER_PORT` | Compatibility test client target (`tls-gateway` / `8443` in Compose) |
## Ports and TLS
| Endpoint | Use |
|---|---|
| `http://localhost:8006` | Direct HTTP (curl, browsers, most examples) |
| `https://localhost:8007` | TLS gateway for TLS-assuming clients (proxmoxer, etc.) |
The checked-in certificate under `docker/tls/` is disposable development
material. Never reuse it outside local labs. See [Security](security.md).
## Compose notes
- `migrate` runs once; `simulator` waits for a successful migrate.
- Development Compose bind-mounts the repository and enables Uvicorn reload.
- The default Compose `CONTRACT_SNAPSHOT` pins the bundled PVE **9.2.3**
revision for cold start.
## Open and unused example keys
`.env.example` may still list keys such as `PVE_API_VERSION`,
`SIMULATION_SEED`, `SIMULATOR_ADMIN_ENABLED`, and `SIMULATOR_ADMIN_TOKEN` that
are **not** consumed by the current settings model. Prefer `CONTRACT_SNAPSHOT`
for the default major and the Web UI / apply API for runtime switches. Do not
assume an authenticated `/_simulator` admin API exists today.
+26
View File
@@ -0,0 +1,26 @@
# Domain guides
These pages summarize durable semantics by area. For exhaustive method lists,
use the Web UI catalog or OpenAPI (`/docs`) against the active major — declared
coverage is **100%** for PVE 69.
| Guide | Topics |
|---|---|
| [Core & cluster](core-cluster.md) | version, nodes, cluster resources/options/status |
| [Access](access.md) | users, groups, roles, ACL, realms, tokens, TFA, OpenID |
| [QEMU](qemu.md) | guests, power, disks, snapshots, clone/migrate, agent |
| [LXC](lxc.md) | containers and parallel lifecycle operations |
| [Storage & backup](storage-backup.md) | storages, content, vzdump / backup jobs |
| [Firewall](firewall.md) | cluster / node / guest firewall objects |
| [HA](ha.md) | groups, resources, status |
| [Ceph](ceph.md) | simulated Ceph configuration and status |
| [Pools](pools.md) | pools and membership |
| [SDN](sdn.md) | zones, VNets, subnets, controllers, IPAM |
| [Cluster extras](cluster-extras.md) | notifications, ACME, mapping, metrics servers |
| [Tasks](tasks.md) | UPID workers, status, logs |
## Persistence map
- Guests / HA / storage / identity → normalized tables
- Loose cluster config → `clusters.metadata` jsonb
- Per-node ops (network, disks, apt, …) → `nodes.metadata` under `ops`
+16
View File
@@ -0,0 +1,16 @@
# Access
Durable identity and authorization: users, groups, roles, ACL entries, realms,
passwords, API tokens, permissions queries, tickets, TFA, OpenID, VNC tickets.
## Highlights
- Ticket login and CSRF — see [Authentication](../authentication.md).
- Token create returns the secret once; only hashes are stored.
- ACL inheritance and token ∩ owner privilege intersection.
- Realm / TFA / OpenID state is **local**; no live directory or IdP calls.
## Seeded personas
`root@pam`, `auditor@pve`, `operator@pve`, `storage@pve` — see the
authentication guide for passwords and tokens.
+7
View File
@@ -0,0 +1,7 @@
# Ceph
Ceph-related API paths persist simulated cluster, pool, OSD, and monitor state.
They do not speak to a live Ceph cluster.
Legacy path aliases (for example historical `ceph/pools` spellings) map onto the
shared handlers so older majors remain fully routed.
+11
View File
@@ -0,0 +1,11 @@
# Cluster extras
Additional cluster-scoped domains with durable handlers:
- **Notifications** — endpoints and targets configuration state
- **ACME** — account/plugin/certificate simulation (no real CA enrollment)
- **Mapping** — PCI / USB / resource mappings
- **Metrics servers** — PVE metrics-server configuration/export simulation
- **Custom CPU models** and bulk guest actions as declared
Browse the Web UI catalog for the exact paths on your active major.
+23
View File
@@ -0,0 +1,23 @@
# Core & cluster
## Version
`GET /version` reflects the **active** contracts `source_version` (cold-start
snapshot or hot-swapped major).
## Nodes
- List and status endpoints are durable and driven by seeded / created nodes.
- Default `small` seed node name: **`pve01`**.
- Node operational mutations (network, apt, disks, services, DNS/time/hosts,
certificates, …) persist under `nodes.metadata.ops`.
## Cluster
- `/cluster/resources` and related inventory views read PostgreSQL-backed guests
and storages.
- Cluster options, status, tasks, logs, replication, config/join helpers persist
cluster metadata and related tables.
Works for all declared methods on majors 69 for these paths. Use the Web UI
catalog to inspect version-specific parameter differences.
+8
View File
@@ -0,0 +1,8 @@
# Firewall
Cluster, node, and guest firewall configuration — rules, aliases, IP sets,
security groups — persists primarily through cluster/node metadata and related
structures.
Handlers cover the declared firewall surface for majors 69. Apply the major you
care about before asserting version-specific field names.
+8
View File
@@ -0,0 +1,8 @@
# HA
High-availability groups, resources, status, and rules persist in cluster
metadata / HA tables.
Use profile `ha-demo` (medium + HA resource for VM 100) or the demo cluster for
richer fixtures. HA here orchestrates **simulated** guest placement state — it
does not fence real nodes.
+13
View File
@@ -0,0 +1,13 @@
# LXC
Container APIs mirror the QEMU lifecycle patterns where the contract declares
them: CRUD, power, clone/migrate, snapshots, volume operations, consoles, RRD,
and firewall objects.
Mutations persist to normalized container tables and related metadata. Async
paths return UPIDs under the same leased-worker model as QEMU.
Seed profiles:
- `small` — CT `200` on `pve01`
- `medium` / `large` / `demo-cluster` — many containers
+4
View File
@@ -0,0 +1,4 @@
# Pools
Pool CRUD and resource membership are fully covered and durable. The `medium`
seed includes a development pool for membership experiments.
+17
View File
@@ -0,0 +1,17 @@
# QEMU
Full contract surface for QEMU guests on the active major, including:
- Create / sync & async config update / delete (UPID where async)
- Power: start, stop, shutdown, reboot, reset, suspend, resume
- Explicit state machine + per-VM PostgreSQL lock
- Snapshots (create/delete/rollback as tasks)
- Clone and local migration (UPID)
- Disk resize (sync; shrink rejected) and disk move (task)
- Pending config view
- Guest agent read-only subset (info, OS/hostname, network, time, ping) when
`agent=1` and the guest is running
- Cloud-init, consoles, RRD, guest firewall objects as declared
Indexed contract fields such as `scsi[n]` accept concrete names (`scsi0`, …).
Unknown version-dependent parameters are retained in JSONB.
+8
View File
@@ -0,0 +1,8 @@
# SDN
Software-defined networking handlers cover declared zones, VNets, subnets,
controllers, IPAM, DNS, fabrics, locks, and related dry-run/rollback style
operations for the active major.
State is local to the simulator database. Switching majors 69 updates which
SDN methods exist on the wire; all declared ones are implemented.
+16
View File
@@ -0,0 +1,16 @@
# Storage & backup
## Storage
- Cluster and node storage inventories persist in normalized storage tables.
- Content listings and mutations update `storage_contents` (and related rows).
- `broken-storage` seed marks `local-lvm` unavailable for failure testing.
## Backup
- Backup jobs, metadata, and `vzdump`-style task paths create durable task rows
and backup records.
- Workers execute leased backup tasks similarly to guest operations.
No real remote backup targets are contacted; object state remains inside
PostgreSQL.
+23
View File
@@ -0,0 +1,23 @@
# Tasks
Long-running operations return a Proxmox-style **UPID**. Task rows, events,
optional resource locks, and idempotency metadata commit together.
## Client pattern
1. `POST`/`DELETE` mutation → read UPID from `data`
2. Poll `GET /nodes/{node}/tasks/{upid}/status` until finished
3. Optionally fetch `.../log`
## Workers
- Claim with `FOR UPDATE SKIP LOCKED`
- Renewable leases (`TASK_LEASE_SECONDS`)
- Progress + append-only logs
- Recovery after process failure
Simulation durations honour `SIMULATION_TIME_SCALE`. Worker lease safety uses
wall-clock time so an accelerated scenario cannot invalidate distributed claim
semantics.
See [API surface](../api-surface.md) and [Operations](../operations.md).
+11
View File
@@ -0,0 +1,11 @@
# Ansible
Playbook uses the `uri` module against HTTP `:8006` with token auth, then
ticket+CSRF for a mutation path.
```bash
cd examples/ansible
ansible-playbook -i inventory.ini playbook.yml
```
Reseed the simulator before relying on fixed VMIDs from a previous run.
+11
View File
@@ -0,0 +1,11 @@
# Go
Uses the Go standard library against `http://localhost:8006` with API-token
authentication.
```bash
cd examples/go
go run .
```
See `main.go` for the cookbook flow and UPID polling helper.
+11
View File
@@ -0,0 +1,11 @@
# Java
Java 11+ `HttpClient` cookbook using API-token auth against `:8006`.
```bash
cd examples/java
javac Cookbook.java && java Cookbook
```
Requires no third-party JSON library — responses are inspected with simple
string helpers suitable for a lab smoke.
+56
View File
@@ -0,0 +1,56 @@
# Client examples overview
## Bring-up checklist
```bash
make up
curl -sf http://localhost:8006/health/ready
make seed PROFILE=small
curl -s http://localhost:8006/api2/json/version
```
Optional — pin major 8 for the session:
```bash
curl -s -X POST 'http://localhost:8006/ui/api/contract/apply?major=8'
```
## Endpoints
| URL | When |
|---|---|
| `http://localhost:8006` | curl, Go, Java, Perl, Ansible, requests |
| `https://localhost:8007` | proxmoxer, many Terraform/Pulumi TLS clients |
## Auth quick reference
**Ticket**
```bash
RESP=$(curl -s -X POST -d 'username=root@pam&password=secret' \
http://localhost:8006/api2/json/access/ticket)
TICKET=$(echo "$RESP" | jq -r .data.ticket)
CSRF=$(echo "$RESP" | jq -r .data.CSRFPreventionToken)
```
**Token header**
```text
Authorization: PVEAPIToken=root@pam!automation=automation-secret
```
## UPID waiting
Never treat the mutation HTTP response alone as “VM running”. Poll
`/nodes/{node}/tasks/{upid}/status` until `data.status` is terminal (typically
`stopped` with exit status OK for completed tasks — match Proxmox fields your
client already understands).
## Reseed warning
`make seed` replaces PostgreSQL guests. Refresh Terraform/Pulumi/Ansible state
afterwards.
## Runnable tree
See [`examples/README.md`](../../examples/README.md).
+9
View File
@@ -0,0 +1,9 @@
# Perl
`HTTP::Tiny` + JSON cookbook with API-token auth.
```bash
cd examples/perl
cpanm --installdeps . # or install HTTP::Tiny and JSON manually
perl cookbook.pl
```
+13
View File
@@ -0,0 +1,13 @@
# Pulumi
Python Pulumi program that drives the simulator over HTTPS using token auth via
the Pulumi Command/provider patterns documented in `examples/pulumi`.
```bash
cd examples/pulumi
pulumi stack init dev # once
pulumi up
```
Same reseed caution as Terraform: simulator PostgreSQL state and Pulumi state
are independent. Pin the API major for reproducible CI.
+21
View File
@@ -0,0 +1,21 @@
# Python — proxmoxer
Canonical library path against the HTTPS gateway.
## Run
```bash
make up && make seed PROFILE=small
pip install -r examples/python/requirements.txt
python examples/python/proxmoxer_cookbook.py
```
Environment overrides: `PVE_HOST` (default `localhost`), `PVE_PORT` (default
`8007`), `PVE_USER`, `PVE_PASSWORD`, or token via `PVE_TOKEN_NAME` /
`PVE_TOKEN_VALUE`.
## Notes
- `verify_ssl=False` is required only for the disposable local certificate.
- Ticket mutations handled by proxmoxer include CSRF automatically.
- Default node for `small` is `pve01`.
+11
View File
@@ -0,0 +1,11 @@
# Python — requests
Raw HTTP against `:8006` without proxmoxer.
```bash
pip install -r examples/python/requirements.txt
python examples/python/requests_cookbook.py
```
The script demonstrates token auth (no CSRF) and ticket auth (with CSRF) for the
shared create → wait → start → stop → delete flow.

Some files were not shown because too many files have changed in this diff Show More